@actuate-media/cms-admin 0.45.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 (36) hide show
  1. package/CHANGELOG.md +16 -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/actuate-admin.css +1 -1
  13. package/dist/components/TagInput.d.ts +16 -0
  14. package/dist/components/TagInput.d.ts.map +1 -0
  15. package/dist/components/TagInput.js +105 -0
  16. package/dist/components/TagInput.js.map +1 -0
  17. package/dist/lib/post-editor-service.d.ts +8 -0
  18. package/dist/lib/post-editor-service.d.ts.map +1 -1
  19. package/dist/lib/post-editor-service.js +25 -0
  20. package/dist/lib/post-editor-service.js.map +1 -1
  21. package/dist/views/post-editor/PostFieldsModal.d.ts +17 -2
  22. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  23. package/dist/views/post-editor/PostFieldsModal.js +13 -4
  24. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  25. package/dist/views/post-editor/PostSectionEditor.d.ts +2 -0
  26. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  27. package/dist/views/post-editor/PostSectionEditor.js +1 -1
  28. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  29. package/package.json +2 -2
  30. package/src/__tests__/components/tag-input.render.test.tsx +103 -0
  31. package/src/__tests__/lib/post-editor-service.test.ts +28 -0
  32. package/src/__tests__/views/post-fields-modal.render.test.tsx +126 -0
  33. package/src/components/TagInput.tsx +196 -0
  34. package/src/lib/post-editor-service.ts +28 -0
  35. package/src/views/post-editor/PostFieldsModal.tsx +79 -10
  36. package/src/views/post-editor/PostSectionEditor.tsx +11 -2
@@ -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,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,
@@ -4,13 +4,27 @@ import { useEffect, useState } from 'react'
4
4
  import { Modal } from '../../components/ui/Modal.js'
5
5
  import { Button } from '../../components/ui/Button.js'
6
6
  import { MediaPickerModal } from '../../components/MediaPickerModal.js'
7
+ import { TagInput } from '../../components/TagInput.js'
7
8
  import { RichTextField } from '../../fields/RichTextField.js'
8
9
  import type { EditorPost } from '../../lib/post-editor-service.js'
9
10
 
11
+ /**
12
+ * The slice of a collection's field config the modal reads to pick controls:
13
+ * a `select` category renders a dropdown (structured vocabulary), an `array`
14
+ * tags field renders the chip input with autocomplete.
15
+ */
16
+ export interface PostFieldConfig {
17
+ type?: string
18
+ options?: Array<{ label: string; value: string }>
19
+ maxRows?: number
20
+ }
21
+
10
22
  interface PostFieldsModalProps {
11
23
  open: boolean
12
24
  post: EditorPost
13
25
  canEdit: boolean
26
+ /** Field definitions from the post type's collection config. */
27
+ fields?: Record<string, PostFieldConfig | undefined>
14
28
  onClose: () => void
15
29
  onSave: (patch: Partial<EditorPost>) => void
16
30
  }
@@ -27,19 +41,35 @@ function slugify(value: string): string {
27
41
  .replace(/^-+|-+$/g, '')
28
42
  }
29
43
 
30
- /** Edits post-level fields: title, slug, excerpt, featured image, category, body, SEO. */
31
- export function PostFieldsModal({ open, post, canEdit, onClose, onSave }: PostFieldsModalProps) {
44
+ /** Edits post-level fields: title, slug, excerpt, featured image, category, tags, body, SEO. */
45
+ export function PostFieldsModal({
46
+ open,
47
+ post,
48
+ canEdit,
49
+ fields,
50
+ onClose,
51
+ onSave,
52
+ }: PostFieldsModalProps) {
32
53
  const [title, setTitle] = useState(post.title)
33
54
  const [slug, setSlug] = useState(post.slug)
34
55
  const [excerpt, setExcerpt] = useState(post.excerpt)
35
56
  const [featuredImage, setFeaturedImage] = useState(post.featuredImage)
36
57
  const [category, setCategory] = useState(post.category)
58
+ const [tags, setTags] = useState<string[]>(post.tags)
37
59
  const [body, setBody] = useState(post.body)
38
60
  const [seoTitle, setSeoTitle] = useState(post.seoTitle)
39
61
  const [seoDescription, setSeoDescription] = useState(post.seoDescription)
40
62
  const [slugTouched, setSlugTouched] = useState(Boolean(post.slug))
41
63
  const [mediaOpen, setMediaOpen] = useState(false)
42
64
 
65
+ const categoryField = fields?.category
66
+ const categoryOptions =
67
+ categoryField?.type === 'select' && Array.isArray(categoryField.options)
68
+ ? categoryField.options
69
+ : null
70
+ const tagsField = fields?.tags
71
+ const hasTags = tagsField?.type === 'array'
72
+
43
73
  useEffect(() => {
44
74
  if (!open) return
45
75
  setTitle(post.title)
@@ -47,6 +77,7 @@ export function PostFieldsModal({ open, post, canEdit, onClose, onSave }: PostFi
47
77
  setExcerpt(post.excerpt)
48
78
  setFeaturedImage(post.featuredImage)
49
79
  setCategory(post.category)
80
+ setTags(post.tags)
50
81
  setBody(post.body)
51
82
  setSeoTitle(post.seoTitle)
52
83
  setSeoDescription(post.seoDescription)
@@ -59,7 +90,7 @@ export function PostFieldsModal({ open, post, canEdit, onClose, onSave }: PostFi
59
90
  }
60
91
 
61
92
  function handleSave() {
62
- onSave({ title, slug, excerpt, featuredImage, category, body, seoTitle, seoDescription })
93
+ onSave({ title, slug, excerpt, featuredImage, category, tags, body, seoTitle, seoDescription })
63
94
  onClose()
64
95
  }
65
96
 
@@ -112,16 +143,54 @@ export function PostFieldsModal({ open, post, canEdit, onClose, onSave }: PostFi
112
143
  <label className={LABEL} htmlFor="pf-category">
113
144
  Category
114
145
  </label>
115
- <input
116
- id="pf-category"
117
- className={INPUT}
118
- value={category}
146
+ {categoryOptions ? (
147
+ <select
148
+ id="pf-category"
149
+ className={INPUT}
150
+ value={category}
151
+ disabled={!canEdit}
152
+ onChange={(e) => setCategory(e.target.value)}
153
+ >
154
+ <option value="">No category</option>
155
+ {categoryOptions.map((o) => (
156
+ <option key={o.value} value={o.value}>
157
+ {o.label}
158
+ </option>
159
+ ))}
160
+ {/* Keep a legacy free-text value selectable so opening the
161
+ modal never silently discards it. */}
162
+ {category && !categoryOptions.some((o) => o.value === category) && (
163
+ <option value={category}>{category}</option>
164
+ )}
165
+ </select>
166
+ ) : (
167
+ <input
168
+ id="pf-category"
169
+ className={INPUT}
170
+ value={category}
171
+ disabled={!canEdit}
172
+ placeholder="e.g. Product"
173
+ onChange={(e) => setCategory(e.target.value)}
174
+ />
175
+ )}
176
+ </div>
177
+ </div>
178
+ {hasTags && (
179
+ <div>
180
+ <label className={LABEL} htmlFor="pf-tags">
181
+ Tags
182
+ </label>
183
+ <TagInput
184
+ id="pf-tags"
185
+ value={tags}
186
+ onChange={setTags}
187
+ collection={post.postType}
188
+ field="tags"
119
189
  disabled={!canEdit}
120
- placeholder="e.g. Product"
121
- onChange={(e) => setCategory(e.target.value)}
190
+ maxTags={tagsField?.maxRows}
122
191
  />
123
192
  </div>
124
- </div>
193
+ )}
125
194
  <div>
126
195
  <label className={LABEL} htmlFor="pf-excerpt">
127
196
  Excerpt
@@ -47,11 +47,19 @@ import { SectionInspector } from '../page-editor/SectionInspector.js'
47
47
  import { AddSectionModal } from '../page-editor/AddSectionModal.js'
48
48
  import { ValidationSummary } from '../page-editor/ValidationSummary.js'
49
49
  import { PostEditorCanvas } from './PostEditorCanvas.js'
50
- import { PostFieldsModal } from './PostFieldsModal.js'
50
+ import { PostFieldsModal, type PostFieldConfig } from './PostFieldsModal.js'
51
51
 
52
52
  /** The slice of the resolved CMS config this editor reads. */
53
53
  interface EditorConfig {
54
- collections?: Record<string, { labels?: { singular?: string }; urlPrefix?: string } | undefined>
54
+ collections?: Record<
55
+ string,
56
+ | {
57
+ labels?: { singular?: string }
58
+ urlPrefix?: string
59
+ fields?: Record<string, PostFieldConfig | undefined>
60
+ }
61
+ | undefined
62
+ >
55
63
  seo?: { siteUrl?: string }
56
64
  }
57
65
 
@@ -576,6 +584,7 @@ export function PostSectionEditor({
576
584
  open={fieldsOpen}
577
585
  post={post}
578
586
  canEdit={canEdit}
587
+ fields={config?.collections?.[postType]?.fields}
579
588
  onClose={() => setFieldsOpen(false)}
580
589
  onSave={handleFields}
581
590
  />