From 57065d7ef23174ed4236ee84cce61bc77d229f2a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:53:50 +0100 Subject: [PATCH] feat: show an organisation template library from the admin --- .../__tests__/org-templates-page.test.tsx | 120 ++++++ .../admin/org/sections/templates-section.tsx | 189 +++++++- components/email/email-composer.tsx | 12 +- components/settings/template-settings.tsx | 9 +- .../template-manager-sources.test.tsx | 72 ++++ .../template-picker-sources.test.tsx | 112 +++++ .../templates/template-body-preview.tsx | 17 + components/templates/template-form.tsx | 35 +- .../templates/template-manager-modal.tsx | 403 ++++++++++++------ components/templates/template-picker.tsx | 149 ++++--- hooks/use-account-policy.ts | 24 ++ hooks/use-template-source-actions.ts | 71 +++ hooks/use-template-sources.ts | 40 ++ lib/admin/restriction-keys.ts | 9 + lib/templates/org-library.ts | 26 ++ lib/templates/template-sources.ts | 32 ++ locales/de/common.json | 17 + locales/en/common.json | 17 + 18 files changed, 1152 insertions(+), 202 deletions(-) create mode 100644 app/(main)/admin/__tests__/org-templates-page.test.tsx create mode 100644 components/templates/__tests__/template-manager-sources.test.tsx create mode 100644 components/templates/__tests__/template-picker-sources.test.tsx create mode 100644 components/templates/template-body-preview.tsx create mode 100644 hooks/use-account-policy.ts create mode 100644 hooks/use-template-source-actions.ts create mode 100644 hooks/use-template-sources.ts create mode 100644 lib/templates/org-library.ts create mode 100644 lib/templates/template-sources.ts diff --git a/app/(main)/admin/__tests__/org-templates-page.test.tsx b/app/(main)/admin/__tests__/org-templates-page.test.tsx new file mode 100644 index 000000000..22564f6d2 --- /dev/null +++ b/app/(main)/admin/__tests__/org-templates-page.test.tsx @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; + +// Organisation > Templates: the admin library per scope (a scope adds to +// Everyone's by id) and the restrictions on users' own and team templates, +// saved in one PUT. + +const puts: Array> = []; +let stored: Record = {}; +vi.mock('@/lib/browser-navigation', () => ({ + apiFetch: vi.fn(async (_url: string, init?: RequestInit) => { + if (init?.method === 'PUT') { + const body = JSON.parse(String(init.body)); + puts.push(body); + stored = body; + return new Response('{"ok":true}'); + } + return new Response(JSON.stringify(stored)); + }), + withBasePath: (p: string) => p, + getPathPrefix: () => '', +})); + +import { OrgTemplatesPage } from '../_pages/org-templates'; +import { EVERYONE, useOrgScopeStore } from '@/components/admin/org/use-org-policy-draft'; + +const WELCOME = { id: 'welcome', name: 'Welcome', subject: 'Hello', body: '

Hi there

', isHTML: true, category: 'Sales' }; + +const POLICY = { + restrictions: {}, + features: {}, defaults: {}, themePolicy: {}, + forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [], + organisation: { templates: { library: [WELCOME] } }, + scopes: [{ id: 'two', label: 'Org two', match: { domains: ['org-two.test'] }, policy: {} }], +}; + +beforeEach(() => { + puts.length = 0; + stored = JSON.parse(JSON.stringify(POLICY)); + useOrgScopeStore.setState({ scopeId: EVERYONE }); +}); + +describe('Organisation > Templates', () => { + it('edits Everyone\'s library with a preview', async () => { + render(); + const items = await screen.findAllByTestId('org-template-item'); + expect(items).toHaveLength(1); + fireEvent.click(within(items[0]).getByRole('button', { name: /^Welcome/ })); + expect(screen.getByTestId('org-template-preview').innerHTML).toContain('there'); + + fireEvent.click(screen.getByTestId('org-template-add')); + const names = screen.getAllByTestId('org-template-name'); + fireEvent.change(names[1], { target: { value: 'Offer' } }); + fireEvent.change(screen.getAllByTestId('org-template-body')[1], { target: { value: 'Our offer' } }); + + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(puts).toHaveLength(1)); + const library = (puts[0].organisation as { templates: { library: Array<{ name: string; body: string }> } }).templates.library; + expect(library.map((t) => t.name)).toEqual(['Welcome', 'Offer']); + expect(library[1].body).toBe('Our offer'); + }); + + it('adds templates for a scope without copying Everyone\'s, and changes one of Everyone\'s for that scope only', async () => { + render(); + await screen.findAllByTestId('org-template-item'); + fireEvent.click(screen.getByRole('button', { name: 'Org two' })); + + // Everyone's template is shown, not editable, and the scope starts empty. + expect(within(screen.getByTestId('org-template-inherited')).getByText('Welcome')).toBeInTheDocument(); + expect(screen.getByTestId('org-template-empty')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('org-template-add')); + fireEvent.change(screen.getByTestId('org-template-name'), { target: { value: 'Two only' } }); + fireEvent.click(screen.getByRole('button', { name: /Change for this scope/ })); + const subjects = screen.getAllByTestId('org-template-subject'); + fireEvent.click(within(screen.getAllByTestId('org-template-item')[1]).getByRole('button', { name: /^Welcome/ })); + fireEvent.change(screen.getAllByTestId('org-template-subject')[1], { target: { value: 'Hello from two' } }); + expect(subjects).toHaveLength(1); + + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(puts).toHaveLength(1)); + const scope = (puts[0].scopes as Array<{ policy: { organisation: { templates: { library: Array<{ id: string; name: string; subject: string }> } } } }>)[0]; + const library = scope.policy.organisation.templates.library; + expect(library.map((t) => t.name)).toEqual(['Two only', 'Welcome']); + expect(library[1]).toMatchObject({ id: 'welcome', subject: 'Hello from two' }); + // Everyone keeps its own. + expect((puts[0].organisation as { templates: { library: Array<{ subject: string }> } }).templates.library[0].subject).toBe('Hello'); + }); + + it('removing the last template of a scope inherits Everyone\'s library again', async () => { + stored = JSON.parse(JSON.stringify({ + ...POLICY, + scopes: [{ ...POLICY.scopes[0], policy: { organisation: { templates: { library: [{ ...WELCOME, id: 'x', name: 'X' }] } } } }], + })); + render(); + await screen.findAllByTestId('org-template-item'); + fireEvent.click(screen.getByRole('button', { name: 'Org two' })); + fireEvent.click(screen.getByRole('button', { name: 'Remove X' })); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(puts).toHaveLength(1)); + const policy = (puts[0].scopes as Array<{ policy: { organisation?: Record } }>)[0].policy; + expect(policy.organisation?.templates).toBeUndefined(); + }); + + it('says that a template without a name is not saved', async () => { + render(); + await screen.findAllByTestId('org-template-item'); + fireEvent.click(screen.getByTestId('org-template-add')); + expect(screen.getByText(/A template without a name is not saved/)).toBeInTheDocument(); + }); + + it('hides personal templates through the restriction row', async () => { + render(); + await screen.findAllByTestId('org-template-item'); + fireEvent.click(screen.getByLabelText('Hide')); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(puts).toHaveLength(1)); + expect((puts[0].restrictions as Record).personalTemplates).toEqual({ hidden: true }); + }); +}); diff --git a/components/admin/org/sections/templates-section.tsx b/components/admin/org/sections/templates-section.tsx index 8cf157268..7356b93bd 100644 --- a/components/admin/org/sections/templates-section.tsx +++ b/components/admin/org/sections/templates-section.tsx @@ -1,14 +1,191 @@ 'use client'; +import { useId, useState } from 'react'; +import { ChevronDown, ChevronRight, Plus, RotateCcw, Trash2 } from '@/components/icons'; import { SettingsSection } from '@/components/settings/settings-section'; +import { TemplateBodyPreview } from '@/components/templates/template-body-preview'; +import { Button } from '@/components/ui/button'; +import type { OrgTemplate } from '@/lib/admin/organisation-policy'; +import { restrictionKeysForPage } from '@/lib/admin/restriction-keys'; +import { cn, generateUUID } from '@/lib/utils'; +import { RestrictionRow } from '../org-page'; import type { OrgPolicyDraft } from '../use-org-policy-draft'; -/** Organisation feature 3; filled in by its own step (local-data/org-features-plan.md). */ -export function TemplatesSection({ draft }: { draft: OrgPolicyDraft }) { - void draft; +const inputClass = + 'block w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ' + + 'placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 ' + + 'focus-visible:ring-ring focus-visible:border-ring'; +const labelClass = 'mb-1 block text-xs font-medium text-foreground'; + +/** Same cap as the sanitiser (organisation-policy.ts): more are not saved. */ +const MAX_LIBRARY = 200; + +function TemplateEditor({ template, onChange, onRemove }: { + template: OrgTemplate; + onChange: (template: OrgTemplate) => void; + onRemove: () => void; +}) { + const id = useId(); + const [open, setOpen] = useState(!template.name); + const set = (patch: Partial) => onChange({ ...template, ...patch }); return ( - -

Nothing to set here yet.

-
+
  • +
    + + +
    + {!template.name.trim() && ( +

    A template without a name is not saved. Give it one.

    + )} + {open && ( +
    +
    +
    + + set({ name: e.target.value })} /> +
    +
    + + set({ category: e.target.value })} /> +
    +
    +
    + + set({ subject: e.target.value })} /> +
    +
    + +