@actuate-media/cms-admin 0.45.0 → 0.47.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 +24 -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/collection-list-facets.render.test.d.ts +2 -0
- package/dist/__tests__/views/collection-list-facets.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/collection-list-facets.render.test.js +131 -0
- package/dist/__tests__/views/collection-list-facets.render.test.js.map +1 -0
- 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/CollectionList.d.ts.map +1 -1
- package/dist/views/CollectionList.js +121 -11
- package/dist/views/CollectionList.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/collection-list-facets.render.test.tsx +148 -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/CollectionList.tsx +187 -23
- package/src/views/post-editor/PostFieldsModal.tsx +79 -10
- package/src/views/post-editor/PostSectionEditor.tsx +11 -2
|
@@ -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,
|
|
@@ -24,14 +24,79 @@ export interface CollectionListProps {
|
|
|
24
24
|
type SortField = 'title' | 'status' | 'updatedAt'
|
|
25
25
|
type SortOrder = 'asc' | 'desc'
|
|
26
26
|
|
|
27
|
-
function
|
|
28
|
-
|
|
29
|
-
if (!config?.collections) return fallback
|
|
27
|
+
function resolveCollection(config: any, slug: string): any | undefined {
|
|
28
|
+
if (!config?.collections) return undefined
|
|
30
29
|
const list = Array.isArray(config.collections)
|
|
31
30
|
? config.collections
|
|
32
31
|
: Object.values(config.collections)
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
return (list as any[]).find((c: any) => c.slug === slug)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resolveLabel(config: any, slug: string): { singular: string; plural: string } {
|
|
36
|
+
return resolveCollection(config, slug)?.labels ?? { singular: slug, plural: slug }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One facet dropdown in the filter bar. */
|
|
40
|
+
interface FacetDef {
|
|
41
|
+
name: string
|
|
42
|
+
label: string
|
|
43
|
+
/** `options` = fixed list from the config; `values` = in-use values from the field-values API. */
|
|
44
|
+
source: 'options' | 'values'
|
|
45
|
+
options?: Array<{ label: string; value: string }>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** True for array fields shaped like tags: a single required text sub-field. */
|
|
49
|
+
function isTagShapedArray(field: any): boolean {
|
|
50
|
+
if (field?.type !== 'array' || !field.fields) return false
|
|
51
|
+
const subs = Object.values(field.fields) as any[]
|
|
52
|
+
return subs.length === 1 && subs[0]?.type === 'text'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Facetable fields for the filter bar (taxonomy Phase B): structured selects
|
|
57
|
+
* use their config options; free-text `category` and tag-shaped arrays load
|
|
58
|
+
* the collection's in-use values. Capped so the bar stays scannable.
|
|
59
|
+
*/
|
|
60
|
+
function facetFieldsFor(config: any, slug: string): FacetDef[] {
|
|
61
|
+
const fields = resolveCollection(config, slug)?.fields
|
|
62
|
+
if (!fields || typeof fields !== 'object') return []
|
|
63
|
+
const out: FacetDef[] = []
|
|
64
|
+
for (const [name, field] of Object.entries(fields) as Array<[string, any]>) {
|
|
65
|
+
if (field?.hidden) continue
|
|
66
|
+
if (field?.type === 'select' && Array.isArray(field.options)) {
|
|
67
|
+
out.push({ name, label: field.label ?? name, source: 'options', options: field.options })
|
|
68
|
+
} else if (field?.type === 'text' && name === 'category') {
|
|
69
|
+
out.push({ name, label: field.label ?? 'Category', source: 'values' })
|
|
70
|
+
} else if (isTagShapedArray(field)) {
|
|
71
|
+
out.push({ name, label: field.label ?? name, source: 'values' })
|
|
72
|
+
}
|
|
73
|
+
if (out.length >= 3) break
|
|
74
|
+
}
|
|
75
|
+
return out
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Read a doc's display value for a facet column (select values map to labels). */
|
|
79
|
+
function facetCellValue(doc: any, facet: FacetDef): string {
|
|
80
|
+
const raw = doc?.data?.[facet.name]
|
|
81
|
+
if (typeof raw === 'string') {
|
|
82
|
+
if (!raw) return ''
|
|
83
|
+
const opt = facet.options?.find((o) => o.value === raw)
|
|
84
|
+
return opt?.label ?? raw
|
|
85
|
+
}
|
|
86
|
+
if (Array.isArray(raw)) {
|
|
87
|
+
const tags = raw
|
|
88
|
+
.map((item) =>
|
|
89
|
+
typeof item === 'string'
|
|
90
|
+
? item
|
|
91
|
+
: item && typeof item === 'object'
|
|
92
|
+
? Object.values(item).find((v) => typeof v === 'string')
|
|
93
|
+
: undefined,
|
|
94
|
+
)
|
|
95
|
+
.filter((v): v is string => Boolean(v))
|
|
96
|
+
if (tags.length === 0) return ''
|
|
97
|
+
return tags.length > 3 ? `${tags.slice(0, 3).join(', ')} +${tags.length - 3}` : tags.join(', ')
|
|
98
|
+
}
|
|
99
|
+
return ''
|
|
35
100
|
}
|
|
36
101
|
|
|
37
102
|
function statusColor(status: string): string {
|
|
@@ -75,6 +140,7 @@ function formatDate(d: string | undefined): string {
|
|
|
75
140
|
|
|
76
141
|
export function CollectionList({ collectionSlug, config, onNavigate }: CollectionListProps) {
|
|
77
142
|
const labels = useMemo(() => resolveLabel(config, collectionSlug), [config, collectionSlug])
|
|
143
|
+
const facets = useMemo(() => facetFieldsFor(config, collectionSlug), [config, collectionSlug])
|
|
78
144
|
|
|
79
145
|
const [page, setPage] = useState(1)
|
|
80
146
|
const [search, setSearch] = useState('')
|
|
@@ -82,6 +148,9 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
|
|
|
82
148
|
const [sort, setSort] = useState<SortField | null>(null)
|
|
83
149
|
const [order, setOrder] = useState<SortOrder>('asc')
|
|
84
150
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
151
|
+
const [filters, setFilters] = useState<Record<string, string>>({})
|
|
152
|
+
// In-use values per `values`-sourced facet, fetched once per collection.
|
|
153
|
+
const [facetValues, setFacetValues] = useState<Record<string, string[]>>({})
|
|
85
154
|
const searchTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
|
86
155
|
|
|
87
156
|
useEffect(() => {
|
|
@@ -97,8 +166,11 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
|
|
|
97
166
|
let url = `/collections/${collectionSlug}?page=${page}&pageSize=25`
|
|
98
167
|
if (debouncedSearch) url += `&search=${encodeURIComponent(debouncedSearch)}`
|
|
99
168
|
if (sort) url += `&sort=${sort}&order=${order}`
|
|
169
|
+
for (const [field, value] of Object.entries(filters)) {
|
|
170
|
+
if (value) url += `&filter.${encodeURIComponent(field)}=${encodeURIComponent(value)}`
|
|
171
|
+
}
|
|
100
172
|
return url
|
|
101
|
-
}, [collectionSlug, page, debouncedSearch, sort, order])
|
|
173
|
+
}, [collectionSlug, page, debouncedSearch, sort, order, filters])
|
|
102
174
|
|
|
103
175
|
const { data, loading, error, refetch } = useApiData<{ docs: any[]; total: number }>(endpoint)
|
|
104
176
|
const docs = data?.docs ?? []
|
|
@@ -109,8 +181,40 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
|
|
|
109
181
|
setPage(1)
|
|
110
182
|
setSelected(new Set())
|
|
111
183
|
setSearch('')
|
|
184
|
+
setFilters({})
|
|
185
|
+
setFacetValues({})
|
|
112
186
|
}, [collectionSlug])
|
|
113
187
|
|
|
188
|
+
// Load in-use values for facets that aren't config-defined selects.
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
let cancelled = false
|
|
191
|
+
for (const facet of facets) {
|
|
192
|
+
if (facet.source !== 'values') continue
|
|
193
|
+
cmsApi<{ field: string; values: Array<{ value: string; count: number }> }>(
|
|
194
|
+
`/collections/${collectionSlug}/field-values?field=${encodeURIComponent(facet.name)}`,
|
|
195
|
+
).then((res) => {
|
|
196
|
+
if (cancelled || !res.data?.values) return
|
|
197
|
+
setFacetValues((prev) => ({
|
|
198
|
+
...prev,
|
|
199
|
+
[facet.name]: res.data!.values.map((v) => v.value),
|
|
200
|
+
}))
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
return () => {
|
|
204
|
+
cancelled = true
|
|
205
|
+
}
|
|
206
|
+
}, [collectionSlug, facets])
|
|
207
|
+
|
|
208
|
+
const setFilter = useCallback((field: string, value: string) => {
|
|
209
|
+
setFilters((prev) => {
|
|
210
|
+
const next = { ...prev }
|
|
211
|
+
if (value) next[field] = value
|
|
212
|
+
else delete next[field]
|
|
213
|
+
return next
|
|
214
|
+
})
|
|
215
|
+
setPage(1)
|
|
216
|
+
}, [])
|
|
217
|
+
|
|
114
218
|
const toggleSort = useCallback((field: SortField) => {
|
|
115
219
|
setSort((prev) => {
|
|
116
220
|
if (prev === field) {
|
|
@@ -198,23 +302,64 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
|
|
|
198
302
|
</button>
|
|
199
303
|
</div>
|
|
200
304
|
|
|
201
|
-
{/* Search */}
|
|
202
|
-
<div className="
|
|
203
|
-
<
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
305
|
+
{/* Search + facet filters */}
|
|
306
|
+
<div className="mb-4 flex flex-wrap items-center gap-2">
|
|
307
|
+
<div className="relative min-w-[200px] flex-1">
|
|
308
|
+
<Search
|
|
309
|
+
className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
|
|
310
|
+
style={{ color: 'var(--actuate-text-muted, #9ca3af)' }}
|
|
311
|
+
/>
|
|
312
|
+
<input
|
|
313
|
+
type="text"
|
|
314
|
+
placeholder={`Search ${labels.plural.toLowerCase()}...`}
|
|
315
|
+
value={search}
|
|
316
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
317
|
+
className="w-full rounded-lg border py-2 pr-3 pl-9 text-sm focus:ring-2 focus:outline-none"
|
|
318
|
+
style={{
|
|
319
|
+
borderColor: 'var(--actuate-border, #d1d5db)',
|
|
320
|
+
color: 'var(--actuate-text, #111827)',
|
|
321
|
+
}}
|
|
322
|
+
/>
|
|
323
|
+
</div>
|
|
324
|
+
{facets.map((facet) => {
|
|
325
|
+
const values =
|
|
326
|
+
facet.source === 'options'
|
|
327
|
+
? (facet.options ?? [])
|
|
328
|
+
: (facetValues[facet.name] ?? []).map((v) => ({ label: v, value: v }))
|
|
329
|
+
if (values.length === 0) return null
|
|
330
|
+
return (
|
|
331
|
+
<select
|
|
332
|
+
key={facet.name}
|
|
333
|
+
aria-label={`Filter by ${facet.label}`}
|
|
334
|
+
value={filters[facet.name] ?? ''}
|
|
335
|
+
onChange={(e) => setFilter(facet.name, e.target.value)}
|
|
336
|
+
className="rounded-lg border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
|
|
337
|
+
style={{
|
|
338
|
+
borderColor: 'var(--actuate-border, #d1d5db)',
|
|
339
|
+
color: filters[facet.name]
|
|
340
|
+
? 'var(--actuate-text, #111827)'
|
|
341
|
+
: 'var(--actuate-text-secondary, #6b7280)',
|
|
342
|
+
}}
|
|
343
|
+
>
|
|
344
|
+
<option value="">All {facet.label.toLowerCase()}</option>
|
|
345
|
+
{values.map((opt) => (
|
|
346
|
+
<option key={opt.value} value={opt.value}>
|
|
347
|
+
{opt.label}
|
|
348
|
+
</option>
|
|
349
|
+
))}
|
|
350
|
+
</select>
|
|
351
|
+
)
|
|
352
|
+
})}
|
|
353
|
+
{Object.keys(filters).length > 0 && (
|
|
354
|
+
<button
|
|
355
|
+
type="button"
|
|
356
|
+
onClick={() => setFilters({})}
|
|
357
|
+
className="rounded-lg px-2 py-2 text-sm underline transition-opacity hover:opacity-75"
|
|
358
|
+
style={{ color: 'var(--actuate-text-secondary, #6b7280)' }}
|
|
359
|
+
>
|
|
360
|
+
Clear filters
|
|
361
|
+
</button>
|
|
362
|
+
)}
|
|
218
363
|
</div>
|
|
219
364
|
|
|
220
365
|
{/* Bulk actions */}
|
|
@@ -317,6 +462,15 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
|
|
|
317
462
|
<th className="px-3 py-2 text-left">
|
|
318
463
|
<SortCol field="title">Title</SortCol>
|
|
319
464
|
</th>
|
|
465
|
+
{facets.map((facet) => (
|
|
466
|
+
<th
|
|
467
|
+
key={facet.name}
|
|
468
|
+
className="px-3 py-2 text-left text-xs font-medium"
|
|
469
|
+
style={{ color: 'var(--actuate-text-secondary, #6b7280)' }}
|
|
470
|
+
>
|
|
471
|
+
{facet.label}
|
|
472
|
+
</th>
|
|
473
|
+
))}
|
|
320
474
|
<th className="px-3 py-2 text-left">
|
|
321
475
|
<SortCol field="status">Status</SortCol>
|
|
322
476
|
</th>
|
|
@@ -356,6 +510,16 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
|
|
|
356
510
|
{doc.title || doc.name || `#${doc.id}`}
|
|
357
511
|
</button>
|
|
358
512
|
</td>
|
|
513
|
+
{facets.map((facet) => (
|
|
514
|
+
<td
|
|
515
|
+
key={facet.name}
|
|
516
|
+
className="max-w-[200px] truncate px-3 py-2"
|
|
517
|
+
style={{ color: 'var(--actuate-text-secondary, #6b7280)' }}
|
|
518
|
+
title={facetCellValue(doc, facet) || undefined}
|
|
519
|
+
>
|
|
520
|
+
{facetCellValue(doc, facet) || '—'}
|
|
521
|
+
</td>
|
|
522
|
+
))}
|
|
359
523
|
<td className="px-3 py-2">
|
|
360
524
|
<span
|
|
361
525
|
className="inline-block rounded-full px-2 py-0.5 text-xs font-medium"
|
|
@@ -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
|