Customize Proclamations Admin page

This commit is contained in:
NeilClack
2026-03-16 13:23:25 -05:00
parent 0fd901b01f
commit 52c740b986
15 changed files with 294 additions and 160 deletions
+6 -2
View File
@@ -2,9 +2,8 @@
import { fieldNameToLabel } from '$lib/svelte-admin/introspect';
import type { PrismaScalarType, RelationOptionConfig } from '$lib/svelte-admin/types';
import ComboBox from './ComboBox.svelte';
import TipTap from '$lib/components/tiptap/TipTap.svelte';
type RelationOptionsData = Record<string, { label: string; value: string | number }[]>;
type Props = {
fields: string[];
fieldTypes: Record<string, PrismaScalarType>;
@@ -56,6 +55,11 @@
placeholder="Select {label}"
value={getValue(field) as string | number | undefined}
/>
{:else if type === 'Tiptap'}
<TipTap
name={field}
initialContent={String(getValue(field) ?? '')}
/>
{:else if type === 'Boolean'}
<input
type="checkbox"
+1 -1
View File
@@ -60,7 +60,6 @@
const related = e.relatedTarget as HTMLElement | null;
if (related?.closest(`[data-combobox="${id}"]`)) return;
open = false;
search = '';
highlightIndex = -1;
}
@@ -97,6 +96,7 @@
<div class="relative" data-combobox={id}>
<input type="hidden" {name} value={selectedValue ?? ''} />
<input type="hidden" name={name + 'Label'} value={selectedValue != null && selectedValue !== '' ? selectedLabel() : search} />
{#if selectedValue != null && selectedValue !== ''}
<div
+3 -3
View File
@@ -97,13 +97,13 @@ const models: AdminModelConfig[] = [
displayNameSingular: 'Proclamation',
idType: 'String',
include: { solfeggioFrequency: true, category: { include: { parent: true } } },
listFields: ['name', 'category.name', 'solfeggioFrequency.name', 'price'],
listFields: ['name', 'category.displayName', 'solfeggioFrequency.name', 'price'],
searchFields: ['name', 'content'],
formFields: ['name', 'content', 'solfeggioFrequencyId', 'categoryId', 'price'],
readonlyFields: ['id'],
fieldTypes: {
name: 'String',
content: 'String',
content: 'Tiptap',
solfeggioFrequencyId: 'Int',
categoryId: 'Int',
price: 'Float'
@@ -153,7 +153,7 @@ const models: AdminModelConfig[] = [
displayNameSingular: 'Proclamation Category',
idType: 'Int',
include: { proclamations: true, children: true, parent: true },
listFields: ['name', 'parent.name'],
listFields: ['displayName', 'parent.name'],
searchFields: ['name'],
formFields: ['name', 'parentId'],
readonlyFields: ['id'],
+1 -1
View File
@@ -1,4 +1,4 @@
export type PrismaScalarType = 'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime';
export type PrismaScalarType = 'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime' | 'Tiptap';
export type RelationOptionConfig = {
model: string;
@@ -7,6 +7,69 @@ import { prisma } from '$lib/db';
import type { RelationOptionConfig } from '$lib/svelte-admin/types';
import { generateLicenseKey } from '$lib/utils';
function getCategoryDisplayName(category: Record<string, unknown> | null | undefined) {
if (!category) return '';
const name = String(category.name ?? '');
const parent = category.parent as Record<string, unknown> | undefined;
if (parent?.name) return `${String(parent.name)}/${name}`;
return name;
}
function parseIntId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value;
if (typeof value === 'string' && value.trim() !== '') {
const parsed = parseInt(value, 10);
return Number.isNaN(parsed) || parsed <= 0 ? null : parsed;
}
return null;
}
async function findOrCreateCategoryByLabel(label: string): Promise<number> {
const normalized = label.trim();
if (!normalized) throw new Error('Invalid category label');
const [parentName, childName] = normalized.split('/').map((v) => v.trim());
if (childName) {
let parent = await prisma.proclamationCategory.findFirst({
where: { name: parentName, parentId: null }
});
if (!parent) {
parent = await prisma.proclamationCategory.create({ data: { name: parentName } });
}
let child = await prisma.proclamationCategory.findFirst({
where: { name: childName, parentId: parent.id }
});
if (!child) {
child = await prisma.proclamationCategory.create({
data: { name: childName, parentId: parent.id }
});
}
return child.id;
}
let category = await prisma.proclamationCategory.findFirst({
where: { name: normalized, parentId: null }
});
if (!category) {
category = await prisma.proclamationCategory.create({ data: { name: normalized } });
}
return category.id;
}
async function resolveCategoryIdFromFormData(formData: FormData, categoryField: string): Promise<number | null> {
const categoryValue = formData.get(categoryField);
const categoryLabelValue = formData.get(`${categoryField}Label`);
const parsedId = parseIntId(categoryValue);
if (parsedId) return parsedId;
const label = String(categoryLabelValue ?? categoryValue ?? '').trim();
if (!label) return null;
return await findOrCreateCategoryByLabel(label);
}
async function loadRelationOptions(
relationOptions: Record<string, RelationOptionConfig> | undefined
) {
@@ -19,14 +82,30 @@ async function loadRelationOptions(
const delegate = (prisma as any)[key];
if (!delegate) continue;
const shouldIncludeParent = config.model === 'ProclamationCategory';
const select: Record<string, unknown> = {
[config.valueField]: true,
[config.labelField]: true
};
if (shouldIncludeParent) {
(select as Record<string, unknown>).parent = { select: { name: true } };
}
const records = await delegate.findMany({
select: { [config.valueField]: true, [config.labelField]: true }
select
});
result[field] = records.map((r: Record<string, unknown>) => ({
label: String(r[config.labelField]),
value: r[config.valueField] as string | number
}));
result[field] = records.map((r: Record<string, unknown>) => {
const value = r[config.valueField] as string | number;
const label =
config.model === 'ProclamationCategory'
? getCategoryDisplayName(r)
: String(r[config.labelField]);
return {
label,
value
};
});
}
return result;
@@ -41,11 +120,28 @@ export const load: PageServerLoad = async ({ params, parent }) => {
const where = config.accessFilter ? config.accessFilter(adminUser.roleId) : undefined;
const records = await delegate.findMany({
let records = await delegate.findMany({
...(config.include ? { include: config.include } : {}),
...(where ? { where } : {})
});
if (config.modelName === 'Proclamation') {
records = records.map((r: Record<string, unknown>) => {
const category = r.category as Record<string, unknown> | undefined;
if (category) {
(category as Record<string, unknown>).displayName = getCategoryDisplayName(category);
}
return r;
});
}
if (config.modelName === 'ProclamationCategory') {
records = records.map((r: Record<string, unknown>) => {
(r as Record<string, unknown>).displayName = getCategoryDisplayName(r as Record<string, unknown>);
return r;
});
}
const relationOptionsData = await loadRelationOptions(config.relationOptions);
return {
@@ -77,6 +173,13 @@ export const actions: Actions = {
(data as Record<string, unknown>).key = generateLicenseKey();
}
if (config.modelName === 'Proclamation') {
const resolvedCategoryId = await resolveCategoryIdFromFormData(formData, 'categoryId');
if (resolvedCategoryId != null) {
(data as Record<string, unknown>).categoryId = resolvedCategoryId;
}
}
try {
const delegate = getDelegate(config);
await delegate.create({ data });
@@ -6,6 +6,69 @@ import { extractFormData } from '$lib/svelte-admin/form-data';
import { prisma } from '$lib/db';
import type { RelationOptionConfig } from '$lib/svelte-admin/types';
function getCategoryDisplayName(category: Record<string, unknown> | null | undefined) {
if (!category) return '';
const name = String(category.name ?? '');
const parent = category.parent as Record<string, unknown> | undefined;
if (parent?.name) return `${String(parent.name)}/${name}`;
return name;
}
function parseIntId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value;
if (typeof value === 'string' && value.trim() !== '') {
const parsed = parseInt(value, 10);
return Number.isNaN(parsed) || parsed <= 0 ? null : parsed;
}
return null;
}
async function findOrCreateCategoryByLabel(label: string): Promise<number> {
const normalized = label.trim();
if (!normalized) throw new Error('Invalid category label');
const [parentName, childName] = normalized.split('/').map((v) => v.trim());
if (childName) {
let parent = await prisma.proclamationCategory.findFirst({
where: { name: parentName, parentId: null }
});
if (!parent) {
parent = await prisma.proclamationCategory.create({ data: { name: parentName } });
}
let child = await prisma.proclamationCategory.findFirst({
where: { name: childName, parentId: parent.id }
});
if (!child) {
child = await prisma.proclamationCategory.create({
data: { name: childName, parentId: parent.id }
});
}
return child.id;
}
let category = await prisma.proclamationCategory.findFirst({
where: { name: normalized, parentId: null }
});
if (!category) {
category = await prisma.proclamationCategory.create({ data: { name: normalized } });
}
return category.id;
}
async function resolveCategoryIdFromFormData(formData: FormData, categoryField: string): Promise<number | null> {
const categoryValue = formData.get(categoryField);
const categoryLabelValue = formData.get(`${categoryField}Label`);
const parsedId = parseIntId(categoryValue);
if (parsedId) return parsedId;
const label = String(categoryLabelValue ?? categoryValue ?? '').trim();
if (!label) return null;
return await findOrCreateCategoryByLabel(label);
}
async function loadRelationOptions(
relationOptions: Record<string, RelationOptionConfig> | undefined
) {
@@ -18,14 +81,30 @@ async function loadRelationOptions(
const delegate = (prisma as any)[key];
if (!delegate) continue;
const shouldIncludeParent = config.model === 'ProclamationCategory';
const select: Record<string, unknown> = {
[config.valueField]: true,
[config.labelField]: true
};
if (shouldIncludeParent) {
(select as Record<string, unknown>).parent = { select: { name: true } };
}
const records = await delegate.findMany({
select: { [config.valueField]: true, [config.labelField]: true }
select
});
result[field] = records.map((r: Record<string, unknown>) => ({
label: String(r[config.labelField]),
value: r[config.valueField] as string | number
}));
result[field] = records.map((r: Record<string, unknown>) => {
const value = r[config.valueField] as string | number;
const label =
config.model === 'ProclamationCategory'
? getCategoryDisplayName(r)
: String(r[config.labelField]);
return {
label,
value
};
});
}
return result;
@@ -73,6 +152,13 @@ export const actions: Actions = {
const id = parseId(config, params.id!);
const data = extractFormData(formData, config.formFields, config.fieldTypes);
if (config.modelName === 'Proclamation') {
const resolvedCategoryId = await resolveCategoryIdFromFormData(formData, 'categoryId');
if (resolvedCategoryId != null) {
(data as Record<string, unknown>).categoryId = resolvedCategoryId;
}
}
try {
const delegate = getDelegate(config);
await delegate.update({ where: { id }, data });
@@ -18,7 +18,7 @@
</a>
<a
href="/proclamations"
href="/admin/proclamations"
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md"
>
<p class="text-sm font-medium text-gray-500">Proclamations</p>
@@ -1,16 +1,21 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
function getCategoryDisplayName(category: Record<string, unknown> | null | undefined) {
if (!category) return '';
const name = String(category.name ?? '');
const parent = category.parent as Record<string, unknown> | undefined;
if (parent?.name) return `${String(parent.name)}/${name}`;
return name;
}
export const load: PageServerLoad = async ({ url }) => {
const search = url.searchParams.get('search') ?? '';
const categoryId = url.searchParams.get('category');
const where: Record<string, unknown> = {};
if (search) {
where.name = { contains: search, mode: 'insensitive' };
}
if (categoryId) {
where.categoryId = parseInt(categoryId, 10);
}
@@ -18,20 +23,29 @@ export const load: PageServerLoad = async ({ url }) => {
const [proclamations, categories] = await Promise.all([
prisma.proclamation.findMany({
where,
include: {
solfeggioFrequency: true,
category: true
},
include: { solfeggioFrequency: true, category: { include: { parent: true } } },
orderBy: { name: 'asc' }
}),
prisma.proclamationCategory.findMany({
orderBy: { name: 'asc' }
})
prisma.proclamationCategory.findMany({ include: { parent: true }, orderBy: { name: 'asc' } })
]);
const normalizedProclamations = proclamations.map((p) => {
if (p.category) {
(p.category as Record<string, unknown>).displayName = getCategoryDisplayName(
p.category as Record<string, unknown>
);
}
return p;
});
const normalizedCategories = categories.map((c) => ({
...c,
displayName: getCategoryDisplayName(c)
}));
return {
proclamations,
categories,
proclamations: normalizedProclamations,
categories: normalizedCategories,
search,
categoryId: categoryId ? parseInt(categoryId, 10) : null
};
@@ -4,6 +4,10 @@
let { data }: PageProps = $props();
function displayCategoryName(category: { name: string; displayName?: string }) {
return category.displayName ?? category.name;
}
let search = $state('');
let categoryId: number | null = $state(null);
let initialized = $state(false);
@@ -56,7 +60,7 @@
>
<option value={null}>All Categories</option>
{#each data.categories as cat}
<option value={cat.id}>{cat.name}</option>
<option value={cat.id}>{displayCategoryName(cat)}</option>
{/each}
</select>
</div>
@@ -87,7 +91,7 @@
class="flex flex-col rounded-lg border border-gray-200 bg-white p-5 shadow-sm transition-shadow hover:shadow-md"
>
<h2 class="text-lg font-medium text-gray-900">{proclamation.name}</h2>
<p class="mt-1 text-sm text-gray-500">{proclamation.category.name}</p>
<p class="mt-1 text-sm text-gray-500">{displayCategoryName(proclamation.category)}</p>
<div class="mt-auto flex items-center justify-between pt-4">
<span class="text-sm text-purple-600">
{proclamation.solfeggioFrequency.value} Hz &mdash;
@@ -1,15 +1,20 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
import { error } from '@sveltejs/kit';
import { prisma } from '$lib/db';
import { canAccessAdmin } from '$lib/svelte-admin/roles';
export const load: PageServerLoad = async ({ params, parent }) => {
const parentData = typeof parent === 'function' ? await parent() : {};
const userRoleId = (parentData as { userRoleId?: number }).userRoleId ?? null;
if (userRoleId != null && !canAccessAdmin(userRoleId)) {
throw error(403, 'not authorized');
}
export const load: PageServerLoad = async ({ params }) => {
const proclamation = await prisma.proclamation.findUnique({
where: { id: params.id },
include: {
solfeggioFrequency: true,
category: {
include: { parent: true }
}
category: { include: { parent: true } }
}
});
@@ -13,7 +13,7 @@ import { load } from './+page.server';
const findUnique = prisma.proclamation.findUnique as ReturnType<typeof vi.fn>;
describe('(protected)/proclamations/[id] page server load', () => {
describe('(protected)/admin/proclamations/[id] page server load', () => {
beforeEach(() => {
findUnique.mockReset();
});
@@ -14,7 +14,7 @@ import { load } from './+page.server';
const proclamationFindMany = prisma.proclamation.findMany as ReturnType<typeof vi.fn>;
const categoryFindMany = prisma.proclamationCategory.findMany as ReturnType<typeof vi.fn>;
describe('(protected)/proclamations page server load', () => {
describe('(protected)/admin/proclamations page server load', () => {
beforeEach(() => {
proclamationFindMany.mockReset();
categoryFindMany.mockReset();
@@ -26,10 +26,13 @@ describe('(protected)/proclamations page server load', () => {
proclamationFindMany.mockResolvedValue(proclamations);
categoryFindMany.mockResolvedValue(categories);
const url = createUrl('/proclamations');
const url = createUrl('/admin/proclamations');
const result = (await load({ url } as any)) as any;
expect(result.proclamations).toEqual(proclamations);
expect(result.categories).toEqual(categories);
expect(result.categories[0]).toEqual(expect.objectContaining({
...categories[0],
displayName: categories[0].name
}));
expect(result.search).toBe('');
expect(result.categoryId).toBeNull();
});
@@ -38,7 +41,7 @@ describe('(protected)/proclamations page server load', () => {
proclamationFindMany.mockResolvedValue([]);
categoryFindMany.mockResolvedValue([]);
const url = createUrl('/proclamations', { search: 'healing' });
const url = createUrl('/admin/proclamations', { search: 'healing' });
await load({ url } as any);
expect(proclamationFindMany).toHaveBeenCalledWith(
@@ -52,7 +55,7 @@ describe('(protected)/proclamations page server load', () => {
proclamationFindMany.mockResolvedValue([]);
categoryFindMany.mockResolvedValue([]);
const url = createUrl('/proclamations', { search: 'peace' });
const url = createUrl('/admin/proclamations', { search: 'peace' });
const result = (await load({ url } as any)) as any;
expect(result.search).toBe('peace');
});
@@ -61,7 +64,7 @@ describe('(protected)/proclamations page server load', () => {
proclamationFindMany.mockResolvedValue([]);
categoryFindMany.mockResolvedValue([]);
const url = createUrl('/proclamations', { category: '3' });
const url = createUrl('/admin/proclamations', { category: '3' });
await load({ url } as any);
expect(proclamationFindMany).toHaveBeenCalledWith(
@@ -75,7 +78,7 @@ describe('(protected)/proclamations page server load', () => {
proclamationFindMany.mockResolvedValue([]);
categoryFindMany.mockResolvedValue([]);
const url = createUrl('/proclamations', { category: '5' });
const url = createUrl('/admin/proclamations', { category: '5' });
const result = (await load({ url } as any)) as any;
expect(result.categoryId).toBe(5);
});
@@ -84,7 +87,7 @@ describe('(protected)/proclamations page server load', () => {
proclamationFindMany.mockResolvedValue([]);
categoryFindMany.mockResolvedValue([]);
const url = createUrl('/proclamations', { search: 'love', category: '2' });
const url = createUrl('/admin/proclamations', { search: 'love', category: '2' });
await load({ url } as any);
expect(proclamationFindMany).toHaveBeenCalledWith(
@@ -101,12 +104,12 @@ describe('(protected)/proclamations page server load', () => {
proclamationFindMany.mockResolvedValue([]);
categoryFindMany.mockResolvedValue([]);
const url = createUrl('/proclamations');
const url = createUrl('/admin/proclamations');
await load({ url } as any);
expect(proclamationFindMany).toHaveBeenCalledWith(
expect.objectContaining({
include: { solfeggioFrequency: true, category: true }
include: { solfeggioFrequency: true, category: { include: { parent: true } } }
})
);
});