@startsimpli/ui 0.4.50 → 0.4.52

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.50",
3
+ "version": "0.4.52",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -18,6 +18,7 @@
18
18
  "./blog": "./src/components/blog/index.ts",
19
19
  "./blog/resolve": "./src/components/blog/resolveArticle.ts",
20
20
  "./collection": "./src/components/collection/index.ts",
21
+ "./document-editor": "./src/components/document-editor/index.ts",
21
22
  "./email-editor": "./src/components/email-editor/index.ts",
22
23
  "./workflows": "./src/components/workflows/index.ts",
23
24
  "./workflows/styles": "./src/components/workflows/workflows.css",
@@ -74,6 +74,52 @@ export const UI_CATALOG: CatalogEntry[] = [
74
74
  source: { export: 'KanbanBoard', from: '@startsimpli/ui' },
75
75
  configExample: { kind: 'kanban-board', type: '<typeKey>', title: 'Board' },
76
76
  },
77
+ {
78
+ id: 'pipeline-health',
79
+ category: 'widget',
80
+ title: 'Pipeline health',
81
+ description:
82
+ 'A compact health card for one status type: how many records sit in each stage, plus a stuck/aging flag that pulls the card to a warning tone. Use on a system-health home to bubble up a content type\'s throughput at a glance (the pipeline funnel shows conversion; this shows health).',
83
+ dataNeeds: 'A type with a status/enum field.',
84
+ source: { export: 'PipelineHealth', from: '@startsimpli/ui' },
85
+ configExample: { kind: 'pipeline-health', type: '<typeKey>', title: 'Pipeline health' },
86
+ },
87
+ {
88
+ id: 'source-freshness',
89
+ category: 'widget',
90
+ title: 'Source freshness',
91
+ description:
92
+ 'A health card listing upstream sources as fresh vs stale by their last-updated time — any stale feed pulls the card to a warning. Use to prove ongoing ingestion is actually flowing (a stale source is a bug, not coverage).',
93
+ dataNeeds: 'A type whose records carry a last-updated / last-fetched timestamp.',
94
+ source: { export: 'SourceFreshness', from: '@startsimpli/ui' },
95
+ configExample: { kind: 'source-freshness', type: '<typeKey>', title: 'Source freshness' },
96
+ },
97
+ {
98
+ id: 'delivery-health',
99
+ category: 'widget',
100
+ title: 'Delivery health',
101
+ description:
102
+ 'A health card for scheduled deliverables: on-time / late / missed / upcoming counts derived from a type\'s scheduled and delivered date fields. Use for recurring outputs like weekly briefs.',
103
+ dataNeeds: 'A type with a scheduled-date and a delivered-date field.',
104
+ source: { export: 'DeliveryHealth', from: '@startsimpli/ui' },
105
+ configExample: {
106
+ kind: 'delivery-health',
107
+ type: '<typeKey>',
108
+ scheduledField: '<scheduled>',
109
+ deliveredField: '<delivered>',
110
+ title: 'Delivery',
111
+ },
112
+ },
113
+ {
114
+ id: 'attention',
115
+ category: 'widget',
116
+ title: 'Needs attention',
117
+ description:
118
+ 'A cross-type queue of records that need a human — stuck records, drafts awaiting review. Empty is the healthy state. Use as the "what should I look at" panel on a system-health home.',
119
+ dataNeeds: 'Any types (flags records by status / age).',
120
+ source: { export: 'AttentionFeed', from: '@startsimpli/ui' },
121
+ configExample: { kind: 'attention', limit: 8, title: 'Needs attention' },
122
+ },
77
123
  {
78
124
  id: 'data-table',
79
125
  category: 'view',
@@ -126,6 +172,15 @@ export const UI_CATALOG: CatalogEntry[] = [
126
172
  dataNeeds: 'Any types.',
127
173
  source: { export: 'CommandPalette', from: '@startsimpli/ui' },
128
174
  },
175
+ {
176
+ id: 'document-editor',
177
+ category: 'component',
178
+ title: 'Document editor',
179
+ description:
180
+ "A Google-Docs-like editor over a record's sub-sections — long-text as markdown (edit/preview split), plain text with word count, structured key/value fields (with an SEO meta-description meter), and editable source lists. Controlled + debounced-autosave (the app owns persistence). Use to review + edit a multi-part content record (e.g. a marketing draft: blog + LinkedIn + SEO + sources); the read-only DraftReviewWorkspace is its compare/approve complement. Map schema→sections with sectionsFromRecord.",
181
+ dataNeeds: 'A type with long-text / structured (json) fields to edit (optionally a source-list field).',
182
+ source: { export: 'DocumentEditor', from: '@startsimpli/ui/document-editor' },
183
+ },
129
184
  {
130
185
  id: 'chat',
131
186
  category: 'component',
@@ -0,0 +1,476 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * DocumentEditor — a Google-Docs-like editor over typed document sub-sections.
5
+ *
6
+ * The reusable surface a tenant app uses to review + edit a multi-part content
7
+ * record (e.g. a marketing draft = blog markdown + LinkedIn text + SEO fields +
8
+ * sources). Generic + schema-driven (see `./sections`), presentational, and
9
+ * CONTROLLED: the parent owns the section values (via `onChange`) and all
10
+ * persistence (`onSave` is debounced autosave — bring your own writer). It never
11
+ * fetches. Complements the read-only DraftReviewWorkspace.
12
+ */
13
+ import * as React from 'react';
14
+ import { Columns2, Eye, Pencil, Plus, Trash2, Loader2, Check, AlertCircle } from 'lucide-react';
15
+
16
+ import { cn } from '../../lib/utils';
17
+ import { MarkdownRenderer } from '../blog/MarkdownRenderer';
18
+ import type { DocSection } from './types';
19
+
20
+ export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
21
+
22
+ export interface DocumentEditorProps {
23
+ sections: DocSection[];
24
+ /** Controlled per-section edit — the parent updates its section state. */
25
+ onChange?(key: string, value: unknown): void;
26
+ /** Debounced autosave (BYO persistence). Receives the current sections. */
27
+ onSave?(sections: DocSection[]): void | Promise<void>;
28
+ /** Autosave debounce, ms. Default 1200. */
29
+ autosaveMs?: number;
30
+ /** Render every section read-only (previews only). */
31
+ readOnly?: boolean;
32
+ /** Title / actions slot rendered in the header bar (e.g. a "Mark ready" button). */
33
+ header?: React.ReactNode;
34
+ className?: string;
35
+ }
36
+
37
+ function countWords(s: string): number {
38
+ const t = s.trim();
39
+ return t ? t.split(/\s+/).length : 0;
40
+ }
41
+
42
+ function asString(v: unknown): string {
43
+ return typeof v === 'string' ? v : v == null ? '' : String(v);
44
+ }
45
+ function asRecord(v: unknown): Record<string, string> {
46
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
47
+ const out: Record<string, string> = {};
48
+ for (const [k, val] of Object.entries(v as Record<string, unknown>)) out[k] = asString(val);
49
+ return out;
50
+ }
51
+ return {};
52
+ }
53
+ function asList(v: unknown): string[] {
54
+ return Array.isArray(v) ? v.map(asString) : [];
55
+ }
56
+
57
+ /** A key like `meta_description` / `metaDescription` → an SEO description meter. */
58
+ function isMetaDescriptionKey(key: string): boolean {
59
+ return /meta[_-]?description/i.test(key) || /^description$/i.test(key);
60
+ }
61
+
62
+ export function DocumentEditor({
63
+ sections,
64
+ onChange,
65
+ onSave,
66
+ autosaveMs = 1200,
67
+ readOnly = false,
68
+ header,
69
+ className,
70
+ }: DocumentEditorProps) {
71
+ const [status, setStatus] = React.useState<SaveStatus>('idle');
72
+
73
+ // Serialize the section values so autosave fires only on a real content change.
74
+ const serialized = React.useMemo(
75
+ () => JSON.stringify(sections.map((s) => [s.key, s.value])),
76
+ [sections],
77
+ );
78
+
79
+ // Keep latest sections + onSave in refs so the debounce effect depends only on
80
+ // the serialized content (not on a fresh inline `onSave` identity each render).
81
+ const sectionsRef = React.useRef(sections);
82
+ sectionsRef.current = sections;
83
+ const onSaveRef = React.useRef(onSave);
84
+ onSaveRef.current = onSave;
85
+ const savedRef = React.useRef(serialized);
86
+
87
+ React.useEffect(() => {
88
+ if (readOnly) return;
89
+ const fn = onSaveRef.current;
90
+ if (!fn) return;
91
+ if (serialized === savedRef.current) return;
92
+
93
+ let cancelled = false;
94
+ const timer = setTimeout(async () => {
95
+ setStatus('saving');
96
+ try {
97
+ await fn(sectionsRef.current);
98
+ if (cancelled) return;
99
+ savedRef.current = serialized;
100
+ setStatus('saved');
101
+ } catch {
102
+ if (!cancelled) setStatus('error');
103
+ }
104
+ }, autosaveMs);
105
+
106
+ return () => {
107
+ cancelled = true;
108
+ clearTimeout(timer);
109
+ };
110
+ }, [serialized, readOnly, autosaveMs]);
111
+
112
+ const showBar = header != null || (!readOnly && !!onSave);
113
+
114
+ return (
115
+ <div className={cn('space-y-5', className)}>
116
+ {showBar && (
117
+ <div className="flex flex-wrap items-center justify-between gap-3">
118
+ <div className="min-w-0">{header}</div>
119
+ {!readOnly && onSave && <SaveStatusPill status={status} />}
120
+ </div>
121
+ )}
122
+
123
+ <div className="space-y-6">
124
+ {sections.map((section) => (
125
+ <SectionEditor
126
+ key={section.key}
127
+ section={section}
128
+ readOnly={readOnly || !!section.readOnly}
129
+ onChange={onChange}
130
+ />
131
+ ))}
132
+ </div>
133
+ </div>
134
+ );
135
+ }
136
+
137
+ function SaveStatusPill({ status }: { status: SaveStatus }) {
138
+ const map: Record<SaveStatus, { label: string; tone: string; icon: React.ReactNode }> = {
139
+ idle: { label: '', tone: 'text-muted-foreground', icon: null },
140
+ saving: { label: 'Saving…', tone: 'text-muted-foreground', icon: <Loader2 className="h-3 w-3 animate-spin" /> },
141
+ saved: { label: 'Saved', tone: 'text-emerald-600', icon: <Check className="h-3 w-3" /> },
142
+ error: { label: 'Save failed', tone: 'text-rose-600', icon: <AlertCircle className="h-3 w-3" /> },
143
+ };
144
+ const s = map[status];
145
+ if (!s.label) return null;
146
+ return (
147
+ <span className={cn('inline-flex items-center gap-1.5 text-xs', s.tone)} role="status" aria-live="polite">
148
+ {s.icon}
149
+ {s.label}
150
+ </span>
151
+ );
152
+ }
153
+
154
+ function SectionEditor({
155
+ section,
156
+ readOnly,
157
+ onChange,
158
+ }: {
159
+ section: DocSection;
160
+ readOnly: boolean;
161
+ onChange?(key: string, value: unknown): void;
162
+ }) {
163
+ const emit = React.useCallback((value: unknown) => onChange?.(section.key, value), [onChange, section.key]);
164
+
165
+ return (
166
+ <section className="rounded-lg border border-border bg-card">
167
+ <SectionBody section={section} readOnly={readOnly} emit={emit} />
168
+ </section>
169
+ );
170
+ }
171
+
172
+ function SectionHeader({ section, right }: { section: DocSection; right?: React.ReactNode }) {
173
+ return (
174
+ <div className="flex items-center justify-between gap-2 border-b border-border px-4 py-2">
175
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{section.label}</h3>
176
+ {right}
177
+ </div>
178
+ );
179
+ }
180
+
181
+ function SectionBody({
182
+ section,
183
+ readOnly,
184
+ emit,
185
+ }: {
186
+ section: DocSection;
187
+ readOnly: boolean;
188
+ emit: (value: unknown) => void;
189
+ }) {
190
+ switch (section.kind) {
191
+ case 'markdown':
192
+ return <MarkdownSection section={section} readOnly={readOnly} emit={emit} />;
193
+ case 'text':
194
+ return <TextSection section={section} readOnly={readOnly} emit={emit} />;
195
+ case 'structured':
196
+ return <StructuredSection section={section} readOnly={readOnly} emit={emit} />;
197
+ case 'list':
198
+ return <ListSection section={section} readOnly={readOnly} emit={emit} />;
199
+ default:
200
+ return null;
201
+ }
202
+ }
203
+
204
+ type ViewMode = 'edit' | 'preview' | 'split';
205
+
206
+ function MarkdownSection({
207
+ section,
208
+ readOnly,
209
+ emit,
210
+ }: {
211
+ section: DocSection;
212
+ readOnly: boolean;
213
+ emit: (value: unknown) => void;
214
+ }) {
215
+ const value = asString(section.value);
216
+ const [mode, setMode] = React.useState<ViewMode>('split');
217
+ const words = countWords(value);
218
+
219
+ if (readOnly) {
220
+ return (
221
+ <>
222
+ <SectionHeader section={section} right={<span className="text-xs text-muted-foreground">{words} words</span>} />
223
+ <div className="px-4 py-3">
224
+ {value ? (
225
+ <MarkdownRenderer content={value} />
226
+ ) : (
227
+ <p className="text-sm italic text-muted-foreground">Empty.</p>
228
+ )}
229
+ </div>
230
+ </>
231
+ );
232
+ }
233
+
234
+ const showEditor = mode === 'edit' || mode === 'split';
235
+ const showPreview = mode === 'preview' || mode === 'split';
236
+
237
+ return (
238
+ <>
239
+ <SectionHeader
240
+ section={section}
241
+ right={
242
+ <div className="flex items-center gap-3">
243
+ <span className="text-xs text-muted-foreground">{words} words</span>
244
+ <ModeToggle mode={mode} onMode={setMode} />
245
+ </div>
246
+ }
247
+ />
248
+ <div className={cn('grid gap-0', mode === 'split' && 'lg:grid-cols-2 lg:divide-x lg:divide-border')}>
249
+ {showEditor && (
250
+ <textarea
251
+ value={value}
252
+ onChange={(e) => emit(e.target.value)}
253
+ placeholder={section.placeholder}
254
+ aria-label={`${section.label} markdown`}
255
+ className="min-h-[16rem] w-full resize-y border-0 bg-transparent p-4 font-mono text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground focus:ring-0"
256
+ spellCheck
257
+ />
258
+ )}
259
+ {showPreview && (
260
+ <div className="min-h-[16rem] overflow-x-auto p-4">
261
+ {value ? (
262
+ <MarkdownRenderer content={value} />
263
+ ) : (
264
+ <p className="text-sm italic text-muted-foreground">Nothing to preview yet.</p>
265
+ )}
266
+ </div>
267
+ )}
268
+ </div>
269
+ </>
270
+ );
271
+ }
272
+
273
+ function ModeToggle({ mode, onMode }: { mode: ViewMode; onMode: (m: ViewMode) => void }) {
274
+ const opts: { value: ViewMode; label: string; icon: React.ReactNode; hideOnNarrow?: boolean }[] = [
275
+ { value: 'edit', label: 'Edit', icon: <Pencil className="h-3 w-3" /> },
276
+ { value: 'preview', label: 'Preview', icon: <Eye className="h-3 w-3" /> },
277
+ { value: 'split', label: 'Split', icon: <Columns2 className="h-3 w-3" />, hideOnNarrow: true },
278
+ ];
279
+ return (
280
+ <div className="inline-flex overflow-hidden rounded-md border border-border" role="group" aria-label="View mode">
281
+ {opts.map((o) => (
282
+ <button
283
+ key={o.value}
284
+ type="button"
285
+ onClick={() => onMode(o.value)}
286
+ aria-pressed={mode === o.value}
287
+ className={cn(
288
+ 'inline-flex items-center gap-1 px-2 py-1 text-xs transition-colors',
289
+ o.hideOnNarrow && 'hidden lg:inline-flex',
290
+ mode === o.value ? 'bg-primary text-primary-foreground' : 'bg-transparent text-muted-foreground hover:bg-muted',
291
+ )}
292
+ >
293
+ {o.icon}
294
+ {o.label}
295
+ </button>
296
+ ))}
297
+ </div>
298
+ );
299
+ }
300
+
301
+ function TextSection({
302
+ section,
303
+ readOnly,
304
+ emit,
305
+ }: {
306
+ section: DocSection;
307
+ readOnly: boolean;
308
+ emit: (value: unknown) => void;
309
+ }) {
310
+ const value = asString(section.value);
311
+ const words = countWords(value);
312
+ const chars = value.length;
313
+
314
+ return (
315
+ <>
316
+ <SectionHeader
317
+ section={section}
318
+ right={
319
+ <span className="text-xs text-muted-foreground">
320
+ {words} words · {chars} chars
321
+ </span>
322
+ }
323
+ />
324
+ <div className="px-4 py-3">
325
+ {readOnly ? (
326
+ value ? (
327
+ <p className="whitespace-pre-wrap text-sm text-foreground">{value}</p>
328
+ ) : (
329
+ <p className="text-sm italic text-muted-foreground">Empty.</p>
330
+ )
331
+ ) : (
332
+ <textarea
333
+ value={value}
334
+ onChange={(e) => emit(e.target.value)}
335
+ placeholder={section.placeholder}
336
+ aria-label={section.label}
337
+ className="min-h-[8rem] w-full resize-y rounded-md border border-border bg-background p-3 text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground focus:border-primary focus:ring-0"
338
+ spellCheck
339
+ />
340
+ )}
341
+ </div>
342
+ </>
343
+ );
344
+ }
345
+
346
+ function StructuredSection({
347
+ section,
348
+ readOnly,
349
+ emit,
350
+ }: {
351
+ section: DocSection;
352
+ readOnly: boolean;
353
+ emit: (value: unknown) => void;
354
+ }) {
355
+ const obj = asRecord(section.value);
356
+ const keys = Object.keys(obj);
357
+
358
+ const setField = (field: string, val: string) => emit({ ...obj, [field]: val });
359
+
360
+ return (
361
+ <>
362
+ <SectionHeader section={section} />
363
+ <div className="space-y-3 px-4 py-3">
364
+ {keys.length === 0 && <p className="text-sm italic text-muted-foreground">No fields.</p>}
365
+ {keys.map((field) => {
366
+ const fieldVal = obj[field];
367
+ const meta = isMetaDescriptionKey(field);
368
+ return (
369
+ <div key={field} className="grid gap-1 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-start sm:gap-3">
370
+ <label htmlFor={`${section.key}-${field}`} className="pt-1.5 text-xs font-medium text-muted-foreground">
371
+ {field.replace(/[_-]+/g, ' ')}
372
+ </label>
373
+ <div>
374
+ {readOnly ? (
375
+ <p className="text-sm text-foreground">{fieldVal || '—'}</p>
376
+ ) : (
377
+ <input
378
+ id={`${section.key}-${field}`}
379
+ value={fieldVal}
380
+ onChange={(e) => setField(field, e.target.value)}
381
+ className="w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-sm text-foreground outline-none focus:border-primary focus:ring-0"
382
+ />
383
+ )}
384
+ {meta && <CharMeter value={fieldVal} good={[150, 160]} />}
385
+ </div>
386
+ </div>
387
+ );
388
+ })}
389
+ </div>
390
+ </>
391
+ );
392
+ }
393
+
394
+ /** A subtle character-count meter with an optional "good" band hint (e.g. SEO meta 150–160). */
395
+ function CharMeter({ value, good }: { value: string; good: [number, number] }) {
396
+ const len = value.length;
397
+ const [lo, hi] = good;
398
+ const tone = len === 0 ? 'text-muted-foreground' : len >= lo && len <= hi ? 'text-emerald-600' : 'text-amber-600';
399
+ return (
400
+ <p className={cn('mt-1 text-[11px]', tone)}>
401
+ {len} chars{good ? ` · aim ${lo}–${hi}` : ''}
402
+ </p>
403
+ );
404
+ }
405
+
406
+ function ListSection({
407
+ section,
408
+ readOnly,
409
+ emit,
410
+ }: {
411
+ section: DocSection;
412
+ readOnly: boolean;
413
+ emit: (value: unknown) => void;
414
+ }) {
415
+ const items = asList(section.value);
416
+
417
+ const setItem = (i: number, val: string) => emit(items.map((it, idx) => (idx === i ? val : it)));
418
+ const removeItem = (i: number) => emit(items.filter((_, idx) => idx !== i));
419
+ const addItem = () => emit([...items, '']);
420
+
421
+ if (readOnly) {
422
+ return (
423
+ <>
424
+ <SectionHeader section={section} right={<span className="text-xs text-muted-foreground">{items.length}</span>} />
425
+ <div className="px-4 py-3">
426
+ {items.length === 0 ? (
427
+ <p className="text-sm italic text-muted-foreground">None.</p>
428
+ ) : (
429
+ <ul className="list-disc space-y-1 pl-5 text-sm text-foreground">
430
+ {items.map((it, i) => (
431
+ <li key={i} className="break-words">
432
+ {it}
433
+ </li>
434
+ ))}
435
+ </ul>
436
+ )}
437
+ </div>
438
+ </>
439
+ );
440
+ }
441
+
442
+ return (
443
+ <>
444
+ <SectionHeader section={section} right={<span className="text-xs text-muted-foreground">{items.length}</span>} />
445
+ <div className="space-y-2 px-4 py-3">
446
+ {items.map((it, i) => (
447
+ <div key={i} className="flex items-center gap-2">
448
+ <input
449
+ value={it}
450
+ onChange={(e) => setItem(i, e.target.value)}
451
+ placeholder={section.placeholder}
452
+ aria-label={`${section.label} item ${i + 1}`}
453
+ className="w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-sm text-foreground outline-none focus:border-primary focus:ring-0"
454
+ />
455
+ <button
456
+ type="button"
457
+ onClick={() => removeItem(i)}
458
+ aria-label={`Remove item ${i + 1}`}
459
+ className="shrink-0 rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-rose-600"
460
+ >
461
+ <Trash2 className="h-4 w-4" />
462
+ </button>
463
+ </div>
464
+ ))}
465
+ <button
466
+ type="button"
467
+ onClick={addItem}
468
+ className="inline-flex items-center gap-1 rounded-md border border-dashed border-border px-2.5 py-1.5 text-xs text-muted-foreground hover:border-primary hover:text-foreground"
469
+ >
470
+ <Plus className="h-3.5 w-3.5" />
471
+ Add
472
+ </button>
473
+ </div>
474
+ </>
475
+ );
476
+ }
@@ -0,0 +1,125 @@
1
+ import * as React from 'react';
2
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3
+
4
+ // MarkdownRenderer pulls react-markdown (ESM, not transformed from node_modules
5
+ // by this package's jest config). The editor's own concern is wiring the preview,
6
+ // not markdown rendering, so isolate it with a lightweight stand-in.
7
+ jest.mock('../../blog/MarkdownRenderer', () => ({
8
+ MarkdownRenderer: ({ content }: { content: string }) =>
9
+ require('react').createElement('div', { 'data-testid': 'md-preview' }, content),
10
+ }));
11
+
12
+ import { DocumentEditor } from '../DocumentEditor';
13
+ import type { DocSection } from '../types';
14
+
15
+ function baseSections(): DocSection[] {
16
+ return [
17
+ { key: 'blog', label: 'Blog post', kind: 'markdown', value: 'hello world' },
18
+ { key: 'linkedin', label: 'LinkedIn', kind: 'text', value: 'a post' },
19
+ { key: 'seo', label: 'SEO', kind: 'structured', value: { meta_description: 'short', primary_keyword: 'kw' } },
20
+ { key: 'sources', label: 'Sources', kind: 'list', value: ['https://a.com'] },
21
+ ];
22
+ }
23
+
24
+ /** Controlled harness that mirrors how a consuming app wires the editor. */
25
+ function Harness({
26
+ onSave,
27
+ autosaveMs,
28
+ readOnly,
29
+ }: {
30
+ onSave?: (s: DocSection[]) => void | Promise<void>;
31
+ autosaveMs?: number;
32
+ readOnly?: boolean;
33
+ }) {
34
+ const [sections, setSections] = React.useState<DocSection[]>(baseSections());
35
+ return (
36
+ <DocumentEditor
37
+ sections={sections}
38
+ readOnly={readOnly}
39
+ autosaveMs={autosaveMs}
40
+ onSave={onSave}
41
+ header={<h2>My draft</h2>}
42
+ onChange={(key, value) =>
43
+ setSections((prev) => prev.map((s) => (s.key === key ? { ...s, value } : s)))
44
+ }
45
+ />
46
+ );
47
+ }
48
+
49
+ describe('DocumentEditor', () => {
50
+ it('renders a header slot and every section label', () => {
51
+ render(<Harness />);
52
+ expect(screen.getByText('My draft')).toBeInTheDocument();
53
+ for (const label of ['Blog post', 'LinkedIn', 'SEO', 'Sources']) {
54
+ expect(screen.getByRole('heading', { name: label })).toBeInTheDocument();
55
+ }
56
+ });
57
+
58
+ it('markdown section shows a word count and a live preview alongside the editor', () => {
59
+ render(<Harness />);
60
+ expect(screen.getByText('2 words')).toBeInTheDocument();
61
+ // split view: an editable textarea AND the rendered preview text
62
+ expect(screen.getByLabelText('Blog post markdown')).toBeInTheDocument();
63
+ const previews = screen.getAllByText('hello world');
64
+ expect(previews.length).toBeGreaterThanOrEqual(1);
65
+ });
66
+
67
+ it('emits per-section edits through onChange (controlled)', () => {
68
+ render(<Harness />);
69
+ const textarea = screen.getByLabelText('LinkedIn') as HTMLTextAreaElement;
70
+ fireEvent.change(textarea, { target: { value: 'edited post' } });
71
+ expect((screen.getByLabelText('LinkedIn') as HTMLTextAreaElement).value).toBe('edited post');
72
+ });
73
+
74
+ it('structured section renders a field input and an SEO char meter on meta_description', () => {
75
+ render(<Harness />);
76
+ const metaInput = screen.getByLabelText('meta description') as HTMLInputElement;
77
+ expect(metaInput.value).toBe('short');
78
+ // the meta meter surfaces the aim band
79
+ expect(screen.getByText(/aim 150/)).toBeInTheDocument();
80
+ fireEvent.change(metaInput, { target: { value: 'new description' } });
81
+ expect((screen.getByLabelText('meta description') as HTMLInputElement).value).toBe('new description');
82
+ });
83
+
84
+ it('list section adds, edits and removes items', () => {
85
+ render(<Harness />);
86
+ expect(screen.getByLabelText('Sources item 1')).toBeInTheDocument();
87
+ fireEvent.click(screen.getByRole('button', { name: 'Add' }));
88
+ const item2 = screen.getByLabelText('Sources item 2') as HTMLInputElement;
89
+ fireEvent.change(item2, { target: { value: 'https://b.com' } });
90
+ expect((screen.getByLabelText('Sources item 2') as HTMLInputElement).value).toBe('https://b.com');
91
+ fireEvent.click(screen.getByRole('button', { name: 'Remove item 1' }));
92
+ expect(screen.queryByLabelText('Sources item 2')).not.toBeInTheDocument();
93
+ expect((screen.getByLabelText('Sources item 1') as HTMLInputElement).value).toBe('https://b.com');
94
+ });
95
+
96
+ it('debounces onSave after an edit and shows a Saved status', async () => {
97
+ const onSave = jest.fn().mockResolvedValue(undefined);
98
+ render(<Harness onSave={onSave} autosaveMs={20} />);
99
+ // no autosave before any edit
100
+ expect(onSave).not.toHaveBeenCalled();
101
+ fireEvent.change(screen.getByLabelText('LinkedIn'), { target: { value: 'changed' } });
102
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
103
+ const saved = onSave.mock.calls[0][0] as DocSection[];
104
+ expect(saved.find((s) => s.key === 'linkedin')!.value).toBe('changed');
105
+ expect(await screen.findByText('Saved')).toBeInTheDocument();
106
+ });
107
+
108
+ it('surfaces a Save failed status when onSave rejects', async () => {
109
+ const onSave = jest.fn().mockRejectedValue(new Error('nope'));
110
+ render(<Harness onSave={onSave} autosaveMs={20} />);
111
+ fireEvent.change(screen.getByLabelText('LinkedIn'), { target: { value: 'boom' } });
112
+ expect(await screen.findByText('Save failed')).toBeInTheDocument();
113
+ });
114
+
115
+ it('readOnly renders previews only (no editable inputs) and skips autosave', async () => {
116
+ const onSave = jest.fn();
117
+ render(<Harness onSave={onSave} autosaveMs={10} readOnly />);
118
+ expect(screen.queryByLabelText('Blog post markdown')).not.toBeInTheDocument();
119
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
120
+ // markdown still renders as a preview
121
+ expect(screen.getAllByText('hello world').length).toBeGreaterThanOrEqual(1);
122
+ await new Promise((r) => setTimeout(r, 40));
123
+ expect(onSave).not.toHaveBeenCalled();
124
+ });
125
+ });