Refactor admin to shared generic CRUD; restructure proclamation categories/groups and APIs

- Replace per-model admin forms with the shared generic AdminTable/AdminModal CRUD
- Add ProclamationGroup model and admin + API v2 routes (replaces subscription levels)
- Drop subscription levels and flatten proclamation categories
- Update v2 serializers and seed data accordingly
This commit is contained in:
NeilClack
2026-08-27 12:03:40 -05:00
parent b618d8cb93
commit 1d11de8cec
50 changed files with 1168 additions and 2000 deletions
@@ -27,11 +27,16 @@ export const load: PageServerLoad = async ({ locals }) => {
}
const categories = await prisma.proclamationCategory.findMany({
select: { id: true, name: true, parentId: true },
select: { id: true, name: true },
orderBy: { name: 'asc' }
});
return { ...base, categories };
const groups = await prisma.proclamationGroup.findMany({
select: { id: true, name: true },
orderBy: { sortOrder: 'asc' }
});
return { ...base, categories, groups };
};
export const actions = {
@@ -54,12 +59,8 @@ export const actions = {
}
const rawId = formData.get('id')?.toString();
const { parentCategoryId, ...rest } = form.data;
const data: Record<string, unknown> = {
...rest,
categoryId: rest.categoryId ?? parentCategoryId
};
const data: Record<string, unknown> = { ...form.data };
try {
if (rawId) {
@@ -1,11 +1,15 @@
<script lang="ts">
import type { PageProps } from './$types';
import type { ActionResult } from '$lib/svelte-admin/types';
import { enhance } from '$app/forms';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Highlight from '@tiptap/extension-highlight';
import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color';
import AdminTable from '$lib/components/admin/AdminTable.svelte';
import AdminModal from '$lib/components/admin/AdminModal.svelte';
import Toast from '$lib/components/admin/Toast.svelte';
import { getModelConfig } from '$lib/svelte-admin/registry';
import ProclamationForm from './ProclamationForm.svelte';
let { data, form }: PageProps = $props();
@@ -17,16 +21,25 @@
let modalOpen = $state(false);
let editingRecord = $state<Record<string, unknown> | null>(null);
let toastMessage = $state('');
let toastType = $state<'success' | 'error'>('success');
let fieldErrors = $state<Record<string, string>>({});
let formError = $state('');
let dirty = $state(false);
let name = $state('');
let solfeggioFrequencyId = $state('');
let categoryId = $state('');
let groupId = $state('');
// Inline TipTap editor
let element = $state<HTMLElement>();
let editor: Editor | null = null;
let content = $state('');
$effect(() => {
if (form && 'success' in (form as any)) {
const result = form as any;
if (result.success) {
toastMessage = 'Saved successfully';
toastType = 'success';
closeModal();
} else {
formError = result.error ?? '';
@@ -35,6 +48,80 @@
}
});
$effect(() => {
const record = editingRecord ?? {};
name = String(record['name'] ?? '');
solfeggioFrequencyId = String(record['solfeggioFrequencyId'] ?? '');
categoryId = String(record['categoryId'] ?? '');
groupId = String(record['groupId'] ?? '');
content = typeof record['content'] === 'string' ? (record['content'] as string) : '';
dirty = false;
});
function markDirty() {
if (editingRecord?.id) dirty = true;
}
function handleCategoryChange(e: Event) {
categoryId = (e.target as HTMLSelectElement).value;
}
function handleGroupChange(e: Event) {
groupId = (e.target as HTMLSelectElement).value;
}
let contentKey = $derived(editingRecord?.id ?? 'new');
$effect(() => {
if (!isSuper || !modalOpen || !element) return;
const initialContent =
typeof (editingRecord ?? {})['content'] === 'string'
? ((editingRecord ?? {})['content'] as string)
: '';
editor = new Editor({
element,
extensions: [
StarterKit.configure({
bulletList: false,
orderedList: false
}),
TextStyle,
Color,
Highlight.configure({ multicolor: true })
],
content: initialContent,
onUpdate: ({ editor }) => {
content = editor.getHTML();
}
});
const instance = editor;
instance.setEditable(true);
return () => {
instance.destroy();
};
});
function toggleBold() {
editor?.chain().focus().toggleBold().run();
}
function toggleItalic() {
editor?.chain().focus().toggleItalic().run();
}
function toggleHighlight() {
editor?.chain().focus().toggleHighlight().run();
}
function setColor() {
const current = editor?.getAttributes('textStyle').color ?? '#000000';
const colors = ['#000000', '#FF0000', '#0000FF', '#008000', '#FFA500', '#800080', '#808080'];
const idx = colors.indexOf(current);
const next = colors[(idx + 1) % colors.length];
editor?.chain().focus().setColor(next).run();
}
function openCreate() {
editingRecord = null;
formError = '';
@@ -57,8 +144,8 @@
}
</script>
<div class="p-6">
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
<div>
<h1>{config.displayName}</h1>
<AdminTable
records={data.records}
listFields={config.listFields}
@@ -79,17 +166,109 @@
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
<ProclamationForm
record={editingRecord ?? {}}
relationOptionsData={data.relationOptionsData}
categories={data.categories}
{fieldErrors}
{formError}
{isSuper}
submitLabel={editingRecord ? 'Update' : 'Create'}
/>
<form action="?/upsert" method="POST" use:enhance oninput={markDirty}>
{#if editingRecord?.id}
<input type="hidden" name="id" value={String(editingRecord.id)} />
{/if}
{#if formError}
<div>{formError}</div>
{/if}
<div>
<div>
<label for="field-name">Name</label>
<input type="text" id="field-name" name="name" bind:value={name} />
{#if fieldErrors.name}
<p>{fieldErrors.name}</p>
{/if}
</div>
<div>
<label for="field-solfeggioFrequencyId">Frequency</label>
<select
id="field-solfeggioFrequencyId"
name="solfeggioFrequencyId"
onchange={(e) => (solfeggioFrequencyId = (e.target as HTMLSelectElement).value)}
>
<option value="">-- Select Frequency --</option>
{#each data.relationOptionsData.solfeggioFrequencyId ?? [] as opt (String(opt.value))}
<option value={opt.value} selected={String(opt.value) === solfeggioFrequencyId}
>{opt.label}</option
>
{/each}
</select>
{#if fieldErrors.solfeggioFrequencyId}
<p>{fieldErrors.solfeggioFrequencyId}</p>
{/if}
</div>
<div>
<label for="field-categoryId">Category</label>
<select id="field-categoryId" name="categoryId" onchange={handleCategoryChange}>
<option value="">-- Select Category --</option>
{#each data.categories as cat (String(cat.id))}
<option value={cat.id} selected={String(cat.id) === categoryId}>{cat.name}</option>
{/each}
</select>
{#if fieldErrors.categoryId}
<p>{fieldErrors.categoryId}</p>
{/if}
</div>
<div>
<label for="field-groupId">Group</label>
<select id="field-groupId" name="groupId" onchange={handleGroupChange}>
<option value="">-- Select Group --</option>
{#each data.groups as group (String(group.id))}
<option value={group.id} selected={String(group.id) === groupId}>{group.name}</option>
{/each}
</select>
{#if fieldErrors.groupId}
<p>{fieldErrors.groupId}</p>
{/if}
</div>
{#if isSuper}
<div>
<label for="field-content">Content</label>
<div id="field-content">
{#key contentKey}
<div>
<div>
<button type="button" onclick={toggleBold} title="Bold">B</button>
<button type="button" onclick={toggleItalic} title="Italic">I</button>
<button type="button" onclick={toggleHighlight} title="Highlight">
<span>H</span>
</button>
<button type="button" onclick={setColor} title="Cycle text color">A</button>
</div>
<div
bind:this={element}
data-testid="tiptap-editor"
role="textbox"
tabindex="0"
contenteditable="true"
></div>
</div>
<input type="hidden" name="content" value={content} />
{/key}
</div>
{#if fieldErrors.content}
<p>{fieldErrors.content}</p>
{/if}
</div>
{/if}
<div>
<button type="submit" disabled={!!editingRecord?.id && !dirty}>
{editingRecord ? 'Update' : 'Create'}
</button>
</div>
</div>
</form>
</AdminModal>
{#if toastMessage}
<Toast message={toastMessage} type={toastType} />
<Toast message={toastMessage} />
{/if}
@@ -1,237 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import TipTap from '$lib/components/tiptap/TipTap.svelte';
let {
record = {} as Record<string, unknown>,
fieldErrors = {} as Record<string, string>,
formError = '',
relationOptionsData = {} as Record<string, { label: string; value: string | number }[]>,
categories = [] as { id: number; name: string; parentId: number | null }[],
isSuper = false,
action = '?/upsert',
submitLabel = 'Save'
}: {
record?: Record<string, unknown>;
fieldErrors?: Record<string, string>;
formError?: string;
relationOptionsData?: Record<string, { label: string; value: string | number }[]>;
categories?: { id: number; name: string; parentId: number | null }[];
isSuper?: boolean;
action?: string;
submitLabel?: string;
} = $props();
let dirty = $state(false);
$effect(() => {
dirty = false;
});
function markDirty() {
if (record.id) dirty = true;
}
let name = $state('');
let solfeggioFrequencyId = $state('');
let subscriptionLevelId = $state('');
let parentCategoryId = $state('');
let categoryId = $state('');
$effect(() => {
name = String(record.name ?? '');
solfeggioFrequencyId = String(record.solfeggioFrequencyId ?? '');
subscriptionLevelId = String(record.subscriptionLevelId ?? '');
parentCategoryId = String(
record.parentCategoryId ??
(record.category as Record<string, unknown> | undefined)?.parentId ??
''
);
categoryId = String(record.categoryId ?? '');
});
let parentOptions = $derived(categories.filter((c) => c.parentId === null));
let childOptions = $derived(
parentCategoryId ? categories.filter((c) => c.parentId === Number(parentCategoryId)) : []
);
function handleParentChange(e: Event) {
const target = e.target as HTMLSelectElement;
parentCategoryId = target.value;
categoryId = '';
}
let contentKey = $derived(record.id ?? 'new');
</script>
<form {action} method="POST" use:enhance oninput={markDirty}>
{#if record?.id}
<input type="hidden" name="id" value={String(record.id)} />
{/if}
{#if formError}
<div class="mb-4 border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
{formError}
</div>
{/if}
<div class="flex flex-col gap-4">
<div>
<label
for="field-name"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Name
</label>
<input
type="text"
id="field-name"
name="name"
bind:value={name}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.name
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
{#if fieldErrors.name}
<p class="mt-1 text-xs text-red-600">{fieldErrors.name}</p>
{/if}
</div>
<div>
<label
for="field-solfeggioFrequencyId"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Frequency
</label>
<select
id="field-solfeggioFrequencyId"
name="solfeggioFrequencyId"
onchange={(e) => (solfeggioFrequencyId = (e.target as HTMLSelectElement).value)}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.solfeggioFrequencyId
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
>
<option value="">-- Select Frequency --</option>
{#each relationOptionsData.solfeggioFrequencyId ?? [] as opt (String(opt.value))}
<option value={opt.value} selected={String(opt.value) === solfeggioFrequencyId}
>{opt.label}</option
>
{/each}
</select>
{#if fieldErrors.solfeggioFrequencyId}
<p class="mt-1 text-xs text-red-600">{fieldErrors.solfeggioFrequencyId}</p>
{/if}
</div>
<div>
<label
for="field-parentCategoryId"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Category
</label>
<select
id="field-parentCategoryId"
name="parentCategoryId"
onchange={handleParentChange}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.parentCategoryId
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
>
<option value="">-- Select Category --</option>
{#each parentOptions as cat (String(cat.id))}
<option value={cat.id} selected={String(cat.id) === parentCategoryId}>{cat.name}</option>
{/each}
</select>
{#if fieldErrors.parentCategoryId}
<p class="mt-1 text-xs text-red-600">{fieldErrors.parentCategoryId}</p>
{/if}
</div>
<div>
<label
for="field-categoryId"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Sub-Category
</label>
<select
id="field-categoryId"
name="categoryId"
onchange={(e) => (categoryId = (e.target as HTMLSelectElement).value)}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.categoryId
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
>
<option value="">-- Select Sub-Category --</option>
{#each childOptions as cat (String(cat.id))}
<option value={cat.id} selected={String(cat.id) === categoryId}>{cat.name}</option>
{/each}
</select>
{#if fieldErrors.categoryId}
<p class="mt-1 text-xs text-red-600">{fieldErrors.categoryId}</p>
{/if}
</div>
<div>
<label
for="field-subscriptionLevelId"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Subscription Level
</label>
<select
id="field-subscriptionLevelId"
name="subscriptionLevelId"
onchange={(e) => (subscriptionLevelId = (e.target as HTMLSelectElement).value)}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.subscriptionLevelId
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
>
<option value="">-- Select Level --</option>
{#each relationOptionsData.subscriptionLevelId ?? [] as opt (String(opt.value))}
<option value={opt.value} selected={String(opt.value) === subscriptionLevelId}
>{opt.label}</option
>
{/each}
</select>
{#if fieldErrors.subscriptionLevelId}
<p class="mt-1 text-xs text-red-600">{fieldErrors.subscriptionLevelId}</p>
{/if}
</div>
{#if isSuper}
<div>
<label
for="field-content"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Content
</label>
<div id="field-content">
{#key record.id}
<TipTap
initialContent={typeof record.content === 'string' ? record.content : ''}
name="content"
/>
{/key}
</div>
{#if fieldErrors.content}
<p class="mt-1 text-xs text-red-600">{fieldErrors.content}</p>
{/if}
</div>
{/if}
<div class="flex justify-end gap-2 pt-2">
<button
type="submit"
disabled={!!record?.id && !dirty}
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
{submitLabel}
</button>
</div>
</div>
</form>
@@ -1,152 +0,0 @@
import { page } from 'vitest/browser';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
vi.mock('$app/forms', () => ({
enhance: () => () => {}
}));
vi.mock('$lib/components/tiptap/TipTap.svelte', () => ({
default: ($anchor: Element) => {
const div = document.createElement('div');
div.setAttribute('data-testid', 'tiptap-mock');
$anchor.replaceWith(div);
}
}));
import ProclamationForm from './ProclamationForm.svelte';
const relationOptionsData = {
solfeggioFrequencyId: [
{ label: 'UT - 396', value: 1 },
{ label: 'RE - 417', value: 2 }
],
subscriptionLevelId: [
{ label: 'Free', value: 1 },
{ label: 'Premium', value: 2 }
]
};
const categories = [
{ id: 1, name: 'Healing', parentId: null },
{ id: 2, name: 'Manifestation', parentId: null },
{ id: 3, name: 'Physical Healing', parentId: 1 },
{ id: 4, name: 'Emotional Healing', parentId: 1 }
];
describe('ProclamationForm', () => {
it('renders all basic fields in create mode', async () => {
render(ProclamationForm, {
relationOptionsData,
categories,
isSuper: true
});
await expect.element(page.getByRole('textbox', { name: 'Name' })).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: /^Frequency$/ })).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: /^Category$/ })).toBeInTheDocument();
await expect
.element(page.getByRole('combobox', { name: /^Sub-Category$/ }))
.toBeInTheDocument();
await expect
.element(page.getByRole('combobox', { name: /^Subscription Level$/ }))
.toBeInTheDocument();
await expect.element(page.getByRole('button', { name: 'Save' })).toBeInTheDocument();
});
it('pre-fills name input when editing an existing record', async () => {
render(ProclamationForm, {
record: {
id: 'proc-1',
name: 'Test Proclamation',
solfeggioFrequencyId: 2,
subscriptionLevelId: 2,
categoryId: 3,
parentCategoryId: 1
},
relationOptionsData,
categories,
isSuper: true,
submitLabel: 'Update'
});
await expect
.element(page.getByRole('textbox', { name: 'Name' }))
.toHaveValue('Test Proclamation');
});
it('pre-fills select values when editing an existing record', async () => {
render(ProclamationForm, {
record: {
id: 'proc-1',
name: 'Test Proclamation',
solfeggioFrequencyId: 2,
subscriptionLevelId: 2,
categoryId: 3,
parentCategoryId: 1
},
relationOptionsData,
categories,
isSuper: true
});
const freqEl = (await page
.getByRole('combobox', { name: /^Frequency$/ })
.element()) as HTMLSelectElement;
const levelEl = (await page
.getByRole('combobox', { name: /^Subscription Level$/ })
.element()) as HTMLSelectElement;
expect(freqEl.value).toBe('2');
expect(levelEl.value).toBe('2');
});
it('shows content field when isSuper is true', async () => {
render(ProclamationForm, {
record: { id: 'proc-1', name: 'Test', content: '<p>Secret</p>' },
relationOptionsData,
categories,
isSuper: true
});
await expect.element(page.getByText('Content')).toBeInTheDocument();
await expect.element(page.getByTestId('tiptap-mock')).toBeInTheDocument();
});
it('hides content field when isSuper is false', async () => {
render(ProclamationForm, {
record: { id: 'proc-1', name: 'Test', content: '<p>Secret</p>' },
relationOptionsData,
categories,
isSuper: false
});
await expect.element(page.getByText('Content')).not.toBeInTheDocument();
});
it('disables submit button when editing and not dirty', async () => {
render(ProclamationForm, {
record: { id: 'proc-1', name: 'Test' },
relationOptionsData,
categories,
isSuper: true,
submitLabel: 'Update'
});
await expect.element(page.getByRole('button', { name: 'Update' })).toBeDisabled();
});
it('enables submit button when editing and user modifies a field', async () => {
render(ProclamationForm, {
record: { id: 'proc-1', name: 'Test' },
relationOptionsData,
categories,
isSuper: true,
submitLabel: 'Update'
});
await page.getByRole('textbox', { name: 'Name' }).fill('New Name');
await expect.element(page.getByRole('button', { name: 'Update' })).toBeEnabled();
});
});
@@ -13,6 +13,9 @@ vi.mock('$lib/db', () => ({
},
proclamationCategory: {
findMany: vi.fn()
},
proclamationGroup: {
findMany: vi.fn()
}
}
}));
@@ -31,6 +34,7 @@ const mockLoadRecords = loadRecords as ReturnType<typeof vi.fn>;
const mockDeleteRecord = deleteRecord as ReturnType<typeof vi.fn>;
const mockSuperFormErrorsToFieldErrors = superFormErrorsToFieldErrors as ReturnType<typeof vi.fn>;
const mockCatFindMany = prisma.proclamationCategory.findMany as ReturnType<typeof vi.fn>;
const mockGroupFindMany = prisma.proclamationGroup.findMany as ReturnType<typeof vi.fn>;
const mockFindUnique = prisma.proclamation.findUnique as ReturnType<typeof vi.fn>;
const mockCreate = prisma.proclamation.create as ReturnType<typeof vi.fn>;
const mockUpdate = prisma.proclamation.update as ReturnType<typeof vi.fn>;
@@ -46,11 +50,13 @@ describe('proclamations admin page server', () => {
{ id: '2', name: 'B', content: '<p>secret2</p>' }
];
const relationOptionsData = {};
const categories = [{ id: 1, name: 'Healing', parentId: null }];
const categories = [{ id: 1, name: 'Healing' }];
const groups = [{ id: 1, name: 'General' }];
it('strips content for non-Super users', async () => {
mockLoadRecords.mockResolvedValue({ records: recordsWithContent, relationOptionsData });
mockCatFindMany.mockResolvedValue(categories);
mockGroupFindMany.mockResolvedValue(groups);
const locals = createLocals(mockUser({ roleId: 4 }) as any);
const result = await load({ locals } as any);
@@ -61,13 +67,15 @@ describe('proclamations admin page server', () => {
{ id: '2', name: 'B' }
],
relationOptionsData,
categories
categories,
groups
});
});
it('keeps content for Super users', async () => {
mockLoadRecords.mockResolvedValue({ records: recordsWithContent, relationOptionsData });
mockCatFindMany.mockResolvedValue(categories);
mockGroupFindMany.mockResolvedValue(groups);
const locals = createLocals(mockUser({ roleId: 1 }) as any);
const result = await load({ locals } as any);
@@ -75,7 +83,8 @@ describe('proclamations admin page server', () => {
expect(result).toEqual({
records: recordsWithContent,
relationOptionsData,
categories
categories,
groups
});
});
});
@@ -0,0 +1,174 @@
import { page } from 'vitest/browser';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import type { PageProps } from './$types';
vi.mock('$app/forms', () => ({
enhance: () => () => {}
}));
import Page from './+page.svelte';
const relationOptionsData = {
solfeggioFrequencyId: [
{ label: 'UT - 396', value: 1 },
{ label: 'RE - 417', value: 2 }
]
};
const categories = [
{ id: 1, name: 'Healing' },
{ id: 2, name: 'Manifestation' }
];
const groups = [
{ id: 1, name: 'General' },
{ id: 2, name: 'Specific' }
];
const superAdmin = {
id: 'admin-1',
name: 'Super Admin',
roleId: 1,
roleName: 'SUPER'
};
function makeData(overrides: Partial<PageProps['data']> = {}): PageProps['data'] {
return {
records: [],
relationOptionsData,
categories,
groups,
adminUser: superAdmin,
userRoleId: superAdmin.roleId,
...overrides
};
}
function mountPage(dataOverrides: Partial<PageProps['data']> = {}) {
return render(Page, { params: {}, data: makeData(dataOverrides), form: null });
}
const editRecord = {
id: 'proc-1',
name: 'Test Proclamation',
content: '<p>Secret</p>',
solfeggioFrequencyId: 2,
categoryId: 1,
groupId: 2,
category: { name: 'Healing' },
group: { name: 'Specific' },
solfeggioFrequency: { name: 'RE - 417' }
};
async function openCreateModal() {
await page.getByRole('button', { name: 'New Proclamation +' }).click();
}
async function openEditModal() {
await page.getByRole('cell', { name: 'Test Proclamation' }).click();
}
describe('proclamations admin page (super user)', () => {
it('renders all basic fields in create mode', async () => {
mountPage();
await openCreateModal();
await expect.element(page.getByRole('textbox', { name: 'Name' })).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: /^Frequency$/ })).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: /^Category$/ })).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: /^Group$/ })).toBeInTheDocument();
await expect.element(page.getByRole('button', { name: 'Create' })).toBeInTheDocument();
});
it('pre-fills name input when editing an existing record', async () => {
mountPage({ records: [editRecord] });
await openEditModal();
await expect
.element(page.getByRole('textbox', { name: 'Name' }))
.toHaveValue('Test Proclamation');
});
it('pre-fills select values when editing an existing record', async () => {
mountPage({ records: [editRecord] });
await openEditModal();
const freqEl = (await page
.getByRole('combobox', { name: /^Frequency$/ })
.element()) as HTMLSelectElement;
const categoryEl = (await page
.getByRole('combobox', { name: /^Category$/ })
.element()) as HTMLSelectElement;
const groupEl = (await page
.getByRole('combobox', { name: /^Group$/ })
.element()) as HTMLSelectElement;
expect(freqEl.value).toBe('2');
expect(categoryEl.value).toBe('1');
expect(groupEl.value).toBe('2');
});
it('keeps group selection independent of category selection (movable filter)', async () => {
mountPage({ records: [editRecord] });
await openEditModal();
const categoryEl = (await page
.getByRole('combobox', { name: /^Category$/ })
.element()) as HTMLSelectElement;
const groupEl = (await page
.getByRole('combobox', { name: /^Group$/ })
.element()) as HTMLSelectElement;
categoryEl.value = '2';
categoryEl.dispatchEvent(new Event('change', { bubbles: true }));
expect(categoryEl.value).toBe('2');
expect(groupEl.value).toBe('2');
});
it('shows content editor when isSuper is true', async () => {
mountPage({ records: [editRecord] });
await openEditModal();
await expect.element(page.getByText('Content')).toBeInTheDocument();
await expect.element(page.getByTestId('tiptap-editor')).toBeInTheDocument();
});
it('disables submit button when editing and not dirty', async () => {
mountPage({ records: [editRecord] });
await openEditModal();
await expect.element(page.getByRole('button', { name: 'Update' })).toBeDisabled();
});
it('enables submit button when editing and user modifies a field', async () => {
mountPage({ records: [editRecord] });
await openEditModal();
await page.getByRole('textbox', { name: 'Name' }).fill('New Name');
await expect.element(page.getByRole('button', { name: 'Update' })).toBeEnabled();
});
});
describe('proclamations admin page (non-super user)', () => {
it('hides create/edit entry points and content field from non-super users', async () => {
mountPage({
records: [editRecord],
adminUser: { ...superAdmin, id: 'admin-2', roleId: 2, roleName: 'ADMIN' }
});
await expect
.element(page.getByRole('button', { name: 'New Proclamation +' }))
.not.toBeInTheDocument();
await expect.element(page.getByText('Content')).not.toBeInTheDocument();
});
});