Build user pages
This commit is contained in:
+318
-142
@@ -1,194 +1,370 @@
|
||||
# svelte-admin
|
||||
|
||||
A lightweight, configuration-driven admin panel framework for SvelteKit + Prisma applications. Models are registered once in a central registry and automatically get list views, create modals, edit pages, and delete functionality — all wired to Prisma via dynamic delegates.
|
||||
A config-driven admin panel framework for SvelteKit + Prisma. Define your models once in a
|
||||
registry and get full CRUD UI with dynamic routing, role-based access control, searchable
|
||||
dropdowns, and related-record panels — no per-model route files needed.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/lib/svelte-admin/ # Framework core
|
||||
types.ts # AdminModelConfig type + supporting types
|
||||
registry.ts # Model configurations (the main config file)
|
||||
roles.ts # Role constants + access helpers
|
||||
introspect.ts # Field label formatting + nested value resolution
|
||||
prisma-delegate.ts # Dynamic Prisma delegate lookup + ID parsing
|
||||
form-data.ts # FormData → typed object extraction
|
||||
index.ts # Barrel re-exports
|
||||
src/lib/svelte-admin/ # Framework core
|
||||
├── types.ts # AdminModelConfig and related types
|
||||
├── registry.ts # Model configurations (the single source of truth)
|
||||
├── roles.ts # Role hierarchy and access helpers
|
||||
├── prisma-delegate.ts # Dynamic Prisma delegate lookup
|
||||
├── introspect.ts # Field label generation and dot-notation resolution
|
||||
├── form-data.ts # FormData → typed object parser
|
||||
└── index.ts # Barrel exports
|
||||
|
||||
src/lib/components/admin/ # UI components
|
||||
AdminTable.svelte # Sortable, searchable list table
|
||||
AdminForm.svelte # Dynamic form (creates inputs from field config)
|
||||
src/routes/(protected)/admin/ # Dynamic admin routes
|
||||
├── +layout.server.ts # Role-based access guard
|
||||
├── +layout.svelte # Layout shell
|
||||
├── +page.server.ts # Admin home (model grid)
|
||||
├── +page.svelte # Admin home UI
|
||||
├── [model]/
|
||||
│ ├── +page.server.ts # List view + create action
|
||||
│ ├── +page.svelte # List view UI
|
||||
│ └── [id]/
|
||||
│ ├── +page.server.ts # Edit view + update/delete actions
|
||||
│ └── +page.svelte # Edit view UI
|
||||
|
||||
src/routes/(protected)/admin/ # SvelteKit routes
|
||||
+layout.server.ts # Auth guard (requires STAFF role or higher)
|
||||
+layout.svelte # Admin shell layout
|
||||
+page.svelte # Home grid (cards linking to each model)
|
||||
+page.server.ts # Loads all model configs for the home grid
|
||||
[model]/+page.svelte # List view + create modal for a model
|
||||
[model]/+page.server.ts # List loader + create action
|
||||
[model]/[id]/+page.svelte # Edit view + delete for a single record
|
||||
[model]/[id]/+page.server.ts # Record loader + update/delete actions
|
||||
src/lib/components/admin/ # Reusable admin UI components
|
||||
├── AdminTable.svelte # Sortable, searchable data table
|
||||
├── AdminForm.svelte # Dynamic form with type-aware inputs
|
||||
├── ComboBox.svelte # Searchable dropdown for FK fields
|
||||
├── RelatedHasOne.svelte # Display panel for 1:1 related records
|
||||
└── RelatedHasMany.svelte # Display table for 1:N related records
|
||||
```
|
||||
|
||||
## Adding a New Model
|
||||
|
||||
1. Open `src/lib/svelte-admin/registry.ts`.
|
||||
2. Add a new object to the `models` array:
|
||||
1. **Define the Prisma model** in `prisma/schema.prisma` and create a migration:
|
||||
|
||||
```ts
|
||||
{
|
||||
slug: 'widgets', // URL segment: /admin/widgets
|
||||
modelName: 'Widget', // Prisma model name (PascalCase)
|
||||
displayName: 'Widgets', // Plural display name
|
||||
displayNameSingular: 'Widget', // Singular display name
|
||||
idType: 'String', // 'String' or 'Int' — matches Prisma @id type
|
||||
include: { category: true }, // Optional: Prisma include for relations
|
||||
listFields: ['name', 'category.name', 'price'],
|
||||
searchFields: ['name'],
|
||||
formFields: ['name', 'categoryId', 'price'],
|
||||
readonlyFields: ['id', 'createdAt'],
|
||||
fieldTypes: {
|
||||
name: 'String',
|
||||
categoryId: 'Int',
|
||||
price: 'Float',
|
||||
createdAt: 'DateTime',
|
||||
},
|
||||
relationOptions: {
|
||||
categoryId: {
|
||||
model: 'Category',
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
```bash
|
||||
npm run migrate -- --name add_my_model
|
||||
```
|
||||
|
||||
3. That's it. The dynamic `[model]` routes handle everything else.
|
||||
2. **Add a config object** to the `models` array in `registry.ts`:
|
||||
|
||||
```ts
|
||||
{
|
||||
slug: 'widgets', // URL segment: /admin/widgets
|
||||
modelName: 'Widget', // Prisma model name (PascalCase, singular)
|
||||
displayName: 'Widgets', // Plural display name
|
||||
displayNameSingular: 'Widget', // Singular display name
|
||||
idType: 'String', // 'String' or 'Int' — matches Prisma @id type
|
||||
include: { category: true }, // Optional: Prisma include for relations
|
||||
listFields: ['name', 'category.name', 'price'],
|
||||
searchFields: ['name'],
|
||||
formFields: ['name', 'categoryId', 'price'],
|
||||
readonlyFields: ['id', 'createdAt'],
|
||||
fieldTypes: {
|
||||
name: 'String',
|
||||
categoryId: 'Int',
|
||||
price: 'Float',
|
||||
createdAt: 'DateTime'
|
||||
},
|
||||
relationOptions: {
|
||||
categoryId: {
|
||||
model: 'Category',
|
||||
labelField: 'name',
|
||||
valueField: 'id'
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Done.** Visit `/admin/widgets`. The dynamic `[model]` routes handle list, create, edit,
|
||||
and delete automatically.
|
||||
|
||||
No new route files, no new components. The registry is the only thing you touch.
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### `AdminModelConfig`
|
||||
### AdminModelConfig
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `slug` | `string` | Yes | URL path segment. Use kebab-case (e.g. `'license-keys'`). |
|
||||
| `modelName` | `string` | Yes | Prisma model name in PascalCase (e.g. `'LicenseKey'`). Used to look up the Prisma delegate. |
|
||||
| `displayName` | `string` | Yes | Plural name shown in headings and the admin home grid. |
|
||||
| `displayNameSingular` | `string` | Yes | Singular name for create/edit headings and button labels. |
|
||||
|---|---|---|---|
|
||||
| `slug` | `string` | Yes | URL segment (kebab-case). Used in `/admin/{slug}`. |
|
||||
| `modelName` | `string` | Yes | Prisma model name (PascalCase, **singular**). Maps to `prisma[lcfirst(modelName)]`. |
|
||||
| `displayName` | `string` | Yes | Plural display name for headings and navigation. |
|
||||
| `displayNameSingular` | `string` | Yes | Singular name for edit page headings. |
|
||||
| `idType` | `'String' \| 'Int'` | Yes | Primary key type. Controls how the `id` URL param is parsed. |
|
||||
| `include` | `Record<string, boolean>` | No | Prisma `include` clause for eager-loading relations in list and edit views. |
|
||||
| `listFields` | `string[]` | Yes | Fields shown as columns in the list table. Supports dot-notation for relations (e.g. `'user.name'`). |
|
||||
| `searchFields` | `string[]` | Yes | Fields searched by the table's search box. Should be scalar fields on the model. |
|
||||
| `formFields` | `string[]` | Yes | Fields shown in the create/edit form. Order determines form field order. |
|
||||
| `readonlyFields` | `string[]` | No | Fields shown as read-only text on the edit page (not editable). Typically `['id', 'createdAt', 'updatedAt']`. |
|
||||
| `listOnly` | `boolean` | No | When `true`, suppresses edit links in the table. The create modal still works. Use for models where records are auto-generated or shouldn't be edited individually. |
|
||||
| `fieldTypes` | `Record<string, PrismaScalarType>` | Yes | Maps field names to their Prisma scalar type. Controls form input type and form data parsing. |
|
||||
| `relationOptions` | `Record<string, RelationOptionConfig>` | No | Configures dropdown selects for foreign key fields. |
|
||||
| `accessFilter` | `(viewerRoleId: number) => Record<string, unknown> \| undefined` | No | Returns a Prisma `where` clause to filter list results based on the viewer's role. Return `undefined` for no filter. |
|
||||
| `include` | `Record<string, boolean \| { include: ... }>` | No | Prisma `include` clause for eager-loading relations. |
|
||||
| `listFields` | `string[]` | Yes | Table columns. Supports dot-notation for relations (e.g., `'role.name'`). |
|
||||
| `searchFields` | `string[]` | Yes | Fields searched by the client-side search filter. |
|
||||
| `formFields` | `string[]` | Yes | Fields rendered in create/edit forms, in display order. |
|
||||
| `readonlyFields` | `string[]` | No | Fields shown as read-only on the edit page. |
|
||||
| `listOnly` | `boolean` | No | If `true`, suppresses edit links in the table. |
|
||||
| `fieldTypes` | `Record<string, PrismaScalarType>` | Yes | Maps field names to scalar types. Controls input rendering and FormData parsing. |
|
||||
| `relationOptions` | `Record<string, RelationOptionConfig>` | No | FK fields that render as ComboBox dropdowns. |
|
||||
| `relatedRecords` | `RelatedRecordConfig[]` | No | Has-one/has-many panels shown on edit pages. |
|
||||
| `accessFilter` | `(viewerRoleId: number) => where \| undefined` | No | Returns a Prisma `where` clause to filter records by viewer role. `undefined` = no filter. |
|
||||
|
||||
### `PrismaScalarType`
|
||||
### PrismaScalarType
|
||||
|
||||
`'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime'`
|
||||
|
||||
Controls both the HTML input type in forms and how `FormData` values are parsed:
|
||||
Controls both form input rendering and FormData parsing:
|
||||
|
||||
| Type | Input | Parsing |
|
||||
|------|-------|---------|
|
||||
| `String` | `<input type="text">` | `.toString()` |
|
||||
| `Int` | `<input type="number" step="1">` | `parseInt()` |
|
||||
| `Float` | `<input type="number" step="0.01">` | `parseFloat()` |
|
||||
| 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()` |
|
||||
| `DateTime` | `<input type="datetime-local">` | `new Date()`, empty → `null` |
|
||||
|
||||
### `RelationOptionConfig`
|
||||
### RelationOptionConfig
|
||||
|
||||
```ts
|
||||
{
|
||||
model: 'UserRole', // Prisma model to query for options
|
||||
labelField: 'name', // Field to display in the dropdown
|
||||
valueField: 'id', // Field value submitted with the form
|
||||
labelField: 'name', // Field displayed in the dropdown
|
||||
valueField: 'id' // Field value submitted with the form
|
||||
}
|
||||
```
|
||||
|
||||
The framework runs `findMany` on the related model, selecting only `labelField` and `valueField`, and renders a `<select>` dropdown in the form.
|
||||
### RelatedRecordConfig
|
||||
|
||||
```ts
|
||||
{
|
||||
relationKey: 'profile', // Key on parent record (must match a key in `include`)
|
||||
label: 'Profile', // Section heading on edit page
|
||||
type: 'has-one', // 'has-one' or 'has-many'
|
||||
displayFields: ['firstName', 'lastName'], // Fields shown in the panel/table
|
||||
editSlug: 'profiles' // Optional: admin slug to link for editing
|
||||
}
|
||||
```
|
||||
|
||||
## Field Types & Form Rendering
|
||||
|
||||
The `fieldTypes` mapping is the central mechanism connecting Prisma scalar types to HTML inputs
|
||||
and form data parsing. Every field in `formFields` must have a corresponding entry in `fieldTypes`.
|
||||
|
||||
When a field also has a `relationOptions` entry, the framework renders a `ComboBox` (searchable
|
||||
dropdown) instead of the default input for that type.
|
||||
|
||||
## Relations
|
||||
|
||||
To display a related field in the list table (e.g. show a user's role name):
|
||||
### Foreign Key Dropdowns (relationOptions)
|
||||
|
||||
1. Add the relation to `include`: `{ role: true }`
|
||||
2. Use dot-notation in `listFields`: `'role.name'`
|
||||
|
||||
To make a foreign key editable as a dropdown:
|
||||
|
||||
1. Add the FK field to `formFields`: `'roleId'`
|
||||
2. Add its type to `fieldTypes`: `roleId: 'Int'`
|
||||
3. Configure `relationOptions`:
|
||||
```ts
|
||||
relationOptions: {
|
||||
roleId: { model: 'UserRole', labelField: 'name', valueField: 'id' }
|
||||
}
|
||||
```
|
||||
|
||||
## List-Only Models
|
||||
|
||||
Set `listOnly: true` on a model config to suppress edit links in the list table. Records still appear in the table and the create modal still works, but there's no way to click into an individual record to edit or delete it.
|
||||
|
||||
This is useful for models like license keys where records are auto-generated and shouldn't be manually edited.
|
||||
|
||||
## Read-Only Fields
|
||||
|
||||
Fields listed in `readonlyFields` are displayed as plain text on the edit page (above the form) but cannot be modified. Common use: `['id', 'createdAt', 'updatedAt']`.
|
||||
|
||||
If you want the edit page to show mostly read-only data with only one or two editable fields, move most fields from `formFields` to `readonlyFields`. For example, the Users config has `formFields: ['roleId']` with everything else in `readonlyFields`.
|
||||
|
||||
## Access Control
|
||||
|
||||
### Admin layout guard
|
||||
|
||||
The admin layout (`admin/+layout.server.ts`) checks `canAccessAdmin(user.roleId)` — which requires `roleId <= ROLES.STAFF` (i.e. Super, Admin, or Staff). Users with higher `roleId` values get a 403.
|
||||
|
||||
### Role constants
|
||||
|
||||
Defined in `roles.ts`:
|
||||
When a form field is a foreign key, add a `relationOptions` entry to render a searchable ComboBox:
|
||||
|
||||
```ts
|
||||
ROLES.SUPER = 1 // Full access
|
||||
ROLES.ADMIN = 2 // Admin access
|
||||
ROLES.STAFF = 3 // Staff access (minimum for admin panel)
|
||||
ROLES.USER = 4 // Regular user (no admin access)
|
||||
```
|
||||
|
||||
Lower numbers = higher privilege.
|
||||
|
||||
### Per-model filtering with `accessFilter`
|
||||
|
||||
The `accessFilter` function receives the viewer's `roleId` and returns a Prisma `where` clause that's merged into the list query. This lets you restrict which records different roles can see.
|
||||
|
||||
Example from the Users config:
|
||||
|
||||
```ts
|
||||
accessFilter: (viewerRoleId: number) => {
|
||||
if (viewerRoleId <= ROLES.SUPER) return undefined; // Super sees all
|
||||
if (viewerRoleId <= ROLES.ADMIN) {
|
||||
return { roleId: { gte: ROLES.STAFF } }; // Admin sees Staff+User
|
||||
{
|
||||
formFields: ['name', 'categoryId'],
|
||||
fieldTypes: { name: 'String', categoryId: 'Int' },
|
||||
relationOptions: {
|
||||
categoryId: {
|
||||
model: 'ProclamationCategory',
|
||||
labelField: 'name',
|
||||
valueField: 'id'
|
||||
}
|
||||
}
|
||||
return { roleId: { gt: ROLES.STAFF } }; // Staff sees only Users
|
||||
}
|
||||
```
|
||||
|
||||
## Customizing the UI
|
||||
The route loader automatically queries the related model and passes options to the ComboBox.
|
||||
|
||||
### Eager Loading (include)
|
||||
|
||||
Use `include` to load related data for display in list tables or edit pages:
|
||||
|
||||
```ts
|
||||
{
|
||||
include: { category: { include: { parent: true } } },
|
||||
listFields: ['name', 'category.name'] // dot-notation resolves nested values
|
||||
}
|
||||
```
|
||||
|
||||
## Related Records
|
||||
|
||||
Display has-one or has-many related records as panels on the edit page using `relatedRecords`:
|
||||
|
||||
```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']
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
The `relationKey` must match a key in `include` so the data is actually loaded. `has-one` renders
|
||||
a key-value panel via `RelatedHasOne`; `has-many` renders a table via `RelatedHasMany`. Both
|
||||
support an optional `editSlug` to link to the related record's edit page.
|
||||
|
||||
## Access Control
|
||||
|
||||
### Role Hierarchy
|
||||
|
||||
Defined in `roles.ts`. Lower numbers = higher privilege:
|
||||
|
||||
| Constant | Value | Access Level |
|
||||
|---|---|---|
|
||||
| `ROLES.SUPER` | 1 | Full access, sees all users |
|
||||
| `ROLES.ADMIN` | 2 | Admin access, sees staff and below |
|
||||
| `ROLES.STAFF` | 3 | Staff access (minimum for admin panel) |
|
||||
| `ROLES.USER` | 4 | Regular user (no admin access) |
|
||||
|
||||
### Layout Guard
|
||||
|
||||
`+layout.server.ts` calls `canAccessAdmin(user.roleId)` which returns `true` when
|
||||
`roleId <= ROLES.STAFF`. Users with `ROLES.USER` (4) or no role get a 403.
|
||||
|
||||
### Per-Model Access Filtering
|
||||
|
||||
Use `accessFilter` to restrict which records a viewer can see:
|
||||
|
||||
```ts
|
||||
accessFilter: (viewerRoleId: number) => {
|
||||
if (viewerRoleId <= ROLES.SUPER) return undefined; // no filter
|
||||
if (viewerRoleId <= ROLES.ADMIN) {
|
||||
return { roleId: { gte: ROLES.STAFF } }; // can't see other admins
|
||||
}
|
||||
return { roleId: { gt: ROLES.STAFF } }; // staff sees only regular users
|
||||
}
|
||||
```
|
||||
|
||||
## How Dynamic Routing Works
|
||||
|
||||
1. User visits `/admin/proclamations` (or `/admin/proclamations/abc123`)
|
||||
2. SvelteKit matches the `[model]` (and `[id]`) route params
|
||||
3. `+page.server.ts` calls `getModelConfig(params.model)` to look up the config
|
||||
4. `getDelegate(config)` returns `prisma[lcfirst(config.modelName)]` (e.g., `prisma.proclamation`)
|
||||
5. The delegate runs `findMany` (list), `findUnique` (edit), `create`, `update`, or `delete`
|
||||
6. Data + config are passed to the Svelte page component
|
||||
7. Components (`AdminTable`, `AdminForm`, etc.) render based on config
|
||||
|
||||
## Component API
|
||||
|
||||
### AdminTable
|
||||
|
||||
Props: `records`, `listFields`, `searchFields`, `modelSlug`, `listOnly?`
|
||||
| Prop | Type | Description |
|
||||
|---|---|---|
|
||||
| `records` | `Record<string, unknown>[]` | Data rows |
|
||||
| `listFields` | `string[]` | Column keys (dot-notation supported) |
|
||||
| `searchFields` | `string[]` | Fields for client-side search |
|
||||
| `modelSlug` | `string` | Used for building edit links |
|
||||
| `listOnly` | `boolean?` | Suppress edit links |
|
||||
|
||||
Features:
|
||||
- Client-side search across `searchFields`
|
||||
- Click column headers to sort (ascending/descending toggle)
|
||||
- Edit links per row (suppressed when `listOnly` is true)
|
||||
Features: client-side search, click-to-sort columns, auto-formatted cells (dates, booleans,
|
||||
decimals), filtered count display.
|
||||
|
||||
### AdminForm
|
||||
|
||||
Props: `fields`, `fieldTypes`, `relationOptions?`, `relationOptionsData?`, `record?`, `form?`, `action`, `submitLabel?`
|
||||
| Prop | Type | Description |
|
||||
|---|---|---|
|
||||
| `fields` | `string[]` | Field names to render |
|
||||
| `fieldTypes` | `Record<string, PrismaScalarType>` | Type mapping for inputs |
|
||||
| `relationOptions` | `Record<string, RelationOptionConfig>?` | FK dropdown configs |
|
||||
| `relationOptionsData` | `Record<string, { label; value }[]>?` | Loaded dropdown options |
|
||||
| `record` | `Record<string, unknown>?` | Pre-fill values (edit mode) |
|
||||
| `form` | `ActionData?` | Carries validation errors |
|
||||
| `action` | `string` | Form action (e.g., `'?/create'`) |
|
||||
| `submitLabel` | `string?` | Button text |
|
||||
|
||||
Renders a `<form>` with inputs determined by `fieldTypes` and `relationOptions`. When `record` is provided, fields are pre-filled. The `form` prop carries SvelteKit action data for error display and field re-population after failed submissions.
|
||||
### ComboBox
|
||||
|
||||
| Prop | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `string` | Element ID |
|
||||
| `name` | `string` | Form field name |
|
||||
| `options` | `{ label; value }[]` | Dropdown items |
|
||||
| `placeholder` | `string?` | Placeholder text |
|
||||
| `value` | `string \| number?` | Pre-selected value |
|
||||
|
||||
Features: type-to-search filtering, keyboard navigation (ArrowUp/Down, Enter, Escape),
|
||||
clear button, scroll-to-highlight.
|
||||
|
||||
### RelatedHasOne
|
||||
|
||||
| Prop | Type | Description |
|
||||
|---|---|---|
|
||||
| `label` | `string` | Section heading |
|
||||
| `record` | `Record<string, unknown> \| null` | Related record |
|
||||
| `displayFields` | `string[]` | Fields to display as key-value pairs |
|
||||
| `editSlug` | `string?` | Links to edit the related record |
|
||||
|
||||
### RelatedHasMany
|
||||
|
||||
| Prop | Type | Description |
|
||||
|---|---|---|
|
||||
| `label` | `string` | Section heading (shows count) |
|
||||
| `records` | `Record<string, unknown>[]` | Related records |
|
||||
| `displayFields` | `string[]` | Table columns |
|
||||
| `editSlug` | `string?` | Per-row edit links |
|
||||
|
||||
## Utility Functions
|
||||
|
||||
| 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` |
|
||||
|
||||
## Complete Example
|
||||
|
||||
A hypothetical `Product` model showing all configuration features:
|
||||
|
||||
```ts
|
||||
{
|
||||
slug: 'products',
|
||||
modelName: 'Product',
|
||||
displayName: 'Products',
|
||||
displayNameSingular: 'Product',
|
||||
idType: 'Int',
|
||||
include: { category: true, variants: true, createdBy: true },
|
||||
listFields: ['name', 'category.name', 'price', 'active', 'createdAt'],
|
||||
searchFields: ['name', 'sku'],
|
||||
formFields: ['name', 'sku', 'price', 'active', 'categoryId'],
|
||||
readonlyFields: ['id', 'createdAt', 'updatedAt'],
|
||||
fieldTypes: {
|
||||
name: 'String',
|
||||
sku: 'String',
|
||||
price: 'Float',
|
||||
active: 'Boolean',
|
||||
categoryId: 'Int',
|
||||
createdAt: 'DateTime',
|
||||
updatedAt: 'DateTime'
|
||||
},
|
||||
relationOptions: {
|
||||
categoryId: { model: 'Category', labelField: 'name', valueField: 'id' }
|
||||
},
|
||||
relatedRecords: [
|
||||
{
|
||||
relationKey: 'variants',
|
||||
label: 'Variants',
|
||||
type: 'has-many',
|
||||
displayFields: ['size', 'color', 'stock'],
|
||||
editSlug: 'variants'
|
||||
},
|
||||
{
|
||||
relationKey: 'createdBy',
|
||||
label: 'Created By',
|
||||
type: 'has-one',
|
||||
displayFields: ['name', 'email']
|
||||
}
|
||||
],
|
||||
accessFilter: (viewerRoleId: number) => {
|
||||
if (viewerRoleId <= 2) return undefined;
|
||||
return { active: true }; // staff only sees active products
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -91,10 +91,10 @@ const models: AdminModelConfig[] = [
|
||||
}
|
||||
},
|
||||
{
|
||||
slug: 'affirmations',
|
||||
modelName: 'Affirmation',
|
||||
displayName: 'Affirmations',
|
||||
displayNameSingular: 'Affirmation',
|
||||
slug: 'proclamations',
|
||||
modelName: 'Proclamation',
|
||||
displayName: 'Proclamations',
|
||||
displayNameSingular: 'Proclamation',
|
||||
idType: 'String',
|
||||
include: { solfeggioFrequency: true, category: { include: { parent: true } } },
|
||||
listFields: ['name', 'category.name', 'solfeggioFrequency.name', 'price'],
|
||||
@@ -115,7 +115,7 @@ const models: AdminModelConfig[] = [
|
||||
valueField: 'id'
|
||||
},
|
||||
categoryId: {
|
||||
model: 'AffirmationCategory',
|
||||
model: 'ProclamationCategory',
|
||||
labelField: 'name',
|
||||
valueField: 'id'
|
||||
}
|
||||
@@ -127,7 +127,7 @@ const models: AdminModelConfig[] = [
|
||||
displayName: 'Solfeggio Frequencies',
|
||||
displayNameSingular: 'Solfeggio Frequency',
|
||||
idType: 'Int',
|
||||
include: { affirmations: true },
|
||||
include: { proclamations: true },
|
||||
listFields: ['name', 'value'],
|
||||
searchFields: ['name'],
|
||||
formFields: ['name', 'value'],
|
||||
@@ -138,21 +138,21 @@ const models: AdminModelConfig[] = [
|
||||
},
|
||||
relatedRecords: [
|
||||
{
|
||||
relationKey: 'affirmations',
|
||||
label: 'Affirmations',
|
||||
relationKey: 'proclamations',
|
||||
label: 'Proclamations',
|
||||
type: 'has-many',
|
||||
displayFields: ['name', 'price'],
|
||||
editSlug: 'affirmations'
|
||||
editSlug: 'proclamations'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
slug: 'affirmation-categories',
|
||||
modelName: 'AffirmationCategory',
|
||||
displayName: 'Affirmation Categories',
|
||||
displayNameSingular: 'Affirmation Category',
|
||||
slug: 'proclamation-categories',
|
||||
modelName: 'ProclamationCategory',
|
||||
displayName: 'Proclamation Categories',
|
||||
displayNameSingular: 'Proclamation Category',
|
||||
idType: 'Int',
|
||||
include: { affirmations: true, children: true, parent: true },
|
||||
include: { proclamations: true, children: true, parent: true },
|
||||
listFields: ['name', 'parent.name'],
|
||||
searchFields: ['name'],
|
||||
formFields: ['name', 'parentId'],
|
||||
@@ -162,22 +162,22 @@ const models: AdminModelConfig[] = [
|
||||
parentId: 'Int'
|
||||
},
|
||||
relationOptions: {
|
||||
parentId: { model: 'AffirmationCategory', labelField: 'name', valueField: 'id' }
|
||||
parentId: { model: 'ProclamationCategory', labelField: 'name', valueField: 'id' }
|
||||
},
|
||||
relatedRecords: [
|
||||
{
|
||||
relationKey: 'affirmations',
|
||||
label: 'Affirmations',
|
||||
relationKey: 'proclamations',
|
||||
label: 'Proclamations',
|
||||
type: 'has-many',
|
||||
displayFields: ['name', 'price'],
|
||||
editSlug: 'affirmations'
|
||||
editSlug: 'proclamations'
|
||||
},
|
||||
{
|
||||
relationKey: 'children',
|
||||
label: 'Sub-categories',
|
||||
type: 'has-many',
|
||||
displayFields: ['name'],
|
||||
editSlug: 'affirmation-categories'
|
||||
editSlug: 'proclamation-categories'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ export const ROLES = {
|
||||
} as const;
|
||||
|
||||
export function hasRole(userRoleId: number | null | undefined, requiredRoleId: number): boolean {
|
||||
if (userRoleId == null) return false;
|
||||
if (userRoleId == null || userRoleId < 1) return false;
|
||||
return userRoleId <= requiredRoleId;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user