@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.
- package/CHANGELOG.md +16 -0
- package/dist/__tests__/components/tag-input.render.test.d.ts +2 -0
- package/dist/__tests__/components/tag-input.render.test.d.ts.map +1 -0
- package/dist/__tests__/components/tag-input.render.test.js +86 -0
- package/dist/__tests__/components/tag-input.render.test.js.map +1 -0
- package/dist/__tests__/lib/post-editor-service.test.js +26 -0
- package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
- package/dist/__tests__/views/post-fields-modal.render.test.d.ts +2 -0
- package/dist/__tests__/views/post-fields-modal.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/post-fields-modal.render.test.js +86 -0
- package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/TagInput.d.ts +16 -0
- package/dist/components/TagInput.d.ts.map +1 -0
- package/dist/components/TagInput.js +105 -0
- package/dist/components/TagInput.js.map +1 -0
- package/dist/lib/post-editor-service.d.ts +8 -0
- package/dist/lib/post-editor-service.d.ts.map +1 -1
- package/dist/lib/post-editor-service.js +25 -0
- package/dist/lib/post-editor-service.js.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts +17 -2
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.js +13 -4
- package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts +2 -0
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +1 -1
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/components/tag-input.render.test.tsx +103 -0
- package/src/__tests__/lib/post-editor-service.test.ts +28 -0
- package/src/__tests__/views/post-fields-modal.render.test.tsx +126 -0
- package/src/components/TagInput.tsx +196 -0
- package/src/lib/post-editor-service.ts +28 -0
- package/src/views/post-editor/PostFieldsModal.tsx +79 -10
- 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({
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
121
|
-
onChange={(e) => setCategory(e.target.value)}
|
|
190
|
+
maxTags={tagsField?.maxRows}
|
|
122
191
|
/>
|
|
123
192
|
</div>
|
|
124
|
-
|
|
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<
|
|
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
|
/>
|