@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 +2 -1
- package/src/catalog/catalog.ts +55 -0
- package/src/components/document-editor/DocumentEditor.tsx +476 -0
- package/src/components/document-editor/__tests__/DocumentEditor.test.tsx +125 -0
- package/src/components/document-editor/__tests__/sections.test.ts +107 -0
- package/src/components/document-editor/index.ts +20 -0
- package/src/components/document-editor/sections.ts +170 -0
- package/src/components/document-editor/types.ts +32 -0
- package/src/components/health/AttentionFeed.tsx +95 -0
- package/src/components/health/DeliveryHealth.tsx +127 -0
- package/src/components/health/HealthCard.tsx +108 -0
- package/src/components/health/PipelineHealth.tsx +99 -0
- package/src/components/health/SourceFreshness.tsx +88 -0
- package/src/components/health/__tests__/health.test.tsx +142 -0
- package/src/components/health/health-status.ts +79 -0
- package/src/components/health/index.ts +24 -0
- package/src/components/index.ts +4 -0
- package/src/components/navigation/__tests__/grouped-nav.test.tsx +69 -0
- package/src/components/navigation/grouped-nav.tsx +192 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cn } from '../../lib/utils'
|
|
3
|
+
import { GitBranch } from 'lucide-react'
|
|
4
|
+
import { HealthCard } from './HealthCard'
|
|
5
|
+
import { HEALTH_TONE, worstStatus, type HealthStatus } from './health-status'
|
|
6
|
+
|
|
7
|
+
export interface PipelineStage {
|
|
8
|
+
label: string
|
|
9
|
+
count: number
|
|
10
|
+
/** Optional per-stage tone accent (default derived: 0 records = neutral, else ok). */
|
|
11
|
+
tone?: HealthStatus
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PipelineHealthProps {
|
|
15
|
+
title?: string
|
|
16
|
+
stages: PipelineStage[]
|
|
17
|
+
/** Records stuck / aging past their SLA — surfaces as the card's warning. */
|
|
18
|
+
attention?: number
|
|
19
|
+
attentionLabel?: string
|
|
20
|
+
onStageClick?: (label: string) => void
|
|
21
|
+
emptyMessage?: string
|
|
22
|
+
className?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Content-pipeline health: the count of records in each stage of a status field,
|
|
27
|
+
* plus a "stuck / aging" attention flag. Presentational — a tenant dashboard
|
|
28
|
+
* widget derives `stages` from groupByStatus over one content type's records.
|
|
29
|
+
*/
|
|
30
|
+
export function PipelineHealth({
|
|
31
|
+
title = 'Pipeline',
|
|
32
|
+
stages,
|
|
33
|
+
attention = 0,
|
|
34
|
+
attentionLabel,
|
|
35
|
+
onStageClick,
|
|
36
|
+
emptyMessage = 'No records yet.',
|
|
37
|
+
className,
|
|
38
|
+
}: PipelineHealthProps) {
|
|
39
|
+
const total = stages.reduce((sum, s) => sum + s.count, 0)
|
|
40
|
+
const max = Math.max(1, ...stages.map((s) => s.count))
|
|
41
|
+
|
|
42
|
+
const status: HealthStatus =
|
|
43
|
+
total === 0
|
|
44
|
+
? 'neutral'
|
|
45
|
+
: attention > 0
|
|
46
|
+
? worstStatus(['warn', ...stages.map((s) => s.tone ?? 'ok')])
|
|
47
|
+
: 'ok'
|
|
48
|
+
|
|
49
|
+
const statusLabel =
|
|
50
|
+
total === 0
|
|
51
|
+
? undefined
|
|
52
|
+
: attention > 0
|
|
53
|
+
? (attentionLabel ?? `${attention} need attention`)
|
|
54
|
+
: `${total} total`
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<HealthCard
|
|
58
|
+
title={title}
|
|
59
|
+
status={status}
|
|
60
|
+
statusLabel={statusLabel}
|
|
61
|
+
icon={GitBranch}
|
|
62
|
+
isEmpty={total === 0}
|
|
63
|
+
emptyMessage={emptyMessage}
|
|
64
|
+
className={className}
|
|
65
|
+
>
|
|
66
|
+
<div className="space-y-2.5" role="list" aria-label={`${title} stages`}>
|
|
67
|
+
{stages.map((stage) => {
|
|
68
|
+
const tone = HEALTH_TONE[stage.tone ?? (stage.count === 0 ? 'neutral' : 'ok')]
|
|
69
|
+
const pct = (stage.count / max) * 100
|
|
70
|
+
const clickable = Boolean(onStageClick)
|
|
71
|
+
return (
|
|
72
|
+
<button
|
|
73
|
+
key={stage.label}
|
|
74
|
+
type="button"
|
|
75
|
+
role="listitem"
|
|
76
|
+
disabled={!clickable}
|
|
77
|
+
onClick={() => onStageClick?.(stage.label)}
|
|
78
|
+
className={cn(
|
|
79
|
+
'w-full text-left group focus:outline-none',
|
|
80
|
+
clickable && 'cursor-pointer focus:ring-2 focus:ring-primary-500 rounded',
|
|
81
|
+
)}
|
|
82
|
+
>
|
|
83
|
+
<div className="flex items-center justify-between mb-1">
|
|
84
|
+
<span className="text-xs font-medium text-gray-600 capitalize">{stage.label}</span>
|
|
85
|
+
<span className="text-xs font-bold text-gray-900 tabular-nums">{stage.count}</span>
|
|
86
|
+
</div>
|
|
87
|
+
<div className="h-1.5 rounded-full bg-gray-100 overflow-hidden">
|
|
88
|
+
<div
|
|
89
|
+
className={cn('h-full rounded-full transition-all', tone.dot, clickable && 'group-hover:opacity-80')}
|
|
90
|
+
style={{ width: `${pct}%` }}
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
</button>
|
|
94
|
+
)
|
|
95
|
+
})}
|
|
96
|
+
</div>
|
|
97
|
+
</HealthCard>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cn } from '../../lib/utils'
|
|
3
|
+
import { Rss } from 'lucide-react'
|
|
4
|
+
import { formatRelativeTime } from '../../utils'
|
|
5
|
+
import { HealthCard, HealthDot } from './HealthCard'
|
|
6
|
+
import { worstStatus, type HealthStatus } from './health-status'
|
|
7
|
+
|
|
8
|
+
export interface SourceFreshnessItem {
|
|
9
|
+
name: string
|
|
10
|
+
/** Last time this source delivered data. Null/undefined = never = stale. */
|
|
11
|
+
lastUpdated?: string | Date | null
|
|
12
|
+
/** Explicit status; otherwise derived from lastUpdated vs staleAfterHours. */
|
|
13
|
+
status?: HealthStatus
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SourceFreshnessProps {
|
|
17
|
+
title?: string
|
|
18
|
+
sources: SourceFreshnessItem[]
|
|
19
|
+
/** A source with no update within this window is stale (default 48h). */
|
|
20
|
+
staleAfterHours?: number
|
|
21
|
+
/** Compare against this instant (injectable for tests); defaults to Date.now(). */
|
|
22
|
+
now?: number
|
|
23
|
+
emptyMessage?: string
|
|
24
|
+
className?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function deriveStatus(item: SourceFreshnessItem, staleAfterHours: number, now: number): HealthStatus {
|
|
28
|
+
if (item.status) return item.status
|
|
29
|
+
if (!item.lastUpdated) return 'critical'
|
|
30
|
+
const ts = new Date(item.lastUpdated).getTime()
|
|
31
|
+
if (Number.isNaN(ts)) return 'neutral'
|
|
32
|
+
const ageHours = (now - ts) / 3_600_000
|
|
33
|
+
if (ageHours <= staleAfterHours) return 'ok'
|
|
34
|
+
if (ageHours <= staleAfterHours * 2) return 'warn'
|
|
35
|
+
return 'critical'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Source / ingestion freshness: which upstream feeds are fresh vs stale. A stale
|
|
40
|
+
* source is a bug (everything is ongoing — no one-time fetches), so any stale
|
|
41
|
+
* feed pulls the whole card to warn/critical. Presentational — a tenant widget
|
|
42
|
+
* feeds it the source records + their last-fetched markers.
|
|
43
|
+
*/
|
|
44
|
+
export function SourceFreshness({
|
|
45
|
+
title = 'Source freshness',
|
|
46
|
+
sources,
|
|
47
|
+
staleAfterHours = 48,
|
|
48
|
+
now,
|
|
49
|
+
emptyMessage = 'No sources configured.',
|
|
50
|
+
className,
|
|
51
|
+
}: SourceFreshnessProps) {
|
|
52
|
+
const at = now ?? Date.now()
|
|
53
|
+
const rows = sources.map((s) => ({ ...s, resolved: deriveStatus(s, staleAfterHours, at) }))
|
|
54
|
+
const staleCount = rows.filter((r) => r.resolved === 'warn' || r.resolved === 'critical').length
|
|
55
|
+
const status = worstStatus(rows.map((r) => r.resolved))
|
|
56
|
+
const statusLabel = sources.length === 0 ? undefined : staleCount > 0 ? `${staleCount} stale` : 'All fresh'
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<HealthCard
|
|
60
|
+
title={title}
|
|
61
|
+
status={status}
|
|
62
|
+
statusLabel={statusLabel}
|
|
63
|
+
icon={Rss}
|
|
64
|
+
isEmpty={sources.length === 0}
|
|
65
|
+
emptyMessage={emptyMessage}
|
|
66
|
+
className={className}
|
|
67
|
+
>
|
|
68
|
+
<ul className="space-y-2" aria-label={`${title} sources`}>
|
|
69
|
+
{rows.map((row) => (
|
|
70
|
+
<li key={row.name} className="flex items-center justify-between gap-3 text-sm">
|
|
71
|
+
<span className="flex items-center gap-2 min-w-0">
|
|
72
|
+
<HealthDot status={row.resolved} />
|
|
73
|
+
<span className="text-gray-700 truncate">{row.name}</span>
|
|
74
|
+
</span>
|
|
75
|
+
<span
|
|
76
|
+
className={cn(
|
|
77
|
+
'text-xs tabular-nums flex-shrink-0',
|
|
78
|
+
row.resolved === 'ok' ? 'text-gray-500' : 'text-gray-900 font-medium',
|
|
79
|
+
)}
|
|
80
|
+
>
|
|
81
|
+
{row.lastUpdated ? formatRelativeTime(row.lastUpdated) : 'never'}
|
|
82
|
+
</span>
|
|
83
|
+
</li>
|
|
84
|
+
))}
|
|
85
|
+
</ul>
|
|
86
|
+
</HealthCard>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
2
|
+
import {
|
|
3
|
+
worstStatus,
|
|
4
|
+
HealthCard,
|
|
5
|
+
PipelineHealth,
|
|
6
|
+
SourceFreshness,
|
|
7
|
+
DeliveryHealth,
|
|
8
|
+
AttentionFeed,
|
|
9
|
+
} from '../index'
|
|
10
|
+
|
|
11
|
+
const NOW = 1_700_000_000_000 // fixed instant for freshness/delivery derivation
|
|
12
|
+
const hoursAgo = (h: number) => new Date(NOW - h * 3_600_000).toISOString()
|
|
13
|
+
const hoursAhead = (h: number) => new Date(NOW + h * 3_600_000).toISOString()
|
|
14
|
+
|
|
15
|
+
describe('worstStatus', () => {
|
|
16
|
+
it('returns neutral for an empty list', () => {
|
|
17
|
+
expect(worstStatus([])).toBe('neutral')
|
|
18
|
+
})
|
|
19
|
+
it('escalates critical > warn > ok > neutral', () => {
|
|
20
|
+
expect(worstStatus(['ok', 'neutral'])).toBe('ok')
|
|
21
|
+
expect(worstStatus(['ok', 'warn'])).toBe('warn')
|
|
22
|
+
expect(worstStatus(['ok', 'warn', 'critical'])).toBe('critical')
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('HealthCard', () => {
|
|
27
|
+
it('renders the title and the tone badge', () => {
|
|
28
|
+
render(<HealthCard title="Pipeline" status="ok" />)
|
|
29
|
+
expect(screen.getByText('Pipeline')).toBeInTheDocument()
|
|
30
|
+
expect(screen.getByText('Healthy')).toBeInTheDocument()
|
|
31
|
+
})
|
|
32
|
+
it('shows the empty message when isEmpty', () => {
|
|
33
|
+
render(<HealthCard title="X" status="neutral" isEmpty emptyMessage="Nothing here" />)
|
|
34
|
+
expect(screen.getByText('Nothing here')).toBeInTheDocument()
|
|
35
|
+
})
|
|
36
|
+
it('honors a custom status label', () => {
|
|
37
|
+
render(<HealthCard title="X" status="warn" statusLabel="3 stale" />)
|
|
38
|
+
expect(screen.getByText('3 stale')).toBeInTheDocument()
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('PipelineHealth', () => {
|
|
43
|
+
const stages = [
|
|
44
|
+
{ label: 'draft', count: 4 },
|
|
45
|
+
{ label: 'review', count: 2 },
|
|
46
|
+
{ label: 'ready', count: 1 },
|
|
47
|
+
]
|
|
48
|
+
it('renders every stage with its count', () => {
|
|
49
|
+
render(<PipelineHealth title="Briefs" stages={stages} />)
|
|
50
|
+
expect(screen.getByText('draft')).toBeInTheDocument()
|
|
51
|
+
expect(screen.getByText('4')).toBeInTheDocument()
|
|
52
|
+
expect(screen.getByText('7 total')).toBeInTheDocument()
|
|
53
|
+
})
|
|
54
|
+
it('flags attention items in the badge', () => {
|
|
55
|
+
render(<PipelineHealth stages={stages} attention={2} />)
|
|
56
|
+
expect(screen.getByText('2 need attention')).toBeInTheDocument()
|
|
57
|
+
})
|
|
58
|
+
it('renders the empty state when there are no records', () => {
|
|
59
|
+
render(<PipelineHealth stages={[{ label: 'draft', count: 0 }]} emptyMessage="No records yet." />)
|
|
60
|
+
expect(screen.getByText('No records yet.')).toBeInTheDocument()
|
|
61
|
+
})
|
|
62
|
+
it('calls onStageClick', () => {
|
|
63
|
+
const onClick = jest.fn()
|
|
64
|
+
render(<PipelineHealth stages={stages} onStageClick={onClick} />)
|
|
65
|
+
fireEvent.click(screen.getByText('review'))
|
|
66
|
+
expect(onClick).toHaveBeenCalledWith('review')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('SourceFreshness', () => {
|
|
71
|
+
it('reports all fresh when every source is recent', () => {
|
|
72
|
+
render(
|
|
73
|
+
<SourceFreshness
|
|
74
|
+
now={NOW}
|
|
75
|
+
staleAfterHours={48}
|
|
76
|
+
sources={[
|
|
77
|
+
{ name: 'RSS A', lastUpdated: hoursAgo(2) },
|
|
78
|
+
{ name: 'RSS B', lastUpdated: hoursAgo(10) },
|
|
79
|
+
]}
|
|
80
|
+
/>,
|
|
81
|
+
)
|
|
82
|
+
expect(screen.getByText('All fresh')).toBeInTheDocument()
|
|
83
|
+
})
|
|
84
|
+
it('counts stale sources and shows "never" for missing timestamps', () => {
|
|
85
|
+
render(
|
|
86
|
+
<SourceFreshness
|
|
87
|
+
now={NOW}
|
|
88
|
+
staleAfterHours={48}
|
|
89
|
+
sources={[
|
|
90
|
+
{ name: 'RSS A', lastUpdated: hoursAgo(2) },
|
|
91
|
+
{ name: 'RSS B', lastUpdated: hoursAgo(200) },
|
|
92
|
+
{ name: 'RSS C', lastUpdated: null },
|
|
93
|
+
]}
|
|
94
|
+
/>,
|
|
95
|
+
)
|
|
96
|
+
expect(screen.getByText('2 stale')).toBeInTheDocument()
|
|
97
|
+
expect(screen.getByText('never')).toBeInTheDocument()
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('DeliveryHealth', () => {
|
|
102
|
+
it('is on track when deliveries are on time', () => {
|
|
103
|
+
render(
|
|
104
|
+
<DeliveryHealth
|
|
105
|
+
now={NOW}
|
|
106
|
+
items={[{ label: 'Week 1', dueAt: hoursAgo(48), deliveredAt: hoursAgo(50) }]}
|
|
107
|
+
/>,
|
|
108
|
+
)
|
|
109
|
+
expect(screen.getByText('On track')).toBeInTheDocument()
|
|
110
|
+
})
|
|
111
|
+
it('flags missed and late deliveries', () => {
|
|
112
|
+
render(
|
|
113
|
+
<DeliveryHealth
|
|
114
|
+
now={NOW}
|
|
115
|
+
items={[
|
|
116
|
+
{ label: 'Week 1', dueAt: hoursAgo(10) }, // undelivered + past due => missed
|
|
117
|
+
{ label: 'Week 2', dueAt: hoursAhead(24) }, // upcoming
|
|
118
|
+
]}
|
|
119
|
+
/>,
|
|
120
|
+
)
|
|
121
|
+
expect(screen.getByText('1 off-track')).toBeInTheDocument()
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe('AttentionFeed', () => {
|
|
126
|
+
it('is all clear when empty', () => {
|
|
127
|
+
render(<AttentionFeed items={[]} />)
|
|
128
|
+
expect(screen.getByText('All clear')).toBeInTheDocument()
|
|
129
|
+
})
|
|
130
|
+
it('counts items to review and links them', () => {
|
|
131
|
+
render(
|
|
132
|
+
<AttentionFeed
|
|
133
|
+
items={[
|
|
134
|
+
{ id: '1', label: 'Draft needs review', href: '/t/general/1' },
|
|
135
|
+
{ id: '2', label: 'Stuck brief' },
|
|
136
|
+
]}
|
|
137
|
+
/>,
|
|
138
|
+
)
|
|
139
|
+
expect(screen.getByText('2 to review')).toBeInTheDocument()
|
|
140
|
+
expect(screen.getByRole('link', { name: /Draft needs review/ })).toHaveAttribute('href', '/t/general/1')
|
|
141
|
+
})
|
|
142
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CheckCircle2,
|
|
3
|
+
AlertTriangle,
|
|
4
|
+
AlertCircle,
|
|
5
|
+
CircleDashed,
|
|
6
|
+
type LucideIcon,
|
|
7
|
+
} from 'lucide-react'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Shared status vocabulary for the SystemHealth blocks (768w.16.8.2).
|
|
11
|
+
*
|
|
12
|
+
* Every health signal collapses to one of four tones. The tone is always
|
|
13
|
+
* encoded REDUNDANTLY — a colored dot AND an icon AND a text label — so the
|
|
14
|
+
* signal never depends on color alone (accessibility), matching the rest of the
|
|
15
|
+
* @startsimpli/ui dashboard kit's static semantic palette.
|
|
16
|
+
*/
|
|
17
|
+
export type HealthStatus = 'ok' | 'warn' | 'critical' | 'neutral'
|
|
18
|
+
|
|
19
|
+
export interface HealthTone {
|
|
20
|
+
/** Default human label for the tone (composers may override). */
|
|
21
|
+
label: string
|
|
22
|
+
/** Solid dot / bar fill. */
|
|
23
|
+
dot: string
|
|
24
|
+
/** Foreground text for the tone. */
|
|
25
|
+
text: string
|
|
26
|
+
/** Soft background for pills / accents. */
|
|
27
|
+
softBg: string
|
|
28
|
+
/** Left-accent border color for HealthCard. */
|
|
29
|
+
border: string
|
|
30
|
+
/** The lucide icon that stands in for the tone. */
|
|
31
|
+
Icon: LucideIcon
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const HEALTH_TONE: Record<HealthStatus, HealthTone> = {
|
|
35
|
+
ok: {
|
|
36
|
+
label: 'Healthy',
|
|
37
|
+
dot: 'bg-emerald-500',
|
|
38
|
+
text: 'text-emerald-700',
|
|
39
|
+
softBg: 'bg-emerald-50',
|
|
40
|
+
border: 'border-l-emerald-500',
|
|
41
|
+
Icon: CheckCircle2,
|
|
42
|
+
},
|
|
43
|
+
warn: {
|
|
44
|
+
label: 'Needs attention',
|
|
45
|
+
dot: 'bg-amber-500',
|
|
46
|
+
text: 'text-amber-700',
|
|
47
|
+
softBg: 'bg-amber-50',
|
|
48
|
+
border: 'border-l-amber-500',
|
|
49
|
+
Icon: AlertTriangle,
|
|
50
|
+
},
|
|
51
|
+
critical: {
|
|
52
|
+
label: 'Critical',
|
|
53
|
+
dot: 'bg-red-500',
|
|
54
|
+
text: 'text-red-700',
|
|
55
|
+
softBg: 'bg-red-50',
|
|
56
|
+
border: 'border-l-red-500',
|
|
57
|
+
Icon: AlertCircle,
|
|
58
|
+
},
|
|
59
|
+
neutral: {
|
|
60
|
+
label: 'No data',
|
|
61
|
+
dot: 'bg-gray-400',
|
|
62
|
+
text: 'text-gray-600',
|
|
63
|
+
softBg: 'bg-gray-50',
|
|
64
|
+
border: 'border-l-gray-300',
|
|
65
|
+
Icon: CircleDashed,
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const SEVERITY: Record<HealthStatus, number> = { neutral: 0, ok: 1, warn: 2, critical: 3 }
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Roll several signals up to the single worst tone — the accent a HealthCard
|
|
73
|
+
* shows. `critical` beats `warn` beats `ok`; `neutral` (no data) only wins when
|
|
74
|
+
* there is nothing else to report.
|
|
75
|
+
*/
|
|
76
|
+
export function worstStatus(statuses: HealthStatus[]): HealthStatus {
|
|
77
|
+
if (statuses.length === 0) return 'neutral'
|
|
78
|
+
return statuses.reduce((worst, s) => (SEVERITY[s] > SEVERITY[worst] ? s : worst), 'neutral')
|
|
79
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SystemHealth blocks (768w.16.8.2) — presentational, bring-your-own-data cards
|
|
3
|
+
* that bubble up the health of a data-driven tenant app: pipeline status, source
|
|
4
|
+
* freshness, delivery/schedule adherence, and a needs-attention queue. A tenant
|
|
5
|
+
* dashboard widget fetches the data and hands each block plain props; the blocks
|
|
6
|
+
* own the tone roll-up + layout so the app wrapper stays thin.
|
|
7
|
+
*/
|
|
8
|
+
export { HealthCard, HealthDot, HealthBadge } from './HealthCard'
|
|
9
|
+
export type { HealthCardProps } from './HealthCard'
|
|
10
|
+
|
|
11
|
+
export { HEALTH_TONE, worstStatus } from './health-status'
|
|
12
|
+
export type { HealthStatus, HealthTone } from './health-status'
|
|
13
|
+
|
|
14
|
+
export { PipelineHealth } from './PipelineHealth'
|
|
15
|
+
export type { PipelineHealthProps, PipelineStage } from './PipelineHealth'
|
|
16
|
+
|
|
17
|
+
export { SourceFreshness } from './SourceFreshness'
|
|
18
|
+
export type { SourceFreshnessProps, SourceFreshnessItem } from './SourceFreshness'
|
|
19
|
+
|
|
20
|
+
export { DeliveryHealth } from './DeliveryHealth'
|
|
21
|
+
export type { DeliveryHealthProps, DeliveryItem, DeliveryState } from './DeliveryHealth'
|
|
22
|
+
|
|
23
|
+
export { AttentionFeed } from './AttentionFeed'
|
|
24
|
+
export type { AttentionFeedProps, AttentionItem } from './AttentionFeed'
|
package/src/components/index.ts
CHANGED
|
@@ -53,6 +53,7 @@ export * from './unified-table/utils'
|
|
|
53
53
|
// Export Navigation
|
|
54
54
|
export * from './navigation/sidebar'
|
|
55
55
|
export * from './navigation/app-shell'
|
|
56
|
+
export * from './navigation/grouped-nav'
|
|
56
57
|
|
|
57
58
|
// Workspace layout primitives (SplitPane, DualPaneWorkspace) — generic two-pane shell
|
|
58
59
|
export * from './workspace'
|
|
@@ -121,6 +122,9 @@ export * from './email-dialogs'
|
|
|
121
122
|
// Dashboard components (MetricCard, PeriodSelector, SparklineTrend, DashboardGrid, DashboardSection)
|
|
122
123
|
export * from './dashboard'
|
|
123
124
|
|
|
125
|
+
// SystemHealth blocks (HealthCard, PipelineHealth, SourceFreshness, DeliveryHealth, AttentionFeed)
|
|
126
|
+
export * from './health'
|
|
127
|
+
|
|
124
128
|
// Enrichment components (QualityBadge, EnrichButton, EnrichmentProgress)
|
|
125
129
|
export * from './enrichment'
|
|
126
130
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
2
|
+
import { GroupedNav, type GroupedNavEntry } from '../grouped-nav'
|
|
3
|
+
|
|
4
|
+
const items: GroupedNavEntry[] = [
|
|
5
|
+
{ href: '/', label: 'Dashboard' },
|
|
6
|
+
{
|
|
7
|
+
label: 'Content',
|
|
8
|
+
items: [
|
|
9
|
+
{ href: '/t/weekly_brief', label: 'Weekly Briefs' },
|
|
10
|
+
{ href: '/t/lead_magnet', label: 'Lead Magnets' },
|
|
11
|
+
{ href: '/t/general', label: 'General' },
|
|
12
|
+
],
|
|
13
|
+
},
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
describe('GroupedNav', () => {
|
|
17
|
+
it('renders top-level links and grouped links', () => {
|
|
18
|
+
render(<GroupedNav items={items} activeHref="/" />)
|
|
19
|
+
expect(screen.getByText('Dashboard')).toBeInTheDocument()
|
|
20
|
+
expect(screen.getByText('Content')).toBeInTheDocument()
|
|
21
|
+
expect(screen.getByText('Weekly Briefs')).toBeInTheDocument()
|
|
22
|
+
expect(screen.getByText('General')).toBeInTheDocument()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('marks the active link with aria-current=page', () => {
|
|
26
|
+
render(<GroupedNav items={items} activeHref="/t/lead_magnet" />)
|
|
27
|
+
expect(screen.getByRole('link', { name: 'Lead Magnets' })).toHaveAttribute('aria-current', 'page')
|
|
28
|
+
expect(screen.getByRole('link', { name: 'Weekly Briefs' })).not.toHaveAttribute('aria-current')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('treats a child path as active for its link', () => {
|
|
32
|
+
render(<GroupedNav items={items} activeHref="/t/general/42" />)
|
|
33
|
+
expect(screen.getByRole('link', { name: 'General' })).toHaveAttribute('aria-current', 'page')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('collapses and expands a group on heading click', () => {
|
|
37
|
+
render(<GroupedNav items={items} activeHref="/" />)
|
|
38
|
+
expect(screen.getByText('Weekly Briefs')).toBeInTheDocument()
|
|
39
|
+
fireEvent.click(screen.getByRole('button', { name: /Content/ }))
|
|
40
|
+
expect(screen.queryByText('Weekly Briefs')).not.toBeInTheDocument()
|
|
41
|
+
fireEvent.click(screen.getByRole('button', { name: /Content/ }))
|
|
42
|
+
expect(screen.getByText('Weekly Briefs')).toBeInTheDocument()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('forces a collapsed group open when one of its children is active', () => {
|
|
46
|
+
const collapsed: GroupedNavEntry[] = [
|
|
47
|
+
{ label: 'Content', defaultOpen: false, items: [{ href: '/t/general', label: 'General' }] },
|
|
48
|
+
]
|
|
49
|
+
render(<GroupedNav items={collapsed} activeHref="/t/general" />)
|
|
50
|
+
// defaultOpen=false but the active child forces it visible
|
|
51
|
+
expect(screen.getByText('General')).toBeInTheDocument()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('uses a custom renderLink (e.g. a router Link)', () => {
|
|
55
|
+
render(
|
|
56
|
+
<GroupedNav
|
|
57
|
+
items={items}
|
|
58
|
+
activeHref="/"
|
|
59
|
+
renderLink={({ link, active, className }) => (
|
|
60
|
+
<a href={link.href} className={className} data-active={active} data-router-link="1">
|
|
61
|
+
{link.label}
|
|
62
|
+
</a>
|
|
63
|
+
)}
|
|
64
|
+
/>,
|
|
65
|
+
)
|
|
66
|
+
const link = screen.getByText('Weekly Briefs')
|
|
67
|
+
expect(link).toHaveAttribute('data-router-link', '1')
|
|
68
|
+
})
|
|
69
|
+
})
|