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:
@@ -6,38 +6,44 @@ import { getModelConfig } from './registry';
|
||||
import { superValidate } from 'sveltekit-superforms/server';
|
||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||
|
||||
vi.mock('$lib/db', () => ({
|
||||
prisma: {
|
||||
userRole: {
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn()
|
||||
vi.mock('$lib/db', () => {
|
||||
const mkdel = () => ({
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn()
|
||||
});
|
||||
return {
|
||||
prisma: {
|
||||
userRole: mkdel(),
|
||||
proclamation: mkdel(),
|
||||
proclamationCategory: mkdel(),
|
||||
solfeggioFrequency: mkdel(),
|
||||
subscriptionLevel: mkdel()
|
||||
}
|
||||
}
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
import { prisma } from '$lib/db';
|
||||
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 update = prisma.userRole.update 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 findFirst = prisma.userRole.findFirst as ReturnType<typeof vi.fn>;
|
||||
|
||||
const config = getModelConfig('user-roles')!;
|
||||
|
||||
describe('crud loadRecords', () => {
|
||||
beforeEach(() => {
|
||||
findMany.mockReset();
|
||||
userRoleFindMany.mockReset();
|
||||
});
|
||||
|
||||
it('returns records', async () => {
|
||||
findMany.mockResolvedValue([{ id: 1, name: 'Admin' }]);
|
||||
userRoleFindMany.mockResolvedValue([{ id: 1, name: 'Admin' }]);
|
||||
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const result = await loadRecords(locals, config);
|
||||
@@ -46,7 +52,7 @@ describe('crud loadRecords', () => {
|
||||
});
|
||||
|
||||
it('returns empty records array', async () => {
|
||||
findMany.mockResolvedValue([]);
|
||||
userRoleFindMany.mockResolvedValue([]);
|
||||
|
||||
const locals = createLocals(mockUser() as any);
|
||||
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', () => {
|
||||
it('imports superValidate from server path without pulling in SuperDebug.svelte', async () => {
|
||||
expect(superValidate).toBeDefined();
|
||||
@@ -151,17 +215,11 @@ describe('prisma client version consistency', () => {
|
||||
it('generated client references existing query compiler files', async () => {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const runtimeDir = path.resolve(
|
||||
'node_modules/@prisma/client/runtime'
|
||||
);
|
||||
const runtimeDir = path.resolve('node_modules/@prisma/client/runtime');
|
||||
|
||||
const generatedFile = path.resolve(
|
||||
'src/lib/generated/prisma/internal/class.ts'
|
||||
);
|
||||
const generatedFile = path.resolve('src/lib/generated/prisma/internal/class.ts');
|
||||
const content = fs.readFileSync(generatedFile, 'utf-8');
|
||||
const compilerMatch = content.match(
|
||||
/import\(["']@prisma\/client\/runtime\/([^"']+)["']\)/
|
||||
);
|
||||
const compilerMatch = content.match(/import\(["']@prisma\/client\/runtime\/([^"']+)["']\)/);
|
||||
expect(compilerMatch).not.toBeNull();
|
||||
const compilerPath = path.join(runtimeDir, compilerMatch![1]);
|
||||
expect(fs.existsSync(compilerPath)).toBe(true);
|
||||
|
||||
@@ -52,8 +52,6 @@ export async function loadRecords(locals: App.Locals, config: AdminModelConfig)
|
||||
});
|
||||
|
||||
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 ?? {})) {
|
||||
const relDelegate = getDelegate({ ...config, modelName: relConfig.model });
|
||||
@@ -61,13 +59,6 @@ export async function loadRecords(locals: App.Locals, config: AdminModelConfig)
|
||||
[relConfig.valueField]: 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({
|
||||
select: selectFields,
|
||||
orderBy: { [relConfig.labelField]: 'asc' }
|
||||
@@ -77,27 +68,11 @@ export async function loadRecords(locals: App.Locals, config: AdminModelConfig)
|
||||
value: opt[relConfig.valueField] as string | number
|
||||
}));
|
||||
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 {
|
||||
records: records as Record<string, unknown>[],
|
||||
relationOptionsData,
|
||||
cascadeOptionParentMap
|
||||
relationOptionsData
|
||||
};
|
||||
}
|
||||
|
||||
@@ -106,8 +81,11 @@ export async function upsertRecord(
|
||||
config: AdminModelConfig,
|
||||
locals: App.Locals
|
||||
) {
|
||||
const formData =
|
||||
requestOrFormData instanceof FormData ? requestOrFormData : await requestOrFormData.formData();
|
||||
|
||||
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) {
|
||||
const { error, fieldErrors } = superFormErrorsToFieldErrors(
|
||||
@@ -116,8 +94,6 @@ export async function upsertRecord(
|
||||
return { ...form, success: false, error, fieldErrors };
|
||||
}
|
||||
|
||||
const formData =
|
||||
requestOrFormData instanceof FormData ? requestOrFormData : await requestOrFormData.formData();
|
||||
const rawId = formData.get('id')?.toString();
|
||||
const data =
|
||||
form?.data ??
|
||||
@@ -163,8 +139,20 @@ export async function upsertRecord(
|
||||
await delegate.create({ data });
|
||||
} catch (e: unknown) {
|
||||
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 ?? [];
|
||||
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 {
|
||||
success: false,
|
||||
error: `A record with this ${fields.join(', ')} already exists.`,
|
||||
|
||||
@@ -102,12 +102,7 @@ const models: AdminModelConfig[] = [
|
||||
category: { include: { parent: true } },
|
||||
subscriptionLevel: true
|
||||
},
|
||||
listFields: [
|
||||
'name',
|
||||
'category.name',
|
||||
'solfeggioFrequency.name',
|
||||
'subscriptionLevel.name'
|
||||
],
|
||||
listFields: ['name', 'category.name', 'solfeggioFrequency.name', 'subscriptionLevel.name'],
|
||||
searchFields: ['name', 'content'],
|
||||
formFields: [
|
||||
'name',
|
||||
|
||||
Reference in New Issue
Block a user