@startsimpli/ui 0.4.42 → 0.4.44
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 +8 -4
- package/src/catalog/catalog.ts +29 -0
- package/src/components/__tests__/conversation-timeline.test.tsx +56 -0
- package/src/components/__tests__/diff-viewer.test.tsx +101 -0
- package/src/components/__tests__/session-components.test.tsx +184 -0
- package/src/components/diff/DiffViewer.tsx +235 -0
- package/src/components/diff/index.ts +2 -0
- package/src/components/index.ts +15 -0
- package/src/components/kanban/KanbanBoard.tsx +168 -59
- package/src/components/kanban/__tests__/kanban.test.tsx +64 -1
- package/src/components/kanban/index.ts +2 -2
- package/src/components/record-detail/RecordDetail.tsx +92 -0
- package/src/components/record-detail/__tests__/record-detail.test.tsx +42 -0
- package/src/components/record-detail/index.ts +2 -0
- package/src/components/session/AgentSessionConsole.tsx +200 -0
- package/src/components/session/ConversationTimeline.tsx +131 -0
- package/src/components/session/SessionCard.tsx +194 -0
- package/src/components/session/SessionList.tsx +271 -0
- package/src/components/session/SessionStatusBadge.tsx +55 -0
- package/src/components/session/index.ts +23 -0
- package/src/components/session/status.ts +80 -0
- package/src/components/session/types.ts +29 -0
- package/src/components/terminal/Terminal.tsx +369 -0
- package/src/components/terminal/index.ts +4 -0
- package/src/components/terminal/themes.ts +91 -0
- package/src/components/terminal/xterm.css +8 -0
package/src/components/index.ts
CHANGED
|
@@ -60,6 +60,18 @@ export * from './workspace'
|
|
|
60
60
|
// Chat surface primitives (ChatThread, ChatMessage, ChatComposer)
|
|
61
61
|
export * from './chat'
|
|
62
62
|
|
|
63
|
+
// Terminal (xterm.js) — generic presentational terminal + skeleton + themes
|
|
64
|
+
// (startsim-768w.16.7.E). Import styles from '@startsimpli/ui/terminal/styles'.
|
|
65
|
+
export * from './terminal'
|
|
66
|
+
|
|
67
|
+
// Diff viewer — presentational unified-diff renderer (no fetching)
|
|
68
|
+
export * from './diff'
|
|
69
|
+
|
|
70
|
+
// Agent-session surfaces (startsim-768w.16.7.E): SessionCard/List/StatusBadge,
|
|
71
|
+
// ConversationTimeline, and the live AgentSessionConsole (driven by
|
|
72
|
+
// @startsimpli/realtime/react). All generic — decoupled from any app model.
|
|
73
|
+
export * from './session'
|
|
74
|
+
|
|
63
75
|
// Slide-deck viewer (Google-Slides-style canvas + filmstrip)
|
|
64
76
|
export * from './slide-deck'
|
|
65
77
|
|
|
@@ -147,3 +159,6 @@ export type {
|
|
|
147
159
|
ActivityTypeOption,
|
|
148
160
|
OutcomeOption,
|
|
149
161
|
} from './LogActivityDialog'
|
|
162
|
+
|
|
163
|
+
// Record detail read-view
|
|
164
|
+
export * from './record-detail'
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
|
+
import {
|
|
5
|
+
DragDropContext,
|
|
6
|
+
Droppable,
|
|
7
|
+
Draggable,
|
|
8
|
+
type DropResult,
|
|
9
|
+
} from '@hello-pangea/dnd'
|
|
4
10
|
import { cn } from '../../lib/utils'
|
|
5
11
|
|
|
6
12
|
export interface KanbanColumnConfig {
|
|
@@ -9,6 +15,36 @@ export interface KanbanColumnConfig {
|
|
|
9
15
|
color?: string
|
|
10
16
|
}
|
|
11
17
|
|
|
18
|
+
/** A resolved card move derived from a drag, in board terms (not DnD terms). */
|
|
19
|
+
export interface KanbanMove {
|
|
20
|
+
cardId: string
|
|
21
|
+
fromColumnId: string
|
|
22
|
+
toColumnId: string
|
|
23
|
+
toIndex: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Pure resolver: turn a @hello-pangea/dnd DropResult into a KanbanMove, or null
|
|
28
|
+
* when the drag is a no-op (dropped outside any column, or back onto the same slot).
|
|
29
|
+
* Exported so consumers and tests can reason about a move without a DOM.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveKanbanMove(result: DropResult): KanbanMove | null {
|
|
32
|
+
const { draggableId, source, destination } = result
|
|
33
|
+
if (!destination) return null
|
|
34
|
+
if (
|
|
35
|
+
source.droppableId === destination.droppableId &&
|
|
36
|
+
source.index === destination.index
|
|
37
|
+
) {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
cardId: draggableId,
|
|
42
|
+
fromColumnId: source.droppableId,
|
|
43
|
+
toColumnId: destination.droppableId,
|
|
44
|
+
toIndex: destination.index,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
12
48
|
export interface KanbanBoardProps<T> {
|
|
13
49
|
columns: KanbanColumnConfig[]
|
|
14
50
|
items: Record<string, T[]>
|
|
@@ -20,6 +56,15 @@ export interface KanbanBoardProps<T> {
|
|
|
20
56
|
columnRef?: (columnId: string) => React.Ref<HTMLDivElement> | undefined
|
|
21
57
|
isColumnOver?: (columnId: string) => boolean
|
|
22
58
|
className?: string
|
|
59
|
+
/**
|
|
60
|
+
* DnD (opt-in). Provide BOTH `getCardId` and `onCardMove` to make cards draggable
|
|
61
|
+
* between columns via @hello-pangea/dnd — all drag mechanics live here in
|
|
62
|
+
* @startsimpli/ui; consumers stay dnd-agnostic and just persist the move in
|
|
63
|
+
* `onCardMove`. Omit either and the board renders exactly as before (no DnD;
|
|
64
|
+
* `columnRef`/`isColumnOver` honored for a parent's own manual wiring).
|
|
65
|
+
*/
|
|
66
|
+
getCardId?: (item: T) => string
|
|
67
|
+
onCardMove?: (move: KanbanMove) => void
|
|
23
68
|
}
|
|
24
69
|
|
|
25
70
|
export function KanbanBoard<T>({
|
|
@@ -33,71 +78,135 @@ export function KanbanBoard<T>({
|
|
|
33
78
|
columnRef,
|
|
34
79
|
isColumnOver,
|
|
35
80
|
className,
|
|
81
|
+
getCardId,
|
|
82
|
+
onCardMove,
|
|
36
83
|
}: KanbanBoardProps<T>) {
|
|
37
|
-
|
|
38
|
-
<div className={cn('flex gap-4 overflow-x-auto pb-4', className)}>
|
|
39
|
-
{columns.map((column) => {
|
|
40
|
-
const columnItems = items[column.id] ?? []
|
|
41
|
-
const ref = columnRef?.(column.id)
|
|
42
|
-
const isOver = isColumnOver?.(column.id) ?? false
|
|
84
|
+
const dndEnabled = !!(getCardId && onCardMove)
|
|
43
85
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
86
|
+
const handleDragEnd = React.useCallback(
|
|
87
|
+
(result: DropResult) => {
|
|
88
|
+
const move = resolveKanbanMove(result)
|
|
89
|
+
if (move) onCardMove?.(move)
|
|
90
|
+
},
|
|
91
|
+
[onCardMove],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
const columnHeader = (column: KanbanColumnConfig, columnItems: T[]) =>
|
|
95
|
+
renderColumnHeader ? (
|
|
96
|
+
renderColumnHeader(column, columnItems)
|
|
97
|
+
) : (
|
|
98
|
+
<div className="flex items-center justify-between px-3 py-2 border-b">
|
|
99
|
+
<div className="flex items-center gap-2">
|
|
100
|
+
{column.color && (
|
|
101
|
+
<span
|
|
102
|
+
className="w-2.5 h-2.5 rounded-full shrink-0"
|
|
103
|
+
style={{ backgroundColor: column.color }}
|
|
104
|
+
aria-hidden="true"
|
|
105
|
+
/>
|
|
106
|
+
)}
|
|
107
|
+
<h3 className="text-sm font-semibold text-foreground truncate">
|
|
108
|
+
{column.label}
|
|
109
|
+
</h3>
|
|
110
|
+
</div>
|
|
111
|
+
<span className="inline-flex items-center justify-center rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
|
112
|
+
{columnItems.length}
|
|
113
|
+
</span>
|
|
114
|
+
</div>
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
const emptyState = (
|
|
118
|
+
<p className="text-xs text-muted-foreground text-center py-8">
|
|
119
|
+
{emptyColumnMessage}
|
|
120
|
+
</p>
|
|
121
|
+
)
|
|
76
122
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
</p>
|
|
83
|
-
) : (
|
|
84
|
-
columnItems.map((item, index) => (
|
|
85
|
-
<React.Fragment key={index}>
|
|
86
|
-
{renderCard(item)}
|
|
87
|
-
</React.Fragment>
|
|
88
|
-
))
|
|
89
|
-
)}
|
|
90
|
-
</div>
|
|
123
|
+
const renderColumn = (column: KanbanColumnConfig) => {
|
|
124
|
+
const columnItems = items[column.id] ?? []
|
|
125
|
+
const footer = renderColumnFooter && (
|
|
126
|
+
<div className="border-t px-3 py-2">{renderColumnFooter(column)}</div>
|
|
127
|
+
)
|
|
91
128
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
129
|
+
if (dndEnabled) {
|
|
130
|
+
return (
|
|
131
|
+
<div
|
|
132
|
+
key={column.id}
|
|
133
|
+
style={{ minWidth: columnWidth, maxWidth: columnWidth }}
|
|
134
|
+
className="flex flex-col rounded-lg border bg-muted/50 transition-colors"
|
|
135
|
+
>
|
|
136
|
+
{columnHeader(column, columnItems)}
|
|
137
|
+
<Droppable droppableId={column.id}>
|
|
138
|
+
{(provided, snapshot) => (
|
|
139
|
+
<div
|
|
140
|
+
ref={provided.innerRef}
|
|
141
|
+
{...provided.droppableProps}
|
|
142
|
+
className={cn(
|
|
143
|
+
'flex-1 overflow-y-auto p-2 space-y-2 transition-colors',
|
|
144
|
+
snapshot.isDraggingOver && 'bg-primary/5',
|
|
145
|
+
)}
|
|
146
|
+
>
|
|
147
|
+
{columnItems.length === 0 && !snapshot.isDraggingOver
|
|
148
|
+
? emptyState
|
|
149
|
+
: columnItems.map((item, index) => {
|
|
150
|
+
const id = getCardId!(item)
|
|
151
|
+
return (
|
|
152
|
+
<Draggable key={id} draggableId={id} index={index}>
|
|
153
|
+
{(dp, ds) => (
|
|
154
|
+
<div
|
|
155
|
+
ref={dp.innerRef}
|
|
156
|
+
{...dp.draggableProps}
|
|
157
|
+
{...dp.dragHandleProps}
|
|
158
|
+
className={cn(ds.isDragging && 'opacity-80')}
|
|
159
|
+
>
|
|
160
|
+
{renderCard(item)}
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
</Draggable>
|
|
164
|
+
)
|
|
165
|
+
})}
|
|
166
|
+
{provided.placeholder}
|
|
96
167
|
</div>
|
|
97
168
|
)}
|
|
98
|
-
</
|
|
99
|
-
|
|
100
|
-
|
|
169
|
+
</Droppable>
|
|
170
|
+
{footer}
|
|
171
|
+
</div>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Non-DnD (backward compatible): honor columnRef/isColumnOver for a parent's own
|
|
176
|
+
// manual drag wiring, render cards keyed by index as before.
|
|
177
|
+
const ref = columnRef?.(column.id)
|
|
178
|
+
const isOver = isColumnOver?.(column.id) ?? false
|
|
179
|
+
return (
|
|
180
|
+
<div
|
|
181
|
+
key={column.id}
|
|
182
|
+
ref={ref}
|
|
183
|
+
style={{ minWidth: columnWidth, maxWidth: columnWidth }}
|
|
184
|
+
className={cn(
|
|
185
|
+
'flex flex-col rounded-lg border bg-muted/50 transition-colors',
|
|
186
|
+
isOver && 'border-primary ring-2 ring-primary/20',
|
|
187
|
+
)}
|
|
188
|
+
>
|
|
189
|
+
{columnHeader(column, columnItems)}
|
|
190
|
+
<div className="flex-1 overflow-y-auto p-2 space-y-2">
|
|
191
|
+
{columnItems.length === 0
|
|
192
|
+
? emptyState
|
|
193
|
+
: columnItems.map((item, index) => (
|
|
194
|
+
<React.Fragment key={index}>{renderCard(item)}</React.Fragment>
|
|
195
|
+
))}
|
|
196
|
+
</div>
|
|
197
|
+
{footer}
|
|
198
|
+
</div>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const board = (
|
|
203
|
+
<div className={cn('flex gap-4 overflow-x-auto pb-4', className)}>
|
|
204
|
+
{columns.map(renderColumn)}
|
|
101
205
|
</div>
|
|
102
206
|
)
|
|
207
|
+
|
|
208
|
+
if (dndEnabled) {
|
|
209
|
+
return <DragDropContext onDragEnd={handleDragEnd}>{board}</DragDropContext>
|
|
210
|
+
}
|
|
211
|
+
return board
|
|
103
212
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { render, screen } from '@testing-library/react'
|
|
2
|
-
import {
|
|
2
|
+
import type { DropResult } from '@hello-pangea/dnd'
|
|
3
|
+
import { KanbanBoard, resolveKanbanMove } from '../KanbanBoard'
|
|
3
4
|
|
|
4
5
|
interface Card {
|
|
5
6
|
id: string
|
|
@@ -154,4 +155,66 @@ describe('KanbanBoard', () => {
|
|
|
154
155
|
const firstCol = container.querySelector('[style*="320"]')
|
|
155
156
|
expect(firstCol).toHaveClass('ring-2')
|
|
156
157
|
})
|
|
158
|
+
|
|
159
|
+
it('renders draggable cards when getCardId + onCardMove are provided (DnD enabled)', () => {
|
|
160
|
+
const onCardMove = jest.fn()
|
|
161
|
+
render(
|
|
162
|
+
<KanbanBoard
|
|
163
|
+
columns={columns}
|
|
164
|
+
items={items}
|
|
165
|
+
renderCard={renderCard}
|
|
166
|
+
getCardId={(c) => c.id}
|
|
167
|
+
onCardMove={onCardMove}
|
|
168
|
+
/>
|
|
169
|
+
)
|
|
170
|
+
// cards still render through the @hello-pangea/dnd path without crashing
|
|
171
|
+
expect(screen.getByTestId('card-1')).toBeInTheDocument()
|
|
172
|
+
expect(screen.getByTestId('card-3')).toBeInTheDocument()
|
|
173
|
+
expect(screen.getByText('To Do')).toBeInTheDocument()
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe('resolveKanbanMove', () => {
|
|
178
|
+
const drop = (
|
|
179
|
+
draggableId: string,
|
|
180
|
+
from: string,
|
|
181
|
+
fromIndex: number,
|
|
182
|
+
to?: string,
|
|
183
|
+
toIndex?: number,
|
|
184
|
+
): DropResult =>
|
|
185
|
+
({
|
|
186
|
+
draggableId,
|
|
187
|
+
type: 'DEFAULT',
|
|
188
|
+
source: { droppableId: from, index: fromIndex },
|
|
189
|
+
destination: to === undefined ? null : { droppableId: to, index: toIndex ?? 0 },
|
|
190
|
+
reason: 'DROP',
|
|
191
|
+
mode: 'FLUID',
|
|
192
|
+
combine: null,
|
|
193
|
+
} as unknown as DropResult)
|
|
194
|
+
|
|
195
|
+
it('resolves a cross-column move', () => {
|
|
196
|
+
expect(resolveKanbanMove(drop('c1', 'new', 0, 'surfaced', 2))).toEqual({
|
|
197
|
+
cardId: 'c1',
|
|
198
|
+
fromColumnId: 'new',
|
|
199
|
+
toColumnId: 'surfaced',
|
|
200
|
+
toIndex: 2,
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('returns null when dropped outside any column', () => {
|
|
205
|
+
expect(resolveKanbanMove(drop('c1', 'new', 0))).toBeNull()
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('returns null when dropped back onto the same slot', () => {
|
|
209
|
+
expect(resolveKanbanMove(drop('c1', 'new', 1, 'new', 1))).toBeNull()
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('resolves a within-column reorder', () => {
|
|
213
|
+
expect(resolveKanbanMove(drop('c1', 'new', 0, 'new', 3))).toEqual({
|
|
214
|
+
cardId: 'c1',
|
|
215
|
+
fromColumnId: 'new',
|
|
216
|
+
toColumnId: 'new',
|
|
217
|
+
toIndex: 3,
|
|
218
|
+
})
|
|
219
|
+
})
|
|
157
220
|
})
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { KanbanBoard } from './KanbanBoard'
|
|
2
|
-
export type { KanbanBoardProps, KanbanColumnConfig } from './KanbanBoard'
|
|
1
|
+
export { KanbanBoard, resolveKanbanMove } from './KanbanBoard'
|
|
2
|
+
export type { KanbanBoardProps, KanbanColumnConfig, KanbanMove } from './KanbanBoard'
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { cn } from '../../lib/utils'
|
|
5
|
+
|
|
6
|
+
export type RecordFieldKind = 'text' | 'longtext' | 'url' | 'boolean' | 'json'
|
|
7
|
+
|
|
8
|
+
export interface RecordField {
|
|
9
|
+
label: string
|
|
10
|
+
value: string
|
|
11
|
+
kind?: RecordFieldKind
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RecordDetailProps {
|
|
15
|
+
fields: RecordField[]
|
|
16
|
+
emptyMessage?: string
|
|
17
|
+
className?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const URL_RE = /^https?:\/\//i
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Read-optimized view of a record's fields (startsim-768w.17.4). Renders short
|
|
24
|
+
* fields as a compact label/value list (auto-linking urls), long-text fields as
|
|
25
|
+
* readable, newline-preserving prose blocks (right for extracted article bodies),
|
|
26
|
+
* and json as a scrollable code block. Generic: a consumer maps its own schema to
|
|
27
|
+
* `fields` and this owns the layout, so every Foundry tenant's detail drawer reads
|
|
28
|
+
* the same without duplicating the view.
|
|
29
|
+
*/
|
|
30
|
+
export function RecordDetail({ fields, emptyMessage = 'No details.', className }: RecordDetailProps) {
|
|
31
|
+
const present = fields.filter((f) => f.value != null && String(f.value).trim() !== '')
|
|
32
|
+
const shorts = present.filter((f) => f.kind !== 'longtext' && f.kind !== 'json')
|
|
33
|
+
const longs = present.filter((f) => f.kind === 'longtext')
|
|
34
|
+
const jsons = present.filter((f) => f.kind === 'json')
|
|
35
|
+
|
|
36
|
+
if (present.length === 0) {
|
|
37
|
+
return <p className={cn('text-sm text-muted-foreground', className)}>{emptyMessage}</p>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className={cn('space-y-4', className)}>
|
|
42
|
+
{shorts.length > 0 && (
|
|
43
|
+
<dl className="space-y-1.5">
|
|
44
|
+
{shorts.map((f, i) => {
|
|
45
|
+
const isUrl = f.kind === 'url' || URL_RE.test(f.value)
|
|
46
|
+
return (
|
|
47
|
+
<div key={i} className="flex gap-2 text-sm">
|
|
48
|
+
<dt className="w-28 shrink-0 capitalize text-muted-foreground">{f.label}</dt>
|
|
49
|
+
<dd className="min-w-0 flex-1 break-words">
|
|
50
|
+
{isUrl ? (
|
|
51
|
+
<a
|
|
52
|
+
href={f.value}
|
|
53
|
+
target="_blank"
|
|
54
|
+
rel="noopener noreferrer"
|
|
55
|
+
className="break-all text-primary underline underline-offset-2 hover:no-underline"
|
|
56
|
+
>
|
|
57
|
+
{f.value}
|
|
58
|
+
</a>
|
|
59
|
+
) : (
|
|
60
|
+
f.value
|
|
61
|
+
)}
|
|
62
|
+
</dd>
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
})}
|
|
66
|
+
</dl>
|
|
67
|
+
)}
|
|
68
|
+
|
|
69
|
+
{longs.map((f, i) => (
|
|
70
|
+
<section key={i} className="space-y-1.5">
|
|
71
|
+
<h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
72
|
+
{f.label}
|
|
73
|
+
</h4>
|
|
74
|
+
<div className="whitespace-pre-wrap text-sm leading-relaxed text-foreground">
|
|
75
|
+
{f.value}
|
|
76
|
+
</div>
|
|
77
|
+
</section>
|
|
78
|
+
))}
|
|
79
|
+
|
|
80
|
+
{jsons.map((f, i) => (
|
|
81
|
+
<section key={i} className="space-y-1.5">
|
|
82
|
+
<h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
83
|
+
{f.label}
|
|
84
|
+
</h4>
|
|
85
|
+
<pre className="overflow-x-auto rounded-md bg-muted p-3 text-xs leading-relaxed">
|
|
86
|
+
{f.value}
|
|
87
|
+
</pre>
|
|
88
|
+
</section>
|
|
89
|
+
))}
|
|
90
|
+
</div>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { RecordDetail, type RecordField } from '../RecordDetail'
|
|
3
|
+
|
|
4
|
+
const fields: RecordField[] = [
|
|
5
|
+
{ label: 'url', value: 'https://example.com/a', kind: 'url' },
|
|
6
|
+
{ label: 'source', value: 'The National', kind: 'text' },
|
|
7
|
+
{ label: 'content', value: 'Para one.\nPara two.', kind: 'longtext' },
|
|
8
|
+
{ label: 'empty', value: '', kind: 'text' },
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
describe('RecordDetail', () => {
|
|
12
|
+
it('renders a url field as a new-tab clickable link', () => {
|
|
13
|
+
render(<RecordDetail fields={fields} />)
|
|
14
|
+
const link = screen.getByRole('link', { name: 'https://example.com/a' })
|
|
15
|
+
expect(link).toHaveAttribute('href', 'https://example.com/a')
|
|
16
|
+
expect(link).toHaveAttribute('target', '_blank')
|
|
17
|
+
expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'))
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('renders long-text as a newline-preserving prose block', () => {
|
|
21
|
+
const { container } = render(<RecordDetail fields={fields} />)
|
|
22
|
+
const prose = container.querySelector('.whitespace-pre-wrap')
|
|
23
|
+
expect(prose).toBeInTheDocument()
|
|
24
|
+
expect(prose?.textContent).toContain('Para one.')
|
|
25
|
+
expect(prose?.textContent).toContain('Para two.')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('skips empty fields', () => {
|
|
29
|
+
render(<RecordDetail fields={fields} />)
|
|
30
|
+
expect(screen.queryByText('empty')).not.toBeInTheDocument()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('auto-links a text field whose value looks like a url', () => {
|
|
34
|
+
render(<RecordDetail fields={[{ label: 'link', value: 'https://x.io', kind: 'text' }]} />)
|
|
35
|
+
expect(screen.getByRole('link', { name: 'https://x.io' })).toBeInTheDocument()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('shows the empty message when no field has a value', () => {
|
|
39
|
+
render(<RecordDetail fields={[{ label: 'a', value: '' }]} emptyMessage="Nothing here" />)
|
|
40
|
+
expect(screen.getByText('Nothing here')).toBeInTheDocument()
|
|
41
|
+
})
|
|
42
|
+
})
|