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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user