diff --git a/bun.lock b/bun.lock index 4336747ab3..bb71533931 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,7 @@ "@codemirror/search": "^6.7.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.43.0", - "@faker-js/faker": "^9.9.0", + "@faker-js/faker": "^10.6.0", "@lezer/highlight": "^1.2.3", "@plausible-analytics/tracker": "^0.4.5", "@popperjs/core": "^2.11.8", @@ -95,7 +95,7 @@ "devalue": "^5.8.1", "flatted": "^3.4.2", "immutable": "^5.1.8", - "js-yaml": "^4.3.1", + "js-yaml": "^4.3.2", "minimatch": "10.2.3", "nanoid": "^3.3.18", "picomatch": "^4.0.4", @@ -229,7 +229,7 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], - "@faker-js/faker": ["@faker-js/faker@9.9.0", "", {}, "sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA=="], + "@faker-js/faker": ["@faker-js/faker@10.6.0", "", {}, "sha512-3RQHgEtvL1Frl/d1cSreo7qhJ3Gk1OdNUai/CtZ8G+wYeRQnJih3s9xJ9/kgYekPQRdwgh0HXRPqMlzWGwivIQ=="], "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], @@ -1025,7 +1025,7 @@ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="], + "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], "jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="], diff --git a/e2e/steps/account.ts b/e2e/steps/account.ts index 5a74a736ee..7e3eb9b458 100644 --- a/e2e/steps/account.ts +++ b/e2e/steps/account.ts @@ -1,4 +1,4 @@ -import { test, type Page } from '@playwright/test'; +import { test, expect, type Page } from '@playwright/test'; type Metadata = { name: string; @@ -27,6 +27,8 @@ export function registerUserStep(page: Page): Promise { await inputs.terms.check({ force: true }); await page.getByRole('button', { name: 'Sign up', exact: true }).click(); await page.waitForURL('./onboarding/create-project'); + await expect(page.getByRole('button', { name: 'Create', exact: true })).toBeVisible(); + await expect(page.getByRole('dialog', { name: 'The new Appwrite Console' })).toHaveCount(0); return values; }); diff --git a/e2e/steps/free-project.ts b/e2e/steps/free-project.ts index b75791f103..d37b0f09e8 100644 --- a/e2e/steps/free-project.ts +++ b/e2e/steps/free-project.ts @@ -1,5 +1,6 @@ import { test, expect, type Page } from '@playwright/test'; import { getOrganizationIdFromUrl, getProjectIdFromUrl } from '../helpers/url'; +import { dismissNewConsolePromotion } from './new-console'; type Metadata = { id: string; @@ -10,6 +11,7 @@ export async function createFreeProject(page: Page): Promise { const organizationId = await test.step('create organization', async () => { await page.goto('./'); await page.waitForURL(/\/organization-[^/]+/); + await dismissNewConsolePromotion(page); return getOrganizationIdFromUrl(page.url()); }); diff --git a/e2e/steps/new-console.ts b/e2e/steps/new-console.ts new file mode 100644 index 0000000000..d597922b1d --- /dev/null +++ b/e2e/steps/new-console.ts @@ -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); + }); +} diff --git a/e2e/steps/pro-project.ts b/e2e/steps/pro-project.ts index 53208c2953..4aeb4b626c 100644 --- a/e2e/steps/pro-project.ts +++ b/e2e/steps/pro-project.ts @@ -1,5 +1,6 @@ import { test, expect, type Page } from '@playwright/test'; import { getOrganizationIdFromUrl, getProjectIdFromUrl } from '../helpers/url'; +import { dismissNewConsolePromotion } from './new-console'; type Metadata = { id: string; @@ -34,6 +35,7 @@ export async function enterCreditCard(page: Page) { export async function createProProject(page: Page): Promise { const organizationId = await test.step('create organization', async () => { await page.goto('./create-organization'); + await dismissNewConsolePromotion(page); await page.locator('id=name').fill('test org'); await page.getByRole('radio', { name: /^Pro\b/ }).check(); // `create organization` because there's already free created on start! diff --git a/package.json b/package.json index 74da8a60d8..42c480235a 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "@codemirror/search": "^6.7.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.43.0", - "@faker-js/faker": "^9.9.0", + "@faker-js/faker": "^10.6.0", "@lezer/highlight": "^1.2.3", "@plausible-analytics/tracker": "^0.4.5", "@popperjs/core": "^2.11.8", @@ -104,7 +104,7 @@ "vite": "npm:rolldown-vite@latest", "minimatch": "10.2.3", "brace-expansion": ">=5.0.9", - "js-yaml": "^4.3.1", + "js-yaml": "^4.3.2", "immutable": "^5.1.8", "flatted": "^3.4.2", "devalue": "^5.8.1", diff --git a/src/lib/components/index.ts b/src/lib/components/index.ts index 84a530f097..0542458bce 100644 --- a/src/lib/components/index.ts +++ b/src/lib/components/index.ts @@ -77,6 +77,7 @@ export { default as ImagePreview } from './imagePreview.svelte'; export { default as MfaChallengeFormList } from './mfaChallengeFormList.svelte'; export { default as BottomModalAlert } from './bottomModalAlert.svelte'; export { default as NewConsoleBanner } from './newConsoleBanner.svelte'; +export { default as NewConsoleModal } from './newConsoleModal.svelte'; export { default as Navbar } from './navbar.svelte'; export { default as Breadcrumbs } from './breadcrumbs.svelte'; export { default as Sidebar } from './sidebar.svelte'; diff --git a/src/lib/components/newConsoleBanner.svelte b/src/lib/components/newConsoleBanner.svelte index dc1f4be158..b0c8c3f5a5 100644 --- a/src/lib/components/newConsoleBanner.svelte +++ b/src/lib/components/newConsoleBanner.svelte @@ -2,7 +2,7 @@ import { trackEvent } from '$lib/actions/analytics'; import { Button } from '$lib/elements/forms'; import { Layout, Typography } from '@appwrite.io/pink-svelte'; - import { isTabletViewport } from '$lib/stores/viewport'; + import { isSmallViewport } from '$lib/stores/viewport'; import { hideNotification } from '$lib/helpers/notifications'; import { headerAlert } from '$lib/stores/headerAlert'; import { activeHeaderAlert } from '$routes/(console)/store'; @@ -35,8 +35,8 @@ alignItems="center" alignContent="center" justifyContent="center" - direction={$isTabletViewport ? 'column' : 'row'}> - + direction={$isSmallViewport ? 'column' : 'row'}> + Introducing the new Appwrite Console, rebuilt from the ground up. diff --git a/src/lib/components/newConsoleModal.svelte b/src/lib/components/newConsoleModal.svelte new file mode 100644 index 0000000000..8ecf311d53 --- /dev/null +++ b/src/lib/components/newConsoleModal.svelte @@ -0,0 +1,242 @@ + + +{#if show && !isOnOnboarding} + { + 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'); + }}> +
+ + + + +
+

The new Appwrite Console

+

+ Faster, redesigned, and everything you're working on comes with you. Nothing to + migrate. +

+ +
+ + + {SUNSET_NOTE} You can switch over any time from the top bar. + + +
+
+ +
+ { + trackEvent('click_new_console', { source: 'new_console_modal' }); + close('try'); + }}> + Take me to the new Console + + + + close('continue')}>Stay here for now +
+
+
+{/if} + + diff --git a/src/lib/components/newConsoleModal.svelte.test.ts b/src/lib/components/newConsoleModal.svelte.test.ts new file mode 100644 index 0000000000..873292f3a7 --- /dev/null +++ b/src/lib/components/newConsoleModal.svelte.test.ts @@ -0,0 +1,328 @@ +import '@testing-library/jest-dom/vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/svelte'; +import { page } from '$app/state'; +import { trackEvent } from '$lib/actions/analytics'; +import { hideNotification, shouldShowNotification } from '$lib/helpers/notifications'; +import { sdk } from '$lib/stores/sdk'; +import { user, type Account } from '$lib/stores/user'; +import { get, type Writable } from 'svelte/store'; +import NewConsoleModal from './newConsoleModal.svelte'; + +vi.mock('$lib/actions/analytics', () => ({ trackEvent: vi.fn() })); +vi.mock('$lib/stores/sdk', async () => { + const { user } = await import('$lib/stores/user'); + return { + sdk: { + forConsole: { + account: { + updatePrefs: vi.fn(async ({ prefs }) => { + const savedPrefs = structuredClone(prefs); + (user as Writable).update((account) => ({ + ...account, + prefs: savedPrefs + })); + return savedPrefs; + }) + } + } + } + }; +}); +vi.mock('$lib/stores/user', async () => { + const { writable } = await import('svelte/store'); + return { user: writable(null) }; +}); +vi.mock('$lib/commandCenter', async () => { + const { readable } = await import('svelte/store'); + return { disableCommands: readable(vi.fn()) }; +}); +vi.mock('$app/state', async () => { + const { fromStore, writable } = await import('svelte/store'); + const url = fromStore(writable(new URL('https://cloud.appwrite.io/console'))); + + return { + page: { + get url() { + return url.current; + }, + set url(value) { + url.current = value; + } + } + }; +}); +const showModal = vi.fn(function (this: HTMLDialogElement) { + this.open = true; +}); +const closeDialog = vi.fn(function (this: HTMLDialogElement) { + this.open = false; +}); + +function setAccount() { + (user as Writable).set({ + prefs: { theme: 'dark', notificationPrefs: {} } + } as unknown as Account); +} + +function setDate(date: string) { + vi.mocked(Date.now).mockReturnValue(Date.parse(`${date}T00:00:00Z`)); +} + +function navigate(path: string) { + return act(() => { + Object.assign(page, { url: new URL(path, 'https://cloud.appwrite.io') }); + }); +} + +function expectSnoozed(action: 'continue' | 'try') { + expect(sdk.forConsole.account.updatePrefs).toHaveBeenCalledOnce(); + expect(shouldShowNotification('newConsoleModal')).toBe(false); + expect(trackEvent).toHaveBeenCalledWith('close_new_console_modal', { + source: 'new_console_modal', + action + }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(closeDialog).toHaveBeenCalledOnce(); +} + +describe('new Console modal', () => { + beforeAll(() => { + // jsdom has no dialog API. These shims only expose open/closed state; native focus + // containment, restoration, and Escape delivery require a real browser. + Object.defineProperties(HTMLDialogElement.prototype, { + showModal: { configurable: true, value: showModal }, + close: { configurable: true, value: closeDialog } + }); + }); + + beforeEach(async () => { + vi.spyOn(Date, 'now'); + setDate('2026-09-01'); + setAccount(); + await navigate('/console'); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + afterAll(() => { + delete HTMLDialogElement.prototype.showModal; + delete HTMLDialogElement.prototype.close; + }); + + it('does not write preferences or analytics when mounted hidden', () => { + render(NewConsoleModal); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(showModal).not.toHaveBeenCalled(); + expect(sdk.forConsole.account.updatePrefs).not.toHaveBeenCalled(); + expect(trackEvent).not.toHaveBeenCalled(); + }); + + it('opens an accessible modal without snoozing it', () => { + render(NewConsoleModal, { show: true }); + + expect(screen.getByRole('dialog', { name: 'The new Appwrite Console' })).toHaveAttribute( + 'open' + ); + expect(showModal).toHaveBeenCalledOnce(); + expect(sdk.forConsole.account.updatePrefs).not.toHaveBeenCalled(); + expect(trackEvent).not.toHaveBeenCalled(); + }); + + it.each(['Close', 'Stay here for now'])('snoozes once when clicking %s', async (name) => { + render(NewConsoleModal, { show: true }); + + await fireEvent.click(screen.getByRole('button', { name })); + + expectSnoozed('continue'); + expect(trackEvent).toHaveBeenCalledOnce(); + }); + + it('snoozes once when Escape requests cancellation', async () => { + render(NewConsoleModal, { show: true }); + const cancel = new Event('cancel', { cancelable: true }); + + await fireEvent(screen.getByRole('dialog'), cancel); + + expect(cancel.defaultPrevented).toBe(true); + expectSnoozed('continue'); + expect(trackEvent).toHaveBeenCalledOnce(); + }); + + it('keeps Escape from reaching background keyboard handlers without preventing cancellation', async () => { + const backgroundKeydown = vi.fn(); + window.addEventListener('keydown', backgroundKeydown); + render(NewConsoleModal, { show: true }); + const escape = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true + }); + + try { + await fireEvent(screen.getByRole('button', { name: 'Close' }), escape); + + expect(backgroundKeydown).not.toHaveBeenCalled(); + expect(escape.defaultPrevented).toBe(false); + expect(sdk.forConsole.account.updatePrefs).not.toHaveBeenCalled(); + } finally { + window.removeEventListener('keydown', backgroundKeydown); + } + }); + + it('restores focus to the previously focused control when dismissed', async () => { + const previous = document.createElement('button'); + document.body.append(previous); + previous.focus(); + render(NewConsoleModal, { show: true }); + const dismiss = screen.getByRole('button', { name: 'Close' }); + dismiss.focus(); + + try { + await fireEvent.click(dismiss); + + expect(previous).toHaveFocus(); + } finally { + previous.remove(); + } + }); + + it('snoozes on a backdrop click but ignores clicks within the dialog content', async () => { + render(NewConsoleModal, { show: true }); + await fireEvent.click(screen.getByRole('heading', { name: 'The new Appwrite Console' })); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(sdk.forConsole.account.updatePrefs).not.toHaveBeenCalled(); + + await fireEvent.click(screen.getByRole('dialog')); + + expectSnoozed('continue'); + expect(trackEvent).toHaveBeenCalledOnce(); + }); + + it('tracks the new Console link and snoozes once when following it', async () => { + render(NewConsoleModal, { show: true }); + const link = screen.getByRole('link', { name: 'Take me to the new Console' }); + const destination = new URL(link.getAttribute('href')); + expect(destination.origin).toBe('https://appwrite.io'); + expect(destination.searchParams.get('utm_medium')).toBe('modal'); + expect(link).toHaveAttribute('target', '_blank'); + + await fireEvent.click(link); + + expectSnoozed('try'); + expect(trackEvent).toHaveBeenCalledWith('click_new_console', { + source: 'new_console_modal' + }); + expect(trackEvent).toHaveBeenCalledTimes(2); + }); + + it('does not double the snooze when two dismissal events arrive together', async () => { + render(NewConsoleModal, { show: true }); + const dialog = screen.getByRole('dialog'); + const dismiss = screen.getByRole('button', { name: 'Close' }); + + await act(() => { + dismiss.click(); + dialog.dispatchEvent(new Event('cancel', { cancelable: true })); + }); + + expectSnoozed('continue'); + expect(trackEvent).toHaveBeenCalledOnce(); + }); + + it('returns after 30 days, then waits 60 days after another dismissal', async () => { + const firstVisit = render(NewConsoleModal, { + show: shouldShowNotification('newConsoleModal') + }); + + await fireEvent.click(screen.getByRole('button', { name: 'Close' })); + expectSnoozed('continue'); + firstVisit.unmount(); + + setDate('2026-09-30'); + expect(shouldShowNotification('newConsoleModal')).toBe(false); + setDate('2026-10-01'); + const eligibleAgain = shouldShowNotification('newConsoleModal'); + expect(eligibleAgain).toBe(true); + + vi.mocked(sdk.forConsole.account.updatePrefs).mockClear(); + vi.mocked(trackEvent).mockClear(); + closeDialog.mockClear(); + render(NewConsoleModal, { show: eligibleAgain }); + await fireEvent.click(screen.getByRole('button', { name: 'Stay here for now' })); + expectSnoozed('continue'); + + setDate('2026-11-01'); + expect(shouldShowNotification('newConsoleModal')).toBe(false); + setDate('2026-11-29'); + expect(shouldShowNotification('newConsoleModal')).toBe(false); + setDate('2026-11-30'); + expect(shouldShowNotification('newConsoleModal')).toBe(true); + }); + + it('preserves unrelated preferences and notification dismissals', async () => { + hideNotification('newConsoleBanner'); + const bannerPreference = structuredClone( + get(user).prefs.notificationPrefs.newConsoleBanner + ); + vi.mocked(sdk.forConsole.account.updatePrefs).mockClear(); + render(NewConsoleModal, { show: true }); + + await fireEvent.click(screen.getByRole('button', { name: 'Close' })); + + expectSnoozed('continue'); + expect(get(user).prefs.theme).toBe('dark'); + expect(get(user).prefs.notificationPrefs.newConsoleBanner).toEqual(bannerPreference); + expect(shouldShowNotification('newConsoleBanner')).toBe(false); + }); + + it('closes silently when the parent hides it and can be shown again', async () => { + const { rerender } = render(NewConsoleModal, { show: true }); + + await rerender({ show: false }); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(closeDialog).toHaveBeenCalledOnce(); + expect(sdk.forConsole.account.updatePrefs).not.toHaveBeenCalled(); + expect(trackEvent).not.toHaveBeenCalled(); + + await rerender({ show: true }); + + expect(screen.getByRole('dialog')).toHaveAttribute('open'); + expect(showModal).toHaveBeenCalledTimes(2); + }); + + it.each(['/console/onboarding', '/console/onboarding/create-organization'])( + 'stays hidden on initial onboarding route %s, then opens after leaving', + async (path) => { + await navigate(path); + render(NewConsoleModal, { show: true }); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(showModal).not.toHaveBeenCalled(); + expect(sdk.forConsole.account.updatePrefs).not.toHaveBeenCalled(); + + await navigate('/console/organization-example'); + + expect(screen.getByRole('dialog')).toHaveAttribute('open'); + expect(sdk.forConsole.account.updatePrefs).not.toHaveBeenCalled(); + expect(trackEvent).not.toHaveBeenCalled(); + } + ); + + it('closes without snoozing when navigating into onboarding', async () => { + render(NewConsoleModal, { show: true }); + + await navigate('/console/onboarding'); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(closeDialog).toHaveBeenCalledOnce(); + expect(sdk.forConsole.account.updatePrefs).not.toHaveBeenCalled(); + expect(trackEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/images/promos/new-console-modal.png b/src/lib/images/promos/new-console-modal.png new file mode 100644 index 0000000000..1e4288e0b9 Binary files /dev/null and b/src/lib/images/promos/new-console-modal.png differ diff --git a/src/routes/(console)/+layout.svelte b/src/routes/(console)/+layout.svelte index b5b2c612ae..ccf19c34f2 100644 --- a/src/routes/(console)/+layout.svelte +++ b/src/routes/(console)/+layout.svelte @@ -44,7 +44,7 @@ import { headerAlert } from '$lib/stores/headerAlert'; import { UsageRates } from '$lib/components/billing'; import { canSeeProjects } from '$lib/stores/roles'; - import { BottomModalAlert, NewConsoleBanner } from '$lib/components'; + import { BottomModalAlert, NewConsoleBanner, NewConsoleModal } from '$lib/components'; import { shouldShowNotification } from '$lib/helpers/notifications'; import { isSmallViewport } from '$lib/stores/viewport'; import { @@ -339,6 +339,13 @@ } }); + let showNewConsoleModal = false; + + onMount(() => { + // The modal reacts to onboarding navigation without recording a dismissal. + showNewConsoleModal = isCloud && shouldShowNotification('newConsoleModal'); + }); + $: (void $headerAlert, ($activeHeaderAlert = headerAlert.getExcluding('impersonation'))); @@ -375,3 +382,5 @@ {/if} + +