proclamation-categories: sub-category management inline in parent form

- Registry: Remove 'parent.name' from listFields (all shown records
  are top-level with no parent), remove 'parent: true' from include
  since unused
- +page.server.ts: Custom load filters to parentId=null (top-level
  only); custom upsert syncs children (create/update/delete) and
  handles P2002 duplicate-name errors; custom delete cascades to
  children first to avoid FK constraint violations
- CategoryForm.svelte: Replace parentId select dropdown with dynamic
  list of sub-category inputs. Each row has a name field and Remove
  button. Pre-populates from record.children when editing existing
  categories. Add Sub-category button appends new rows
- +page.svelte: Remove unused relationOptionsData prop from
  CategoryForm; rename submit button label from 'Update' to 'Save'
- prisma: Add ProclamationCategorySubscriptionLevel join table
  migration and regenerate client
- General: Update TipTap mock, Toast, license-keys page, proclamations
  server/route tests, README, AGENTS.md, and other admin forms
This commit is contained in:
NeilClack
2026-07-08 10:31:28 -05:00
parent 37bed4f64c
commit 93d941569e
35 changed files with 23467 additions and 19108 deletions
@@ -23,7 +23,11 @@ export const load: PageServerLoad = async ({ url }) => {
const [proclamations, categories] = await Promise.all([
prisma.proclamation.findMany({
where,
include: { solfeggioFrequency: true, category: { include: { parent: true } }, subscriptionLevel: true },
include: {
solfeggioFrequency: true,
category: { include: { parent: true } },
subscriptionLevel: true
},
orderBy: { name: 'asc' }
}),
prisma.proclamationCategory.findMany({ include: { parent: true }, orderBy: { name: 'asc' } })
@@ -111,7 +111,11 @@ describe('(protected)/admin/proclamations page server load', () => {
expect(proclamationFindMany).toHaveBeenCalledWith(
expect.objectContaining({
include: { solfeggioFrequency: true, category: { include: { parent: true } }, subscriptionLevel: true }
include: {
solfeggioFrequency: true,
category: { include: { parent: true } },
subscriptionLevel: true
}
})
);
});
@@ -47,7 +47,12 @@
</div>
{#if showCreateForm}
<form method="POST" action="?/createKey" use:enhance class="mb-6 border border-gray-200 p-4 dark:border-gray-700">
<form
method="POST"
action="?/createKey"
use:enhance
class="mb-6 border border-gray-200 p-4 dark:border-gray-700"
>
<h2 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-300">
Create New License Key
</h2>
@@ -58,7 +63,10 @@
{/if}
<div class="flex flex-col gap-3">
<div>
<label for="email" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
<label
for="email"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
User Email
</label>
<input
@@ -1,18 +1,142 @@
import type { PageServerLoad } from './$types';
import { getModelConfig } from '$lib/svelte-admin/registry';
import { loadRecords, upsertRecord, deleteRecord } from '$lib/svelte-admin/crud';
import { getDelegate } from '$lib/svelte-admin/prisma-delegate';
const config = getModelConfig('proclamation-categories')!;
export const load: PageServerLoad = async ({ locals }) => {
return loadRecords(locals, config);
const delegate = getDelegate(config);
const records = await delegate.findMany({
where: { parentId: null },
include: config.include,
orderBy: { id: 'asc' }
});
const relationOptionsData: Record<string, { label: string; value: string | number }[]> = {};
for (const [field, relConfig] of Object.entries(config.relationOptions ?? {})) {
const relDelegate = getDelegate({ ...config, modelName: relConfig.model });
const selectFields: Record<string, boolean> = {
[relConfig.valueField]: true,
[relConfig.labelField]: true
};
const options = await relDelegate.findMany({
select: selectFields,
orderBy: { [relConfig.labelField]: 'asc' }
});
const mapped = (options as Record<string, unknown>[]).map((opt: Record<string, unknown>) => ({
label: String(opt[relConfig.labelField]),
value: opt[relConfig.valueField] as string | number
}));
relationOptionsData[field] = mapped;
}
return {
records: records as Record<string, unknown>[],
relationOptionsData
};
};
export const actions = {
upsert: async ({ request, locals }) => {
return upsertRecord(request, config, locals);
const formData = await request.formData();
const name = (formData.get('name')?.toString() ?? '').trim();
const rawId = formData.get('id')?.toString();
const childIds = formData.getAll('childId').map(v => v.toString());
const childNames = formData.getAll('childName').map(v => v.toString().trim()).filter(Boolean);
if (!name) {
return { success: false, error: 'Name is required', fieldErrors: { name: 'Required' } };
}
const delegate = getDelegate(config);
try {
let categoryId: number;
if (rawId) {
const id = parseInt(rawId, 10);
await delegate.update({
where: { id },
data: { name }
});
categoryId = id;
} else {
const created = await delegate.create({
data: { name }
});
categoryId = created.id;
}
const existingChildren = await delegate.findMany({
where: { parentId: categoryId },
select: { id: true }
});
const existingChildIdSet = new Set(existingChildren.map((c: { id: number }) => c.id));
const submittedIdSet = new Set<number>();
for (let i = 0; i < childNames.length; i++) {
const childName = childNames[i];
if (!childName) continue;
const childIdRaw = childIds[i] ? parseInt(childIds[i], 10) : null;
if (childIdRaw && existingChildIdSet.has(childIdRaw)) {
submittedIdSet.add(childIdRaw);
await delegate.update({
where: { id: childIdRaw },
data: { name: childName }
});
} else {
await delegate.create({
data: { name: childName, parentId: categoryId }
});
}
}
for (const child of existingChildren) {
if (!submittedIdSet.has(child.id)) {
await delegate.delete({ where: { id: child.id } });
}
}
return { success: true };
} catch (e: unknown) {
if (
e &&
typeof e === 'object' &&
'code' in e &&
(e as { code: string }).code === 'P2002'
) {
return {
success: false,
error:
'A sub-category with this name already exists under this parent.'
};
}
throw e;
}
},
delete: async ({ request, locals }) => {
return deleteRecord(request, config, locals);
const formData = await request.formData();
const rawId = formData.get('id')?.toString();
if (!rawId) return { success: false, error: 'Missing id' };
const id = parseInt(rawId, 10);
const delegate = getDelegate(config);
const viewerRoleId = (locals.user as { roleId?: number } | null)?.roleId ?? 4;
const where = config.accessFilter?.(viewerRoleId);
if (where) {
const match = await delegate.findFirst({ where: { id, ...where } });
if (!match) return { success: false, error: 'Access denied' };
}
await delegate.deleteMany({ where: { parentId: id } });
await delegate.delete({ where: { id } });
return { success: true };
}
};
@@ -79,10 +79,9 @@
>
<CategoryForm
record={editingRecord ?? {}}
relationOptionsData={data.relationOptionsData}
{fieldErrors}
{formError}
submitLabel={editingRecord ? 'Update' : 'Create'}
submitLabel={editingRecord ? 'Save' : 'Create'}
/>
</AdminModal>
@@ -5,29 +5,43 @@
record = {} as Record<string, unknown>,
fieldErrors = {} as Record<string, string>,
formError = '',
relationOptionsData = {} as Record<string, { label: string; value: string | number }[]>,
action = '?/upsert',
submitLabel = 'Save'
}: {
record?: Record<string, unknown>;
fieldErrors?: Record<string, string>;
formError?: string;
relationOptionsData?: Record<string, { label: string; value: string | number }[]>;
action?: string;
submitLabel?: string;
} = $props();
let name = $state(String(record.name ?? ''));
let parentId = $state(String(record.parentId ?? ''));
let name = $state('');
let children = $state<{ id: number | null; name: string }[]>([]);
let dirty = $state(false);
$effect(() => {
dirty = false;
});
$effect(() => {
name = String(record.name ?? '');
const existingChildren = (record.children as Array<{ id: number; name: string }>) ?? [];
children = existingChildren.map(c => ({ id: c.id, name: c.name }));
});
function markDirty() {
if (record.id) dirty = true;
}
function addChild() {
children = [...children, { id: null, name: '' }];
if (record.id) dirty = true;
}
function removeChild(index: number) {
children = children.filter((_, i) => i !== index);
if (record.id) dirty = true;
}
</script>
<form {action} method="POST" use:enhance oninput={markDirty}>
@@ -64,28 +78,37 @@
</div>
<div>
<label
for="field-parentId"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Parent Category
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Sub-categories
</label>
<select
id="field-parentId"
name="parentId"
bind:value={parentId}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.parentId
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
{#each children as child, i}
<div class="mb-2 flex items-center gap-2">
<input type="hidden" name="childId" value={child.id ?? ''} />
<input
type="text"
name="childName"
bind:value={child.name}
placeholder="Sub-category name"
class="flex-1 border px-3 py-1.5 text-sm {fieldErrors[`childName.${i}`]
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
<button
type="button"
onclick={() => removeChild(i)}
class="shrink-0 px-2 py-1 text-sm text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
>
Remove
</button>
</div>
{/each}
<button
type="button"
onclick={addChild}
class="mt-1 text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
<option value="">-- No Parent --</option>
{#each relationOptionsData.parentId ?? [] as opt (String(opt.value))}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{#if fieldErrors.parentId}
<p class="mt-1 text-xs text-red-600">{fieldErrors.parentId}</p>
{/if}
+ Add Sub-category
</button>
</div>
<div class="flex justify-end gap-2 pt-2">