Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions web-common/src/features/dashboards/workspace/Dashboard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
181 changes: 181 additions & 0 deletions web-common/src/features/github-star/GithubStarButton.spec.ts
Original file line number Diff line number Diff line change
@@ -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<GithubStarState>({ 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();
});
});
177 changes: 177 additions & 0 deletions web-common/src/features/github-star/GithubStarButton.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
<script lang="ts">
import Button from "@rilldata/web-common/components/button/Button.svelte";
import Github from "@rilldata/web-common/components/icons/Github.svelte";
import {
Popover,
PopoverContent,
} from "@rilldata/web-common/components/popover";
import { featureFlags } from "@rilldata/web-common/features/feature-flags";
import { m } from "@rilldata/web-common/lib/i18n/gen/messages";
import {
GITHUB_STAR_URL,
githubStarNudge,
type GithubStarNudge,
} from "./github-star.svelte";

/** Injectable so tests can supply a nudge backed by their own store. */
let { nudge = githubStarNudge }: { nudge?: GithubStarNudge } = $props();

/** Grace period so the nudge does not appear mid route transition. */
const AUTO_OPEN_DELAY_MS = 1500;

// This is a Rill Developer feature only: asking a Cloud viewer or a paying
// customer to star the repo is off-brand. Today the footer is not mounted on
// Cloud at all, but that is incidental, so gate it explicitly here rather than
// relying on a `showFooterLinks={false}` in an unrelated layout.
const { adminServer } = featureFlags;

let open = $state(false);
/**
* Whether a nudge is outstanding, i.e. the popover opened by itself and the user
* has not yet answered it. Cleared by the terminal actions so that the close they
* trigger is not also recorded as a dismissal.
*/
let nudging = $state(false);
/** Guards against re-opening unprompted more than once per page load. */
let autoOpened = false;
/** The popover has no trigger, so it anchors to the footer link instead. */
let anchor = $state<HTMLAnchorElement | null>(null);

// Opening a popover on a timer has no reactive equivalent, so this is one of the
// cases where an effect is the right tool.
$effect(() => {
if ($adminServer || autoOpened || !nudge.visible) return;

const timeout = setTimeout(() => {
autoOpened = true;
nudging = true;
open = true;
}, AUTO_OPEN_DELAY_MS);

return () => clearTimeout(timeout);
});

function handleOpenChange(next: boolean) {
if (next || !nudging) return;
// Every exit path counts as a soft dismissal: the X, a click outside, or
// navigating away. They are indistinguishable from here, and not counting
// silent ignores would re-ask engaged users at every mute expiry.
nudging = false;
nudge.recordSoftDismiss();
}

// Shared by the footer link and the popover's primary action: both send the user
// to the repo, so both retire the nudge.
function star() {
nudge.recordStar();
nudging = false;
open = false;
}

function optOut() {
nudge.recordOptOut();
nudging = false;
open = false;
}
</script>

{#if !$adminServer}
<!-- A plain link, like the sibling "Report an issue" row: clicking it goes
straight to the repo rather than opening the popover. -->
<a
bind:this={anchor}
href={GITHUB_STAR_URL}
target="_blank"
rel="noreferrer noopener"
onclick={star}
>
<div
class="flex flex-row items-center px-4 py-1 gap-x-2 text-fg-secondary font-normal hover:bg-popover-accent"
>
<!-- Matches the icon sizing workaround in Footer.svelte -->
<div
class="grid place-content-center"
style:width="16px"
style:height="16px"
>
<Github className="fill-fg-secondary" size="14px" />
</div>
{m.github_star_footer_label()}
</div>
</a>

<Popover bind:open onOpenChange={handleOpenChange}>
<!-- Nobody asked for this popover, so it must not take the keyboard. It behaves
like a notification rather than a dialog: no focus on open, no trap while open,
and no focus restore on close, since the user may have moved on since. All three
are needed; bits-ui focuses the content on mount regardless of `trapFocus`, and
leaving the trap on would drag focus back the moment they clicked away.
Escape and click-outside listen on the document, so dismissal still works. -->
<PopoverContent
customAnchor={anchor}
align="start"
side="top"
sideOffset={8}
class="github-star-popover w-[280px] overflow-hidden border-primary-200 bg-surface-overlay p-0 shadow-xl"
role="status"
trapFocus={false}
onOpenAutoFocus={(e: Event) => e.preventDefault()}
onCloseAutoFocus={(e: Event) => e.preventDefault()}
>
<div aria-hidden="true" class="h-1 bg-accent-primary"></div>
<div class="flex flex-col gap-y-4 p-4">
<div class="flex items-start gap-x-3">
<div
class="grid size-9 shrink-0 place-content-center rounded-full bg-primary-50 text-accent-primary-action"
>
<Github size="18px" className="fill-current" />
</div>
<div class="flex flex-col gap-y-1">
<h3 class="text-[15px] font-semibold leading-5 text-fg-primary">
{m.github_star_title()}
</h3>
<p class="text-xs leading-4 text-fg-secondary">
{m.github_star_message()}
</p>
</div>
</div>
<div class="flex flex-col gap-y-1">
<Button
type="primary"
wide
href={GITHUB_STAR_URL}
target="_blank"
rel="noreferrer noopener"
onClick={star}
>
<Github size="14px" className="fill-current" />
{m.github_star_cta()}
</Button>
<Button type="text" onClick={optOut}>
{m.github_star_dismiss()}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
{/if}

<style>
@media (prefers-reduced-motion: no-preference) {
:global(.github-star-popover[data-state="open"]) {
animation: github-star-popover-in 180ms ease-out;
transform-origin: bottom left;
}
}

@keyframes github-star-popover-in {
from {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
</style>
Loading
Loading