diff --git a/web-common/src/features/dashboards/workspace/Dashboard.svelte b/web-common/src/features/dashboards/workspace/Dashboard.svelte index cbac3e2f4682..2e78e25f02a6 100644 --- a/web-common/src/features/dashboards/workspace/Dashboard.svelte +++ b/web-common/src/features/dashboards/workspace/Dashboard.svelte @@ -12,8 +12,9 @@ import { dynamicHeight } from "@rilldata/web-common/layout/layout-settings.ts"; import { navigationOpen } from "@rilldata/web-common/layout/navigation/Navigation.svelte"; import Resizer from "@rilldata/web-common/layout/Resizer.svelte"; - import { onDestroy } from "svelte"; - import { readable, type Readable } from "svelte/store"; + import { githubStarNudge } from "@rilldata/web-common/features/github-star/github-star.svelte"; + import { onDestroy, onMount } from "svelte"; + import { get, readable, type Readable } from "svelte/store"; import { useExploreState } from "web-common/src/features/dashboards/stores/dashboard-stores"; import { DashboardState_ActivePage } from "../../../proto/gen/rill/ui/v1/dashboard_pb"; import { useRuntimeClient } from "../../../runtime-client/v2"; @@ -57,10 +58,16 @@ dashboardStore, } = StateManagers; - const { cloudDataViewer, readOnly } = featureFlags; + const { adminServer, cloudDataViewer, readOnly } = featureFlags; const timeControlsStore = useTimeControlStore(StateManagers); + onMount(() => { + // Github star nudge is Rill developer only. + // Nudge on dashboard render. + if (!isEmbedded && !get(adminServer)) githubStarNudge.armPayoff(); + }); + let exploreContainerWidth: number; let exploreContainerHeight: number; let resizing = false; diff --git a/web-common/src/features/github-star/GithubStarButton.spec.ts b/web-common/src/features/github-star/GithubStarButton.spec.ts new file mode 100644 index 000000000000..920de00b4b6a --- /dev/null +++ b/web-common/src/features/github-star/GithubStarButton.spec.ts @@ -0,0 +1,181 @@ +import { render, screen } from "@testing-library/svelte"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { featureFlags } from "@rilldata/web-common/features/feature-flags"; +import { InMemoryRuneStore } from "@rilldata/web-common/lib/store-utils/types.svelte.ts"; +import GithubStarButton from "./GithubStarButton.svelte"; +import { + GITHUB_STAR_URL, + GithubStarNudge, + type GithubStarState, +} from "./github-star.svelte"; + +/** Lets the auto-open timer and the resulting Svelte update settle. */ +function settle() { + return vi.advanceTimersByTimeAsync(2000); +} + +/** A nudge of its own, so these tests neither touch localStorage nor the app-wide singleton. */ +function createNudge() { + return new GithubStarNudge( + new InMemoryRuneStore({ status: "unarmed" }), + ); +} + +function renderArmed() { + const nudge = createNudge(); + nudge.armPayoff(); + render(GithubStarButton, { props: { nudge } }); + return nudge; +} + +const outsideElements: HTMLElement[] = []; + +/** Stands in for whatever the user was actually working on, e.g. the editor. */ +function renderOutsideElement() { + const element = document.createElement("button"); + document.body.append(element); + outsideElements.push(element); + return element; +} + +describe("GithubStarButton", () => { + beforeEach(() => { + featureFlags.adminServer.resetToDefault(); + vi.useFakeTimers(); + }); + + afterEach(() => { + featureFlags.adminServer.resetToDefault(); + outsideElements.splice(0).forEach((element) => element.remove()); + vi.useRealTimers(); + }); + + it("renders nothing at all on Rill Cloud", async () => { + featureFlags.adminServer.set(true); + const nudge = renderArmed(); + await settle(); + + // Neither the button nor the nudge: this is a Rill Developer feature. + expect(screen.queryByText("Star us on GitHub")).not.toBeInTheDocument(); + expect(screen.queryByText("Enjoying Rill?")).not.toBeInTheDocument(); + expect(nudge.state.status).toBe("armed"); + expect(nudge.state.mutedUntil).toBeUndefined(); + }); + + it("always renders the footer link, pointing straight at the repo", () => { + render(GithubStarButton, { props: { nudge: createNudge() } }); + + const link = screen.getByText("Star us on GitHub").closest("a"); + expect(link).toHaveAttribute("href", GITHUB_STAR_URL); + expect(link).toHaveAttribute("target", "_blank"); + }); + + it("does not open unprompted when no payoff has happened", async () => { + render(GithubStarButton, { props: { nudge: createNudge() } }); + await settle(); + + expect(screen.queryByText("Enjoying Rill?")).not.toBeInTheDocument(); + }); + + it("opens unprompted once a payoff has armed it", async () => { + renderArmed(); + await settle(); + + expect(screen.getByText("Enjoying Rill?")).toBeInTheDocument(); + }); + + it("does not take the keyboard when the nudge opens", async () => { + const editor = renderOutsideElement(); + editor.focus(); + + renderArmed(); + await settle(); + + expect(screen.getByText("Enjoying Rill?")).toBeInTheDocument(); + expect(document.activeElement).toBe(editor); + }); + + it("lets focus leave the open nudge instead of trapping it", async () => { + const editor = renderOutsideElement(); + renderArmed(); + await settle(); + + editor.focus(); + await settle(); + + // The nudge stays open; it just does not hold the keyboard hostage. + expect(screen.getByText("Enjoying Rill?")).toBeInTheDocument(); + expect(document.activeElement).toBe(editor); + }); + + it("leaves focus where it is when the nudge closes", async () => { + renderArmed(); + await settle(); + + const editor = renderOutsideElement(); + editor.focus(); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + await settle(); + + expect(screen.queryByText("Enjoying Rill?")).not.toBeInTheDocument(); + expect(document.activeElement).toBe(editor); + }); + + it("counts dismissing the nudge with Escape as a soft dismissal", async () => { + const nudge = renderArmed(); + await settle(); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + await settle(); + + // Still armed, but muted: the ask is deferred rather than abandoned. + expect(nudge.state.status).toBe("armed"); + expect(nudge.state.mutedUntil).toBeGreaterThan(Date.now()); + }); + + it("retires the nudge when the footer link is clicked, without opening the popover", async () => { + // Armed, and clicked inside the grace period: the pending auto-open must be + // cancelled rather than firing at someone already on their way to the repo. + const nudge = renderArmed(); + screen.getByText("Star us on GitHub").closest("a")!.click(); + await settle(); + + expect(screen.queryByText("Enjoying Rill?")).not.toBeInTheDocument(); + expect(nudge.state.status).toBe("done"); + expect(nudge.state.mutedUntil).toBeUndefined(); + }); + + it("records an opt-out as terminal without spending a dismissal", async () => { + const nudge = renderArmed(); + await settle(); + + screen.getByText("Don't show again").click(); + await settle(); + + expect(nudge.state.status).toBe("done"); + expect(nudge.state.mutedUntil).toBeUndefined(); + }); + + it("records the star click as terminal without spending a dismissal", async () => { + const nudge = renderArmed(); + await settle(); + + screen.getByText("Star on GitHub").click(); + await settle(); + + expect(nudge.state.status).toBe("done"); + expect(nudge.state.mutedUntil).toBeUndefined(); + }); + + it("does not re-open unprompted after being dismissed", async () => { + renderArmed(); + await settle(); + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + await settle(); + await settle(); + + expect(screen.queryByText("Enjoying Rill?")).not.toBeInTheDocument(); + }); +}); diff --git a/web-common/src/features/github-star/GithubStarButton.svelte b/web-common/src/features/github-star/GithubStarButton.svelte new file mode 100644 index 000000000000..a077ae9b7d1f --- /dev/null +++ b/web-common/src/features/github-star/GithubStarButton.svelte @@ -0,0 +1,177 @@ + + +{#if !$adminServer} + + +
+ +
+ +
+ {m.github_star_footer_label()} +
+
+ + + + e.preventDefault()} + onCloseAutoFocus={(e: Event) => e.preventDefault()} + > + +
+
+
+ +
+
+

+ {m.github_star_title()} +

+

+ {m.github_star_message()} +

+
+
+
+ + +
+
+
+
+{/if} + + diff --git a/web-common/src/features/github-star/github-star.svelte.spec.ts b/web-common/src/features/github-star/github-star.svelte.spec.ts new file mode 100644 index 000000000000..ea848c90b506 --- /dev/null +++ b/web-common/src/features/github-star/github-star.svelte.spec.ts @@ -0,0 +1,150 @@ +import { SvelteLocalStorage } from "@rilldata/web-common/lib/store-utils/svelte-local-storage.svelte.ts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { GithubStarNudge } from "./github-star.svelte"; + +const DAY_MS = 24 * 60 * 60 * 1000; +const START = new Date("2026-08-04T12:00:00Z").getTime(); + +/** Sets the clock to `days` after the start of the test. */ +function atDay(days: number) { + vi.setSystemTime(START + days * DAY_MS); +} + +/** A fresh nudge that re-reads localStorage, standing in for an app reload. */ +function reload() { + SvelteLocalStorage.clearInstanceCache(); + return new GithubStarNudge(); +} + +describe("GithubStarNudge", () => { + beforeEach(() => { + localStorage.clear(); + SvelteLocalStorage.clearInstanceCache(); + vi.useFakeTimers(); + vi.setSystemTime(START); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("stays hidden until a payoff arms it", () => { + const nudge = new GithubStarNudge(); + expect(nudge.visible).toBe(false); + + nudge.armPayoff(); + expect(nudge.visible).toBe(true); + }); + + it("persists the armed state across reloads", () => { + new GithubStarNudge().armPayoff(); + expect(reload().visible).toBe(true); + }); + + it("treats a star click as terminal", () => { + const nudge = new GithubStarNudge(); + nudge.armPayoff(); + nudge.recordStar(); + expect(nudge.visible).toBe(false); + + // Neither a later payoff nor the passage of time may resurrect it. + nudge.armPayoff(); + expect(nudge.visible).toBe(false); + atDay(365); + expect(reload().visible).toBe(false); + }); + + it("treats an explicit opt-out as terminal", () => { + const nudge = new GithubStarNudge(); + nudge.armPayoff(); + nudge.recordOptOut(); + + atDay(365); + expect(reload().visible).toBe(false); + }); + + it("mutes a soft dismissal until the following day", () => { + const nudge = new GithubStarNudge(); + nudge.armPayoff(); + nudge.recordSoftDismiss(); + + expect(nudge.state.status).toBe("armed"); + expect(nudge.visible).toBe(false); + atDay(0.99); + expect(reload().visible).toBe(false); + atDay(1); + expect(reload().visible).toBe(true); + }); + + it("continues asking daily until the user stars or opts out", () => { + new GithubStarNudge().armPayoff(); + + let asks = 0; + for (let day = 0; day < 400; day++) { + atDay(day); + const nudge = reload(); + if (nudge.visible) { + asks++; + nudge.recordSoftDismiss(); + } + } + + expect(asks).toBe(400); + expect(reload().state.status).toBe("armed"); + }); + + it("ignores a soft dismissal while muted", () => { + const nudge = new GithubStarNudge(); + nudge.armPayoff(); + nudge.recordSoftDismiss(); + const mutedUntil = nudge.state.mutedUntil; + + // A dismissal that could not have been seen must not extend the mute. + nudge.recordSoftDismiss(); + expect(nudge.state.mutedUntil).toBe(mutedUntil); + }); + + it("ignores a soft dismissal that was never armed", () => { + const nudge = new GithubStarNudge(); + nudge.recordSoftDismiss(); + + expect(nudge.state.status).toBe("unarmed"); + }); + + it("does not re-arm on a second payoff once muted", () => { + const nudge = new GithubStarNudge(); + nudge.armPayoff(); + nudge.recordSoftDismiss(); + + nudge.armPayoff(); + expect(nudge.visible).toBe(false); + }); + + it("treats corrupt stored state as unarmed", () => { + localStorage.setItem("rill:github-star", "{not json"); + expect(reload().visible).toBe(false); + + localStorage.setItem("rill:github-star", JSON.stringify({ nonsense: 1 })); + expect(reload().visible).toBe(false); + }); + + it("degrades to in-memory when localStorage throws", () => { + const setItem = vi + .spyOn(Storage.prototype, "setItem") + .mockImplementation(() => { + throw new DOMException("denied", "SecurityError"); + }); + const getItem = vi + .spyOn(Storage.prototype, "getItem") + .mockImplementation(() => { + throw new DOMException("denied", "SecurityError"); + }); + + const nudge = new GithubStarNudge(); + expect(() => nudge.armPayoff()).not.toThrow(); + expect(nudge.visible).toBe(true); + + setItem.mockRestore(); + getItem.mockRestore(); + }); +}); diff --git a/web-common/src/features/github-star/github-star.svelte.ts b/web-common/src/features/github-star/github-star.svelte.ts new file mode 100644 index 000000000000..68fae386fd7e --- /dev/null +++ b/web-common/src/features/github-star/github-star.svelte.ts @@ -0,0 +1,79 @@ +import { SvelteLocalStorage } from "@rilldata/web-common/lib/store-utils/svelte-local-storage.svelte.ts"; +import type { RuneStore } from "@rilldata/web-common/lib/store-utils/types.svelte.ts"; + +export const GITHUB_STAR_URL = "https://github.com/rilldata/rill"; + +const STORAGE_KEY = "rill:github-star"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export type GithubStarStatus = "unarmed" | "armed" | "done"; + +export interface GithubStarState { + status: GithubStarStatus; + /** While in the future, an armed nudge stays hidden. */ + mutedUntil?: number; +} + +const INITIAL_STATE: GithubStarState = { status: "unarmed" }; + +/** + * Tracks whether to nudge the user to star Rill on GitHub. + * The nudge is armed by a dashboard render. + * A soft dismissal mutes it for one day; only starring or opting out retires it. + */ +export class GithubStarNudge { + public constructor( + private readonly store: RuneStore = SvelteLocalStorage.createJsonStore( + STORAGE_KEY, + INITIAL_STATE, + ), + ) {} + + public get state() { + return this.store.value; + } + + /** + * Whether the nudge should be shown right now. + * Recomputed whenever the state changes; re-reading on each app load is ample + * granularity for the one-day mute to expire. + */ + public get visible() { + const { status, mutedUntil } = this.store.value; + if (status !== "armed") return false; + return !mutedUntil || mutedUntil <= Date.now(); + } + + /** Called when the user reaches a payoff moment: a dashboard rendered. */ + public armPayoff() { + const { status } = this.store.value; + if (status === "armed" || status === "done") return; + this.store.setter({ ...this.store.value, status: "armed" }); + } + + /** The user starred. Terminal. */ + public recordStar() { + this.store.setter({ ...this.store.value, status: "done" }); + } + + /** The user clicked "Don't show again". Terminal. */ + public recordOptOut() { + this.store.setter({ ...this.store.value, status: "done" }); + } + + /** + * A non-terminal exit, such as Escape, X, or click-outside, mutes the nudge + * until the following day. + */ + public recordSoftDismiss() { + if (!this.visible) return; + + this.store.setter({ + ...this.store.value, + mutedUntil: Date.now() + DAY_MS, + }); + } +} + +export const githubStarNudge = new GithubStarNudge(); diff --git a/web-common/src/layout/navigation/Footer.svelte b/web-common/src/layout/navigation/Footer.svelte index 050d80f86eb7..68e942f3e668 100644 --- a/web-common/src/layout/navigation/Footer.svelte +++ b/web-common/src/layout/navigation/Footer.svelte @@ -10,6 +10,7 @@ import { fly } from "svelte/transition"; import { createLocalServiceGetMetadata } from "@rilldata/web-common/runtime-client/local-service"; import RuntimeTrafficLights from "@rilldata/web-common/features/entity-management/RuntimeTrafficLights.svelte"; + import GithubStarButton from "@rilldata/web-common/features/github-star/GithubStarButton.svelte"; const metadataQuery = createLocalServiceGetMetadata(); @@ -51,6 +52,7 @@ {/each} +
return store; } + /** + * Drops the cache so that the next `getInstance` re-reads localStorage. + * Instances already handed out keep the store they were given. + */ + public static clearInstanceCache() { + this.stores.clear(); + } + + /** For values that round-trip through JSON, such as objects and records. */ + public static createJsonStore(key: string, defaultVal: Val) { + return SvelteLocalStorage.getInstance( + key, + (value: Val) => JSON.stringify(value), + (value) => (value ? (JSON.parse(value) as Val) : defaultVal), + defaultVal, + ); + } + public static createStringArrayStore(key: string) { return new ArrayRuneStore( SvelteLocalStorage.getInstance(