Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }) => (
<div data-testid="frame" data-switched={String(switched)} />
),
}))
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(<QueryClientProvider client={client}>{ui}</QueryClientProvider>)
}

const card = <DesignSwitchCard workspaceName="Acme" faviconUrl={null} />

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 = (
<HistoryDialog
open
onOpenChange={() => {}}
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()
})
})
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-1">
<h3 className="text-xs font-medium">{label}</h3>
<ul aria-label={label} className="space-y-1 text-xs text-muted-foreground">
{issues.map((issue, index) => (
<li key={`${issue.line}-${index}`}>
{issue.line ? `Line ${issue.line}: ` : ''}
{issue.message}
</li>
))}
</ul>
</div>
)
}

/**
* 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<string | null>(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) => (
<ClientOnly>
<PortalPreview
theme="light"
refreshKey={switched ? 'switched' : 'today'}
draftCss=""
cssDirty={false}
draft={NO_DRAFT}
draftDirty={false}
viewport="desktop"
workspaceName={workspaceName}
faviconUrl={faviconUrl}
scaleToDesktop
switched={switched}
/>
</ClientOnly>
)

return (
<section
aria-labelledby="design-switch-title"
className="space-y-4 rounded-lg border border-border/60 bg-card p-4"
>
<div className="flex flex-wrap items-center gap-3">
<p id="design-switch-title" className="min-w-0 flex-1 text-sm font-medium">
Your portal uses custom CSS. Switch to the new design system.
</p>
<Button variant="outline" size="sm" onClick={() => setComparing(!comparing)}>
{comparing ? 'Hide comparison' : 'Compare'}
</Button>
<Button size="sm" disabled={blocked || switching} onClick={() => void switchNow()}>
Switch
</Button>
</div>
{refusal && (
<p role="alert" className="text-[13px] text-destructive">
{refusal}
</p>
)}
<Issues label="Fix in Advanced CSS before switching" issues={report.errors} />
<Issues label="Moves into the design" issues={report.carried} />
<Issues label="Check after switching" issues={report.warnings} />
{comparing && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<figure className="min-w-0 space-y-2">
<figcaption className="text-xs font-medium text-muted-foreground">Today</figcaption>
{frame(false)}
</figure>
<figure className="min-w-0 space-y-2">
<figcaption className="text-xs font-medium text-muted-foreground">
After switching
</figcaption>
{frame(true)}
</figure>
</div>
)}
</section>
)
}
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<string, string> = {
appearance: 'Appearance',
Expand Down Expand Up @@ -52,6 +54,20 @@ export function HistoryDialog({
number: number
} | null>(null)
const [refusal, setRefusal] = useState<string | null>(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
Expand Down Expand Up @@ -104,6 +120,14 @@ export function HistoryDialog({
</li>
))}
</ul>
{switched.data?.canSwitchBack && (
<div className="flex items-center gap-3 text-[13px]">
<span className="flex-1 text-muted-foreground">Switched from the old theme.</span>
<Button variant="outline" size="sm" onClick={() => setConfirmingBack(true)}>
Switch back
</Button>
</div>
)}
{refusal && (
<p role="alert" className="text-[13px] text-destructive">
{refusal}
Expand All @@ -125,6 +149,21 @@ export function HistoryDialog({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={confirmingBack} onOpenChange={setConfirmingBack}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Switch back to the old theme?</AlertDialogTitle>
<AlertDialogDescription>
Visitors see the old theme and its CSS straight away. The design and its versions
stay.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={switchBack}>Switch back</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -90,6 +92,7 @@ export function PortalPreview({
scaleToDesktop = false,
sort = 'trending',
page = 'root',
switched = false,
}: PortalPreviewProps) {
const frameBox = useRef<HTMLDivElement>(null)
const [boxWidth, setBoxWidth] = useState(0)
Expand Down Expand Up @@ -189,7 +192,7 @@ export function PortalPreview({
<iframe
key={refreshKey}
ref={iframeRef}
src={portalPreviewSrc(theme, sort, page)}
src={`${portalPreviewSrc(theme, sort, page)}${switched ? '&design=switch' : ''}`}
title="Portal preview"
className="w-full border-0 bg-background"
style={
Expand Down
Loading
Loading