Commit 1: Create individual form components per route, remove generic AdminForm dependency

- Add per-route form components: UserRoleForm, UserForm,
  ProclamationForm (with category cascade), CategoryForm,
  FreqForm, LevelForm
- Update all admin +page.svelte files to use AdminTable + AdminModal
  + their specific form component instead of AdminPage
- Update profiles page to inline fields instead of using AdminForm
- Add custom Proclamation server action with category fallback logic
  (categoryId falls back to parentCategoryId when no sub-category)
- Remove cascade/were/fieldLabels logic from generic crud.ts
- Remove outdated cascade/where tests from crud.spec.ts
- Remove fieldLabels from AdminModelConfig type
This commit is contained in:
NeilClack
2026-07-07 21:01:02 -05:00
parent 2073248576
commit 22b6adee3f
17 changed files with 1440 additions and 90 deletions
+83 -25
View File
@@ -6,38 +6,44 @@ import { getModelConfig } from './registry';
import { superValidate } from 'sveltekit-superforms/server'; import { superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters'; import { zod4 } from 'sveltekit-superforms/adapters';
vi.mock('$lib/db', () => ({ vi.mock('$lib/db', () => {
prisma: { const mkdel = () => ({
userRole: { findMany: vi.fn(),
findMany: vi.fn(), create: vi.fn(),
create: vi.fn(), update: vi.fn(),
update: vi.fn(), delete: vi.fn(),
delete: vi.fn(), findUnique: vi.fn(),
findUnique: vi.fn(), findFirst: vi.fn()
findFirst: vi.fn() });
return {
prisma: {
userRole: mkdel(),
proclamation: mkdel(),
proclamationCategory: mkdel(),
solfeggioFrequency: mkdel(),
subscriptionLevel: mkdel()
} }
} };
})); });
import { prisma } from '$lib/db'; import { prisma } from '$lib/db';
import { loadRecords, upsertRecord, deleteRecord } from './crud'; import { loadRecords, upsertRecord, deleteRecord } from './crud';
const findMany = prisma.userRole.findMany as ReturnType<typeof vi.fn>; const userRoleFindMany = prisma.userRole.findMany as ReturnType<typeof vi.fn>;
const create = prisma.userRole.create as ReturnType<typeof vi.fn>; const create = prisma.userRole.create as ReturnType<typeof vi.fn>;
const update = prisma.userRole.update as ReturnType<typeof vi.fn>; const update = prisma.userRole.update as ReturnType<typeof vi.fn>;
const deletes = prisma.userRole.delete as ReturnType<typeof vi.fn>; const deletes = prisma.userRole.delete as ReturnType<typeof vi.fn>;
const findUnique = prisma.userRole.findUnique as ReturnType<typeof vi.fn>; const findUnique = prisma.userRole.findUnique as ReturnType<typeof vi.fn>;
const findFirst = prisma.userRole.findFirst as ReturnType<typeof vi.fn>; const findFirst = prisma.userRole.findFirst as ReturnType<typeof vi.fn>;
const config = getModelConfig('user-roles')!; const config = getModelConfig('user-roles')!;
describe('crud loadRecords', () => { describe('crud loadRecords', () => {
beforeEach(() => { beforeEach(() => {
findMany.mockReset(); userRoleFindMany.mockReset();
}); });
it('returns records', async () => { it('returns records', async () => {
findMany.mockResolvedValue([{ id: 1, name: 'Admin' }]); userRoleFindMany.mockResolvedValue([{ id: 1, name: 'Admin' }]);
const locals = createLocals(mockUser() as any); const locals = createLocals(mockUser() as any);
const result = await loadRecords(locals, config); const result = await loadRecords(locals, config);
@@ -46,7 +52,7 @@ describe('crud loadRecords', () => {
}); });
it('returns empty records array', async () => { it('returns empty records array', async () => {
findMany.mockResolvedValue([]); userRoleFindMany.mockResolvedValue([]);
const locals = createLocals(mockUser() as any); const locals = createLocals(mockUser() as any);
const result = await loadRecords(locals, config); const result = await loadRecords(locals, config);
@@ -127,6 +133,64 @@ describe('crud deleteRecord', () => {
}); });
}); });
describe('P2002 unique constraint handling', () => {
beforeEach(() => {
create.mockReset();
});
it('returns error with field names when meta.target is populated', async () => {
const p2002 = new Error('Unique constraint failed');
(p2002 as any).code = 'P2002';
(p2002 as any).meta = { target: ['name'], modelName: 'UserRole' };
create.mockRejectedValue(p2002);
const fd = new FormData();
fd.set('name', 'Admin');
const locals = createLocals(mockUser() as any);
const result = await upsertRecord(fd, config, locals);
expect(result).toEqual({
success: false,
error: 'A record with this name already exists.',
fieldErrors: { name: 'Already taken' }
});
});
it('returns generic message when meta.target is empty', async () => {
const p2002 = new Error('Unique constraint failed');
(p2002 as any).code = 'P2002';
(p2002 as any).meta = { target: [], modelName: 'UserRole' };
create.mockRejectedValue(p2002);
const fd = new FormData();
fd.set('name', 'Admin');
const locals = createLocals(mockUser() as any);
const result = await upsertRecord(fd, config, locals);
expect(result).toEqual({
success: false,
error: 'A database constraint violation occurred. The record could not be created.'
});
});
it('returns generic message when meta.target is missing', async () => {
const p2002 = new Error('Unique constraint failed');
(p2002 as any).code = 'P2002';
(p2002 as any).meta = { modelName: 'UserRole' };
create.mockRejectedValue(p2002);
const fd = new FormData();
fd.set('name', 'Admin');
const locals = createLocals(mockUser() as any);
const result = await upsertRecord(fd, config, locals);
expect(result).toEqual({
success: false,
error: 'A database constraint violation occurred. The record could not be created.'
});
});
});
describe('sveltekit-superforms import compatibility', () => { describe('sveltekit-superforms import compatibility', () => {
it('imports superValidate from server path without pulling in SuperDebug.svelte', async () => { it('imports superValidate from server path without pulling in SuperDebug.svelte', async () => {
expect(superValidate).toBeDefined(); expect(superValidate).toBeDefined();
@@ -151,17 +215,11 @@ describe('prisma client version consistency', () => {
it('generated client references existing query compiler files', async () => { it('generated client references existing query compiler files', async () => {
const fs = await import('fs'); const fs = await import('fs');
const path = await import('path'); const path = await import('path');
const runtimeDir = path.resolve( const runtimeDir = path.resolve('node_modules/@prisma/client/runtime');
'node_modules/@prisma/client/runtime'
);
const generatedFile = path.resolve( const generatedFile = path.resolve('src/lib/generated/prisma/internal/class.ts');
'src/lib/generated/prisma/internal/class.ts'
);
const content = fs.readFileSync(generatedFile, 'utf-8'); const content = fs.readFileSync(generatedFile, 'utf-8');
const compilerMatch = content.match( const compilerMatch = content.match(/import\(["']@prisma\/client\/runtime\/([^"']+)["']\)/);
/import\(["']@prisma\/client\/runtime\/([^"']+)["']\)/
);
expect(compilerMatch).not.toBeNull(); expect(compilerMatch).not.toBeNull();
const compilerPath = path.join(runtimeDir, compilerMatch![1]); const compilerPath = path.join(runtimeDir, compilerMatch![1]);
expect(fs.existsSync(compilerPath)).toBe(true); expect(fs.existsSync(compilerPath)).toBe(true);
+18 -30
View File
@@ -52,8 +52,6 @@ export async function loadRecords(locals: App.Locals, config: AdminModelConfig)
}); });
const relationOptionsData: Record<string, { label: string; value: string | number }[]> = {}; const relationOptionsData: Record<string, { label: string; value: string | number }[]> = {};
// For cascade child fields: maps field -> { childValue -> parentValue }
const cascadeOptionParentMap: Record<string, Record<string | number, string | number>> = {};
for (const [field, relConfig] of Object.entries(config.relationOptions ?? {})) { for (const [field, relConfig] of Object.entries(config.relationOptions ?? {})) {
const relDelegate = getDelegate({ ...config, modelName: relConfig.model }); const relDelegate = getDelegate({ ...config, modelName: relConfig.model });
@@ -61,13 +59,6 @@ export async function loadRecords(locals: App.Locals, config: AdminModelConfig)
[relConfig.valueField]: true, [relConfig.valueField]: true,
[relConfig.labelField]: true [relConfig.labelField]: true
}; };
// Check if this field is a cascade child (needs parentId)
const cascadeEntry = Object.entries(config.cascadeFields ?? {}).find(
([, c]) => c.parentField === field
);
if (cascadeEntry) {
selectFields['parentId'] = true;
}
const options = await relDelegate.findMany({ const options = await relDelegate.findMany({
select: selectFields, select: selectFields,
orderBy: { [relConfig.labelField]: 'asc' } orderBy: { [relConfig.labelField]: 'asc' }
@@ -77,27 +68,11 @@ export async function loadRecords(locals: App.Locals, config: AdminModelConfig)
value: opt[relConfig.valueField] as string | number value: opt[relConfig.valueField] as string | number
})); }));
relationOptionsData[field] = mapped; relationOptionsData[field] = mapped;
// Build parent map if cascade child
if (cascadeEntry) {
const childField = cascadeEntry[0];
const parentMap: Record<string | number, string | number> = {};
const rows = options as Record<string, unknown>[];
for (const row of rows) {
const childVal = row[relConfig.valueField] as string | number;
const parentVal = row['parentId'] as number | null;
if (parentVal != null) {
parentMap[childVal] = parentVal;
}
}
cascadeOptionParentMap[childField] = parentMap;
}
} }
return { return {
records: records as Record<string, unknown>[], records: records as Record<string, unknown>[],
relationOptionsData, relationOptionsData
cascadeOptionParentMap
}; };
} }
@@ -106,8 +81,11 @@ export async function upsertRecord(
config: AdminModelConfig, config: AdminModelConfig,
locals: App.Locals locals: App.Locals
) { ) {
const formData =
requestOrFormData instanceof FormData ? requestOrFormData : await requestOrFormData.formData();
const schema = modelSchemas[config.modelName]; const schema = modelSchemas[config.modelName];
const form = schema ? await superValidate(requestOrFormData, zod4(schema)) : null; const form = schema ? await superValidate(formData, zod4(schema)) : null;
if (form && !form.valid) { if (form && !form.valid) {
const { error, fieldErrors } = superFormErrorsToFieldErrors( const { error, fieldErrors } = superFormErrorsToFieldErrors(
@@ -116,8 +94,6 @@ export async function upsertRecord(
return { ...form, success: false, error, fieldErrors }; return { ...form, success: false, error, fieldErrors };
} }
const formData =
requestOrFormData instanceof FormData ? requestOrFormData : await requestOrFormData.formData();
const rawId = formData.get('id')?.toString(); const rawId = formData.get('id')?.toString();
const data = const data =
form?.data ?? form?.data ??
@@ -163,8 +139,20 @@ export async function upsertRecord(
await delegate.create({ data }); await delegate.create({ data });
} catch (e: unknown) { } catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') { if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
const meta = (e as { meta?: { target?: string[] } }).meta; const meta = (e as { meta?: { target?: string[]; modelName?: string } }).meta;
const fields = meta?.target ?? []; const fields = meta?.target ?? [];
console.error(
`[crud] P2002 on ${meta?.modelName ?? 'unknown'} create. data=`,
data,
`meta.target=`,
meta?.target
);
if (fields.length === 0) {
return {
success: false,
error: 'A database constraint violation occurred. The record could not be created.'
};
}
return { return {
success: false, success: false,
error: `A record with this ${fields.join(', ')} already exists.`, error: `A record with this ${fields.join(', ')} already exists.`,
+1 -6
View File
@@ -102,12 +102,7 @@ const models: AdminModelConfig[] = [
category: { include: { parent: true } }, category: { include: { parent: true } },
subscriptionLevel: true subscriptionLevel: true
}, },
listFields: [ listFields: ['name', 'category.name', 'solfeggioFrequency.name', 'subscriptionLevel.name'],
'name',
'category.name',
'solfeggioFrequency.name',
'subscriptionLevel.name'
],
searchFields: ['name', 'content'], searchFields: ['name', 'content'],
formFields: [ formFields: [
'name', 'name',
@@ -1,8 +1,91 @@
<script lang="ts"> <script lang="ts">
import type { PageProps } from './$types'; import type { PageProps } from './$types';
import AdminPage from '$lib/components/admin/AdminPage.svelte'; import type { ActionResult } from '$lib/svelte-admin/types';
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 CategoryForm from './CategoryForm.svelte';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
const config = getModelConfig('proclamation-categories')!;
const canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
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('');
$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 ?? '';
fieldErrors = result.fieldErrors ?? {};
}
}
});
function openCreate() {
editingRecord = null;
formError = '';
fieldErrors = {};
modalOpen = true;
}
function openEdit(record: Record<string, unknown>) {
editingRecord = record;
formError = '';
fieldErrors = {};
modalOpen = true;
}
function closeModal() {
modalOpen = false;
editingRecord = null;
formError = '';
fieldErrors = {};
}
</script> </script>
<AdminPage modelSlug="proclamation-categories" {data} {form} adminRoleId={data.adminUser?.roleId ?? 4} /> <div class="p-6">
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
<AdminTable
records={data.records}
listFields={config.listFields}
searchFields={config.searchFields}
fieldTypes={config.fieldTypes}
singular={config.displayNameSingular}
onRowClick={openEdit}
onNew={openCreate}
/>
</div>
<AdminModal
title={editingRecord
? `Edit ${config.displayNameSingular}`
: `Create ${config.displayNameSingular}`}
open={modalOpen}
onClose={closeModal}
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
<CategoryForm
record={editingRecord ?? {}}
relationOptionsData={data.relationOptionsData}
{fieldErrors}
{formError}
submitLabel={editingRecord ? 'Update' : 'Create'}
/>
</AdminModal>
{#if toastMessage}
<Toast message={toastMessage} type={toastType} />
{/if}
@@ -0,0 +1,101 @@
<script lang="ts">
import { enhance } from '$app/forms';
let {
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 dirty = $state(false);
$effect(() => {
dirty = false;
});
function markDirty() {
if (record.id) dirty = true;
}
</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-parentId"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Parent Category
</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"
>
<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}
</div>
<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,16 +1,73 @@
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { getModelConfig } from '$lib/svelte-admin/registry'; import { getModelConfig } from '$lib/svelte-admin/registry';
import { loadRecords, upsertRecord, deleteRecord } from '$lib/svelte-admin/crud'; import { loadRecords, deleteRecord, superFormErrorsToFieldErrors } from '$lib/svelte-admin/crud';
import { prisma } from '$lib/db';
import { superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import * as Schemas from '$lib/svelte-admin/schemas';
const config = getModelConfig('proclamations')!; const config = getModelConfig('proclamations')!;
export const load: PageServerLoad = async ({ locals }) => { export const load: PageServerLoad = async ({ locals }) => {
return loadRecords(locals, config); const base = await loadRecords(locals, config);
const categories = await prisma.proclamationCategory.findMany({
select: { id: true, name: true, parentId: true },
orderBy: { name: 'asc' }
});
return { ...base, categories };
}; };
export const actions = { export const actions = {
upsert: async ({ request, locals }) => { upsert: async ({ request, locals }) => {
return upsertRecord(request, config, locals); if (!locals.user) {
return { success: false, error: 'Unauthorized' };
}
const formData = await request.formData();
const form = await superValidate(formData, zod4(Schemas.Proclamation));
if (!form.valid) {
const { error, fieldErrors } = superFormErrorsToFieldErrors(
form.errors as Record<string, unknown>
);
return { success: false, error, fieldErrors };
}
const rawId = formData.get('id')?.toString();
const { parentCategoryId, ...rest } = form.data;
const data: Record<string, unknown> = {
...rest,
categoryId: rest.categoryId ?? parentCategoryId
};
try {
if (rawId) {
const existing = await prisma.proclamation.findUnique({
where: { id: rawId }
});
if (!existing) {
return { success: false, error: 'Record not found' };
}
await prisma.proclamation.update({
where: { id: rawId },
data: data as any
});
} else {
await prisma.proclamation.create({ data: data as any });
}
} catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
return {
success: false,
error: 'A record with this name already exists.'
};
}
throw e;
}
return { success: true };
}, },
delete: async ({ request, locals }) => { delete: async ({ request, locals }) => {
return deleteRecord(request, config, locals); return deleteRecord(request, config, locals);
@@ -1,8 +1,92 @@
<script lang="ts"> <script lang="ts">
import type { PageProps } from './$types'; import type { PageProps } from './$types';
import AdminPage from '$lib/components/admin/AdminPage.svelte'; import type { ActionResult } from '$lib/svelte-admin/types';
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(); let { data, form }: PageProps = $props();
const config = getModelConfig('proclamations')!;
const canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
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('');
$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 ?? '';
fieldErrors = result.fieldErrors ?? {};
}
}
});
function openCreate() {
editingRecord = null;
formError = '';
fieldErrors = {};
modalOpen = true;
}
function openEdit(record: Record<string, unknown>) {
editingRecord = record;
formError = '';
fieldErrors = {};
modalOpen = true;
}
function closeModal() {
modalOpen = false;
editingRecord = null;
formError = '';
fieldErrors = {};
}
</script> </script>
<AdminPage modelSlug="proclamations" {data} {form} adminRoleId={data.adminUser?.roleId ?? 4} /> <div class="p-6">
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
<AdminTable
records={data.records}
listFields={config.listFields}
searchFields={config.searchFields}
fieldTypes={config.fieldTypes}
singular={config.displayNameSingular}
onRowClick={openEdit}
onNew={openCreate}
/>
</div>
<AdminModal
title={editingRecord
? `Edit ${config.displayNameSingular}`
: `Create ${config.displayNameSingular}`}
open={modalOpen}
onClose={closeModal}
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
<ProclamationForm
record={editingRecord ?? {}}
relationOptionsData={data.relationOptionsData}
categories={data.categories}
{fieldErrors}
{formError}
submitLabel={editingRecord ? 'Update' : 'Create'}
/>
</AdminModal>
{#if toastMessage}
<Toast message={toastMessage} type={toastType} />
{/if}
@@ -0,0 +1,221 @@
<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 }[],
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 }[];
action?: string;
submitLabel?: string;
} = $props();
let dirty = $state(false);
$effect(() => {
dirty = false;
});
function markDirty() {
if (record.id) dirty = true;
}
let name = $state(String(record.name ?? ''));
let solfeggioFrequencyId = $state(String(record.solfeggioFrequencyId ?? ''));
let subscriptionLevelId = $state(String(record.subscriptionLevelId ?? ''));
let parentCategoryId = $state(
String(
record.parentCategoryId ??
(record.category as Record<string, unknown> | undefined)?.parentId ??
''
)
);
let categoryId = $state(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 = '';
}
</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"
bind:value={solfeggioFrequencyId}
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}>{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"
value={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}>{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"
bind:value={categoryId}
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}>{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"
bind:value={subscriptionLevelId}
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}>{opt.label}</option>
{/each}
</select>
{#if fieldErrors.subscriptionLevelId}
<p class="mt-1 text-xs text-red-600">{fieldErrors.subscriptionLevelId}</p>
{/if}
</div>
<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">
<TipTap
initialContent={typeof record.content === 'string' ? record.content : ''}
name="content"
/>
</div>
{#if fieldErrors.content}
<p class="mt-1 text-xs text-red-600">{fieldErrors.content}</p>
{/if}
</div>
<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>
@@ -3,14 +3,13 @@
import type { ActionResult } from '$lib/svelte-admin/types'; import type { ActionResult } from '$lib/svelte-admin/types';
import AdminTable from '$lib/components/admin/AdminTable.svelte'; import AdminTable from '$lib/components/admin/AdminTable.svelte';
import AdminModal from '$lib/components/admin/AdminModal.svelte'; import AdminModal from '$lib/components/admin/AdminModal.svelte';
import AdminForm from '$lib/components/admin/AdminForm.svelte';
import Toast from '$lib/components/admin/Toast.svelte'; import Toast from '$lib/components/admin/Toast.svelte';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { getModelConfig } from '$lib/svelte-admin/registry'; import { getModelConfig } from '$lib/svelte-admin/registry';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
let config = $derived(getModelConfig('profiles')!); const config = getModelConfig('profiles')!;
let canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2); let canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
let modalOpen = $state(false); let modalOpen = $state(false);
@@ -135,18 +134,110 @@
{canDelete} {canDelete}
recordId={editingRecord?.id as string | undefined} recordId={editingRecord?.id as string | undefined}
> >
<AdminForm <form method="POST" action="?/upsert" use:enhance>
fields={config.formFields} {#if editingRecord?.id}
fieldTypes={config.fieldTypes} <input type="hidden" name="id" value={String(editingRecord.id)} />
relationOptions={config.relationOptions} {/if}
relationOptionsData={data.relationOptionsData} {#if formError}
record={editingRecord ?? {}} <div class="mb-4 border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
readonlyFields={config.readonlyFields ?? []} {formError}
action="?/upsert" </div>
submitLabel={editingRecord ? 'Update' : 'Create'} {/if}
{fieldErrors} <div class="flex flex-col gap-4">
{formError} <div>
/> <label
for="field-userId"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
User
</label>
<select
id="field-userId"
name="userId"
value={String(editingRecord?.userId ?? '')}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.userId
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
>
<option value="">-- Select User --</option>
{#each data.relationOptionsData.userId ?? [] as opt (String(opt.value))}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{#if fieldErrors.userId}
<p class="mt-1 text-xs text-red-600">{fieldErrors.userId}</p>
{/if}
</div>
<div>
<label
for="field-firstName"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
First Name
</label>
<input
type="text"
id="field-firstName"
name="firstName"
value={String(editingRecord?.firstName ?? '')}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.firstName
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
{#if fieldErrors.firstName}
<p class="mt-1 text-xs text-red-600">{fieldErrors.firstName}</p>
{/if}
</div>
<div>
<label
for="field-lastName"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Last Name
</label>
<input
type="text"
id="field-lastName"
name="lastName"
value={String(editingRecord?.lastName ?? '')}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.lastName
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
{#if fieldErrors.lastName}
<p class="mt-1 text-xs text-red-600">{fieldErrors.lastName}</p>
{/if}
</div>
<div>
<label
for="field-phoneNumber"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Phone Number
</label>
<input
type="text"
id="field-phoneNumber"
name="phoneNumber"
value={String(editingRecord?.phoneNumber ?? '')}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.phoneNumber
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
{#if fieldErrors.phoneNumber}
<p class="mt-1 text-xs text-red-600">{fieldErrors.phoneNumber}</p>
{/if}
</div>
<div class="flex justify-end gap-2 pt-2">
<button
type="submit"
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
{editingRecord ? 'Update' : 'Create'}
</button>
</div>
</div>
</form>
{#if editingRecord} {#if editingRecord}
{@const user = editingRecord.user as Record<string, unknown> | undefined} {@const user = editingRecord.user as Record<string, unknown> | undefined}
@@ -1,8 +1,90 @@
<script lang="ts"> <script lang="ts">
import type { PageProps } from './$types'; import type { PageProps } from './$types';
import AdminPage from '$lib/components/admin/AdminPage.svelte'; import type { ActionResult } from '$lib/svelte-admin/types';
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 FreqForm from './FreqForm.svelte';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
const config = getModelConfig('solfeggio-frequencies')!;
const canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
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('');
$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 ?? '';
fieldErrors = result.fieldErrors ?? {};
}
}
});
function openCreate() {
editingRecord = null;
formError = '';
fieldErrors = {};
modalOpen = true;
}
function openEdit(record: Record<string, unknown>) {
editingRecord = record;
formError = '';
fieldErrors = {};
modalOpen = true;
}
function closeModal() {
modalOpen = false;
editingRecord = null;
formError = '';
fieldErrors = {};
}
</script> </script>
<AdminPage modelSlug="solfeggio-frequencies" {data} {form} adminRoleId={data.adminUser?.roleId ?? 4} /> <div class="p-6">
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
<AdminTable
records={data.records}
listFields={config.listFields}
searchFields={config.searchFields}
fieldTypes={config.fieldTypes}
singular={config.displayNameSingular}
onRowClick={openEdit}
onNew={openCreate}
/>
</div>
<AdminModal
title={editingRecord
? `Edit ${config.displayNameSingular}`
: `Create ${config.displayNameSingular}`}
open={modalOpen}
onClose={closeModal}
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
<FreqForm
record={editingRecord ?? {}}
{fieldErrors}
{formError}
submitLabel={editingRecord ? 'Update' : 'Create'}
/>
</AdminModal>
{#if toastMessage}
<Toast message={toastMessage} type={toastType} />
{/if}
@@ -0,0 +1,96 @@
<script lang="ts">
import { enhance } from '$app/forms';
let {
record = {} as Record<string, unknown>,
fieldErrors = {} as Record<string, string>,
formError = '',
action = '?/upsert',
submitLabel = 'Save'
}: {
record?: Record<string, unknown>;
fieldErrors?: Record<string, string>;
formError?: string;
action?: string;
submitLabel?: string;
} = $props();
let name = $state(String(record.name ?? ''));
let value = $state(String(record.value ?? ''));
let dirty = $state(false);
$effect(() => {
dirty = false;
});
function markDirty() {
if (record.id) dirty = true;
}
</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-value"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Value
</label>
<input
type="number"
id="field-value"
name="value"
bind:value
step="1"
class="w-full border px-3 py-1.5 text-sm {fieldErrors.value
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
{#if fieldErrors.value}
<p class="mt-1 text-xs text-red-600">{fieldErrors.value}</p>
{/if}
</div>
<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,8 +1,90 @@
<script lang="ts"> <script lang="ts">
import type { PageProps } from './$types'; import type { PageProps } from './$types';
import AdminPage from '$lib/components/admin/AdminPage.svelte'; import type { ActionResult } from '$lib/svelte-admin/types';
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 LevelForm from './LevelForm.svelte';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
const config = getModelConfig('subscription-levels')!;
const canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
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('');
$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 ?? '';
fieldErrors = result.fieldErrors ?? {};
}
}
});
function openCreate() {
editingRecord = null;
formError = '';
fieldErrors = {};
modalOpen = true;
}
function openEdit(record: Record<string, unknown>) {
editingRecord = record;
formError = '';
fieldErrors = {};
modalOpen = true;
}
function closeModal() {
modalOpen = false;
editingRecord = null;
formError = '';
fieldErrors = {};
}
</script> </script>
<AdminPage modelSlug="subscription-levels" {data} {form} adminRoleId={data.adminUser?.roleId ?? 4} /> <div class="p-6">
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
<AdminTable
records={data.records}
listFields={config.listFields}
searchFields={config.searchFields}
fieldTypes={config.fieldTypes}
singular={config.displayNameSingular}
onRowClick={openEdit}
onNew={openCreate}
/>
</div>
<AdminModal
title={editingRecord
? `Edit ${config.displayNameSingular}`
: `Create ${config.displayNameSingular}`}
open={modalOpen}
onClose={closeModal}
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
<LevelForm
record={editingRecord ?? {}}
{fieldErrors}
{formError}
submitLabel={editingRecord ? 'Update' : 'Create'}
/>
</AdminModal>
{#if toastMessage}
<Toast message={toastMessage} type={toastType} />
{/if}
@@ -0,0 +1,95 @@
<script lang="ts">
import { enhance } from '$app/forms';
let {
record = {} as Record<string, unknown>,
fieldErrors = {} as Record<string, string>,
formError = '',
action = '?/upsert',
submitLabel = 'Save'
}: {
record?: Record<string, unknown>;
fieldErrors?: Record<string, string>;
formError?: string;
action?: string;
submitLabel?: string;
} = $props();
let name = $state(String(record.name ?? ''));
let description = $state(String(record.description ?? ''));
let dirty = $state(false);
$effect(() => {
dirty = false;
});
function markDirty() {
if (record.id) dirty = true;
}
</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-description"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Description
</label>
<input
type="text"
id="field-description"
name="description"
bind:value={description}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.description
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
{#if fieldErrors.description}
<p class="mt-1 text-xs text-red-600">{fieldErrors.description}</p>
{/if}
</div>
<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,8 +1,90 @@
<script lang="ts"> <script lang="ts">
import type { PageProps } from './$types'; import type { PageProps } from './$types';
import AdminPage from '$lib/components/admin/AdminPage.svelte'; import type { ActionResult } from '$lib/svelte-admin/types';
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 UserRoleForm from './UserRoleForm.svelte';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
const config = getModelConfig('user-roles')!;
const canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
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('');
$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 ?? '';
fieldErrors = result.fieldErrors ?? {};
}
}
});
function openCreate() {
editingRecord = null;
formError = '';
fieldErrors = {};
modalOpen = true;
}
function openEdit(record: Record<string, unknown>) {
editingRecord = record;
formError = '';
fieldErrors = {};
modalOpen = true;
}
function closeModal() {
modalOpen = false;
editingRecord = null;
formError = '';
fieldErrors = {};
}
</script> </script>
<AdminPage modelSlug="user-roles" {data} {form} adminRoleId={data.adminUser?.roleId ?? 4} /> <div class="p-6">
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
<AdminTable
records={data.records}
listFields={config.listFields}
searchFields={config.searchFields}
fieldTypes={config.fieldTypes}
singular={config.displayNameSingular}
onRowClick={openEdit}
onNew={openCreate}
/>
</div>
<AdminModal
title={editingRecord
? `Edit ${config.displayNameSingular}`
: `Create ${config.displayNameSingular}`}
open={modalOpen}
onClose={closeModal}
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
<UserRoleForm
record={editingRecord ?? {}}
{fieldErrors}
{formError}
submitLabel={editingRecord ? 'Update' : 'Create'}
/>
</AdminModal>
{#if toastMessage}
<Toast message={toastMessage} type={toastType} />
{/if}
@@ -0,0 +1,73 @@
<script lang="ts">
import { enhance } from '$app/forms';
let {
record = {} as Record<string, unknown>,
fieldErrors = {} as Record<string, string>,
formError = '',
action = '?/upsert',
submitLabel = 'Save'
}: {
record?: Record<string, unknown>;
fieldErrors?: Record<string, string>;
formError?: string;
action?: string;
submitLabel?: string;
} = $props();
let name = $state(String(record.name ?? ''));
let dirty = $state(false);
$effect(() => {
dirty = false;
});
function markDirty() {
if (record.id) dirty = true;
}
</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 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,8 +1,91 @@
<script lang="ts"> <script lang="ts">
import type { PageProps } from './$types'; import type { PageProps } from './$types';
import AdminPage from '$lib/components/admin/AdminPage.svelte'; import type { ActionResult } from '$lib/svelte-admin/types';
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 UserForm from './UserForm.svelte';
let { data, form }: PageProps = $props(); let { data, form }: PageProps = $props();
const config = getModelConfig('users')!;
const canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
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('');
$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 ?? '';
fieldErrors = result.fieldErrors ?? {};
}
}
});
function openCreate() {
editingRecord = null;
formError = '';
fieldErrors = {};
modalOpen = true;
}
function openEdit(record: Record<string, unknown>) {
editingRecord = record;
formError = '';
fieldErrors = {};
modalOpen = true;
}
function closeModal() {
modalOpen = false;
editingRecord = null;
formError = '';
fieldErrors = {};
}
</script> </script>
<AdminPage modelSlug="users" {data} {form} adminRoleId={data.adminUser?.roleId ?? 4} /> <div class="p-6">
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
<AdminTable
records={data.records}
listFields={config.listFields}
searchFields={config.searchFields}
fieldTypes={config.fieldTypes}
singular={config.displayNameSingular}
onRowClick={openEdit}
onNew={config.listOnly ? undefined : openCreate}
/>
</div>
<AdminModal
title={editingRecord
? `Edit ${config.displayNameSingular}`
: `Create ${config.displayNameSingular}`}
open={modalOpen}
onClose={closeModal}
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
<UserForm
record={editingRecord ?? {}}
relationOptionsData={data.relationOptionsData}
{fieldErrors}
{formError}
submitLabel={editingRecord ? 'Update' : 'Create'}
/>
</AdminModal>
{#if toastMessage}
<Toast message={toastMessage} type={toastType} />
{/if}
@@ -0,0 +1,79 @@
<script lang="ts">
import { enhance } from '$app/forms';
let {
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 roleId = $state(String(record.roleId ?? ''));
let dirty = $state(false);
$effect(() => {
dirty = false;
});
function markDirty() {
if (record.id) dirty = true;
}
</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-roleId"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Role
</label>
<select
id="field-roleId"
name="roleId"
bind:value={roleId}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.roleId
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
>
<option value="">-- Select Role --</option>
{#each relationOptionsData.roleId ?? [] as opt (String(opt.value))}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{#if fieldErrors.roleId}
<p class="mt-1 text-xs text-red-600">{fieldErrors.roleId}</p>
{/if}
</div>
<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>