@actuate-media/cms-admin 0.43.0 → 0.45.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 +30 -0
- package/dist/__tests__/views/email-settings.render.test.d.ts +2 -0
- package/dist/__tests__/views/email-settings.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/email-settings.render.test.js +184 -0
- package/dist/__tests__/views/email-settings.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/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +5 -3
- package/dist/views/Settings.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/settings/EmailSettingsTab.d.ts +4 -0
- package/dist/views/settings/EmailSettingsTab.d.ts.map +1 -0
- package/dist/views/settings/EmailSettingsTab.js +75 -0
- package/dist/views/settings/EmailSettingsTab.js.map +1 -0
- package/dist/views/settings/useEmailSettings.d.ts +58 -0
- package/dist/views/settings/useEmailSettings.d.ts.map +1 -0
- package/dist/views/settings/useEmailSettings.js +209 -0
- package/dist/views/settings/useEmailSettings.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/views/email-settings.render.test.tsx +225 -0
- package/src/__tests__/views/section-inspector-repeater.render.test.tsx +90 -0
- package/src/views/Settings.tsx +13 -0
- package/src/views/page-editor/SectionInspector.tsx +40 -2
- package/src/views/settings/EmailSettingsTab.tsx +436 -0
- package/src/views/settings/useEmailSettings.ts +296 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@actuate-media/cms-admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/actuate-media/actuatecms.git",
|
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
"tailwindcss": "^4.0.0",
|
|
90
90
|
"typescript": "^5.7.0",
|
|
91
91
|
"vitest": "^4.1.0",
|
|
92
|
-
"@actuate-media/cms-core": "0.
|
|
92
|
+
"@actuate-media/cms-core": "0.62.0",
|
|
93
93
|
"@actuate-media/component-blocks": "0.2.1"
|
|
94
94
|
},
|
|
95
95
|
"scripts": {
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Settings > Email: provider picker + per-provider credential fields backed by
|
|
7
|
+
* GET/PUT /email/settings and POST /email/settings/test. Secrets are
|
|
8
|
+
* write-only — the API returns only `hasApiKey` / `apiKeyLast4`, never the key.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
interface EmailSettingsData {
|
|
12
|
+
settings: Record<string, unknown>
|
|
13
|
+
envProvider: string | null
|
|
14
|
+
factoryAvailable: boolean
|
|
15
|
+
envSenderActive: boolean
|
|
16
|
+
platformAdapter: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const EMPTY_RESPONSE: EmailSettingsData = {
|
|
20
|
+
settings: { provider: null, hasApiKey: false, hasSmtpPass: false },
|
|
21
|
+
envProvider: null,
|
|
22
|
+
factoryAvailable: true,
|
|
23
|
+
envSenderActive: false,
|
|
24
|
+
platformAdapter: false,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let getResponse: EmailSettingsData = EMPTY_RESPONSE
|
|
28
|
+
let putResponse: { settings: Record<string, unknown> } | null = null
|
|
29
|
+
let testResponse: { data?: unknown; error?: string; status: number } = {
|
|
30
|
+
data: { success: true, sentTo: 'admin@example.com' },
|
|
31
|
+
status: 200,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const cmsApi = vi.fn(async (endpoint: string, options?: { method?: string; body?: string }) => {
|
|
35
|
+
const method = options?.method ?? 'GET'
|
|
36
|
+
if (endpoint === '/email/settings' && method === 'GET') {
|
|
37
|
+
return { data: getResponse, status: 200 }
|
|
38
|
+
}
|
|
39
|
+
if (endpoint === '/email/settings' && method === 'PUT') {
|
|
40
|
+
return putResponse
|
|
41
|
+
? { data: putResponse, status: 200 }
|
|
42
|
+
: { error: 'unexpected PUT', status: 500 }
|
|
43
|
+
}
|
|
44
|
+
if (endpoint === '/email/settings/test' && method === 'POST') {
|
|
45
|
+
return testResponse
|
|
46
|
+
}
|
|
47
|
+
return { data: {}, status: 200 }
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
vi.mock('../../lib/api.js', () => ({
|
|
51
|
+
cmsApi: (e: string, o?: unknown) => cmsApi(e, o as any),
|
|
52
|
+
ensureCsrfToken: vi.fn(async () => {}),
|
|
53
|
+
}))
|
|
54
|
+
|
|
55
|
+
const { EmailSettingsTab } = await import('../../views/settings/EmailSettingsTab.js')
|
|
56
|
+
|
|
57
|
+
function deepClone<T>(v: T): T {
|
|
58
|
+
return JSON.parse(JSON.stringify(v))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
cmsApi.mockClear()
|
|
63
|
+
getResponse = deepClone(EMPTY_RESPONSE)
|
|
64
|
+
putResponse = null
|
|
65
|
+
testResponse = { data: { success: true, sentTo: 'admin@example.com' }, status: 200 }
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('EmailSettingsTab', () => {
|
|
69
|
+
it('renders the provider picker with the unconfigured state', async () => {
|
|
70
|
+
render(<EmailSettingsTab role="ADMIN" />)
|
|
71
|
+
expect(await screen.findByRole('radio', { name: 'None' })).toBeTruthy()
|
|
72
|
+
expect(screen.getByRole('radio', { name: 'Resend' })).toBeTruthy()
|
|
73
|
+
expect(screen.getByRole('radio', { name: 'SMTP.com' })).toBeTruthy()
|
|
74
|
+
expect(screen.getByRole('radio', { name: 'Amazon SES' })).toBeTruthy()
|
|
75
|
+
expect(screen.getByRole('radio', { name: 'SMTP server' })).toBeTruthy()
|
|
76
|
+
expect(screen.getByText(/No email provider configured/)).toBeTruthy()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('saves an SMTP.com configuration with the entered key (PUT payload contract)', async () => {
|
|
80
|
+
putResponse = {
|
|
81
|
+
settings: {
|
|
82
|
+
provider: 'smtpcom',
|
|
83
|
+
from: 'noreply@example.com',
|
|
84
|
+
channel: 'transactional',
|
|
85
|
+
hasApiKey: true,
|
|
86
|
+
apiKeyLast4: '-key',
|
|
87
|
+
hasSmtpPass: false,
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
render(<EmailSettingsTab role="ADMIN" />)
|
|
91
|
+
fireEvent.click(await screen.findByRole('radio', { name: 'SMTP.com' }))
|
|
92
|
+
|
|
93
|
+
fireEvent.change(screen.getByLabelText('Sender address'), {
|
|
94
|
+
target: { value: 'noreply@example.com' },
|
|
95
|
+
})
|
|
96
|
+
fireEvent.change(screen.getByLabelText('API key'), {
|
|
97
|
+
target: { value: 'smtpcom-secret-key' },
|
|
98
|
+
})
|
|
99
|
+
fireEvent.change(screen.getByLabelText('Sender channel'), {
|
|
100
|
+
target: { value: 'transactional' },
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }))
|
|
104
|
+
|
|
105
|
+
await waitFor(() => {
|
|
106
|
+
const put = cmsApi.mock.calls.find(
|
|
107
|
+
([e, o]) => e === '/email/settings' && (o as any)?.method === 'PUT',
|
|
108
|
+
)
|
|
109
|
+
expect(put).toBeTruthy()
|
|
110
|
+
expect(JSON.parse((put![1] as any).body)).toEqual({
|
|
111
|
+
provider: 'smtpcom',
|
|
112
|
+
from: 'noreply@example.com',
|
|
113
|
+
apiKey: 'smtpcom-secret-key',
|
|
114
|
+
channel: 'transactional',
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// After save the key input is cleared and the masked hint appears.
|
|
119
|
+
expect((screen.getByLabelText('API key') as HTMLInputElement).value).toBe('')
|
|
120
|
+
expect(await screen.findByText(/ends in -key/)).toBeTruthy()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('omits the apiKey from the payload when a stored key is kept', async () => {
|
|
124
|
+
getResponse = {
|
|
125
|
+
...deepClone(EMPTY_RESPONSE),
|
|
126
|
+
settings: {
|
|
127
|
+
provider: 'resend',
|
|
128
|
+
from: 'old@example.com',
|
|
129
|
+
hasApiKey: true,
|
|
130
|
+
apiKeyLast4: 'k3y9',
|
|
131
|
+
hasSmtpPass: false,
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
putResponse = {
|
|
135
|
+
settings: {
|
|
136
|
+
provider: 'resend',
|
|
137
|
+
from: 'new@example.com',
|
|
138
|
+
hasApiKey: true,
|
|
139
|
+
apiKeyLast4: 'k3y9',
|
|
140
|
+
hasSmtpPass: false,
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
render(<EmailSettingsTab role="ADMIN" />)
|
|
144
|
+
const from = await screen.findByLabelText('Sender address')
|
|
145
|
+
expect(await screen.findByText(/ends in k3y9/)).toBeTruthy()
|
|
146
|
+
|
|
147
|
+
fireEvent.change(from, { target: { value: 'new@example.com' } })
|
|
148
|
+
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }))
|
|
149
|
+
|
|
150
|
+
await waitFor(() => {
|
|
151
|
+
const put = cmsApi.mock.calls.find(
|
|
152
|
+
([e, o]) => e === '/email/settings' && (o as any)?.method === 'PUT',
|
|
153
|
+
)
|
|
154
|
+
expect(put).toBeTruthy()
|
|
155
|
+
const body = JSON.parse((put![1] as any).body)
|
|
156
|
+
expect(body).toEqual({ provider: 'resend', from: 'new@example.com' })
|
|
157
|
+
expect(body).not.toHaveProperty('apiKey')
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('sends a test email and shows the result', async () => {
|
|
162
|
+
getResponse = {
|
|
163
|
+
...deepClone(EMPTY_RESPONSE),
|
|
164
|
+
settings: {
|
|
165
|
+
provider: 'resend',
|
|
166
|
+
from: 'a@b.co',
|
|
167
|
+
hasApiKey: true,
|
|
168
|
+
apiKeyLast4: 'k3y9',
|
|
169
|
+
hasSmtpPass: false,
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
render(<EmailSettingsTab role="ADMIN" />)
|
|
173
|
+
fireEvent.click(await screen.findByRole('button', { name: /Send test email/ }))
|
|
174
|
+
await waitFor(() =>
|
|
175
|
+
expect(cmsApi).toHaveBeenCalledWith(
|
|
176
|
+
'/email/settings/test',
|
|
177
|
+
expect.objectContaining({ method: 'POST' }),
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
expect(await screen.findByText(/Test email sent to admin@example.com/)).toBeTruthy()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('surfaces a failed test send', async () => {
|
|
184
|
+
getResponse = {
|
|
185
|
+
...deepClone(EMPTY_RESPONSE),
|
|
186
|
+
settings: {
|
|
187
|
+
provider: 'resend',
|
|
188
|
+
from: 'a@b.co',
|
|
189
|
+
hasApiKey: true,
|
|
190
|
+
hasSmtpPass: false,
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
testResponse = { error: 'SMTP.com API error (401): bad key', status: 502 }
|
|
194
|
+
render(<EmailSettingsTab role="ADMIN" />)
|
|
195
|
+
fireEvent.click(await screen.findByRole('button', { name: /Send test email/ }))
|
|
196
|
+
expect(await screen.findByText(/SMTP\.com API error \(401\)/)).toBeTruthy()
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('shows the env-managed notice and disables editing for non-admins', async () => {
|
|
200
|
+
getResponse = {
|
|
201
|
+
...deepClone(EMPTY_RESPONSE),
|
|
202
|
+
envProvider: 'resend',
|
|
203
|
+
envSenderActive: true,
|
|
204
|
+
}
|
|
205
|
+
render(<EmailSettingsTab role="EDITOR" />)
|
|
206
|
+
expect(await screen.findByText(/read-only access/)).toBeTruthy()
|
|
207
|
+
expect(screen.getByText(/Environment variables currently configure/)).toBeTruthy()
|
|
208
|
+
expect((screen.getByRole('radio', { name: 'Resend' }) as HTMLButtonElement).disabled).toBe(true)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('warns when the email plugin is missing for a configured provider', async () => {
|
|
212
|
+
getResponse = {
|
|
213
|
+
...deepClone(EMPTY_RESPONSE),
|
|
214
|
+
factoryAvailable: false,
|
|
215
|
+
settings: {
|
|
216
|
+
provider: 'resend',
|
|
217
|
+
from: 'a@b.co',
|
|
218
|
+
hasApiKey: true,
|
|
219
|
+
hasSmtpPass: false,
|
|
220
|
+
},
|
|
221
|
+
}
|
|
222
|
+
render(<EmailSettingsTab role="ADMIN" />)
|
|
223
|
+
expect(await screen.findByText(/requires the email plugin/)).toBeTruthy()
|
|
224
|
+
})
|
|
225
|
+
})
|
|
@@ -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
|
+
})
|
package/src/views/Settings.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
AlertTriangle,
|
|
8
8
|
Download,
|
|
9
9
|
Layers,
|
|
10
|
+
Mail,
|
|
10
11
|
Users as UsersIcon,
|
|
11
12
|
KeyRound,
|
|
12
13
|
} from 'lucide-react'
|
|
@@ -18,6 +19,7 @@ import { RelationshipField } from '../fields/RelationshipField.js'
|
|
|
18
19
|
import { GeneralSettingsTab } from './settings/GeneralSettingsTab.js'
|
|
19
20
|
import { SeoSettingsTab } from './settings/SeoSettingsTab.js'
|
|
20
21
|
import { AISettingsTab } from './settings/AISettingsTab.js'
|
|
22
|
+
import { EmailSettingsTab } from './settings/EmailSettingsTab.js'
|
|
21
23
|
import { AppearanceTab } from './settings/AppearanceTab.js'
|
|
22
24
|
import { SecurityTab } from './settings/SecurityTab.js'
|
|
23
25
|
import { SettingsCard } from './settings/components.js'
|
|
@@ -85,6 +87,7 @@ export function Settings({
|
|
|
85
87
|
'users',
|
|
86
88
|
'api-keys',
|
|
87
89
|
'seo',
|
|
90
|
+
'email',
|
|
88
91
|
'ai',
|
|
89
92
|
'updates',
|
|
90
93
|
] as const,
|
|
@@ -214,6 +217,12 @@ export function Settings({
|
|
|
214
217
|
<Tabs.Trigger value="seo" className={tabTriggerClass}>
|
|
215
218
|
SEO
|
|
216
219
|
</Tabs.Trigger>
|
|
220
|
+
<Tabs.Trigger value="email" className={tabTriggerClass}>
|
|
221
|
+
<span className="flex items-center gap-1.5">
|
|
222
|
+
<Mail className="h-4 w-4" />
|
|
223
|
+
Email
|
|
224
|
+
</span>
|
|
225
|
+
</Tabs.Trigger>
|
|
217
226
|
<Tabs.Trigger value="ai" className={tabTriggerClass}>
|
|
218
227
|
<span className="flex items-center gap-1.5">
|
|
219
228
|
<Bot className="h-4 w-4" />
|
|
@@ -311,6 +320,10 @@ export function Settings({
|
|
|
311
320
|
<SeoSettingsTab onNavigate={onNavigate} role={session?.user?.role} />
|
|
312
321
|
</Tabs.Content>
|
|
313
322
|
|
|
323
|
+
<Tabs.Content value="email" className="space-y-4">
|
|
324
|
+
<EmailSettingsTab role={session?.user?.role} />
|
|
325
|
+
</Tabs.Content>
|
|
326
|
+
|
|
314
327
|
<Tabs.Content value="ai" className="space-y-4">
|
|
315
328
|
<AISettingsTab role={session?.user?.role} />
|
|
316
329
|
</Tabs.Content>
|
|
@@ -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
|