@startsimpli/ui 0.4.49 → 0.4.51
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 +1 -1
- package/src/catalog/catalog.ts +46 -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
- package/src/components/record-detail/RecordDetail.tsx +36 -14
package/package.json
CHANGED
package/src/catalog/catalog.ts
CHANGED
|
@@ -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',
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cn } from '../../lib/utils'
|
|
3
|
+
import { Inbox } 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 AttentionItem {
|
|
9
|
+
id: string
|
|
10
|
+
label: string
|
|
11
|
+
/** Optional context line, e.g. "Weekly brief · awaiting review". */
|
|
12
|
+
meta?: string
|
|
13
|
+
/** Link target; when set the row is a link. */
|
|
14
|
+
href?: string
|
|
15
|
+
/** Row tone (default warn — it's in the attention queue for a reason). */
|
|
16
|
+
tone?: HealthStatus
|
|
17
|
+
/** Timestamp shown as relative time on the right. */
|
|
18
|
+
at?: string | Date | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface AttentionFeedProps {
|
|
22
|
+
title?: string
|
|
23
|
+
items: AttentionItem[]
|
|
24
|
+
limit?: number
|
|
25
|
+
emptyMessage?: string
|
|
26
|
+
className?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The "needs a human" queue: records flagged for review across every type. Empty
|
|
31
|
+
* is the healthy state (all clear); anything in the queue pulls the card to warn
|
|
32
|
+
* (or worse, per item tone). Presentational — a tenant widget collects the items
|
|
33
|
+
* from whatever it considers actionable (stuck records, drafts awaiting review…).
|
|
34
|
+
*/
|
|
35
|
+
export function AttentionFeed({
|
|
36
|
+
title = 'Needs attention',
|
|
37
|
+
items,
|
|
38
|
+
limit,
|
|
39
|
+
emptyMessage = 'All clear — nothing needs review.',
|
|
40
|
+
className,
|
|
41
|
+
}: AttentionFeedProps) {
|
|
42
|
+
const status: HealthStatus =
|
|
43
|
+
items.length === 0 ? 'ok' : worstStatus(items.map((it) => it.tone ?? 'warn'))
|
|
44
|
+
const statusLabel = items.length === 0 ? 'All clear' : `${items.length} to review`
|
|
45
|
+
const listed = limit ? items.slice(0, limit) : items
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<HealthCard
|
|
49
|
+
title={title}
|
|
50
|
+
status={status}
|
|
51
|
+
statusLabel={statusLabel}
|
|
52
|
+
icon={Inbox}
|
|
53
|
+
isEmpty={items.length === 0}
|
|
54
|
+
emptyMessage={emptyMessage}
|
|
55
|
+
className={className}
|
|
56
|
+
>
|
|
57
|
+
<ul className="divide-y divide-gray-100" aria-label={`${title} items`}>
|
|
58
|
+
{listed.map((item) => {
|
|
59
|
+
const body = (
|
|
60
|
+
<span className="flex items-start justify-between gap-3 py-2">
|
|
61
|
+
<span className="flex items-start gap-2 min-w-0">
|
|
62
|
+
<HealthDot status={item.tone ?? 'warn'} className="mt-1.5" />
|
|
63
|
+
<span className="min-w-0">
|
|
64
|
+
<span className="block text-sm text-gray-900 truncate">{item.label}</span>
|
|
65
|
+
{item.meta && <span className="block text-xs text-gray-500 truncate">{item.meta}</span>}
|
|
66
|
+
</span>
|
|
67
|
+
</span>
|
|
68
|
+
{item.at && (
|
|
69
|
+
<span className="text-xs text-gray-400 tabular-nums flex-shrink-0 pt-0.5">
|
|
70
|
+
{formatRelativeTime(item.at)}
|
|
71
|
+
</span>
|
|
72
|
+
)}
|
|
73
|
+
</span>
|
|
74
|
+
)
|
|
75
|
+
return (
|
|
76
|
+
<li key={item.id}>
|
|
77
|
+
{item.href ? (
|
|
78
|
+
<a
|
|
79
|
+
href={item.href}
|
|
80
|
+
className={cn(
|
|
81
|
+
'block rounded px-1 -mx-1 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500',
|
|
82
|
+
)}
|
|
83
|
+
>
|
|
84
|
+
{body}
|
|
85
|
+
</a>
|
|
86
|
+
) : (
|
|
87
|
+
body
|
|
88
|
+
)}
|
|
89
|
+
</li>
|
|
90
|
+
)
|
|
91
|
+
})}
|
|
92
|
+
</ul>
|
|
93
|
+
</HealthCard>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cn } from '../../lib/utils'
|
|
3
|
+
import { CalendarClock } 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 type DeliveryState = 'on_time' | 'upcoming' | 'late' | 'missed'
|
|
9
|
+
|
|
10
|
+
export interface DeliveryItem {
|
|
11
|
+
label: string
|
|
12
|
+
/** Explicit state; otherwise derived from due/delivered vs now. */
|
|
13
|
+
status?: DeliveryState
|
|
14
|
+
dueAt?: string | Date | null
|
|
15
|
+
deliveredAt?: string | Date | null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DeliveryHealthProps {
|
|
19
|
+
title?: string
|
|
20
|
+
items: DeliveryItem[]
|
|
21
|
+
/** Compare against this instant (injectable for tests); defaults to Date.now(). */
|
|
22
|
+
now?: number
|
|
23
|
+
/** Cap the number of rows listed (summary counts still cover all). */
|
|
24
|
+
limit?: number
|
|
25
|
+
emptyMessage?: string
|
|
26
|
+
className?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const STATE_TONE: Record<DeliveryState, HealthStatus> = {
|
|
30
|
+
on_time: 'ok',
|
|
31
|
+
upcoming: 'neutral',
|
|
32
|
+
late: 'warn',
|
|
33
|
+
missed: 'critical',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const STATE_LABEL: Record<DeliveryState, string> = {
|
|
37
|
+
on_time: 'On time',
|
|
38
|
+
upcoming: 'Upcoming',
|
|
39
|
+
late: 'Late',
|
|
40
|
+
missed: 'Missed',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function deriveState(item: DeliveryItem, now: number): DeliveryState {
|
|
44
|
+
if (item.status) return item.status
|
|
45
|
+
const due = item.dueAt ? new Date(item.dueAt).getTime() : null
|
|
46
|
+
if (item.deliveredAt) {
|
|
47
|
+
const delivered = new Date(item.deliveredAt).getTime()
|
|
48
|
+
return due !== null && delivered > due ? 'late' : 'on_time'
|
|
49
|
+
}
|
|
50
|
+
if (due === null) return 'upcoming'
|
|
51
|
+
return due < now ? 'missed' : 'upcoming'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Delivery / schedule health: were scheduled deliverables (e.g. weekly briefs)
|
|
56
|
+
* shipped on time? Missed/late deliveries drive the card critical/warn; upcoming
|
|
57
|
+
* ones are informational. Presentational — a tenant widget derives states from a
|
|
58
|
+
* type's scheduled_for / delivered_at fields.
|
|
59
|
+
*/
|
|
60
|
+
export function DeliveryHealth({
|
|
61
|
+
title = 'Delivery',
|
|
62
|
+
items,
|
|
63
|
+
now,
|
|
64
|
+
limit,
|
|
65
|
+
emptyMessage = 'Nothing scheduled.',
|
|
66
|
+
className,
|
|
67
|
+
}: DeliveryHealthProps) {
|
|
68
|
+
const at = now ?? Date.now()
|
|
69
|
+
const rows = items.map((it) => ({ ...it, state: deriveState(it, at) }))
|
|
70
|
+
const counts = rows.reduce(
|
|
71
|
+
(acc, r) => ((acc[r.state] += 1), acc),
|
|
72
|
+
{ on_time: 0, upcoming: 0, late: 0, missed: 0 } as Record<DeliveryState, number>,
|
|
73
|
+
)
|
|
74
|
+
const status = worstStatus(rows.map((r) => STATE_TONE[r.state]))
|
|
75
|
+
const problems = counts.late + counts.missed
|
|
76
|
+
const statusLabel =
|
|
77
|
+
items.length === 0 ? undefined : problems > 0 ? `${problems} off-track` : 'On track'
|
|
78
|
+
|
|
79
|
+
const listed = limit ? rows.slice(0, limit) : rows
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<HealthCard
|
|
83
|
+
title={title}
|
|
84
|
+
status={status}
|
|
85
|
+
statusLabel={statusLabel}
|
|
86
|
+
icon={CalendarClock}
|
|
87
|
+
isEmpty={items.length === 0}
|
|
88
|
+
emptyMessage={emptyMessage}
|
|
89
|
+
className={className}
|
|
90
|
+
>
|
|
91
|
+
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600">
|
|
92
|
+
{(['on_time', 'late', 'missed', 'upcoming'] as DeliveryState[])
|
|
93
|
+
.filter((s) => counts[s] > 0)
|
|
94
|
+
.map((s) => (
|
|
95
|
+
<span key={s} className="inline-flex items-center gap-1.5">
|
|
96
|
+
<HealthDot status={STATE_TONE[s]} />
|
|
97
|
+
<span className="tabular-nums font-medium text-gray-900">{counts[s]}</span> {STATE_LABEL[s]}
|
|
98
|
+
</span>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<ul className="space-y-2" aria-label={`${title} items`}>
|
|
103
|
+
{listed.map((row, i) => (
|
|
104
|
+
<li key={`${row.label}-${i}`} className="flex items-center justify-between gap-3 text-sm">
|
|
105
|
+
<span className="flex items-center gap-2 min-w-0">
|
|
106
|
+
<HealthDot status={STATE_TONE[row.state]} />
|
|
107
|
+
<span className="text-gray-700 truncate">{row.label}</span>
|
|
108
|
+
</span>
|
|
109
|
+
<span
|
|
110
|
+
className={cn(
|
|
111
|
+
'text-xs tabular-nums flex-shrink-0',
|
|
112
|
+
STATE_TONE[row.state] === 'ok' ? 'text-gray-500' : 'text-gray-900 font-medium',
|
|
113
|
+
)}
|
|
114
|
+
>
|
|
115
|
+
{STATE_LABEL[row.state]}
|
|
116
|
+
{row.deliveredAt
|
|
117
|
+
? ` · ${formatRelativeTime(row.deliveredAt)}`
|
|
118
|
+
: row.dueAt
|
|
119
|
+
? ` · ${formatRelativeTime(row.dueAt)}`
|
|
120
|
+
: ''}
|
|
121
|
+
</span>
|
|
122
|
+
</li>
|
|
123
|
+
))}
|
|
124
|
+
</ul>
|
|
125
|
+
</HealthCard>
|
|
126
|
+
)
|
|
127
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cn } from '../../lib/utils'
|
|
3
|
+
import { HEALTH_TONE, type HealthStatus } from './health-status'
|
|
4
|
+
|
|
5
|
+
/** A small solid status dot. Pair it with a label — never rely on color alone. */
|
|
6
|
+
export function HealthDot({ status, className }: { status: HealthStatus; className?: string }) {
|
|
7
|
+
return (
|
|
8
|
+
<span
|
|
9
|
+
className={cn('inline-block w-2.5 h-2.5 rounded-full flex-shrink-0', HEALTH_TONE[status].dot, className)}
|
|
10
|
+
aria-hidden="true"
|
|
11
|
+
/>
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A tone pill: icon + label, used in a HealthCard header to name the roll-up state. */
|
|
16
|
+
export function HealthBadge({
|
|
17
|
+
status,
|
|
18
|
+
label,
|
|
19
|
+
className,
|
|
20
|
+
}: {
|
|
21
|
+
status: HealthStatus
|
|
22
|
+
label?: string
|
|
23
|
+
className?: string
|
|
24
|
+
}) {
|
|
25
|
+
const tone = HEALTH_TONE[status]
|
|
26
|
+
const Icon = tone.Icon
|
|
27
|
+
return (
|
|
28
|
+
<span
|
|
29
|
+
className={cn(
|
|
30
|
+
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium',
|
|
31
|
+
tone.softBg,
|
|
32
|
+
tone.text,
|
|
33
|
+
className,
|
|
34
|
+
)}
|
|
35
|
+
>
|
|
36
|
+
<Icon className="w-3.5 h-3.5" aria-hidden="true" />
|
|
37
|
+
{label ?? tone.label}
|
|
38
|
+
</span>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface HealthCardProps {
|
|
43
|
+
/** Card heading. */
|
|
44
|
+
title: string
|
|
45
|
+
/** The rolled-up tone — drives the left accent + the header badge. */
|
|
46
|
+
status: HealthStatus
|
|
47
|
+
/** Optional short label overriding the tone's default badge text (e.g. "3 stale"). */
|
|
48
|
+
statusLabel?: string
|
|
49
|
+
/** Optional leading icon for the title. */
|
|
50
|
+
icon?: React.ComponentType<{ className?: string }>
|
|
51
|
+
/** Optional right-aligned header slot (a link, a period selector…). */
|
|
52
|
+
action?: React.ReactNode
|
|
53
|
+
/** Body — the signal-specific content. */
|
|
54
|
+
children?: React.ReactNode
|
|
55
|
+
/** Shown (muted, centered) when there is nothing to report. */
|
|
56
|
+
emptyMessage?: string
|
|
57
|
+
/** Render the empty state instead of children. */
|
|
58
|
+
isEmpty?: boolean
|
|
59
|
+
isLoading?: boolean
|
|
60
|
+
className?: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The base surface every SystemHealth block renders into: a titled card with a
|
|
65
|
+
* left tone-accent and a status badge, so a wall of them reads as a scannable
|
|
66
|
+
* health board. Presentational only — the tone + contents are computed by the
|
|
67
|
+
* composer (or, in a tenant app, by the dashboard widget that fetches the data).
|
|
68
|
+
*/
|
|
69
|
+
export function HealthCard({
|
|
70
|
+
title,
|
|
71
|
+
status,
|
|
72
|
+
statusLabel,
|
|
73
|
+
icon: Icon,
|
|
74
|
+
action,
|
|
75
|
+
children,
|
|
76
|
+
emptyMessage = 'Nothing to report.',
|
|
77
|
+
isEmpty,
|
|
78
|
+
isLoading,
|
|
79
|
+
className,
|
|
80
|
+
}: HealthCardProps) {
|
|
81
|
+
return (
|
|
82
|
+
<div
|
|
83
|
+
className={cn(
|
|
84
|
+
'rounded-xl border border-l-4 bg-card text-card-foreground shadow p-5 space-y-4',
|
|
85
|
+
HEALTH_TONE[status].border,
|
|
86
|
+
isLoading && 'animate-pulse',
|
|
87
|
+
className,
|
|
88
|
+
)}
|
|
89
|
+
>
|
|
90
|
+
<div className="flex items-center justify-between gap-3">
|
|
91
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
92
|
+
{Icon && <Icon className="w-4 h-4 text-gray-400 flex-shrink-0" aria-hidden="true" />}
|
|
93
|
+
<h3 className="text-sm font-semibold text-gray-900 truncate">{title}</h3>
|
|
94
|
+
</div>
|
|
95
|
+
<div className="flex items-center gap-2 flex-shrink-0">
|
|
96
|
+
{action}
|
|
97
|
+
<HealthBadge status={status} label={statusLabel} />
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
{isEmpty ? (
|
|
102
|
+
<p className="text-sm text-gray-500 py-4 text-center">{emptyMessage}</p>
|
|
103
|
+
) : (
|
|
104
|
+
children
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { useState } from 'react'
|
|
5
|
+
import { ChevronDown } from 'lucide-react'
|
|
6
|
+
import { cn } from '../../lib/utils'
|
|
7
|
+
|
|
8
|
+
export interface GroupedNavLink {
|
|
9
|
+
href: string
|
|
10
|
+
label: string
|
|
11
|
+
icon?: React.ReactNode
|
|
12
|
+
/** Optional trailing slot (count pill, status dot…). */
|
|
13
|
+
badge?: React.ReactNode
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface GroupedNavGroup {
|
|
17
|
+
label: string
|
|
18
|
+
icon?: React.ReactNode
|
|
19
|
+
items: GroupedNavLink[]
|
|
20
|
+
/** Start expanded (default true). Ignored while a child is active — then forced open. */
|
|
21
|
+
defaultOpen?: boolean
|
|
22
|
+
/** Show a chevron toggle (default true). false = a static section heading. */
|
|
23
|
+
collapsible?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A top-level link, or a named group of links. Discriminated by `items`. */
|
|
27
|
+
export type GroupedNavEntry = GroupedNavLink | GroupedNavGroup
|
|
28
|
+
|
|
29
|
+
export interface GroupedNavRenderLinkArgs {
|
|
30
|
+
link: GroupedNavLink
|
|
31
|
+
active: boolean
|
|
32
|
+
className: string
|
|
33
|
+
/** The pre-composed inner content (icon + label + badge). */
|
|
34
|
+
content: React.ReactNode
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface GroupedNavProps {
|
|
38
|
+
items: GroupedNavEntry[]
|
|
39
|
+
/** The current path; drives active state. */
|
|
40
|
+
activeHref?: string
|
|
41
|
+
/** Override active matching (default: exact, or `activeHref` under `href/`). */
|
|
42
|
+
isActive?: (href: string, activeHref?: string) => boolean
|
|
43
|
+
/**
|
|
44
|
+
* Render a link — pass `next/link` here in a Next app. Default renders a plain
|
|
45
|
+
* `<a>` so the primitive stays framework-agnostic.
|
|
46
|
+
*/
|
|
47
|
+
renderLink?: (args: GroupedNavRenderLinkArgs) => React.ReactNode
|
|
48
|
+
className?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isGroup(entry: GroupedNavEntry): entry is GroupedNavGroup {
|
|
52
|
+
return Array.isArray((entry as GroupedNavGroup).items)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function defaultIsActive(href: string, activeHref?: string): boolean {
|
|
56
|
+
if (!activeHref) return false
|
|
57
|
+
if (href === '/') return activeHref === '/'
|
|
58
|
+
return activeHref === href || activeHref.startsWith(href + '/')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const defaultRenderLink = ({ link, active, className, content }: GroupedNavRenderLinkArgs) => (
|
|
62
|
+
<a href={link.href} className={className} aria-current={active ? 'page' : undefined}>
|
|
63
|
+
{content}
|
|
64
|
+
</a>
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
function linkClasses(active: boolean, indented: boolean) {
|
|
68
|
+
return cn(
|
|
69
|
+
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
|
70
|
+
indented && 'pl-9',
|
|
71
|
+
active
|
|
72
|
+
? 'bg-primary-600 text-white'
|
|
73
|
+
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900',
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A grouped sidebar navigation list: a mix of top-level links and collapsible
|
|
79
|
+
* groups (e.g. Dashboard + a "Content" group of the three content types). Purely
|
|
80
|
+
* the `<nav>` list — the app shell owns the logo, user footer, and chrome. Active
|
|
81
|
+
* state and link rendering are injectable so it drops into any router.
|
|
82
|
+
*/
|
|
83
|
+
export function GroupedNav({
|
|
84
|
+
items,
|
|
85
|
+
activeHref,
|
|
86
|
+
isActive = defaultIsActive,
|
|
87
|
+
renderLink = defaultRenderLink,
|
|
88
|
+
className,
|
|
89
|
+
}: GroupedNavProps) {
|
|
90
|
+
return (
|
|
91
|
+
<nav className={cn('space-y-1', className)} aria-label="Primary">
|
|
92
|
+
{items.map((entry, idx) =>
|
|
93
|
+
isGroup(entry) ? (
|
|
94
|
+
<NavGroup
|
|
95
|
+
key={`group-${entry.label}-${idx}`}
|
|
96
|
+
group={entry}
|
|
97
|
+
activeHref={activeHref}
|
|
98
|
+
isActive={isActive}
|
|
99
|
+
renderLink={renderLink}
|
|
100
|
+
/>
|
|
101
|
+
) : (
|
|
102
|
+
<NavLinkRow
|
|
103
|
+
key={entry.href}
|
|
104
|
+
link={entry}
|
|
105
|
+
active={isActive(entry.href, activeHref)}
|
|
106
|
+
indented={false}
|
|
107
|
+
renderLink={renderLink}
|
|
108
|
+
/>
|
|
109
|
+
),
|
|
110
|
+
)}
|
|
111
|
+
</nav>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function NavLinkRow({
|
|
116
|
+
link,
|
|
117
|
+
active,
|
|
118
|
+
indented,
|
|
119
|
+
renderLink,
|
|
120
|
+
}: {
|
|
121
|
+
link: GroupedNavLink
|
|
122
|
+
active: boolean
|
|
123
|
+
indented: boolean
|
|
124
|
+
renderLink: NonNullable<GroupedNavProps['renderLink']>
|
|
125
|
+
}) {
|
|
126
|
+
const content = (
|
|
127
|
+
<>
|
|
128
|
+
{link.icon ? <span className="flex-shrink-0 w-5 h-5 flex items-center justify-center">{link.icon}</span> : null}
|
|
129
|
+
<span className="flex-1 truncate">{link.label}</span>
|
|
130
|
+
{link.badge ? <span className="flex-shrink-0">{link.badge}</span> : null}
|
|
131
|
+
</>
|
|
132
|
+
)
|
|
133
|
+
return <>{renderLink({ link, active, className: linkClasses(active, indented), content })}</>
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function NavGroup({
|
|
137
|
+
group,
|
|
138
|
+
activeHref,
|
|
139
|
+
isActive,
|
|
140
|
+
renderLink,
|
|
141
|
+
}: {
|
|
142
|
+
group: GroupedNavGroup
|
|
143
|
+
activeHref?: string
|
|
144
|
+
isActive: NonNullable<GroupedNavProps['isActive']>
|
|
145
|
+
renderLink: NonNullable<GroupedNavProps['renderLink']>
|
|
146
|
+
}) {
|
|
147
|
+
const collapsible = group.collapsible ?? true
|
|
148
|
+
const hasActiveChild = group.items.some((l) => isActive(l.href, activeHref))
|
|
149
|
+
const [open, setOpen] = useState(group.defaultOpen ?? true)
|
|
150
|
+
const expanded = hasActiveChild || open
|
|
151
|
+
|
|
152
|
+
const links = expanded
|
|
153
|
+
? group.items.map((link) => (
|
|
154
|
+
<NavLinkRow
|
|
155
|
+
key={link.href}
|
|
156
|
+
link={link}
|
|
157
|
+
active={isActive(link.href, activeHref)}
|
|
158
|
+
indented
|
|
159
|
+
renderLink={renderLink}
|
|
160
|
+
/>
|
|
161
|
+
))
|
|
162
|
+
: null
|
|
163
|
+
|
|
164
|
+
const heading = (
|
|
165
|
+
<span className="flex items-center gap-2 min-w-0">
|
|
166
|
+
{group.icon ? <span className="flex-shrink-0 w-5 h-5 flex items-center justify-center">{group.icon}</span> : null}
|
|
167
|
+
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wider truncate">{group.label}</span>
|
|
168
|
+
</span>
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<div className="pt-3 first:pt-0">
|
|
173
|
+
{collapsible ? (
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
onClick={() => setOpen((o) => !o)}
|
|
177
|
+
aria-expanded={expanded}
|
|
178
|
+
className="w-full flex items-center justify-between gap-2 px-3 py-1.5 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
179
|
+
>
|
|
180
|
+
{heading}
|
|
181
|
+
<ChevronDown
|
|
182
|
+
className={cn('w-4 h-4 text-gray-400 transition-transform flex-shrink-0', expanded ? '' : '-rotate-90')}
|
|
183
|
+
aria-hidden="true"
|
|
184
|
+
/>
|
|
185
|
+
</button>
|
|
186
|
+
) : (
|
|
187
|
+
<div className="px-3 py-1.5">{heading}</div>
|
|
188
|
+
)}
|
|
189
|
+
{links && <div className="mt-1 space-y-1">{links}</div>}
|
|
190
|
+
</div>
|
|
191
|
+
)
|
|
192
|
+
}
|
|
@@ -15,10 +15,21 @@ export interface RecordDetailProps {
|
|
|
15
15
|
fields: RecordField[]
|
|
16
16
|
emptyMessage?: string
|
|
17
17
|
className?: string
|
|
18
|
+
/**
|
|
19
|
+
* Show declared fields even when they have no value — the header stays with a
|
|
20
|
+
* muted placeholder — instead of hiding them. Lets a reader tell that a field
|
|
21
|
+
* (e.g. `content`) exists on the record but simply wasn't captured, rather than
|
|
22
|
+
* wondering whether it's missing from the schema. Default false (hide empties).
|
|
23
|
+
*/
|
|
24
|
+
showEmpty?: boolean
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
const URL_RE = /^https?:\/\//i
|
|
21
28
|
|
|
29
|
+
function hasValue(f: RecordField): boolean {
|
|
30
|
+
return f.value != null && String(f.value).trim() !== ''
|
|
31
|
+
}
|
|
32
|
+
|
|
22
33
|
/**
|
|
23
34
|
* Read-optimized view of a record's fields (startsim-768w.17.4). Renders short
|
|
24
35
|
* fields as a compact label/value list (auto-linking urls), long-text fields as
|
|
@@ -27,13 +38,13 @@ const URL_RE = /^https?:\/\//i
|
|
|
27
38
|
* `fields` and this owns the layout, so every Foundry tenant's detail drawer reads
|
|
28
39
|
* the same without duplicating the view.
|
|
29
40
|
*/
|
|
30
|
-
export function RecordDetail({ fields, emptyMessage = 'No details.', className }: RecordDetailProps) {
|
|
31
|
-
const
|
|
32
|
-
const shorts =
|
|
33
|
-
const longs =
|
|
34
|
-
const jsons =
|
|
41
|
+
export function RecordDetail({ fields, emptyMessage = 'No details.', className, showEmpty = false }: RecordDetailProps) {
|
|
42
|
+
const visible = showEmpty ? fields : fields.filter(hasValue)
|
|
43
|
+
const shorts = visible.filter((f) => f.kind !== 'longtext' && f.kind !== 'json')
|
|
44
|
+
const longs = visible.filter((f) => f.kind === 'longtext')
|
|
45
|
+
const jsons = visible.filter((f) => f.kind === 'json')
|
|
35
46
|
|
|
36
|
-
if (
|
|
47
|
+
if (visible.length === 0) {
|
|
37
48
|
return <p className={cn('text-sm text-muted-foreground', className)}>{emptyMessage}</p>
|
|
38
49
|
}
|
|
39
50
|
|
|
@@ -42,12 +53,15 @@ export function RecordDetail({ fields, emptyMessage = 'No details.', className }
|
|
|
42
53
|
{shorts.length > 0 && (
|
|
43
54
|
<dl className="space-y-1.5">
|
|
44
55
|
{shorts.map((f, i) => {
|
|
45
|
-
const
|
|
56
|
+
const empty = !hasValue(f)
|
|
57
|
+
const isUrl = !empty && (f.kind === 'url' || URL_RE.test(f.value))
|
|
46
58
|
return (
|
|
47
59
|
<div key={i} className="flex gap-2 text-sm">
|
|
48
60
|
<dt className="w-28 shrink-0 capitalize text-muted-foreground">{f.label}</dt>
|
|
49
61
|
<dd className="min-w-0 flex-1 break-words">
|
|
50
|
-
{
|
|
62
|
+
{empty ? (
|
|
63
|
+
<span className="text-muted-foreground">—</span>
|
|
64
|
+
) : isUrl ? (
|
|
51
65
|
<a
|
|
52
66
|
href={f.value}
|
|
53
67
|
target="_blank"
|
|
@@ -71,9 +85,13 @@ export function RecordDetail({ fields, emptyMessage = 'No details.', className }
|
|
|
71
85
|
<h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
72
86
|
{f.label}
|
|
73
87
|
</h4>
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
88
|
+
{hasValue(f) ? (
|
|
89
|
+
<div className="whitespace-pre-wrap text-sm leading-relaxed text-foreground">
|
|
90
|
+
{f.value}
|
|
91
|
+
</div>
|
|
92
|
+
) : (
|
|
93
|
+
<p className="text-sm italic text-muted-foreground">Not captured.</p>
|
|
94
|
+
)}
|
|
77
95
|
</section>
|
|
78
96
|
))}
|
|
79
97
|
|
|
@@ -82,9 +100,13 @@ export function RecordDetail({ fields, emptyMessage = 'No details.', className }
|
|
|
82
100
|
<h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
83
101
|
{f.label}
|
|
84
102
|
</h4>
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
103
|
+
{hasValue(f) ? (
|
|
104
|
+
<pre className="overflow-x-auto rounded-md bg-muted p-3 text-xs leading-relaxed">
|
|
105
|
+
{f.value}
|
|
106
|
+
</pre>
|
|
107
|
+
) : (
|
|
108
|
+
<p className="text-sm italic text-muted-foreground">Not captured.</p>
|
|
109
|
+
)}
|
|
88
110
|
</section>
|
|
89
111
|
))}
|
|
90
112
|
</div>
|