diff --git a/apps/web/src/components/admin/settings/appearance/__tests__/appearance-view.test.tsx b/apps/web/src/components/admin/settings/appearance/__tests__/appearance-view.test.tsx index c88f7cab1a..33bc7991e1 100644 --- a/apps/web/src/components/admin/settings/appearance/__tests__/appearance-view.test.tsx +++ b/apps/web/src/components/admin/settings/appearance/__tests__/appearance-view.test.tsx @@ -56,6 +56,12 @@ vi.mock('@/lib/server/functions/portal-design', () => ({ return { ok: true, revisionId: 'portal_design_revision_1', number: 1 } }, restorePortalDesignVersionFn: async () => ({ ok: true }), + getPortalDesignSwitchFn: async () => ({ + available: false, + canSwitchBack: false, + report: { errors: [], warnings: [], carried: [] }, + }), + switchBackPortalDesignFn: async () => ({ ok: true }), })) vi.mock('@/lib/client/analytics', () => ({ diff --git a/apps/web/src/components/admin/settings/appearance/__tests__/design-switch.test.tsx b/apps/web/src/components/admin/settings/appearance/__tests__/design-switch.test.tsx new file mode 100644 index 0000000000..a592c28bae --- /dev/null +++ b/apps/web/src/components/admin/settings/appearance/__tests__/design-switch.test.tsx @@ -0,0 +1,152 @@ +// @vitest-environment happy-dom +/** + * A portal with its own CSS is offered the switch on its old Settings › Portal + * page: the CSS report, today's portal beside the switched one, and Switch, + * held back while the CSS has refusals. Once switched, Versions offers the way + * back. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { DesignSwitch } from '@/lib/server/domains/design/switch' + +const server = vi.hoisted(() => ({ + invalidations: 0, + switches: 0, + switchesBack: 0, + outcome: { ok: true } as { ok: true } | { ok: false; code: string; message: string }, +})) + +vi.mock('@tanstack/react-router', () => ({ + useRouter: () => ({ + invalidate: async () => { + server.invalidations++ + }, + }), + ClientOnly: ({ children }: { children: ReactNode }) => <>{children}, +})) +vi.mock('@/components/admin/settings/branding/portal-preview', () => ({ + PortalPreview: ({ switched }: { switched: boolean }) => ( +
+ ), +})) +vi.mock('@/lib/server/functions/portal-design', () => ({ + getPortalDesignSwitchFn: async () => null, + listPortalDesignVersionsFn: async () => [], + switchPortalDesignFn: async () => { + server.switches++ + return server.outcome + }, + switchBackPortalDesignFn: async () => { + server.switchesBack++ + return server.outcome + }, +})) + +const { portalDesignQueries } = await import('@/lib/client/queries/portal-design') +const { DesignSwitchCard } = await import('../design-switch-card') +const { HistoryDialog } = await import('../history-dialog') + +const REPORT = { + errors: [] as Array<{ message: string; line?: number }>, + warnings: [{ message: '.portal-nav__item is internal and may change.', line: 3 }], + carried: [{ message: '--primary on :root becomes the accent.' }], +} + +function withClient(ui: ReactNode, state: DesignSwitch) { + const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity } } }) + client.setQueryData(portalDesignQueries.switch().queryKey, state) + client.setQueryData(portalDesignQueries.versions().queryKey, []) + render({ui}) +} + +const card = + +afterEach(() => { + cleanup() + server.invalidations = 0 + server.switches = 0 + server.switchesBack = 0 + server.outcome = { ok: true } +}) + +describe('the switch card', () => { + it('is absent when there is nothing to switch', () => { + withClient(card, { available: false, canSwitchBack: true, report: REPORT }) + expect(screen.queryByRole('button', { name: 'Switch' })).toBeNull() + }) + + it('reports the CSS and compares today with the portal switched', () => { + withClient(card, { available: true, canSwitchBack: false, report: REPORT }) + expect(screen.getByRole('list', { name: 'Moves into the design' }).textContent).toBe( + '--primary on :root becomes the accent.' + ) + expect(screen.getByRole('list', { name: 'Check after switching' }).textContent).toBe( + 'Line 3: .portal-nav__item is internal and may change.' + ) + expect(screen.queryAllByTestId('frame')).toEqual([]) + fireEvent.click(screen.getByRole('button', { name: 'Compare' })) + expect(screen.getAllByTestId('frame').map((frame) => frame.dataset.switched)).toEqual([ + 'false', + 'true', + ]) + }) + + it('switches and reloads the page as Appearance', async () => { + withClient(card, { available: true, canSwitchBack: false, report: REPORT }) + await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Switch' }))) + expect(server.switches).toBe(1) + expect(server.invalidations).toBe(1) + }) + + it('holds the switch while the CSS has refusals, and shows a refusal', async () => { + withClient(card, { + available: true, + canSwitchBack: false, + report: { ...REPORT, errors: [{ message: '@import is not allowed.', line: 1 }] }, + }) + expect( + screen.getByRole('list', { name: 'Fix in Advanced CSS before switching' }).textContent + ).toBe('Line 1: @import is not allowed.') + expect(screen.getByRole('button', { name: 'Switch' }).hasAttribute('disabled')).toBe(true) + + cleanup() + server.outcome = { ok: false, code: 'DESIGN_CHANGED', message: 'Already switched.' } + withClient(card, { available: true, canSwitchBack: false, report: REPORT }) + await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Switch' }))) + expect(screen.getByRole('alert').textContent).toBe('Already switched.') + expect(server.invalidations).toBe(0) + }) +}) + +describe('switching back', () => { + const dialog = ( + {}} + currentNumber={1} + onRestore={async () => ({ ok: true })} + /> + ) + + it('is offered once the portal has switched, and asks first', async () => { + withClient(dialog, { available: false, canSwitchBack: true, report: REPORT }) + fireEvent.click(await screen.findByRole('button', { name: 'Switch back' })) + expect(server.switchesBack).toBe(0) + const confirm = await screen.findByRole('alertdialog') + await act(async () => + fireEvent.click( + [...confirm.querySelectorAll('button')].find((b) => b.textContent === 'Switch back')! + ) + ) + expect(server.switchesBack).toBe(1) + await waitFor(() => expect(server.invalidations).toBe(1)) + }) + + it('is not offered to a portal that never switched', async () => { + withClient(dialog, { available: false, canSwitchBack: false, report: REPORT }) + await screen.findByRole('dialog') + expect(screen.queryByRole('button', { name: 'Switch back' })).toBeNull() + }) +}) diff --git a/apps/web/src/components/admin/settings/appearance/design-switch-card.tsx b/apps/web/src/components/admin/settings/appearance/design-switch-card.tsx new file mode 100644 index 0000000000..541a74c618 --- /dev/null +++ b/apps/web/src/components/admin/settings/appearance/design-switch-card.tsx @@ -0,0 +1,128 @@ +import { useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { ClientOnly, useRouter } from '@tanstack/react-router' +import { Button } from '@/components/ui/button' +import { PortalPreview } from '@/components/admin/settings/branding/portal-preview' +import { portalDesignQueries } from '@/lib/client/queries/portal-design' +import { switchPortalDesignFn } from '@/lib/server/functions/portal-design' +import type { PortalPreviewDraft } from '@/components/public/preview-draft-context' + +interface Issue { + message: string + line?: number +} + +const NO_DRAFT: PortalPreviewDraft = {} + +function Issues({ label, issues }: { label: string; issues: readonly Issue[] }) { + if (issues.length === 0) return null + return ( +
+

{label}

+
    + {issues.map((issue, index) => ( +
  • + {issue.line ? `Line ${issue.line}: ` : ''} + {issue.message} +
  • + ))} +
+
+ ) +} + +/** + * A portal with its own CSS stays on its old theme until someone switches. + * The card compares the portal as it is with the portal switched, reports + * what becomes of the CSS, and switches. Refused CSS has to be fixed in + * Advanced CSS first. + */ +export function DesignSwitchCard({ + workspaceName, + faviconUrl, +}: { + workspaceName: string + faviconUrl: string | null +}) { + const state = useQuery(portalDesignQueries.switch()) + const router = useRouter() + const queryClient = useQueryClient() + const [comparing, setComparing] = useState(false) + const [switching, setSwitching] = useState(false) + const [refusal, setRefusal] = useState(null) + if (!state.data?.available) return null + const { report } = state.data + const blocked = report.errors.length > 0 + + async function switchNow() { + setSwitching(true) + setRefusal(null) + const outcome = await switchPortalDesignFn() + if (outcome.ok) { + await queryClient.invalidateQueries({ queryKey: ['settings'] }) + await router.invalidate() + } else { + setRefusal(outcome.message) + } + setSwitching(false) + } + + const frame = (switched: boolean) => ( + + + + ) + + return ( +
+
+

+ Your portal uses custom CSS. Switch to the new design system. +

+ + +
+ {refusal && ( +

+ {refusal} +

+ )} + + + + {comparing && ( +
+
+
Today
+ {frame(false)} +
+
+
+ After switching +
+ {frame(true)} +
+
+ )} +
+ ) +} diff --git a/apps/web/src/components/admin/settings/appearance/history-dialog.tsx b/apps/web/src/components/admin/settings/appearance/history-dialog.tsx index ac18d17ab9..9dbe0a0b0c 100644 --- a/apps/web/src/components/admin/settings/appearance/history-dialog.tsx +++ b/apps/web/src/components/admin/settings/appearance/history-dialog.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' -import { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useRouter } from '@tanstack/react-router' import type { PortalDesignRevisionId } from '@quackback/ids' import { AlertDialog, @@ -21,6 +22,7 @@ import { } from '@/components/ui/dialog' import { TimeAgo } from '@/components/ui/time-ago' import { portalDesignQueries } from '@/lib/client/queries/portal-design' +import { switchBackPortalDesignFn } from '@/lib/server/functions/portal-design' const SOURCE: Record = { appearance: 'Appearance', @@ -52,6 +54,20 @@ export function HistoryDialog({ number: number } | null>(null) const [refusal, setRefusal] = useState(null) + // A portal that switched from its old theme can go back to it. + const switched = useQuery({ ...portalDesignQueries.switch(), enabled: open }) + const [confirmingBack, setConfirmingBack] = useState(false) + const router = useRouter() + const queryClient = useQueryClient() + + async function switchBack() { + const outcome = await switchBackPortalDesignFn() + setConfirmingBack(false) + if (!outcome.ok) return setRefusal(outcome.message) + onOpenChange(false) + await queryClient.invalidateQueries({ queryKey: ['settings'] }) + await router.invalidate() + } async function restore() { if (!confirming) return @@ -104,6 +120,14 @@ export function HistoryDialog({ ))} + {switched.data?.canSwitchBack && ( +
+ Switched from the old theme. + +
+ )} {refusal && (

{refusal} @@ -125,6 +149,21 @@ export function HistoryDialog({ + + + + Switch back to the old theme? + + Visitors see the old theme and its CSS straight away. The design and its versions + stay. + + + + Cancel + Switch back + + + ) } diff --git a/apps/web/src/components/admin/settings/branding/portal-preview.tsx b/apps/web/src/components/admin/settings/branding/portal-preview.tsx index cfa0c13598..ba89709927 100644 --- a/apps/web/src/components/admin/settings/branding/portal-preview.tsx +++ b/apps/web/src/components/admin/settings/branding/portal-preview.tsx @@ -48,6 +48,8 @@ interface PortalPreviewProps { } | null /** The page the frame shows: the root, Home or the feed at its own address. */ page?: PreviewPage + /** Draw the portal as it would be after switching to the design. */ + switched?: boolean } export type PreviewPage = 'root' | 'home' | 'feedback' @@ -90,6 +92,7 @@ export function PortalPreview({ scaleToDesktop = false, sort = 'trending', page = 'root', + switched = false, }: PortalPreviewProps) { const frameBox = useRef(null) const [boxWidth, setBoxWidth] = useState(0) @@ -189,7 +192,7 @@ export function PortalPreview({