From e31a778806d53ab187883a15e808c15b331fba8f Mon Sep 17 00:00:00 2001 From: Guido X Jansen Date: Wed, 4 Mar 2026 08:58:51 +0100 Subject: [PATCH] feat: onboarding source awareness in admin UI (#145) * feat(types): add source and hostingMode to onboarding types Add OnboardingFieldSource, HostingMode, and AdminOnboardingFieldsResponse types. Update getOnboardingFields client to return new response shape. Update hook to extract fields and hostingMode. Update all mocks to include source field. * feat(admin): show source badges and SaaS read-only state for platform fields Platform fields display a "Platform" badge in the admin onboarding list. In SaaS hosting mode, platform field controls (edit, delete, reorder) are disabled with an explanatory note. In selfhosted mode all fields remain fully editable. Adds platform age_confirmation to mock data. * style: fix Prettier formatting --- src/app/admin/onboarding/page.test.tsx | 72 +++++++++- src/app/admin/onboarding/page.tsx | 2 + .../onboarding/onboarding-field-item.tsx | 129 ++++++++++-------- src/components/onboarding-modal.test.tsx | 1 + src/hooks/admin/use-onboarding-fields.ts | 7 +- src/lib/api/client.ts | 5 +- src/lib/api/types.ts | 8 +- src/mocks/data.ts | 16 +++ src/mocks/handlers.ts | 3 +- src/test/mock-onboarding.tsx | 1 + 10 files changed, 180 insertions(+), 64 deletions(-) diff --git a/src/app/admin/onboarding/page.test.tsx b/src/app/admin/onboarding/page.test.tsx index 6a4a160..ca5b2d7 100644 --- a/src/app/admin/onboarding/page.test.tsx +++ b/src/app/admin/onboarding/page.test.tsx @@ -6,6 +6,9 @@ import { describe, it, expect, vi } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { axe } from 'vitest-axe' +import { http, HttpResponse } from 'msw' +import { server } from '@/mocks/server' +import { mockOnboardingFields } from '@/mocks/data' import AdminOnboardingPage from './page' vi.mock('next/navigation', () => ({ @@ -67,6 +70,7 @@ describe('AdminOnboardingPage', () => { expect(screen.getByText('Terms of Service')).toBeInTheDocument() }) expect(screen.getByText('Introduce yourself')).toBeInTheDocument() + expect(screen.getByText('Age Declaration')).toBeInTheDocument() }) it('renders field type badges', async () => { @@ -83,7 +87,8 @@ describe('AdminOnboardingPage', () => { await waitFor(() => { expect(screen.getByText('Terms of Service')).toBeInTheDocument() }) - expect(screen.getByText('Required')).toBeInTheDocument() + // ToS and Age Declaration are both mandatory + expect(screen.getAllByText('Required')).toHaveLength(2) }) it('renders add field button', () => { @@ -143,8 +148,8 @@ describe('AdminOnboardingPage', () => { await waitFor(() => { expect(screen.getByText('Terms of Service')).toBeInTheDocument() }) - expect(screen.getAllByRole('button', { name: /move.*up/i })).toHaveLength(2) - expect(screen.getAllByRole('button', { name: /move.*down/i })).toHaveLength(2) + expect(screen.getAllByRole('button', { name: /move.*up/i })).toHaveLength(3) + expect(screen.getAllByRole('button', { name: /move.*down/i })).toHaveLength(3) }) it('disables move up on first field and move down on last field', async () => { @@ -163,6 +168,67 @@ describe('AdminOnboardingPage', () => { expect(screen.getByText(/configure fields that users must complete/i)).toBeInTheDocument() }) + it('shows Platform badge for platform-sourced fields', async () => { + render() + await waitFor(() => { + expect(screen.getByText('Age Declaration')).toBeInTheDocument() + }) + expect(screen.getByText('Platform')).toBeInTheDocument() + }) + + it('does not show Platform badge for admin-sourced fields', async () => { + render() + await waitFor(() => { + expect(screen.getByText('Terms of Service')).toBeInTheDocument() + }) + // Only one Platform badge (for the age_confirmation field) + const badges = screen.getAllByText('Platform') + expect(badges).toHaveLength(1) + }) + + it('does not disable controls for platform fields in selfhosted mode', async () => { + render() + await waitFor(() => { + expect(screen.getByText('Age Declaration')).toBeInTheDocument() + }) + // All edit/delete buttons should be enabled in selfhosted mode + const editButtons = screen.getAllByRole('button', { name: /edit/i }) + const deleteButtons = screen.getAllByRole('button', { name: /delete/i }) + editButtons.forEach((btn) => expect(btn).not.toBeDisabled()) + deleteButtons.forEach((btn) => expect(btn).not.toBeDisabled()) + }) + + it('disables controls for platform fields in SaaS mode', async () => { + server.use( + http.get('/api/admin/onboarding-fields', () => { + return HttpResponse.json({ fields: mockOnboardingFields, hostingMode: 'saas' }) + }) + ) + render() + await waitFor(() => { + expect(screen.getByText('Age Declaration')).toBeInTheDocument() + }) + // Platform field buttons should be disabled + expect(screen.getByRole('button', { name: /edit age declaration/i })).toBeDisabled() + expect(screen.getByRole('button', { name: /delete age declaration/i })).toBeDisabled() + // Admin field buttons should remain enabled + expect(screen.getByRole('button', { name: /edit terms of service/i })).not.toBeDisabled() + expect(screen.getByRole('button', { name: /delete terms of service/i })).not.toBeDisabled() + }) + + it('shows SaaS note for platform fields in SaaS mode', async () => { + server.use( + http.get('/api/admin/onboarding-fields', () => { + return HttpResponse.json({ fields: mockOnboardingFields, hostingMode: 'saas' }) + }) + ) + render() + await waitFor(() => { + expect(screen.getByText('Age Declaration')).toBeInTheDocument() + }) + expect(screen.getByText(/this field is required by the barazo platform/i)).toBeInTheDocument() + }) + it('passes axe accessibility check', async () => { const { container } = render() await waitFor(() => { diff --git a/src/app/admin/onboarding/page.tsx b/src/app/admin/onboarding/page.tsx index 8a7b1ee..93b5795 100644 --- a/src/app/admin/onboarding/page.tsx +++ b/src/app/admin/onboarding/page.tsx @@ -16,6 +16,7 @@ import { useOnboardingFields } from '@/hooks/admin/use-onboarding-fields' export default function AdminOnboardingPage() { const { fields, + hostingMode, loading, editing, setEditing, @@ -87,6 +88,7 @@ export default function AdminOnboardingPage() { field={field} index={index} totalCount={fields.length} + hostingMode={hostingMode} onMoveUp={(i) => void handleMoveUp(i)} onMoveDown={(i) => void handleMoveDown(i)} onEdit={handleEdit} diff --git a/src/components/admin/onboarding/onboarding-field-item.tsx b/src/components/admin/onboarding/onboarding-field-item.tsx index 1ca30d0..6fe2d08 100644 --- a/src/components/admin/onboarding/onboarding-field-item.tsx +++ b/src/components/admin/onboarding/onboarding-field-item.tsx @@ -5,7 +5,7 @@ import { PencilSimple, TrashSimple, ArrowUp, ArrowDown } from '@phosphor-icons/react' import { cn } from '@/lib/utils' -import type { OnboardingField, OnboardingFieldType } from '@/lib/api/types' +import type { OnboardingField, OnboardingFieldType, HostingMode } from '@/lib/api/types' const FIELD_TYPE_LABELS: Record = { age_confirmation: 'Age Confirmation', @@ -20,6 +20,7 @@ interface OnboardingFieldItemProps { field: OnboardingField index: number totalCount: number + hostingMode: HostingMode onMoveUp: (index: number) => void onMoveDown: (index: number) => void onEdit: (field: OnboardingField) => void @@ -30,70 +31,88 @@ export function OnboardingFieldItem({ field, index, totalCount, + hostingMode, onMoveUp, onMoveDown, onEdit, onDelete, }: OnboardingFieldItemProps) { + const isPlatform = field.source === 'platform' + const isLockedBySaas = isPlatform && hostingMode === 'saas' + return ( -
-
-
-

{field.label}

- - {FIELD_TYPE_LABELS[field.fieldType]} - - {field.isMandatory && ( - - Required +
+
+
+
+

{field.label}

+ + {FIELD_TYPE_LABELS[field.fieldType]} + {isPlatform && ( + + Platform + + )} + {field.isMandatory && ( + + Required + + )} +
+ {field.description && ( +

{field.description}

)}
- {field.description && ( -

{field.description}

- )} -
-
- - - - +
+ + + + +
+ {isLockedBySaas && ( +

+ This field is required by the Barazo platform. Self-host to customize. +

+ )}
) } diff --git a/src/components/onboarding-modal.test.tsx b/src/components/onboarding-modal.test.tsx index e80d167..e13f946 100644 --- a/src/components/onboarding-modal.test.tsx +++ b/src/components/onboarding-modal.test.tsx @@ -20,6 +20,7 @@ function makeField(overrides: Partial = {}): OnboardingField { description: 'A brief introduction.', isMandatory: true, sortOrder: 0, + source: 'admin', config: null, createdAt: NOW, updatedAt: NOW, diff --git a/src/hooks/admin/use-onboarding-fields.ts b/src/hooks/admin/use-onboarding-fields.ts index 004d5a7..741d942 100644 --- a/src/hooks/admin/use-onboarding-fields.ts +++ b/src/hooks/admin/use-onboarding-fields.ts @@ -12,7 +12,7 @@ import { deleteOnboardingField, reorderOnboardingFields, } from '@/lib/api/client' -import type { OnboardingField, CreateOnboardingFieldInput } from '@/lib/api/types' +import type { OnboardingField, CreateOnboardingFieldInput, HostingMode } from '@/lib/api/types' import { EMPTY_FIELD } from '@/components/admin/onboarding/onboarding-field-form' import type { EditingField } from '@/components/admin/onboarding/onboarding-field-form' import { useAuth } from '@/hooks/use-auth' @@ -22,6 +22,7 @@ export function useOnboardingFields() { const { getAccessToken } = useAuth() const { toast } = useToast() const [fields, setFields] = useState([]) + const [hostingMode, setHostingMode] = useState('selfhosted') const [loading, setLoading] = useState(true) const [editing, setEditing] = useState(null) const [saving, setSaving] = useState(false) @@ -32,8 +33,9 @@ export function useOnboardingFields() { const fetchFields = useCallback(async () => { setLoadError(null) try { - const fields = await getOnboardingFields(getAccessToken() ?? '') + const { fields, hostingMode } = await getOnboardingFields(getAccessToken() ?? '') setFields(fields) + setHostingMode(hostingMode) } catch { setLoadError('Failed to load onboarding fields. The API may be unreachable.') } finally { @@ -143,6 +145,7 @@ export function useOnboardingFields() { return { fields, + hostingMode, loading, editing, setEditing, diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 5d36c12..b1a59dc 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -43,6 +43,7 @@ import type { MaturityRating, PluginsResponse, OnboardingField, + AdminOnboardingFieldsResponse, CreateOnboardingFieldInput, UpdateOnboardingFieldInput, OnboardingStatus, @@ -773,8 +774,8 @@ export function unmuteUser( export function getOnboardingFields( accessToken: string, options?: FetchOptions -): Promise { - return apiFetch('/api/admin/onboarding-fields', { +): Promise { + return apiFetch('/api/admin/onboarding-fields', { ...options, headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, }) diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 6af21f4..7a20299 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -549,6 +549,10 @@ export type OnboardingFieldType = | 'custom_select' | 'custom_checkbox' +export type OnboardingFieldSource = 'platform' | 'admin' + +export type HostingMode = 'saas' | 'selfhosted' + export interface OnboardingField { id: string communityDid: string @@ -557,13 +561,15 @@ export interface OnboardingField { description: string | null isMandatory: boolean sortOrder: number + source: OnboardingFieldSource config: Record | null createdAt: string updatedAt: string } -export interface OnboardingFieldsResponse { +export interface AdminOnboardingFieldsResponse { fields: OnboardingField[] + hostingMode: HostingMode } export interface CreateOnboardingFieldInput { diff --git a/src/mocks/data.ts b/src/mocks/data.ts index a22e7ae..267672c 100644 --- a/src/mocks/data.ts +++ b/src/mocks/data.ts @@ -1026,6 +1026,7 @@ export const mockOnboardingFields: OnboardingField[] = [ description: 'You must accept our community rules to participate.', isMandatory: true, sortOrder: 0, + source: 'admin', config: { tosUrl: 'https://example.com/tos' }, createdAt: TWO_DAYS_AGO, updatedAt: TWO_DAYS_AGO, @@ -1038,6 +1039,21 @@ export const mockOnboardingFields: OnboardingField[] = [ description: 'Tell us a bit about yourself and why you joined.', isMandatory: false, sortOrder: 1, + source: 'admin', + config: null, + createdAt: TWO_DAYS_AGO, + updatedAt: TWO_DAYS_AGO, + }, + { + id: 'platform:age_confirmation', + communityDid: COMMUNITY_DID, + fieldType: 'age_confirmation', + label: 'Age Declaration', + description: + 'Please select your age bracket. This determines which content is available to you.', + isMandatory: true, + sortOrder: -1, + source: 'platform', config: null, createdAt: TWO_DAYS_AGO, updatedAt: TWO_DAYS_AGO, diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index ea6b93f..9b5cbcd 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -641,7 +641,7 @@ export const handlers = [ if (!auth?.startsWith('Bearer ')) { return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - return HttpResponse.json(mockOnboardingFields) + return HttpResponse.json({ fields: mockOnboardingFields, hostingMode: 'selfhosted' }) }), // POST /api/admin/onboarding-fields @@ -660,6 +660,7 @@ export const handlers = [ description: body.description ?? null, isMandatory: body.isMandatory ?? true, sortOrder: body.sortOrder ?? 0, + source: 'admin' as const, config: body.config ?? null, createdAt: now, updatedAt: now, diff --git a/src/test/mock-onboarding.tsx b/src/test/mock-onboarding.tsx index c44bbd7..6d77423 100644 --- a/src/test/mock-onboarding.tsx +++ b/src/test/mock-onboarding.tsx @@ -45,6 +45,7 @@ export function createIncompleteOnboardingContext( description: null, isMandatory: true, sortOrder: 0, + source: 'platform', config: null, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', -- 2.51.2