-
Notifications
You must be signed in to change notification settings - Fork 257
feat(console): offer the new Console in a dismissible modal #3195
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d981a55
a2a59bf
7784bb5
cda569b
dc7f4ed
306715d
39e1b14
cc3debb
c1f859f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import { test, expect, type Page } from '@playwright/test'; | ||
|
|
||
| export function dismissNewConsolePromotion(page: Page) { | ||
| return test.step('dismiss new Console promotion after onboarding', async () => { | ||
| const dialog = page.getByRole('dialog', { name: 'The new Appwrite Console' }); | ||
| await expect(dialog).toBeVisible(); | ||
| await dialog.getByRole('button', { name: 'Stay here for now' }).click(); | ||
| await expect(dialog).toHaveCount(0); | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,242 @@ | ||
| <script lang="ts"> | ||
| import { base } from '$app/paths'; | ||
| import { page } from '$app/state'; | ||
| import { trackEvent } from '$lib/actions/analytics'; | ||
| import { disableCommands } from '$lib/commandCenter'; | ||
| import { hideNotification } from '$lib/helpers/notifications'; | ||
| import NewConsoleCover from '$lib/images/promos/new-console-modal.png'; | ||
| import { Alert, Button, Icon, Typography } from '@appwrite.io/pink-svelte'; | ||
| import { IconArrowSmRight, IconX } from '@appwrite.io/pink-icons-svelte'; | ||
|
|
||
| let { show = $bindable(false) }: { show?: boolean } = $props(); | ||
| const isOnOnboarding = $derived( | ||
| page.url.pathname === `${base}/onboarding` || | ||
| page.url.pathname.startsWith(`${base}/onboarding/`) | ||
| ); | ||
|
|
||
| $effect(() => { | ||
| $disableCommands(show && !isOnOnboarding); | ||
| }); | ||
|
|
||
| // @todo: replace with the confirmed retirement date once it is set. | ||
| const SUNSET_NOTE = 'The old Console will be retired in the coming weeks.'; | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
|
|
||
| // utm_medium separates this from the banner and the promo card. | ||
| const href = | ||
| 'https://appwrite.io/?utm_source=old-console&utm_medium=modal&utm_campaign=new-console'; | ||
|
|
||
| // The most interruptive of the three surfaces, so it snoozes for a month rather than the | ||
| // banner's week, and still doubles on each dismissal. | ||
| const COOL_OFF_HOURS = 24 * 30; | ||
|
|
||
| let recorded = false; | ||
|
|
||
| function close(action: 'try' | 'continue') { | ||
| if (!show || recorded) return; | ||
|
|
||
| recorded = true; | ||
| trackEvent('close_new_console_modal', { source: 'new_console_modal', action }); | ||
| hideNotification('newConsoleModal', { | ||
| coolOffPeriod: COOL_OFF_HOURS, | ||
| exponentialBackoff: true | ||
| }); | ||
|
|
||
| show = false; | ||
| } | ||
|
Comment on lines
+34
to
+45
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The new modal has bespoke handling for Escape, backdrop clicks, action choices, one-time snoozing, and navigation behavior, but none of these observable paths is covered by a test. A later change could silently restore premature dismissal, double-snoozing, or navigation-triggered dismissal. Add behavior-level coverage that exercises user exits and verifies the resulting notification state rather than mirroring constants or source structure. Prompt To Fix With AIThis is a comment left during a code review.
Path: src/lib/components/newConsoleModal.svelte
Line: 24-39
Comment:
**Dismissal behavior is untested**
The new modal has bespoke handling for Escape, backdrop clicks, action choices, one-time snoozing, and navigation behavior, but none of these observable paths is covered by a test. A later change could silently restore premature dismissal, double-snoozing, or navigation-triggered dismissal. Add behavior-level coverage that exercises user exits and verifies the resulting notification state rather than mirroring constants or source structure.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly. |
||
|
|
||
| function openDialog(node: HTMLDialogElement) { | ||
| const previousFocus = document.activeElement; | ||
| recorded = false; | ||
| // Native modality contains keyboard focus and makes the rest of the page inert. | ||
| node.showModal(); | ||
|
|
||
| return { | ||
| destroy() { | ||
| node.close(); | ||
| // Also restore focus when Svelte removes the dialog during teardown. | ||
| if (previousFocus instanceof HTMLElement && previousFocus.isConnected) { | ||
| previousFocus.focus({ preventScroll: true }); | ||
| } | ||
| } | ||
| }; | ||
| } | ||
| </script> | ||
|
|
||
| {#if show && !isOnOnboarding} | ||
| <dialog | ||
| class="scrim" | ||
| aria-labelledby="new-console-title" | ||
| use:openDialog | ||
| oncancel={(event) => { | ||
| event.preventDefault(); | ||
| close('continue'); | ||
| }} | ||
| onkeydown={(event) => { | ||
| // Let the native cancel event close only this dialog, not a background wizard. | ||
| if (event.key === 'Escape') event.stopPropagation(); | ||
| }} | ||
| onclick={(event) => { | ||
| if (event.target === event.currentTarget) close('continue'); | ||
| }}> | ||
| <div class="dialog"> | ||
| <button | ||
| class="dismiss" | ||
| type="button" | ||
| aria-label="Close" | ||
| onclick={() => close('continue')}> | ||
| <Icon icon={IconX} size="s" /> | ||
| </button> | ||
|
|
||
| <img class="cover" src={NewConsoleCover} alt="" /> | ||
|
|
||
| <div class="body"> | ||
| <h2 id="new-console-title">The new Appwrite Console</h2> | ||
| <p class="lede"> | ||
| Faster, redesigned, and everything you're working on comes with you. Nothing to | ||
| migrate. | ||
| </p> | ||
|
|
||
| <div class="notice"> | ||
| <Alert.Inline status="info"> | ||
| <Typography.Text> | ||
| {SUNSET_NOTE} You can switch over any time from the top bar. | ||
| </Typography.Text> | ||
| </Alert.Inline> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="actions"> | ||
| <Button.Anchor | ||
| {href} | ||
| size="s" | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| onclick={() => { | ||
| trackEvent('click_new_console', { source: 'new_console_modal' }); | ||
| close('try'); | ||
| }}> | ||
| Take me to the new Console | ||
| <Icon icon={IconArrowSmRight} size="s" slot="end" /> | ||
| </Button.Anchor> | ||
|
|
||
| <Button.Button | ||
| type="button" | ||
| variant="text" | ||
| size="s" | ||
| onclick={() => close('continue')}>Stay here for now</Button.Button> | ||
| </div> | ||
| </div> | ||
| </dialog> | ||
| {/if} | ||
|
|
||
| <style lang="scss"> | ||
| .scrim { | ||
| box-sizing: border-box; | ||
| position: fixed; | ||
| inset: 0; | ||
| inline-size: 100%; | ||
| max-inline-size: 100%; | ||
| block-size: 100%; | ||
| max-block-size: 100%; | ||
| margin: 0; | ||
| border: none; | ||
| background: transparent; | ||
| padding: var(--space-6, 12px); | ||
| overflow-y: auto; | ||
| } | ||
|
|
||
| .scrim[open] { | ||
| display: grid; | ||
| place-items: center; | ||
| align-items: safe center; | ||
| } | ||
|
|
||
| .scrim::backdrop { | ||
| background: var(--overlay-scrim); | ||
| backdrop-filter: blur(4px); | ||
| } | ||
|
|
||
| .dialog { | ||
| position: relative; | ||
| inline-size: min(100%, 30rem); | ||
| overflow: hidden; | ||
| outline: none; | ||
| border-radius: var(--border-radius-m, 12px); | ||
| border: var(--border-width-s, 1px) solid var(--border-neutral); | ||
| background: var(--bgcolor-neutral-primary); | ||
| box-shadow: 0 24px 64px rgb(0 0 0 / 24%); | ||
| } | ||
|
|
||
| /* The source is a wide 2400x1260 capture, so it is cropped rather than letterboxed. */ | ||
| .cover { | ||
| display: block; | ||
| inline-size: 100%; | ||
| block-size: 11rem; | ||
| object-fit: cover; | ||
| object-position: center; | ||
| border-block-end: var(--border-width-s, 1px) solid var(--border-neutral); | ||
| } | ||
|
|
||
| .body { | ||
| padding: var(--space-9, 24px) var(--space-9, 24px) 0; | ||
| } | ||
|
|
||
| h2 { | ||
| margin: 0 0 var(--space-3, 6px); | ||
| font-size: var(--font-size-l, 20px); | ||
| font-weight: 600; | ||
| line-height: 1.3; | ||
| color: var(--fgcolor-neutral-primary); | ||
| } | ||
|
|
||
| .lede { | ||
| margin: 0; | ||
| font-size: var(--font-size-s, 14px); | ||
| line-height: 1.6; | ||
| color: var(--fgcolor-neutral-secondary); | ||
| text-wrap: pretty; | ||
| } | ||
|
|
||
| .notice { | ||
| margin-block-start: var(--space-7, 16px); | ||
| } | ||
|
|
||
| .actions { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: var(--space-4, 8px); | ||
| padding: var(--space-8, 20px) var(--space-9, 24px) var(--space-9, 24px); | ||
| } | ||
|
|
||
| .dismiss { | ||
| position: absolute; | ||
| z-index: 2; | ||
| inset-block-start: var(--space-5, 10px); | ||
| inset-inline-end: var(--space-5, 10px); | ||
| display: grid; | ||
| place-items: center; | ||
| inline-size: 28px; | ||
| block-size: 28px; | ||
| border: none; | ||
| border-radius: var(--border-radius-xs, 6px); | ||
| background: rgb(0 0 0 / 35%); | ||
| color: #fff; | ||
| cursor: pointer; | ||
|
|
||
| &:hover { | ||
| background: rgb(0 0 0 / 55%); | ||
| } | ||
|
|
||
| &:focus-visible { | ||
| outline: 2px solid var(--border-accent); | ||
| outline-offset: 2px; | ||
| } | ||
| } | ||
|
|
||
| @media (max-width: 768px) { | ||
| .actions { | ||
| flex-direction: column; | ||
| align-items: stretch; | ||
| } | ||
| } | ||
| </style> | ||
Uh oh!
There was an error while loading. Please reload this page.