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:
@@ -111,14 +111,14 @@ src/lib/components/admin/ # Reusable admin UI components
|
||||
|
||||
Controls both form input rendering and FormData parsing:
|
||||
|
||||
| Type | HTML Input | Parse Logic |
|
||||
| ---------- | ------------------------------------- | ------------------------------ |
|
||||
| `String` | `<input type="text">` | `raw.toString()` |
|
||||
| `Int` | `<input type="number" step="1">` | `parseInt()`, empty -> `null` |
|
||||
| `Float` | `<input type="number" step="0.01">` | `parseFloat()`, empty -> `null` |
|
||||
| `Boolean` | `<input type="checkbox">` | `'on'` or `'true'` -> `true` |
|
||||
| `DateTime` | `<input type="datetime-local">` | `new Date()`, empty -> `null` |
|
||||
| `Tiptap` | TipTap rich-text editor (hidden input)| `raw.toString()` |
|
||||
| Type | HTML Input | Parse Logic |
|
||||
| ---------- | -------------------------------------- | ------------------------------- |
|
||||
| `String` | `<input type="text">` | `raw.toString()` |
|
||||
| `Int` | `<input type="number" step="1">` | `parseInt()`, empty -> `null` |
|
||||
| `Float` | `<input type="number" step="0.01">` | `parseFloat()`, empty -> `null` |
|
||||
| `Boolean` | `<input type="checkbox">` | `'on'` or `'true'` -> `true` |
|
||||
| `DateTime` | `<input type="datetime-local">` | `new Date()`, empty -> `null` |
|
||||
| `Tiptap` | TipTap rich-text editor (hidden input) | `raw.toString()` |
|
||||
|
||||
### RelationOptionConfig
|
||||
|
||||
@@ -194,19 +194,19 @@ Display has-one or has-many related records as panels on the edit page using `re
|
||||
|
||||
```ts
|
||||
relatedRecords: [
|
||||
{
|
||||
relationKey: 'profile',
|
||||
label: 'Profile',
|
||||
type: 'has-one',
|
||||
displayFields: ['firstName', 'lastName'],
|
||||
editSlug: 'profiles' // links to /admin/profiles/{id}
|
||||
},
|
||||
{
|
||||
relationKey: 'licenseKeys',
|
||||
label: 'License Keys',
|
||||
type: 'has-many',
|
||||
displayFields: ['key', 'active', 'level']
|
||||
}
|
||||
{
|
||||
relationKey: 'profile',
|
||||
label: 'Profile',
|
||||
type: 'has-one',
|
||||
displayFields: ['firstName', 'lastName'],
|
||||
editSlug: 'profiles' // links to /admin/profiles/{id}
|
||||
},
|
||||
{
|
||||
relationKey: 'licenseKeys',
|
||||
label: 'License Keys',
|
||||
type: 'has-many',
|
||||
displayFields: ['key', 'active', 'level']
|
||||
}
|
||||
];
|
||||
```
|
||||
|
||||
@@ -236,11 +236,11 @@ Use `accessFilter` to restrict which records a viewer can see:
|
||||
|
||||
```ts
|
||||
accessFilter: (viewerRoleId: number) => {
|
||||
if (viewerRoleId <= ROLES.SUPER) return undefined;
|
||||
if (viewerRoleId <= ROLES.ADMIN) {
|
||||
return { roleId: { gte: ROLES.STAFF } };
|
||||
}
|
||||
return { roleId: { gt: ROLES.STAFF } };
|
||||
if (viewerRoleId <= ROLES.SUPER) return undefined;
|
||||
if (viewerRoleId <= ROLES.ADMIN) {
|
||||
return { roleId: { gte: ROLES.STAFF } };
|
||||
}
|
||||
return { roleId: { gt: ROLES.STAFF } };
|
||||
};
|
||||
```
|
||||
|
||||
@@ -258,15 +258,15 @@ accessFilter: (viewerRoleId: number) => {
|
||||
|
||||
### AdminTable
|
||||
|
||||
| Prop | Type | Description |
|
||||
| -------------- | --------------------------- | ------------------------------------ |
|
||||
| `records` | `Record<string, unknown>[]` | Data rows |
|
||||
| `listFields` | `string[]` | Column keys (dot-notation supported) |
|
||||
| `searchFields` | `string[]` | Fields for client-side search |
|
||||
| `fieldTypes` | `Record<string, PrismaScalarType>` | Type mapping for cell formatting |
|
||||
| `singular` | `string` | Singular label for UI text |
|
||||
| `onRowClick` | `(record) => void?` | Click handler for edit modal |
|
||||
| `onNew` | `() => void?` | Click handler for create modal |
|
||||
| Prop | Type | Description |
|
||||
| -------------- | ---------------------------------- | ------------------------------------ |
|
||||
| `records` | `Record<string, unknown>[]` | Data rows |
|
||||
| `listFields` | `string[]` | Column keys (dot-notation supported) |
|
||||
| `searchFields` | `string[]` | Fields for client-side search |
|
||||
| `fieldTypes` | `Record<string, PrismaScalarType>` | Type mapping for cell formatting |
|
||||
| `singular` | `string` | Singular label for UI text |
|
||||
| `onRowClick` | `(record) => void?` | Click handler for edit modal |
|
||||
| `onNew` | `() => void?` | Click handler for create modal |
|
||||
|
||||
Features: client-side search, click-to-sort columns, auto-formatted cells (dates, booleans,
|
||||
decimals), empty state.
|
||||
@@ -286,12 +286,12 @@ decimals), empty state.
|
||||
|
||||
### AdminModal
|
||||
|
||||
| Prop | Type | Description |
|
||||
| --------- | ----------------- | ------------------------ |
|
||||
| `open` | `boolean` | Show/hide modal |
|
||||
| `title` | `string` | Modal heading |
|
||||
| `onClose` | `() => void` | Close handler |
|
||||
| `children`| `Snippet` | Modal body content |
|
||||
| Prop | Type | Description |
|
||||
| ---------- | ------------ | ------------------ |
|
||||
| `open` | `boolean` | Show/hide modal |
|
||||
| `title` | `string` | Modal heading |
|
||||
| `onClose` | `() => void` | Close handler |
|
||||
| `children` | `Snippet` | Modal body content |
|
||||
|
||||
### TipTap
|
||||
|
||||
@@ -302,43 +302,43 @@ decimals), empty state.
|
||||
|
||||
## Utility Functions
|
||||
|
||||
| Function | Module | Description |
|
||||
| ------------------------------------------ | -------------------- | ----------------------------------------------------------------------------- |
|
||||
| Function | Module | Description |
|
||||
| ------------------------------------------ | -------------------- | ------------------------------------------------------------------------------ |
|
||||
| `fieldNameToLabel(name)` | `introspect.ts` | `'solfeggioFrequencyId'` -> `'Solfeggio Frequency'` (strips `Id`, adds spaces) |
|
||||
| `resolveFieldValue(record, path)` | `introspect.ts` | Resolves dot-notation paths like `'category.name'` on nested objects |
|
||||
| `extractFormData(formData, fields, types)` | `form-data.ts` | Parses `FormData` with type-aware conversion per `PrismaScalarType` |
|
||||
| `getDelegate(config)` | `prisma-delegate.ts` | Returns `prisma[lcfirst(modelName)]` delegate |
|
||||
| `parseId(config, raw)` | `prisma-delegate.ts` | Parses string to `number` when `idType` is `'Int'` |
|
||||
| `getModelConfig(slug)` | `registry.ts` | Looks up config by URL slug |
|
||||
| `getAllModelConfigs()` | `registry.ts` | Returns all registered model configs |
|
||||
| `hasRole(roleId, required)` | `roles.ts` | `true` if `roleId <= required` |
|
||||
| `canAccessAdmin(roleId)` | `roles.ts` | `true` if `roleId <= ROLES.STAFF` |
|
||||
| `loadRecords(locals, config)` | `crud.ts` | Fetches records + FK dropdown options for a model |
|
||||
| `upsertRecord(formData, config, locals)` | `crud.ts` | Creates or updates a record based on presence of `id` in form data |
|
||||
| `deleteRecord(formData, config, locals)` | `crud.ts` | Deletes a record by ID |
|
||||
| `resolveFieldValue(record, path)` | `introspect.ts` | Resolves dot-notation paths like `'category.name'` on nested objects |
|
||||
| `extractFormData(formData, fields, types)` | `form-data.ts` | Parses `FormData` with type-aware conversion per `PrismaScalarType` |
|
||||
| `getDelegate(config)` | `prisma-delegate.ts` | Returns `prisma[lcfirst(modelName)]` delegate |
|
||||
| `parseId(config, raw)` | `prisma-delegate.ts` | Parses string to `number` when `idType` is `'Int'` |
|
||||
| `getModelConfig(slug)` | `registry.ts` | Looks up config by URL slug |
|
||||
| `getAllModelConfigs()` | `registry.ts` | Returns all registered model configs |
|
||||
| `hasRole(roleId, required)` | `roles.ts` | `true` if `roleId <= required` |
|
||||
| `canAccessAdmin(roleId)` | `roles.ts` | `true` if `roleId <= ROLES.STAFF` |
|
||||
| `loadRecords(locals, config)` | `crud.ts` | Fetches records + FK dropdown options for a model |
|
||||
| `upsertRecord(formData, config, locals)` | `crud.ts` | Creates or updates a record based on presence of `id` in form data |
|
||||
| `deleteRecord(formData, config, locals)` | `crud.ts` | Deletes a record by ID |
|
||||
|
||||
## Models in Admin
|
||||
|
||||
| Registry Slug | Prisma Model | Notes |
|
||||
| -------------------------- | ---------------------- | ---------------------------------- |
|
||||
| `users` | User | Role assignment, view-only fields |
|
||||
| `profiles` | Profile | Linked to user FK |
|
||||
| `addresses` | Address | Linked to profile FK |
|
||||
| `user-roles` | UserRole | |
|
||||
| `proclamations` | Proclamation | TipTap rich-text editor for content|
|
||||
| `solfeggio-frequencies` | SolfeggioFrequency | |
|
||||
| `proclamation-categories` | ProclamationCategory | Parent category hierarchy |
|
||||
| `license-keys` | LicenseKey | listOnly — read-only table, no CRUD|
|
||||
| `subscription-levels` | SubscriptionLevel | |
|
||||
| Registry Slug | Prisma Model | Notes |
|
||||
| ------------------------- | -------------------- | ----------------------------------- |
|
||||
| `users` | User | Role assignment, view-only fields |
|
||||
| `profiles` | Profile | Linked to user FK |
|
||||
| `addresses` | Address | Linked to profile FK |
|
||||
| `user-roles` | UserRole | |
|
||||
| `proclamations` | Proclamation | TipTap rich-text editor for content |
|
||||
| `solfeggio-frequencies` | SolfeggioFrequency | |
|
||||
| `proclamation-categories` | ProclamationCategory | Parent category hierarchy |
|
||||
| `license-keys` | LicenseKey | listOnly — read-only table, no CRUD |
|
||||
| `subscription-levels` | SubscriptionLevel | |
|
||||
|
||||
### Excluded from Admin
|
||||
|
||||
| Model | Reason |
|
||||
| ------------------ | -------------------------------------------- |
|
||||
| Session | Better Auth internal — not for manual editing|
|
||||
| Account | Better Auth internal |
|
||||
| Verification | Better Auth internal |
|
||||
| UserProclamations | Composite key — managed via other models |
|
||||
| Model | Reason |
|
||||
| ----------------- | --------------------------------------------- |
|
||||
| Session | Better Auth internal — not for manual editing |
|
||||
| Account | Better Auth internal |
|
||||
| Verification | Better Auth internal |
|
||||
| UserProclamations | Composite key — managed via other models |
|
||||
|
||||
## Complete Example
|
||||
|
||||
|
||||
Reference in New Issue
Block a user