@actuate-media/cms-admin 0.44.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/__tests__/components/tag-input.render.test.d.ts +2 -0
  3. package/dist/__tests__/components/tag-input.render.test.d.ts.map +1 -0
  4. package/dist/__tests__/components/tag-input.render.test.js +86 -0
  5. package/dist/__tests__/components/tag-input.render.test.js.map +1 -0
  6. package/dist/__tests__/lib/post-editor-service.test.js +26 -0
  7. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
  8. package/dist/__tests__/views/post-fields-modal.render.test.d.ts +2 -0
  9. package/dist/__tests__/views/post-fields-modal.render.test.d.ts.map +1 -0
  10. package/dist/__tests__/views/post-fields-modal.render.test.js +86 -0
  11. package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -0
  12. package/dist/__tests__/views/section-inspector-repeater.render.test.d.ts +2 -0
  13. package/dist/__tests__/views/section-inspector-repeater.render.test.d.ts.map +1 -0
  14. package/dist/__tests__/views/section-inspector-repeater.render.test.js +68 -0
  15. package/dist/__tests__/views/section-inspector-repeater.render.test.js.map +1 -0
  16. package/dist/actuate-admin.css +1 -1
  17. package/dist/components/TagInput.d.ts +16 -0
  18. package/dist/components/TagInput.d.ts.map +1 -0
  19. package/dist/components/TagInput.js +105 -0
  20. package/dist/components/TagInput.js.map +1 -0
  21. package/dist/lib/post-editor-service.d.ts +8 -0
  22. package/dist/lib/post-editor-service.d.ts.map +1 -1
  23. package/dist/lib/post-editor-service.js +25 -0
  24. package/dist/lib/post-editor-service.js.map +1 -1
  25. package/dist/views/page-editor/SectionInspector.d.ts +9 -0
  26. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
  27. package/dist/views/page-editor/SectionInspector.js +38 -1
  28. package/dist/views/page-editor/SectionInspector.js.map +1 -1
  29. package/dist/views/post-editor/PostFieldsModal.d.ts +17 -2
  30. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  31. package/dist/views/post-editor/PostFieldsModal.js +13 -4
  32. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  33. package/dist/views/post-editor/PostSectionEditor.d.ts +2 -0
  34. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  35. package/dist/views/post-editor/PostSectionEditor.js +1 -1
  36. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  37. package/package.json +2 -2
  38. package/src/__tests__/components/tag-input.render.test.tsx +103 -0
  39. package/src/__tests__/lib/post-editor-service.test.ts +28 -0
  40. package/src/__tests__/views/post-fields-modal.render.test.tsx +126 -0
  41. package/src/__tests__/views/section-inspector-repeater.render.test.tsx +90 -0
  42. package/src/components/TagInput.tsx +196 -0
  43. package/src/lib/post-editor-service.ts +28 -0
  44. package/src/views/page-editor/SectionInspector.tsx +40 -2
  45. package/src/views/post-editor/PostFieldsModal.tsx +79 -10
  46. package/src/views/post-editor/PostSectionEditor.tsx +11 -2
@@ -0,0 +1,103 @@
1
+ // @vitest-environment happy-dom
2
+ import { useState } from 'react'
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
4
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
5
+
6
+ /**
7
+ * TagInput — chip editor with autocomplete from the collection's in-use
8
+ * vocabulary (`GET /collections/:slug/field-values`). Pins the dedupe
9
+ * contract: adding "ai" when "AI" already exists in the collection resolves
10
+ * to the canonical "AI", and tags already on the post are never duplicated.
11
+ */
12
+
13
+ let fieldValues: Array<{ value: string; count: number }> = []
14
+
15
+ const cmsApi = vi.fn(async (endpoint: string, _options?: unknown) => {
16
+ if (endpoint.startsWith('/collections/posts/field-values')) {
17
+ return { data: { field: 'tags', values: fieldValues }, status: 200 }
18
+ }
19
+ return { data: {}, status: 200 }
20
+ })
21
+
22
+ vi.mock('../../lib/api.js', () => ({
23
+ cmsApi: (e: string, o?: unknown) => cmsApi(e, o as never),
24
+ ensureCsrfToken: vi.fn(async () => {}),
25
+ }))
26
+
27
+ const { TagInput } = await import('../../components/TagInput.js')
28
+
29
+ function Harness({ initial = [] as string[], maxTags }: { initial?: string[]; maxTags?: number }) {
30
+ const [tags, setTags] = useState(initial)
31
+ return (
32
+ <div>
33
+ <TagInput id="tags" value={tags} onChange={setTags} collection="posts" maxTags={maxTags} />
34
+ <output data-testid="value">{JSON.stringify(tags)}</output>
35
+ </div>
36
+ )
37
+ }
38
+
39
+ function currentValue(): string[] {
40
+ return JSON.parse(screen.getByTestId('value').textContent ?? '[]')
41
+ }
42
+
43
+ beforeEach(() => {
44
+ cmsApi.mockClear()
45
+ fieldValues = []
46
+ })
47
+
48
+ describe('TagInput', () => {
49
+ it('adds a tag on Enter and clears the draft', () => {
50
+ render(<Harness />)
51
+ const input = screen.getByRole('combobox')
52
+ fireEvent.change(input, { target: { value: 'Design' } })
53
+ fireEvent.keyDown(input, { key: 'Enter' })
54
+ expect(currentValue()).toEqual(['Design'])
55
+ expect((input as HTMLInputElement).value).toBe('')
56
+ })
57
+
58
+ it('dedupes case-insensitively against tags already on the post', () => {
59
+ render(<Harness initial={['AI']} />)
60
+ const input = screen.getByRole('combobox')
61
+ fireEvent.change(input, { target: { value: 'ai' } })
62
+ fireEvent.keyDown(input, { key: 'Enter' })
63
+ expect(currentValue()).toEqual(['AI'])
64
+ })
65
+
66
+ it('resolves new tags to the canonical casing already used in the collection', async () => {
67
+ fieldValues = [{ value: 'AI', count: 7 }]
68
+ render(<Harness />)
69
+ const input = screen.getByRole('combobox')
70
+ fireEvent.focus(input)
71
+ await waitFor(() => expect(cmsApi).toHaveBeenCalled())
72
+ fireEvent.change(input, { target: { value: 'ai' } })
73
+ fireEvent.keyDown(input, { key: 'Enter' })
74
+ expect(currentValue()).toEqual(['AI'])
75
+ })
76
+
77
+ it('shows suggestions on focus and adds one on click', async () => {
78
+ fieldValues = [
79
+ { value: 'Design', count: 3 },
80
+ { value: 'DevOps', count: 1 },
81
+ ]
82
+ render(<Harness />)
83
+ fireEvent.focus(screen.getByRole('combobox'))
84
+ const option = await screen.findByText('Design')
85
+ fireEvent.click(option)
86
+ expect(currentValue()).toEqual(['Design'])
87
+ // An added tag drops out of the suggestion list.
88
+ expect(screen.queryByRole('option', { name: 'Design' })).toBeNull()
89
+ })
90
+
91
+ it('removes a tag via its remove button and the last tag via Backspace', () => {
92
+ render(<Harness initial={['One', 'Two']} />)
93
+ fireEvent.click(screen.getByRole('button', { name: 'Remove tag One' }))
94
+ expect(currentValue()).toEqual(['Two'])
95
+ fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Backspace' })
96
+ expect(currentValue()).toEqual([])
97
+ })
98
+
99
+ it('disables input at the maxTags cap', () => {
100
+ render(<Harness initial={['a', 'b']} maxTags={2} />)
101
+ expect((screen.getByRole('combobox') as HTMLInputElement).disabled).toBe(true)
102
+ })
103
+ })
@@ -29,6 +29,7 @@ function basePost(over: Partial<EditorPostT> = {}): EditorPostT {
29
29
  featuredImage: '',
30
30
  body: '',
31
31
  category: '',
32
+ tags: [],
32
33
  status: 'DRAFT',
33
34
  publishDate: null,
34
35
  sections: [],
@@ -404,3 +405,30 @@ describe('savePostDraft', () => {
404
405
  expect(out.id).toBe('new1')
405
406
  })
406
407
  })
408
+
409
+ describe('tags round-trip', () => {
410
+ it('writes tags in the canonical `[{ tag }]` row shape', async () => {
411
+ cmsApiMock.mockResolvedValueOnce({
412
+ data: { id: 'p1', title: 'Hello', slug: 'hello', status: 'DRAFT', data: {} },
413
+ })
414
+ await savePostDraft(basePost({ tags: ['AI', 'CMS'] }))
415
+ const [, init] = cmsApiMock.mock.calls[0]!
416
+ const body = JSON.parse(init.body as string)
417
+ expect(body.tags).toEqual([{ tag: 'AI' }, { tag: 'CMS' }])
418
+ })
419
+
420
+ it('reads stored tags from row objects AND plain strings', async () => {
421
+ cmsApiMock.mockResolvedValueOnce({
422
+ data: {
423
+ id: 'p1',
424
+ title: 'Hello',
425
+ slug: 'hello',
426
+ status: 'DRAFT',
427
+ // Mixed legacy shapes must both surface in the editor.
428
+ data: { tags: [{ tag: 'AI' }, 'Nextjs', { tag: ' ' }, 42] },
429
+ },
430
+ })
431
+ const post = await fetchPostForEditor('blog', 'p1')
432
+ expect(post.tags).toEqual(['AI', 'Nextjs'])
433
+ })
434
+ })
@@ -0,0 +1,126 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { fireEvent, render, screen } from '@testing-library/react'
4
+
5
+ /**
6
+ * PostFieldsModal — control selection is driven by the post type's field
7
+ * config: a `select` category renders a structured dropdown (UpChat taxonomy
8
+ * feedback P1), an `array` tags field renders the chip input (P2). Free-text
9
+ * configs keep their legacy inputs so existing sites are unaffected.
10
+ */
11
+
12
+ const cmsApi = vi.fn(async () => ({ data: { field: 'tags', values: [] }, status: 200 }))
13
+
14
+ vi.mock('../../lib/api.js', () => ({
15
+ cmsApi: (...args: unknown[]) => cmsApi(...(args as [])),
16
+ ensureCsrfToken: vi.fn(async () => {}),
17
+ }))
18
+
19
+ // The rich text editor pulls in heavy dependencies; stub it.
20
+ vi.mock('../../fields/RichTextField.js', () => ({
21
+ RichTextField: () => <div data-testid="rich-text" />,
22
+ }))
23
+
24
+ const { PostFieldsModal } = await import('../../views/post-editor/PostFieldsModal.js')
25
+ type ModalProps = Parameters<typeof PostFieldsModal>[0]
26
+
27
+ function basePost(over: Partial<ModalProps['post']> = {}): ModalProps['post'] {
28
+ return {
29
+ id: 'p1',
30
+ postType: 'posts',
31
+ title: 'Hello',
32
+ slug: 'hello',
33
+ excerpt: '',
34
+ featuredImage: '',
35
+ body: '',
36
+ category: '',
37
+ tags: [],
38
+ status: 'DRAFT',
39
+ publishDate: null,
40
+ sections: [],
41
+ seoTitle: '',
42
+ seoDescription: '',
43
+ publishedAt: null,
44
+ updatedAt: null,
45
+ ...over,
46
+ }
47
+ }
48
+
49
+ const SELECT_FIELDS: ModalProps['fields'] = {
50
+ category: {
51
+ type: 'select',
52
+ options: [
53
+ { label: 'News', value: 'news' },
54
+ { label: 'Tutorials', value: 'tutorials' },
55
+ ],
56
+ },
57
+ tags: { type: 'array', maxRows: 12 },
58
+ }
59
+
60
+ beforeEach(() => {
61
+ cmsApi.mockClear()
62
+ })
63
+
64
+ describe('PostFieldsModal', () => {
65
+ it('renders a category dropdown when the field config is a select', () => {
66
+ render(
67
+ <PostFieldsModal
68
+ open
69
+ post={basePost()}
70
+ canEdit
71
+ fields={SELECT_FIELDS}
72
+ onClose={() => {}}
73
+ onSave={() => {}}
74
+ />,
75
+ )
76
+ const select = screen.getByLabelText('Category') as HTMLSelectElement
77
+ expect(select.tagName).toBe('SELECT')
78
+ const labels = [...select.options].map((o) => o.textContent)
79
+ expect(labels).toEqual(['No category', 'News', 'Tutorials'])
80
+ })
81
+
82
+ it('keeps a legacy free-text category selectable instead of discarding it', () => {
83
+ render(
84
+ <PostFieldsModal
85
+ open
86
+ post={basePost({ category: 'Hand Typed' })}
87
+ canEdit
88
+ fields={SELECT_FIELDS}
89
+ onClose={() => {}}
90
+ onSave={() => {}}
91
+ />,
92
+ )
93
+ const select = screen.getByLabelText('Category') as HTMLSelectElement
94
+ expect(select.value).toBe('Hand Typed')
95
+ })
96
+
97
+ it('falls back to a free-text category input without field config', () => {
98
+ render(<PostFieldsModal open post={basePost()} canEdit onClose={() => {}} onSave={() => {}} />)
99
+ const input = screen.getByLabelText('Category') as HTMLInputElement
100
+ expect(input.tagName).toBe('INPUT')
101
+ // No tags config → no tags control.
102
+ expect(screen.queryByLabelText('Tags')).toBeNull()
103
+ })
104
+
105
+ it('saves selected category and edited tags in the patch', () => {
106
+ const onSave = vi.fn()
107
+ render(
108
+ <PostFieldsModal
109
+ open
110
+ post={basePost({ tags: ['AI'] })}
111
+ canEdit
112
+ fields={SELECT_FIELDS}
113
+ onClose={() => {}}
114
+ onSave={onSave}
115
+ />,
116
+ )
117
+ fireEvent.change(screen.getByLabelText('Category'), { target: { value: 'tutorials' } })
118
+ const tagInput = screen.getByLabelText('Tags')
119
+ fireEvent.change(tagInput, { target: { value: 'CMS' } })
120
+ fireEvent.keyDown(tagInput, { key: 'Enter' })
121
+ fireEvent.click(screen.getByRole('button', { name: 'Done' }))
122
+ expect(onSave).toHaveBeenCalledWith(
123
+ expect.objectContaining({ category: 'tutorials', tags: ['AI', 'CMS'] }),
124
+ )
125
+ })
126
+ })
@@ -0,0 +1,90 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { render, screen } from '@testing-library/react'
4
+
5
+ import { createSection } from '../../lib/page-editor-service.js'
6
+ import { SectionInspector, repeaterRowSummary } from '../../views/page-editor/SectionInspector.js'
7
+ import type { SectionFieldDef } from '../../views/page-editor/section-types.js'
8
+
9
+ /**
10
+ * Repeater rows derive a scannable header from their values ("Pro · $79/mo")
11
+ * instead of only the positional "Plan 2" label, so editors can find a row
12
+ * without expanding every form (Upchat feedback: "repeaters feel like
13
+ * spreadsheets").
14
+ */
15
+
16
+ const PLAN_FIELDS: SectionFieldDef[] = [
17
+ { key: 'name', label: 'Name', type: 'text' },
18
+ { key: 'price', label: 'Price', type: 'text' },
19
+ { key: 'description', label: 'Description', type: 'textarea' },
20
+ {
21
+ key: 'tier',
22
+ label: 'Tier',
23
+ type: 'select',
24
+ options: [{ label: 'Most popular', value: 'popular' }],
25
+ },
26
+ ]
27
+
28
+ describe('repeaterRowSummary', () => {
29
+ it('joins the first two short text values', () => {
30
+ expect(repeaterRowSummary(PLAN_FIELDS, { name: 'Pro', price: '$79/mo' })).toBe('Pro · $79/mo')
31
+ })
32
+
33
+ it('skips empty values and long-form fields', () => {
34
+ expect(
35
+ repeaterRowSummary(PLAN_FIELDS, {
36
+ name: ' ',
37
+ price: '$79/mo',
38
+ description: 'long prose that must never appear',
39
+ }),
40
+ ).toBe('$79/mo')
41
+ })
42
+
43
+ it('maps select values to their human label', () => {
44
+ expect(repeaterRowSummary(PLAN_FIELDS, { tier: 'popular' })).toBe('Most popular')
45
+ })
46
+
47
+ it('returns null for a blank row (caller falls back to "Plan N")', () => {
48
+ expect(repeaterRowSummary(PLAN_FIELDS, {})).toBeNull()
49
+ expect(repeaterRowSummary(PLAN_FIELDS, { name: '', price: '' })).toBeNull()
50
+ })
51
+
52
+ it('truncates very long summaries with an ellipsis', () => {
53
+ const summary = repeaterRowSummary(PLAN_FIELDS, { name: 'x'.repeat(100) })
54
+ expect(summary!.length).toBeLessThanOrEqual(60)
55
+ expect(summary!.endsWith('…')).toBe(true)
56
+ })
57
+ })
58
+
59
+ describe('SectionInspector repeater rows', () => {
60
+ function renderStatsInspector(stats: Array<Record<string, unknown>>) {
61
+ const section = createSection('stats')
62
+ section.content = { ...section.content, stats }
63
+ render(
64
+ <SectionInspector
65
+ section={section}
66
+ canEdit
67
+ onClose={vi.fn()}
68
+ onContentChange={vi.fn()}
69
+ onSettingsChange={vi.fn()}
70
+ onMetaChange={vi.fn()}
71
+ onToggleVisibility={vi.fn()}
72
+ />,
73
+ )
74
+ }
75
+
76
+ it('shows value-derived row headers instead of "Stat N"', () => {
77
+ renderStatsInspector([
78
+ { value: '99.99%', label: 'Uptime', description: '' },
79
+ { value: '3M+', label: 'API requests', description: '' },
80
+ ])
81
+ expect(screen.getByText('99.99% · Uptime')).toBeTruthy()
82
+ expect(screen.getByText('3M+ · API requests')).toBeTruthy()
83
+ expect(screen.queryByText('Stat 1')).toBeNull()
84
+ })
85
+
86
+ it('falls back to the positional label for blank rows', () => {
87
+ renderStatsInspector([{ value: '', label: '', description: '' }])
88
+ expect(screen.getByText('Stat 1')).toBeTruthy()
89
+ })
90
+ })
@@ -0,0 +1,196 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Chip-style tag editor with autocomplete from values already in use.
5
+ *
6
+ * Suggestions come from `GET /collections/:collection/field-values?field=...`
7
+ * (distinct values across the collection, most-used first) and are fetched
8
+ * once on first focus, then filtered client-side as the user types.
9
+ *
10
+ * Deduplication is case-insensitive and resolves to the existing canonical
11
+ * casing: typing "ai" when the collection already uses "AI" adds "AI", so a
12
+ * vocabulary never splinters into `AI` / `ai` / `Ai` variants.
13
+ */
14
+ import { useCallback, useEffect, useRef, useState } from 'react'
15
+ import { X } from 'lucide-react'
16
+ import { cmsApi } from '../lib/api.js'
17
+
18
+ interface TagInputProps {
19
+ id: string
20
+ value: string[]
21
+ onChange: (tags: string[]) => void
22
+ /** Collection slug used to fetch suggestions. Omit to disable autocomplete. */
23
+ collection?: string
24
+ /** Field to aggregate for suggestions (default `tags`). */
25
+ field?: string
26
+ disabled?: boolean
27
+ placeholder?: string
28
+ /** Hard cap on the number of tags (mirrors the field's `maxRows`). */
29
+ maxTags?: number
30
+ }
31
+
32
+ interface FieldValuesResponse {
33
+ field: string
34
+ values: Array<{ value: string; count: number }>
35
+ }
36
+
37
+ const INPUT =
38
+ 'border-input bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-2.5 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none'
39
+
40
+ export function TagInput({
41
+ id,
42
+ value,
43
+ onChange,
44
+ collection,
45
+ field = 'tags',
46
+ disabled,
47
+ placeholder,
48
+ maxTags,
49
+ }: TagInputProps) {
50
+ const [draft, setDraft] = useState('')
51
+ const [known, setKnown] = useState<string[] | null>(null)
52
+ const [open, setOpen] = useState(false)
53
+ const [activeIndex, setActiveIndex] = useState(-1)
54
+ const rootRef = useRef<HTMLDivElement>(null)
55
+
56
+ const atCap = typeof maxTags === 'number' && value.length >= maxTags
57
+
58
+ // Fetch the in-use vocabulary once, on first focus.
59
+ const ensureSuggestions = useCallback(() => {
60
+ if (known !== null || !collection) return
61
+ setKnown([]) // sentinel: fetch in flight, don't refetch
62
+ cmsApi<FieldValuesResponse>(
63
+ `/collections/${encodeURIComponent(collection)}/field-values?field=${encodeURIComponent(field)}`,
64
+ ).then((res) => {
65
+ if (res.data?.values) setKnown(res.data.values.map((v) => v.value))
66
+ })
67
+ }, [known, collection, field])
68
+
69
+ // Close the suggestion list on outside click.
70
+ useEffect(() => {
71
+ if (!open) return
72
+ function onPointerDown(e: PointerEvent) {
73
+ if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false)
74
+ }
75
+ document.addEventListener('pointerdown', onPointerDown)
76
+ return () => document.removeEventListener('pointerdown', onPointerDown)
77
+ }, [open])
78
+
79
+ const lowerExisting = new Set(value.map((t) => t.toLowerCase()))
80
+ const matches = (known ?? [])
81
+ .filter((s) => !lowerExisting.has(s.toLowerCase()))
82
+ .filter((s) => !draft.trim() || s.toLowerCase().includes(draft.trim().toLowerCase()))
83
+ .slice(0, 8)
84
+
85
+ function addTag(raw: string) {
86
+ const tag = raw.trim()
87
+ if (!tag || atCap) return
88
+ const lower = tag.toLowerCase()
89
+ if (lowerExisting.has(lower)) {
90
+ setDraft('')
91
+ return
92
+ }
93
+ // Prefer the canonical casing already used elsewhere in the collection.
94
+ const canonical = (known ?? []).find((s) => s.toLowerCase() === lower) ?? tag
95
+ onChange([...value, canonical])
96
+ setDraft('')
97
+ setActiveIndex(-1)
98
+ }
99
+
100
+ function removeTag(index: number) {
101
+ onChange(value.filter((_, i) => i !== index))
102
+ }
103
+
104
+ function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
105
+ if (e.key === 'Enter' || e.key === ',') {
106
+ e.preventDefault()
107
+ if (activeIndex >= 0 && activeIndex < matches.length) addTag(matches[activeIndex]!)
108
+ else addTag(draft)
109
+ } else if (e.key === 'ArrowDown') {
110
+ e.preventDefault()
111
+ setOpen(true)
112
+ setActiveIndex((i) => Math.min(i + 1, matches.length - 1))
113
+ } else if (e.key === 'ArrowUp') {
114
+ e.preventDefault()
115
+ setActiveIndex((i) => Math.max(i - 1, -1))
116
+ } else if (e.key === 'Escape') {
117
+ setOpen(false)
118
+ setActiveIndex(-1)
119
+ } else if (e.key === 'Backspace' && !draft && value.length > 0) {
120
+ removeTag(value.length - 1)
121
+ }
122
+ }
123
+
124
+ return (
125
+ <div ref={rootRef} className="relative">
126
+ {value.length > 0 && (
127
+ <div className="mb-1.5 flex flex-wrap gap-1.5">
128
+ {value.map((tag, i) => (
129
+ <span
130
+ key={`${tag}-${i}`}
131
+ className="bg-accent text-foreground inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs"
132
+ >
133
+ {tag}
134
+ {!disabled && (
135
+ <button
136
+ type="button"
137
+ aria-label={`Remove tag ${tag}`}
138
+ onClick={() => removeTag(i)}
139
+ className="text-muted-foreground hover:text-destructive rounded p-0.5"
140
+ >
141
+ <X className="h-3 w-3" aria-hidden />
142
+ </button>
143
+ )}
144
+ </span>
145
+ ))}
146
+ </div>
147
+ )}
148
+ <input
149
+ id={id}
150
+ className={INPUT}
151
+ role="combobox"
152
+ aria-expanded={open && matches.length > 0}
153
+ aria-autocomplete="list"
154
+ aria-controls={`${id}-listbox`}
155
+ value={draft}
156
+ disabled={disabled || atCap}
157
+ placeholder={atCap ? `Maximum of ${maxTags} tags` : (placeholder ?? 'Add a tag…')}
158
+ onFocus={() => {
159
+ ensureSuggestions()
160
+ setOpen(true)
161
+ }}
162
+ onChange={(e) => {
163
+ setDraft(e.target.value)
164
+ setOpen(true)
165
+ setActiveIndex(-1)
166
+ }}
167
+ onKeyDown={handleKeyDown}
168
+ />
169
+ {open && matches.length > 0 && !disabled && (
170
+ <ul
171
+ id={`${id}-listbox`}
172
+ role="listbox"
173
+ aria-label="Tag suggestions"
174
+ className="border-border bg-card absolute z-20 mt-1 max-h-44 w-full overflow-y-auto rounded-md border py-1 shadow-md"
175
+ >
176
+ {matches.map((s, i) => (
177
+ <li key={s} role="option" aria-selected={i === activeIndex}>
178
+ <button
179
+ type="button"
180
+ className={`w-full px-2.5 py-1.5 text-left text-sm ${
181
+ i === activeIndex
182
+ ? 'bg-accent text-foreground'
183
+ : 'text-foreground hover:bg-accent'
184
+ }`}
185
+ onMouseEnter={() => setActiveIndex(i)}
186
+ onClick={() => addTag(s)}
187
+ >
188
+ {s}
189
+ </button>
190
+ </li>
191
+ ))}
192
+ </ul>
193
+ )}
194
+ </div>
195
+ )
196
+ }
@@ -73,6 +73,8 @@ export interface EditorPost {
73
73
  featuredImage: string
74
74
  body: string
75
75
  category: string
76
+ /** Editorial tags as plain strings (stored as `[{ tag }]` rows, see {@link parseTags}). */
77
+ tags: string[]
76
78
  status: PostStatus
77
79
  publishDate: string | null
78
80
  sections: PageSection[]
@@ -190,6 +192,26 @@ function dataStr(data: Record<string, unknown>, key: string): string {
190
192
  return typeof v === 'string' ? v : ''
191
193
  }
192
194
 
195
+ /**
196
+ * Read stored tags into plain strings. The `tagsField` preset stores
197
+ * `[{ tag: 'AI' }]` rows; older/hand-rolled configs may store `['AI']`
198
+ * directly — accept both so no site loses tags in the editor.
199
+ */
200
+ export function parseTags(raw: unknown): string[] {
201
+ if (!Array.isArray(raw)) return []
202
+ const out: string[] = []
203
+ for (const item of raw) {
204
+ const value =
205
+ typeof item === 'string'
206
+ ? item
207
+ : item && typeof item === 'object'
208
+ ? (item as { tag?: unknown }).tag
209
+ : undefined
210
+ if (typeof value === 'string' && value.trim()) out.push(value.trim())
211
+ }
212
+ return out
213
+ }
214
+
193
215
  function rowToEditorPost(postType: string, doc: DocumentApiRow): EditorPost {
194
216
  const data = doc.data ?? {}
195
217
  return {
@@ -201,6 +223,7 @@ function rowToEditorPost(postType: string, doc: DocumentApiRow): EditorPost {
201
223
  featuredImage: dataStr(data, 'featuredImage'),
202
224
  body: dataStr(data, 'body'),
203
225
  category: dataStr(data, 'category'),
226
+ tags: parseTags(data.tags),
204
227
  status: doc.status ?? 'DRAFT',
205
228
  publishDate: (data.publishDate as string) ?? doc.scheduledAt ?? null,
206
229
  // Preserve unknown (externally-managed) sections so the editor can show
@@ -290,6 +313,7 @@ interface PostWritePayload {
290
313
  featuredImage: string
291
314
  body: string
292
315
  category: string
316
+ tags: Array<{ tag: string }>
293
317
  publishDate: string | null
294
318
  sections: PageSection[]
295
319
  metaTitle: string
@@ -313,6 +337,9 @@ function toWriteBody(post: EditorPost, extra?: Record<string, unknown>): string
313
337
  featuredImage: post.featuredImage,
314
338
  body: post.body,
315
339
  category: post.category,
340
+ // Store the canonical `tagsField` row shape so server-side array-field
341
+ // validation and the public renderer see the schema they expect.
342
+ tags: post.tags.map((tag) => ({ tag })),
316
343
  publishDate: post.publishDate,
317
344
  // Unmanaged sections are part of `post.sections`, so they round-trip on
318
345
  // every save (no data loss).
@@ -378,6 +405,7 @@ export async function emptyPostFromTemplate(postType: string): Promise<EditorPos
378
405
  featuredImage: '',
379
406
  body: '',
380
407
  category: '',
408
+ tags: [],
381
409
  status: 'DRAFT',
382
410
  publishDate: null,
383
411
  sections,
@@ -525,6 +525,39 @@ function RelationshipField({
525
525
 
526
526
  type RowRecord = Record<string, unknown>
527
527
 
528
+ // Field types whose values are short enough to scan in a row header. Long-form
529
+ // fields (textarea, richText) and non-text values (media, url) stay out.
530
+ const SUMMARY_FIELD_TYPES = new Set(['text', 'select'])
531
+ const SUMMARY_MAX_LENGTH = 60
532
+
533
+ /**
534
+ * Derive a scannable one-line summary for a repeater row from its first two
535
+ * short text values — e.g. a pricing row renders as "Pro · $79/mo" instead of
536
+ * "Plan 2". Returns `null` when the row has no usable values yet, so the
537
+ * caller falls back to the positional "Plan 2" label.
538
+ */
539
+ export function repeaterRowSummary(fields: SectionFieldDef[], row: RowRecord): string | null {
540
+ const parts: string[] = []
541
+ for (const field of fields) {
542
+ if (parts.length >= 2) break
543
+ if (!SUMMARY_FIELD_TYPES.has(field.type)) continue
544
+ const raw = row[field.key]
545
+ const value = typeof raw === 'string' ? raw.trim() : typeof raw === 'number' ? String(raw) : ''
546
+ if (!value) continue
547
+ if (field.type === 'select') {
548
+ const option = field.options?.find((o) => o.value === value)
549
+ parts.push(option?.label ?? value)
550
+ } else {
551
+ parts.push(value)
552
+ }
553
+ }
554
+ if (parts.length === 0) return null
555
+ const joined = parts.join(' · ')
556
+ return joined.length > SUMMARY_MAX_LENGTH
557
+ ? `${joined.slice(0, SUMMARY_MAX_LENGTH - 1).trimEnd()}…`
558
+ : joined
559
+ }
560
+
528
561
  /**
529
562
  * Generic repeater editor: an orderable list of rows, each row a small form of
530
563
  * the repeater's sub-fields. Drives the generic `repeater` field type and the
@@ -665,6 +698,8 @@ function SortableRepeaterRow({
665
698
  zIndex: isDragging ? 10 : undefined,
666
699
  }
667
700
 
701
+ const summary = repeaterRowSummary(fields, row)
702
+
668
703
  return (
669
704
  <div
670
705
  ref={setNodeRef}
@@ -684,8 +719,11 @@ function SortableRepeaterRow({
684
719
  <GripVertical className="h-3.5 w-3.5" aria-hidden />
685
720
  </button>
686
721
  )}
687
- <span className="text-muted-foreground text-xs">
688
- {rowLabel} {index + 1}
722
+ <span
723
+ className="text-muted-foreground max-w-[200px] truncate text-xs"
724
+ title={summary ? `${rowLabel} ${index + 1}: ${summary}` : undefined}
725
+ >
726
+ {summary ?? `${rowLabel} ${index + 1}`}
689
727
  </span>
690
728
  </span>
691
729
  <button