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 @@ -21,6 +21,7 @@ import {
getPublishedDesign,
listRevisions,
publishDraft,
publishStartingDesign,
restoreRevision,
REVISIONS_KEPT,
saveDraft,
Expand All @@ -38,16 +39,14 @@ async function admin() {
const userId = createId('user') as UserId
const id = createId('principal') as PrincipalId
await testDb.insert(user).values({ id: userId, name: 'Admin', email: `${userId}@example.com` })
await testDb
.insert(principal)
.values({
id,
userId,
role: 'admin',
type: 'user',
displayName: 'Admin',
createdAt: new Date(),
})
await testDb.insert(principal).values({
id,
userId,
role: 'admin',
type: 'user',
displayName: 'Admin',
createdAt: new Date(),
})
return {
principalId: id,
actor: { userId, email: `${userId}@example.com`, role: 'admin', type: 'user' as const },
Expand All @@ -60,6 +59,28 @@ const withAccent = (accent: string) => {
return doc
}

describe('a new workspace’s starting design', () => {
it('publishes it as the first version, with no author', async () => {
const doc = withAccent('#0F766E')
doc.nav.home.on = true
expect(await testDb.transaction((tx) => publishStartingDesign(tx, doc))).toBe(true)
const state = await getDesignState()
expect(state?.published?.number).toBe(1)
expect(state?.published?.document.nav.home.on).toBe(true)
const [row] = await testDb.select().from(portalDesignRevisions)
expect(row).toMatchObject({ source: 'conversion', principalId: null })
})

it('leaves a workspace that already has a design alone', async () => {
const who = await admin()
await saveDraft(withAccent('#0F766E'), who)
await publishDraft({ ...who, source: 'appearance' })
expect(await testDb.transaction((tx) => publishStartingDesign(tx, defaultDesign()))).toBe(false)
expect((await getPublishedDesign())?.brand.light.accent).toBe('#0f766e')
expect(await listRevisions()).toHaveLength(1)
})
})

describe('drafts', () => {
it('saves a validated draft and leaves the published design alone', async () => {
const who = await admin()
Expand Down
27 changes: 25 additions & 2 deletions apps/web/src/lib/server/domains/design/design.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ async function publishDocument(
tx: Transaction,
designId: PortalDesignId,
document: PortalDesign,
author: DesignAuthor,
author: Pick<DesignAuthor, 'principalId'> | null,
source: PortalDesignSource,
message: string | null
) {
Expand All @@ -183,7 +183,14 @@ async function publishDocument(
const number = (latest?.number ?? 0) + 1
const [created] = await tx
.insert(portalDesignRevisions)
.values({ designId, number, document, source, principalId: author.principalId, message })
.values({
designId,
number,
document,
source,
principalId: author?.principalId ?? null,
message,
})
.returning({ id: portalDesignRevisions.id })
await tx
.update(portalDesigns)
Expand Down Expand Up @@ -261,6 +268,22 @@ export async function publishDraftInTransaction(
return published
}

/**
* Publishes the design a new workspace starts with as its first version, from
* setup rather than a person. A workspace with a design keeps it; the result
* says whether this one was published.
*/
export async function publishStartingDesign(
tx: Transaction,
document: PortalDesign
): Promise<boolean> {
await tx.insert(portalDesigns).values({}).onConflictDoNothing()
const row = (await designRow(tx, true))!
if (row.publishedRevisionId || row.draft) return false
await publishDocument(tx, row.id, validateDesign(document), null, 'conversion', null)
return true
}

/** Publishes an old revision's document as the next revision. A draft in progress is kept. */
export async function restoreRevision(
revisionId: PortalDesignRevisionId,
Expand Down
58 changes: 58 additions & 0 deletions apps/web/src/lib/server/domains/design/starting-design.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* The design a new workspace starts with: its branding converted, as an
* established workspace's is on read, with Home on and laid out from the
* pages the workspace has. An established workspace's `/` never changes on
* upgrade; only setup publishes this.
*/
import { eq, settings, type Transaction } from '@/lib/server/db'
import { parseJsonOrNull, parsePortalConfig } from '@/lib/server/domains/settings/settings.helpers'
import { resolveStatusSettings } from '@/lib/server/domains/settings/settings.status'
import {
resolveFeatureFlags,
type BrandingConfig,
} from '@/lib/server/domains/settings/settings.types'
import { convertLegacyBranding } from '@/lib/shared/design/convert'
import { starterHomeBlocks } from '@/lib/shared/design/home'
import { portalPageGates } from '@/lib/shared/design/paths'
import type { PortalDesign } from '@/lib/shared/design/schema'
import { publishStartingDesign } from './design.service'
import { advancedCssRemainder, legacyBrandingOf } from './engine'

type SettingsRow = typeof settings.$inferSelect

/**
* The starting design for a workspace's settings, or null when it stays on
* its theme: the design engine off, or custom CSS a switch would have to
* carry over. `PORTAL_DESIGN_ENGINE=legacy` still keeps the portal on its
* theme, published design or not.
*/
export function startingDesign(row: SettingsRow): PortalDesign | null {
const featureFlags = resolveFeatureFlags(row.featureFlags)
if (!featureFlags.portalDesignV2) return null
if (advancedCssRemainder(row.customCss ?? '') !== '') return null
const portalConfig = parsePortalConfig(row.portalConfig)
const doc = convertLegacyBranding(
legacyBrandingOf({
...row,
brandingConfig: parseJsonOrNull<BrandingConfig>(row.brandingConfig) ?? {},
portalConfig,
})
)
const gates = portalPageGates(
{ featureFlags, portalConfig, statusConfig: resolveStatusSettings(row.metadata) },
false
)
doc.nav.home.on = true
doc.pages.home.blocks = starterHomeBlocks(gates)
return doc
}

/** Publishes the starting design for the workspace in `tx`, unless it has a design. */
export async function seedStartingDesign(
tx: Transaction,
workspaceId: SettingsRow['id']
): Promise<boolean> {
const [row] = await tx.select().from(settings).where(eq(settings.id, workspaceId)).limit(1)
const doc = row ? startingDesign(row) : null
return doc ? publishStartingDesign(tx, doc) : false
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ vi.mock('@/lib/server/domains/principals/principal.factory', () => ({
ensurePrincipalForUser: hoisted.ensurePrincipalForUser,
setPrincipalRole: hoisted.setPrincipalRole,
}))
vi.mock('@/lib/server/domains/settings/settings.helpers', () => ({
vi.mock('@/lib/server/domains/settings/settings.helpers', async (original) => ({
...(await original<typeof import('@/lib/server/domains/settings/settings.helpers')>()),
invalidateSettingsCache: hoisted.invalidateSettingsCache,
}))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ vi.mock('@/lib/server/domains/settings/settings.helpers', async (original) => ({
}))

import { saveWorkspaceAndGoalFn, ensureOnboardingHomeReadyFn } from '../onboarding'
const { getPublishedDesign } = await import('@/lib/server/domains/design/design.service')

const fixture = await createDbTestFixture({
probe: async (db) => {
Expand Down Expand Up @@ -383,6 +384,39 @@ describe('wizard goals read and write', () => {
expect(getSetupState(row!.setupState)!.ownerPrincipalId).toBe(owner!.id)
})

it('starts a new workspace’s portal on Home, laid out from its goals', async () => {
await saveWorkspaceAndGoalFn({
data: { workspaceName: 'Acme', goals: ['product_feedback', 'help_center'] },
})
const design = await getPublishedDesign()
expect(design?.nav.home.on).toBe(true)
const blocks = design!.pages.home.blocks.map((block) => block.type)
expect(blocks).toEqual(expect.arrayContaining(['composer', 'ideas', 'updates', 'help_topics']))
expect(blocks).not.toContain('status')
// Saving the goals again keeps the design it has.
await saveWorkspaceAndGoalFn({ data: { workspaceName: 'Acme', goals: ['status_page'] } })
expect((await getPublishedDesign())?.pages.home.blocks.map((block) => block.type)).toEqual(
blocks
)
})

it('gives no design to an established workspace, or one with the design engine off', async () => {
// An established workspace that turned the design engine on in Labs.
const yearAgo = new Date(Date.now() - 365 * 86_400_000)
await testDb
.insert(settings)
.values(operatorRow(yearAgo, JSON.stringify({ portalDesignV2: true })))
await ensureOnboardingHomeReadyFn()
expect(await getPublishedDesign()).toBeNull()

await testDb.delete(settings)
await testDb
.insert(settings)
.values(operatorRow(new Date(), JSON.stringify({ portalDesignV2: false })))
await ensureOnboardingHomeReadyFn()
expect(await getPublishedDesign()).toBeNull()
})

it('turns Copilot on Home on for a new workspace an operator provisioned', async () => {
await testDb.insert(settings).values(operatorRow(new Date()))
await ensureOnboardingHomeReadyFn()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ vi.mock('@tanstack/react-start', () => ({

const hoisted = vi.hoisted(() => ({ getSession: vi.fn() }))
vi.mock('@/lib/server/auth/session', () => ({ getSession: hoisted.getSession }))
vi.mock('@/lib/server/domains/settings/settings.helpers', () => ({
vi.mock('@/lib/server/domains/settings/settings.helpers', async (original) => ({
...(await original<typeof import('@/lib/server/domains/settings/settings.helpers')>()),
invalidateSettingsCache: vi.fn(),
}))

Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/lib/server/onboarding-board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,12 @@ export async function applyOnboardingGoals(
changesStoredJson((stored[key] as string | null | undefined) ?? null, next)
)
}
// A new workspace's portal starts on Home, laid out from the pages its
// goals turned on; an established workspace keeps its root as it is.
if (isNew) {
const { seedStartingDesign } = await import('@/lib/server/domains/design/starting-design')
if (await seedStartingDesign(tx, row.id)) modulesChanged = true
}
await prepareOnboardingBoard(tx, { ...state, goals })
await seedGoalPages(tx, row.name, goals)
return { modulesChanged }
Expand Down
Loading