Skip to content
Merged
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
54 changes: 53 additions & 1 deletion src/__tests__/data/newFieldDialogModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@
import { afterEach, describe, expect, it } from 'bun:test'
import { Value } from '@sinclair/typebox/value'
import { DataFieldSchema } from '@core/data/schemas'
import { makeOption, slugifyOptionValue } from '@admin/pages/data/components/NewFieldDialog/newFieldDialogModel'
import { fieldIdError, makeOption, slugifyOptionValue } from '@admin/pages/data/components/NewFieldDialog/newFieldDialogModel'
import {
POST_TYPE_FIELD_FEATURED_MEDIA,
POST_TYPE_FIELD_SEO_DESCRIPTION,
POST_TYPE_FIELD_SEO_TITLE,
} from '@core/data/schemas'

// ---------------------------------------------------------------------------
// Helpers
Expand Down Expand Up @@ -76,3 +81,50 @@ describe('makeOption', () => {
expect(Value.Check(DataFieldSchema, field)).toBe(true)
})
})

describe('fieldIdError', () => {
it('accepts camelCase, the convention the built-in fields already use', () => {
for (const id of ['firmaUrl', 'interviewUrl', 'aufStartseite', 'seoTitle']) {
expect(fieldIdError(id, [])).toBeNull()
}
})

it("accepts Instatic's own built-in post-type field ids", () => {
// The regression in #434: the dialog rejected ids that the product itself
// ships, so one table could end up holding two naming conventions.
for (const id of [
POST_TYPE_FIELD_FEATURED_MEDIA,
POST_TYPE_FIELD_SEO_TITLE,
POST_TYPE_FIELD_SEO_DESCRIPTION,
]) {
expect(id).toMatch(/[A-Z]/)
expect(fieldIdError(id, [])).toBeNull()
}
})

it('still accepts snake_case, so nothing that validated before stops', () => {
for (const id of ['firma_url', 'title', 'field_1', 'a']) {
expect(fieldIdError(id, [])).toBeNull()
}
})

it('still requires a lowercase first character', () => {
for (const id of ['FirmaUrl', '_leading', '1st', 'Ärger']) {
expect(fieldIdError(id, [])).not.toBeNull()
}
})

it('still rejects characters that are unsafe as a token key', () => {
for (const id of ['firma-url', 'firma url', 'firma.url', 'firma$url']) {
expect(fieldIdError(id, [])).not.toBeNull()
}
})

it('reports an empty id as no error, leaving the submit gate to handle it', () => {
expect(fieldIdError('', [])).toBeNull()
})

it('still flags a duplicate id ahead of the pattern message', () => {
expect(fieldIdError('firmaUrl', ['firmaUrl'])).toBe('This ID is already in use.')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,18 @@ export const MEDIA_KIND_OPTIONS = [
{ value: 'video', label: 'Video' },
]

const FIELD_ID_PATTERN = /^[a-z][a-z0-9_]*$/
/**
* Field ids must start with a lowercase letter so they stay safe as token
* and binding keys, but the body accepts any letter case.
*
* camelCase is deliberately allowed: the storage schema puts no pattern on
* `id` at all (`FieldCommonProps.id` is a bare `Type.String()`), the API
* accepts camelCase today, and three of the built-in post-type fields —
* `featuredMedia`, `seoTitle`, `seoDescription` — are themselves camelCase.
* Rejecting it here was the only thing forcing a second convention into
* tables that already carry the first.
*/
const FIELD_ID_PATTERN = /^[a-z][a-zA-Z0-9_]*$/

export function slugifyOptionValue(label: string): string {
return label
Expand All @@ -65,7 +76,7 @@ export function makeOption(label: string): DraftOption {

export function fieldIdError(id: string, existingIds: string[]): string | null {
if (!id) return null
if (!FIELD_ID_PATTERN.test(id)) return 'Must start with a lowercase letter; use letters, numbers, underscores only.'
if (!FIELD_ID_PATTERN.test(id)) return 'Must start with a lowercase letter, then letters, numbers or underscores.'
if (existingIds.includes(id)) return 'This ID is already in use.'
return null
}
Loading