Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 38 additions & 11 deletions components/AutoForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -67,28 +67,55 @@ 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. 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<Record<string, boolean>>({})
const slugLockedByDefault = props.spec.slugLockedByDefault ?? true
if (props.spec.slugFields) {
for (const slugField of Object.keys(props.spec.slugFields)) {
slugLocks[slugField] = slugLockedByDefault
}
}

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' },
)
}
}
Expand Down
22 changes: 21 additions & 1 deletion components/AutoFormField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, boolean> | 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.
Expand Down Expand Up @@ -338,7 +346,19 @@ async function openRelationModal(mode: 'create' | 'update', lookupValue?: string
class="w-full"
color="neutral"
type="text"
/>
:readonly="isSlugLocked"
>
<template v-if="isSlugField" #trailing>
<UButton
:aria-label="isSlugLocked ? 'Unlock slug for manual editing' : 'Lock slug to auto-sync from source field'"
:icon="isSlugLocked ? 'i-lucide-lock' : 'i-lucide-lock-open'"
color="neutral"
size="xs"
variant="ghost"
@click.prevent="toggleSlugLock?.(field.name)"
/>
</template>
</UInput>

<!-- Textarea input with nullable modifier -->
<UTextarea
Expand Down
1 change: 1 addition & 0 deletions server/api/autoadmin/formspec/[modelKey]/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ export default defineEventHandler(async (event) => {
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
specWithMetadata.slugLockedByDefault = cfg.slugLockedByDefault

return {
spec: specWithMetadata,
Expand Down
12 changes: 11 additions & 1 deletion server/services/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends Table>(cfg: AdminModelConfig<T>, data: any): Promise<any> {
Expand Down Expand Up @@ -45,12 +45,22 @@ export async function createRecord<T extends Table>(cfg: AdminModelConfig<T>, da

const validatedData = schema.parse(preprocessed) as InferInsertModel<T>

if (cfg.slugCollision === 'reject') {
await assertUniqueSlugs(cfg, validatedData as Record<string, any>)
}

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<string, any>)
throw createError(handleDrizzleError(error))
}
await ensureUniqueSlugs(cfg, validatedData as Record<string, any>)
try {
result = await db.insert(model).values(validatedData).returning()
Expand Down
17 changes: 17 additions & 0 deletions server/services/jsonResourceCrud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, any>
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]))) {
Expand Down Expand Up @@ -467,6 +476,14 @@ export async function updateJsonArrayRecord(cfg: JsonArrayResourceConfig, lookup
})
}
const validated = cfg.elementSchema.parse(merged) as Record<string, any>
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
Expand Down
11 changes: 10 additions & 1 deletion server/services/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends Table>(cfg: AdminModelConfig<T>, lookupValue: string, data: any): Promise<any> {
Expand Down Expand Up @@ -47,12 +47,21 @@ export async function updateRecord<T extends Table>(cfg: AdminModelConfig<T>, lo

const validatedData = schema.parse(preprocessed)

if (cfg.slugCollision === 'reject') {
await assertUniqueSlugs(cfg, validatedData as Record<string, any>, 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<string, any>, lookupValue)
throw createError(handleDrizzleError(error))
}
await ensureUniqueSlugs(cfg, validatedData as Record<string, any>, lookupValue)
try {
result = await db.update(model).set(validatedData).where(eq(cfg.lookupColumn, lookupValue)).returning()
Expand Down
1 change: 1 addition & 0 deletions server/utils/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export interface FormSpec<T extends Table = Table> {
canList?: boolean
schema?: SzType
slugFields?: Partial<Record<ColKey<T>, ColKey<T>[]>>
slugLockedByDefault?: boolean
}

export function zodToFormSpec(schema: ZodObject<Record<string, ZodType>>): FormSpec {
Expand Down
4 changes: 4 additions & 0 deletions server/utils/jsonFormSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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
}

Expand Down
16 changes: 16 additions & 0 deletions server/utils/jsonResourceRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,16 @@ export interface RegisterJsonArrayResourceInput {
delete?: Partial<JsonDeleteOptions>
fields?: FieldSpec[]
warnOnUnsavedChanges?: boolean
/** Example: `{ slug: ['title'] }` — same shape as the DB registry. */
slugFields?: Partial<Record<string, string[]>>
/**
* 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.
*/
Expand Down Expand Up @@ -153,6 +163,9 @@ export interface JsonArrayResourceConfig {
fields?: FieldSpec[]
warnOnUnsavedChanges: boolean
apiPrefix: string
slugFields?: Partial<Record<string, string[]>>
slugCollision?: 'suffix' | 'reject'
slugLockedByDefault?: boolean
/** Normalized from `RegisterJsonArrayResourceInput.roles` (array → `{ full }`). */
roles?: AutoadminRolesConfig
}
Expand Down Expand Up @@ -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),
}
}
Expand Down
14 changes: 14 additions & 0 deletions server/utils/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,18 @@ export interface AdminModelOptions<T extends Table = Table, C extends CustomSele
icon?: string
/** Example: { slug: ["title", "date"] } */
slugFields?: Partial<Record<ColKey<T>, ColKey<T>[]>>
/**
* 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'
/**
* Whether slug fields are locked (readonly, auto-synced from source fields)
* by default in the form. Editors can still toggle the lock per-field.
* Defaults to `true`.
*/
slugLockedByDefault?: boolean
enableIndex?: boolean
labelColumnName?: ColKey<T>
lookupColumnName?: ColKey<T>
Expand Down Expand Up @@ -219,6 +231,8 @@ export interface AdminModelConfig<T extends Table = Table, C extends CustomSelec
metadata: TableMetadata
apiPrefix: string
slugFields?: Partial<Record<ColKey<T>, ColKey<T>[]>>
slugCollision?: 'suffix' | 'reject'
slugLockedByDefault?: boolean
/** Normalized from `AdminModelOptions.roles` (array → `{ full }`). */
roles?: AutoadminRolesConfig
}
Expand Down
Loading