@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.
- package/CHANGELOG.md +37 -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/__tests__/views/section-inspector-repeater.render.test.d.ts +2 -0
- package/dist/__tests__/views/section-inspector-repeater.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/section-inspector-repeater.render.test.js +68 -0
- package/dist/__tests__/views/section-inspector-repeater.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/page-editor/SectionInspector.d.ts +9 -0
- package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
- package/dist/views/page-editor/SectionInspector.js +38 -1
- package/dist/views/page-editor/SectionInspector.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/__tests__/views/section-inspector-repeater.render.test.tsx +90 -0
- package/src/components/TagInput.tsx +196 -0
- package/src/lib/post-editor-service.ts +28 -0
- package/src/views/page-editor/SectionInspector.tsx +40 -2
- package/src/views/post-editor/PostFieldsModal.tsx +79 -10
- 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
|
|
688
|
-
|
|
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
|