From 30521c86cd7934d8cda18964dc2f72db8592226c Mon Sep 17 00:00:00 2001 From: Nitesh Rijal Date: Wed, 20 May 2026 19:50:17 -0500 Subject: [PATCH 1/3] feat(slug): keep slug in sync with title, lockable slug field, reject-on-collision option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move slug auto-sync watcher out of create-only gate so the slug also follows the title on update — but only while the field is locked. - Add lock state per slug field (provided to AutoFormField via inject); slug fields render readonly with a lock toggle. Unlocking pauses the auto-sync so manual edits stick. - New `slugCollision: 'suffix' | 'reject'` option on registered models (default `'suffix'` keeps existing behavior). With `'reject'` the server pre-checks uniqueness and returns a validation error keyed by the slug field, surfaced on the form. - Pass `slugFields` through the update formspec route (was missing). Closes #7 Co-Authored-By: Claude Opus 4.7 (1M context) --- components/AutoForm.vue | 47 ++++++++++++---- components/AutoFormField.vue | 22 +++++++- .../[modelKey]/update/[lookupValue].ts | 1 + server/services/create.ts | 12 +++- server/services/update.ts | 11 +++- server/utils/registry.ts | 7 +++ server/utils/slug.ts | 55 +++++++++++++++++++ 7 files changed, 141 insertions(+), 14 deletions(-) diff --git a/components/AutoForm.vue b/components/AutoForm.vue index f81ee1a..5e6a8b5 100644 --- a/components/AutoForm.vue +++ b/components/AutoForm.vue @@ -67,28 +67,53 @@ const { updateOriginalState } = useWarnOnUnsavedChanges(toRef(() => state), prop enabled: props.spec.warnOnUnsavedChanges ?? false, }) -// Auto-generate slugs based on slugFields configuration -if (props.mode === 'create' && props.spec.slugFields) { +// Per-slug-field lock state. Slug fields are locked (readonly, auto-synced +// from the source fields) by default. Clicking the lock icon in +// AutoFormField unlocks the field for manual editing and stops auto-sync. +const slugLocks = reactive>({}) +if (props.spec.slugFields) { + for (const slugField of Object.keys(props.spec.slugFields)) { + slugLocks[slugField] = true + } +} + +function toggleSlugLock(slugField: string) { + if (slugField in slugLocks) { + slugLocks[slugField] = !slugLocks[slugField] + } +} + +// Expose lock state to AutoFormField via inject. +provide('slugLocks', slugLocks) +provide('toggleSlugLock', toggleSlugLock) + +// Auto-generate slugs based on slugFields configuration. Runs in both create +// and update modes, but only while the slug field is locked. +if (props.spec.slugFields) { for (const [slugField, sourceFields] of Object.entries(props.spec.slugFields)) { - // Watch the source fields and update the slug field when they change watch( - () => sourceFields?.map(field => state[field]).filter(Boolean), - async (values) => { - if (values && values.length > 0) { - // If any value is a Date, use toIsoDateString, else .toString() - const slugSource = values.map(v => v instanceof Date && typeof v.toISOString === 'function' ? v.toISOString().split('T')[0] : v.toString()).join(' ') + () => ({ + locked: slugLocks[slugField], + sources: sourceFields?.map(field => state[field]).filter(Boolean), + }), + async ({ locked, sources }) => { + if (!locked) + return + if (sources && sources.length > 0) { + const slugSource = sources.map(v => v instanceof Date && typeof v.toISOString === 'function' ? v.toISOString().split('T')[0] : v.toString()).join(' ') const nextSlug = slugify(slugSource) if (state[slugField] !== nextSlug) { state[slugField] = nextSlug } } - else if (state[slugField]) { + else if (state[slugField] && props.mode === 'create') { + // Only clear in create mode — never destroy an existing slug on update + // just because the editor briefly emptied the source field. state[slugField] = '' } - // Re-validate the slug field if there is an error await form.value?.validate({ name: slugField, silent: true }) }, - { immediate: true }, + { immediate: props.mode === 'create' }, ) } } diff --git a/components/AutoFormField.vue b/components/AutoFormField.vue index 626f5f9..03e74b0 100644 --- a/components/AutoFormField.vue +++ b/components/AutoFormField.vue @@ -21,6 +21,14 @@ const richTextClientConfig = computed(() => { return useAdminClient().getRichTextConfig(modelKey, props.field.name) }) +// Slug lock state — provided by AutoForm.vue when this field is declared in +// `slugFields`. When locked, the field renders as readonly and auto-syncs from +// the source field. Clicking the lock unlocks it for manual editing. +const slugLocks = inject | null>('slugLocks', null) +const toggleSlugLock = inject<((name: string) => void) | null>('toggleSlugLock', null) +const isSlugField = computed(() => slugLocks !== null && props.field.name in slugLocks) +const isSlugLocked = computed(() => isSlugField.value && slugLocks![props.field.name]) + // Rich-text editors use contenteditable which doesn't fire native DOM events // that UForm relies on for re-validation. Manually re-validate when the value // changes and there's an existing error for this field. @@ -338,7 +346,19 @@ async function openRelationModal(mode: 'create' | 'update', lookupValue?: string class="w-full" color="neutral" type="text" - /> + :readonly="isSlugLocked" + > + + { specWithMetadata.listTitle = cfg.list.title ?? cfg.label specWithMetadata.canList = getAllowedActions(event, { roles: cfg.roles }).list specWithMetadata.schema = zerialize(cfg.update.schema) + specWithMetadata.slugFields = cfg.slugFields return { spec: specWithMetadata, diff --git a/server/services/create.ts b/server/services/create.ts index 2a97631..fbcdb96 100644 --- a/server/services/create.ts +++ b/server/services/create.ts @@ -3,7 +3,7 @@ import type { InferInsertModel, InferSelectModel, Table } from 'drizzle-orm' import { useAdminDb } from '../utils/db' import { colKey, handleDrizzleError } from '../utils/drizzle' import { parseM2mRelations, saveM2mRelation, saveO2mRelation } from '../utils/relation' -import { ensureUniqueSlugs, isSlugUniqueViolation } from '../utils/slug' +import { assertUniqueSlugs, ensureUniqueSlugs, isSlugUniqueViolation } from '../utils/slug' import { unwrapZodType } from '../utils/zod' export async function createRecord(cfg: AdminModelConfig, data: any): Promise { @@ -45,12 +45,22 @@ export async function createRecord(cfg: AdminModelConfig, da const validatedData = schema.parse(preprocessed) as InferInsertModel + if (cfg.slugCollision === 'reject') { + await assertUniqueSlugs(cfg, validatedData as Record) + } + let result try { result = await db.insert(model).values(validatedData).returning() } catch (error) { if (isSlugUniqueViolation(cfg, error)) { + if (cfg.slugCollision === 'reject') { + // Race: pre-check passed but another request inserted the same slug. + // Re-run the assertion so the client sees a validation error. + await assertUniqueSlugs(cfg, validatedData as Record) + throw createError(handleDrizzleError(error)) + } await ensureUniqueSlugs(cfg, validatedData as Record) try { result = await db.insert(model).values(validatedData).returning() diff --git a/server/services/update.ts b/server/services/update.ts index ab7bafc..b1a2581 100644 --- a/server/services/update.ts +++ b/server/services/update.ts @@ -4,7 +4,7 @@ import { eq } from 'drizzle-orm' import { useAdminDb } from '../utils/db' import { colKey, handleDrizzleError } from '../utils/drizzle' import { parseM2mRelations, saveM2mRelation, saveO2mRelation } from '../utils/relation' -import { ensureUniqueSlugs, isSlugUniqueViolation } from '../utils/slug' +import { assertUniqueSlugs, ensureUniqueSlugs, isSlugUniqueViolation } from '../utils/slug' import { unwrapZodType } from '../utils/zod' export async function updateRecord(cfg: AdminModelConfig, lookupValue: string, data: any): Promise { @@ -47,12 +47,21 @@ export async function updateRecord(cfg: AdminModelConfig, lo const validatedData = schema.parse(preprocessed) + if (cfg.slugCollision === 'reject') { + await assertUniqueSlugs(cfg, validatedData as Record, lookupValue) + } + let result try { result = await db.update(model).set(validatedData).where(eq(cfg.lookupColumn, lookupValue)).returning() } catch (error) { if (isSlugUniqueViolation(cfg, error)) { + if (cfg.slugCollision === 'reject') { + // Race: pre-check passed but another request claimed the slug. + await assertUniqueSlugs(cfg, validatedData as Record, lookupValue) + throw createError(handleDrizzleError(error)) + } await ensureUniqueSlugs(cfg, validatedData as Record, lookupValue) try { result = await db.update(model).set(validatedData).where(eq(cfg.lookupColumn, lookupValue)).returning() diff --git a/server/utils/registry.ts b/server/utils/registry.ts index e6c75e6..6a0c14c 100644 --- a/server/utils/registry.ts +++ b/server/utils/registry.ts @@ -173,6 +173,12 @@ export interface AdminModelOptions, ColKey[]>> + /** + * How to handle slug collisions on create/update. + * - `'suffix'` (default): silently append `-1`, `-2`, etc. to make the slug unique. + * - `'reject'`: return a validation error on the slug field so the editor can change it. + */ + slugCollision?: 'suffix' | 'reject' enableIndex?: boolean labelColumnName?: ColKey lookupColumnName?: ColKey @@ -219,6 +225,7 @@ export interface AdminModelConfig, ColKey[]>> + slugCollision?: 'suffix' | 'reject' /** Normalized from `AdminModelOptions.roles` (array → `{ full }`). */ roles?: AutoadminRolesConfig } diff --git a/server/utils/slug.ts b/server/utils/slug.ts index 3b94014..1b0eca4 100644 --- a/server/utils/slug.ts +++ b/server/utils/slug.ts @@ -95,3 +95,58 @@ export async function ensureUniqueSlugs( data[slugFieldName] = `${slug}-${suffix}` } } + +/** + * For each slug field, checks whether the value in `data` already exists in the + * table. If any do, throws a 422 validation error keyed by the slug field name + * so the client surfaces it on the field. Used when `slugCollision: 'reject'`. + * When `excludeLookupValue` is provided (update mode), the current record is + * excluded from the uniqueness check. + */ +export async function assertUniqueSlugs( + cfg: AdminModelConfig, + data: Record, + excludeLookupValue?: string, +): Promise { + if (!cfg.slugFields) + return + + const db = useAdminDb() + const errors: { name: string, message: string }[] = [] + + for (const slugFieldName of Object.keys(cfg.slugFields)) { + const slug = data[slugFieldName] + if (typeof slug !== 'string' || slug === '') + continue + + const column = cfg.columns[slugFieldName] as Column | undefined + if (!column) + continue + + const conditions = [eq(column, slug)] + if (excludeLookupValue) { + conditions.push(ne(cfg.lookupColumn, excludeLookupValue)) + } + + const rows = await (db as any) + .select({ val: column }) + .from(cfg.model) + .where(and(...conditions)) + .limit(1) + + if (rows.length > 0) { + errors.push({ + name: slugFieldName, + message: `"${slug}" is already in use. Edit the slug or change the source field.`, + }) + } + } + + if (errors.length > 0) { + throw createError({ + statusCode: 422, + statusMessage: 'Slug already in use', + data: { errors }, + }) + } +} From 5ec34eebfe2acec0c3bf8a00a15c02c1a43d87b7 Mon Sep 17 00:00:00 2001 From: Nitesh Rijal Date: Wed, 20 May 2026 20:01:48 -0500 Subject: [PATCH 2/3] feat(slug): add slugLockedByDefault option to control initial lock state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New per-resource option `slugLockedByDefault?: boolean` (default `true`). When `false`, slug fields start unlocked so editors can type a custom slug immediately on create — the lock toggle is still available to re-enable auto-sync from the source field. Co-Authored-By: Claude Opus 4.7 (1M context) --- components/AutoForm.vue | 10 ++++++---- server/api/autoadmin/formspec/[modelKey]/index.ts | 1 + .../formspec/[modelKey]/update/[lookupValue].ts | 1 + server/utils/form.ts | 1 + server/utils/registry.ts | 7 +++++++ 5 files changed, 16 insertions(+), 4 deletions(-) diff --git a/components/AutoForm.vue b/components/AutoForm.vue index 5e6a8b5..9ca6104 100644 --- a/components/AutoForm.vue +++ b/components/AutoForm.vue @@ -67,13 +67,15 @@ const { updateOriginalState } = useWarnOnUnsavedChanges(toRef(() => state), prop enabled: props.spec.warnOnUnsavedChanges ?? false, }) -// Per-slug-field lock state. Slug fields are locked (readonly, auto-synced -// from the source fields) by default. Clicking the lock icon in -// AutoFormField unlocks the field for manual editing and stops auto-sync. +// Per-slug-field lock state. When locked, the field is readonly and +// auto-syncs from the source fields. The starting value comes from +// `spec.slugLockedByDefault` (defaults to `true`). Clicking the lock icon +// in AutoFormField toggles it per-field. const slugLocks = reactive>({}) +const slugLockedByDefault = props.spec.slugLockedByDefault ?? true if (props.spec.slugFields) { for (const slugField of Object.keys(props.spec.slugFields)) { - slugLocks[slugField] = true + slugLocks[slugField] = slugLockedByDefault } } diff --git a/server/api/autoadmin/formspec/[modelKey]/index.ts b/server/api/autoadmin/formspec/[modelKey]/index.ts index 1a01e92..0dd4d66 100644 --- a/server/api/autoadmin/formspec/[modelKey]/index.ts +++ b/server/api/autoadmin/formspec/[modelKey]/index.ts @@ -53,6 +53,7 @@ export default defineEventHandler(async (event) => { specWithMetadata.canList = getAllowedActions(event, { roles: cfg.roles }).list specWithMetadata.schema = zerialize(cfg.create.schema) specWithMetadata.slugFields = cfg.slugFields + specWithMetadata.slugLockedByDefault = cfg.slugLockedByDefault return { spec: specWithMetadata, diff --git a/server/api/autoadmin/formspec/[modelKey]/update/[lookupValue].ts b/server/api/autoadmin/formspec/[modelKey]/update/[lookupValue].ts index b883636..3e46f86 100644 --- a/server/api/autoadmin/formspec/[modelKey]/update/[lookupValue].ts +++ b/server/api/autoadmin/formspec/[modelKey]/update/[lookupValue].ts @@ -115,6 +115,7 @@ export default defineEventHandler(async (event) => { specWithMetadata.canList = getAllowedActions(event, { roles: cfg.roles }).list specWithMetadata.schema = zerialize(cfg.update.schema) specWithMetadata.slugFields = cfg.slugFields + specWithMetadata.slugLockedByDefault = cfg.slugLockedByDefault return { spec: specWithMetadata, diff --git a/server/utils/form.ts b/server/utils/form.ts index 9a34218..6501297 100644 --- a/server/utils/form.ts +++ b/server/utils/form.ts @@ -69,6 +69,7 @@ export interface FormSpec { canList?: boolean schema?: SzType slugFields?: Partial, ColKey[]>> + slugLockedByDefault?: boolean } export function zodToFormSpec(schema: ZodObject>): FormSpec { diff --git a/server/utils/registry.ts b/server/utils/registry.ts index 6a0c14c..9938cf4 100644 --- a/server/utils/registry.ts +++ b/server/utils/registry.ts @@ -179,6 +179,12 @@ export interface AdminModelOptions lookupColumnName?: ColKey @@ -226,6 +232,7 @@ export interface AdminModelConfig, ColKey[]>> slugCollision?: 'suffix' | 'reject' + slugLockedByDefault?: boolean /** Normalized from `AdminModelOptions.roles` (array → `{ full }`). */ roles?: AutoadminRolesConfig } From 60d782e7e158764cec11dd52e354f211a235910b Mon Sep 17 00:00:00 2001 From: Nitesh Rijal Date: Wed, 20 May 2026 20:10:06 -0500 Subject: [PATCH 3/3] feat(slug): mirror slug feature on JSON array resources Adds the same three options to `useJsonResourceRegistry().register(...)` for `kind: 'array'` resources: `slugFields`, `slugCollision`, `slugLockedByDefault`. Behavior matches the DB-registered models so editors can use one mental model regardless of storage backend. - `jsonResourceRegistry.ts`: new options on input + config; carried through `defaultArrayConfig`. - `jsonFormSpec.ts`: passes `slugFields` and `slugLockedByDefault` through both create and update form specs so the existing AutoForm/AutoFormField client wiring picks them up. - `slug.ts`: new in-memory helpers `ensureUniqueSlugsInRows` and `assertUniqueSlugsInRows` for array resources (mirroring the DB-side helpers, but pure functions over the in-memory rows). - `jsonResourceCrud.ts`: `createJsonArrayRecord` and `updateJsonArrayRecord` now run the uniqueness check (suffix or reject) inside the existing `writeArrayWithRetry` closure, before writing back to storage. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/services/jsonResourceCrud.ts | 17 +++++++ server/utils/jsonFormSpec.ts | 4 ++ server/utils/jsonResourceRegistry.ts | 16 ++++++ server/utils/slug.ts | 76 ++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+) diff --git a/server/services/jsonResourceCrud.ts b/server/services/jsonResourceCrud.ts index b35c0ac..107ce0f 100644 --- a/server/services/jsonResourceCrud.ts +++ b/server/services/jsonResourceCrud.ts @@ -7,6 +7,7 @@ import { JSON_ARRAY_ROW_ID, JSON_OBJECT_LOOKUP } from '#layers/autoadmin/server/ import { createJsonStorageRepository } from '#layers/autoadmin/server/utils/jsonStorage/factory' import { getZodObjectWithLenientJsonRead } from '#layers/autoadmin/server/utils/jsonZodLenientRead' import { zodToListSpec } from '#layers/autoadmin/server/utils/list' +import { assertUniqueSlugsInRows, ensureUniqueSlugsInRows } from '#layers/autoadmin/server/utils/slug' import { unwrapZodType } from '#layers/autoadmin/server/utils/zod' import { toTitleCase } from '#layers/autoadmin/utils/string' import { z } from 'zod' @@ -422,6 +423,14 @@ export async function createJsonArrayRecord(cfg: JsonArrayResourceConfig, data: delete input[cfg.idField] const preprocessed = preprocessDates(cfg.elementSchema, input) const validated = cfg.elementSchema.parse(preprocessed) as Record + if (cfg.slugFields) { + if (cfg.slugCollision === 'reject') { + assertUniqueSlugsInRows(cfg.slugFields, validated, rows, cfg.idField) + } + else { + ensureUniqueSlugsInRows(cfg.slugFields, validated, rows, cfg.idField) + } + } const ids = new Set(rows.map(r => String(r[cfg.idField]))) validated[cfg.idField] = crypto.randomUUID() if (ids.has(String(validated[cfg.idField]))) { @@ -467,6 +476,14 @@ export async function updateJsonArrayRecord(cfg: JsonArrayResourceConfig, lookup }) } const validated = cfg.elementSchema.parse(merged) as Record + if (cfg.slugFields) { + if (cfg.slugCollision === 'reject') { + assertUniqueSlugsInRows(cfg.slugFields, validated, rows, cfg.idField, decoded) + } + else { + ensureUniqueSlugsInRows(cfg.slugFields, validated, rows, cfg.idField, decoded) + } + } updated = validated const next = [...rows] next[idx] = validated diff --git a/server/utils/jsonFormSpec.ts b/server/utils/jsonFormSpec.ts index 0c99ad3..1868bd8 100644 --- a/server/utils/jsonFormSpec.ts +++ b/server/utils/jsonFormSpec.ts @@ -21,6 +21,8 @@ export async function buildJsonArrayCreateFormSpec(cfg: JsonArrayResourceConfig, spec.endpoint = cfg.create.endpoint ?? `${apiPrefix}/${modelKey}` spec.listTitle = cfg.list.title ?? cfg.label spec.schema = zerialize(cfg.elementSchema) + spec.slugFields = cfg.slugFields + spec.slugLockedByDefault = cfg.slugLockedByDefault return spec } @@ -44,6 +46,8 @@ export async function buildJsonArrayUpdateFormSpec(cfg: JsonArrayResourceConfig, spec.endpoint = cfg.update.endpoint ?? `${apiPrefix}/${modelKey}/${encodeURIComponent(lookupValue)}` spec.listTitle = cfg.list.title ?? cfg.label spec.schema = zerialize(cfg.elementSchema) + spec.slugFields = cfg.slugFields + spec.slugLockedByDefault = cfg.slugLockedByDefault return spec } diff --git a/server/utils/jsonResourceRegistry.ts b/server/utils/jsonResourceRegistry.ts index 74fe1f5..63f635f 100644 --- a/server/utils/jsonResourceRegistry.ts +++ b/server/utils/jsonResourceRegistry.ts @@ -101,6 +101,16 @@ export interface RegisterJsonArrayResourceInput { delete?: Partial fields?: FieldSpec[] warnOnUnsavedChanges?: boolean + /** Example: `{ slug: ['title'] }` — same shape as the DB registry. */ + slugFields?: Partial> + /** + * How to handle slug collisions on create/update. + * - `'suffix'` (default): silently append `-1`, `-2`, etc. + * - `'reject'`: return a validation error on the slug field. + */ + slugCollision?: 'suffix' | 'reject' + /** Whether slug fields are locked (readonly, auto-synced) by default. Defaults to `true`. */ + slugLockedByDefault?: boolean /** * Role allowlists: `string[]` or an object for per-action roles. */ @@ -153,6 +163,9 @@ export interface JsonArrayResourceConfig { fields?: FieldSpec[] warnOnUnsavedChanges: boolean apiPrefix: string + slugFields?: Partial> + slugCollision?: 'suffix' | 'reject' + slugLockedByDefault?: boolean /** Normalized from `RegisterJsonArrayResourceInput.roles` (array → `{ full }`). */ roles?: AutoadminRolesConfig } @@ -302,6 +315,9 @@ function defaultArrayConfig( fields: input.fields, warnOnUnsavedChanges: input.warnOnUnsavedChanges ?? false, apiPrefix, + slugFields: input.slugFields, + slugCollision: input.slugCollision, + slugLockedByDefault: input.slugLockedByDefault, roles: normalizeAutoadminRolesInput(input.roles), } } diff --git a/server/utils/slug.ts b/server/utils/slug.ts index 1b0eca4..53f782b 100644 --- a/server/utils/slug.ts +++ b/server/utils/slug.ts @@ -150,3 +150,79 @@ export async function assertUniqueSlugs( }) } } + +/** + * In-memory equivalent of `ensureUniqueSlugs` for JSON array resources. + * For each slug field, mutates `data` to append `-1`, `-2`, etc. if the value + * collides with another row. When `excludeId` is provided (update mode), the + * row with that id is skipped. + */ +export function ensureUniqueSlugsInRows( + slugFields: Partial> | undefined, + data: Record, + rows: Record[], + idField: string, + excludeId?: string, +): void { + if (!slugFields) + return + for (const slugFieldName of Object.keys(slugFields)) { + const slug = data[slugFieldName] + if (typeof slug !== 'string' || slug === '') + continue + const existing = new Set() + for (const r of rows) { + if (excludeId && String(r[idField]) === excludeId) + continue + const v = r[slugFieldName] + if (typeof v === 'string') + existing.add(v) + } + if (!existing.has(slug)) + continue + let suffix = 1 + while (existing.has(`${slug}-${suffix}`)) { + suffix++ + } + data[slugFieldName] = `${slug}-${suffix}` + } +} + +/** + * In-memory equivalent of `assertUniqueSlugs` for JSON array resources. + * Throws a 422 validation error keyed by the slug field if any value collides. + */ +export function assertUniqueSlugsInRows( + slugFields: Partial> | undefined, + data: Record, + rows: Record[], + idField: string, + excludeId?: string, +): void { + if (!slugFields) + return + const errors: { name: string, message: string }[] = [] + for (const slugFieldName of Object.keys(slugFields)) { + const slug = data[slugFieldName] + if (typeof slug !== 'string' || slug === '') + continue + const collided = rows.some((r) => { + if (excludeId && String(r[idField]) === excludeId) + return false + return r[slugFieldName] === slug + }) + if (collided) { + errors.push({ + name: slugFieldName, + message: `"${slug}" is already in use. Edit the slug or change the source field.`, + }) + } + } + if (errors.length > 0) { + throw createError({ + statusCode: 422, + statusMessage: 'Slug already in use', + data: { errors }, + }) + } +}