@startsimpli/ui 0.4.25 → 0.4.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.25",
3
+ "version": "0.4.26",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -1,4 +1,4 @@
1
- import { render, screen, fireEvent } from '@testing-library/react'
1
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
2
2
  import { ChatMessage } from '../chat/ChatMessage'
3
3
  import { ChatThread } from '../chat/ChatThread'
4
4
  import { ChatComposer } from '../chat/ChatComposer'
@@ -36,6 +36,28 @@ describe('ChatMessage', () => {
36
36
  expect(screen.getByText(/generateSlide/i)).toBeInTheDocument()
37
37
  expect(screen.getByText('Generating slide 1…')).toBeInTheDocument()
38
38
  })
39
+
40
+ it('renders message attachment chips (image thumbnail + file chip)', () => {
41
+ render(
42
+ <ChatMessage
43
+ message={{
44
+ id: '4',
45
+ role: 'user',
46
+ content: 'Match this brand',
47
+ attachments: [
48
+ { id: 'att-1', fileUrl: 'logo.png', filename: 'logo.png', contentType: 'image/png' },
49
+ { id: 'att-2', fileUrl: 'deck.pdf', filename: 'deck.pdf', contentType: 'application/pdf' },
50
+ ],
51
+ }}
52
+ />
53
+ )
54
+ // image attachment renders an <img> thumbnail with its filename as alt
55
+ const img = screen.getByAltText('logo.png') as HTMLImageElement
56
+ expect(img).toBeInTheDocument()
57
+ expect(img.src).toContain('logo.png')
58
+ // non-image attachment renders its filename as text
59
+ expect(screen.getByText('deck.pdf')).toBeInTheDocument()
60
+ })
39
61
  })
40
62
 
41
63
  describe('ChatThread', () => {
@@ -62,6 +84,23 @@ describe('ChatThread', () => {
62
84
  rerender(<ChatThread messages={[...messages, { id: 'd', role: 'user', content: 'Fourth' }]} />)
63
85
  expect(Element.prototype.scrollIntoView).toHaveBeenCalled()
64
86
  })
87
+
88
+ it('shows an animated working indicator when busy (startsim-ku8o)', () => {
89
+ render(<ChatThread messages={messages} busy busyLabel="Working on your deck…" />)
90
+ expect(screen.getByTestId('chat-working-indicator')).toBeInTheDocument()
91
+ expect(screen.getByText('Working on your deck…')).toBeInTheDocument()
92
+ })
93
+
94
+ it('hides the working indicator when not busy', () => {
95
+ render(<ChatThread messages={messages} />)
96
+ expect(screen.queryByTestId('chat-working-indicator')).not.toBeInTheDocument()
97
+ })
98
+
99
+ it('shows the working indicator even when the thread is empty (busy overrides empty state)', () => {
100
+ render(<ChatThread messages={[]} busy emptyState={<div>Start chatting</div>} />)
101
+ expect(screen.getByTestId('chat-working-indicator')).toBeInTheDocument()
102
+ expect(screen.queryByText('Start chatting')).not.toBeInTheDocument()
103
+ })
65
104
  })
66
105
 
67
106
  describe('ChatComposer', () => {
@@ -126,4 +165,152 @@ describe('ChatComposer', () => {
126
165
  fireEvent.keyDown(textarea, { key: 'Enter' })
127
166
  expect(onSubmit).not.toHaveBeenCalled()
128
167
  })
168
+
169
+ describe('attachments', () => {
170
+ function makeFile(name: string, type = 'image/png') {
171
+ return new File(['data'], name, { type })
172
+ }
173
+
174
+ it('fires onAttachFiles when files are picked via the attach button input', () => {
175
+ const onAttachFiles = jest.fn()
176
+ render(<ChatComposer onSubmit={jest.fn()} onAttachFiles={onAttachFiles} />)
177
+ // The attach button is present and labelled
178
+ expect(screen.getByRole('button', { name: /attach/i })).toBeInTheDocument()
179
+ const input = screen.getByTestId('chat-attach-input') as HTMLInputElement
180
+ fireEvent.change(input, { target: { files: [makeFile('a.png'), makeFile('b.pdf', 'application/pdf')] } })
181
+ expect(onAttachFiles).toHaveBeenCalledTimes(1)
182
+ expect(onAttachFiles.mock.calls[0][0]).toHaveLength(2)
183
+ expect(onAttachFiles.mock.calls[0][0][0].name).toBe('a.png')
184
+ })
185
+
186
+ it('does NOT render an attach button when onAttachFiles is omitted', () => {
187
+ render(<ChatComposer onSubmit={jest.fn()} />)
188
+ expect(screen.queryByRole('button', { name: /attach/i })).not.toBeInTheDocument()
189
+ })
190
+
191
+ it('renders chips for controlled attachments with a remove control', () => {
192
+ const onRemoveAttachment = jest.fn()
193
+ render(
194
+ <ChatComposer
195
+ onSubmit={jest.fn()}
196
+ onAttachFiles={jest.fn()}
197
+ onRemoveAttachment={onRemoveAttachment}
198
+ attachments={[
199
+ { id: 'p1', name: 'brand.png', status: 'ready', thumbnailUrl: 'blob:brand', sizeBytes: 2048 },
200
+ { id: 'p2', name: 'notes.pdf', status: 'ready', sizeBytes: 1024 },
201
+ ]}
202
+ />
203
+ )
204
+ expect(screen.getByText('notes.pdf')).toBeInTheDocument()
205
+ // image chip uses thumbnail with the name as alt
206
+ expect(screen.getByAltText('brand.png')).toBeInTheDocument()
207
+ fireEvent.click(screen.getByRole('button', { name: /remove brand\.png/i }))
208
+ expect(onRemoveAttachment).toHaveBeenCalledWith('p1')
209
+ })
210
+
211
+ it('shows upload progress while a chip is uploading and an error state on failure', () => {
212
+ render(
213
+ <ChatComposer
214
+ onSubmit={jest.fn()}
215
+ onAttachFiles={jest.fn()}
216
+ attachments={[
217
+ { id: 'u1', name: 'big.pptx', status: 'uploading', progress: 0.42 },
218
+ { id: 'e1', name: 'bad.png', status: 'error' },
219
+ ]}
220
+ />
221
+ )
222
+ const uploading = screen.getByTestId('chat-attachment-u1')
223
+ expect(uploading).toHaveAttribute('data-status', 'uploading')
224
+ expect(uploading.querySelector('[role="progressbar"]')).toBeInTheDocument()
225
+ const errored = screen.getByTestId('chat-attachment-e1')
226
+ expect(errored).toHaveAttribute('data-status', 'error')
227
+ })
228
+
229
+ it('still submits text with attachments present (attachments are presentation-only)', () => {
230
+ const onSubmit = jest.fn()
231
+ render(
232
+ <ChatComposer
233
+ onSubmit={onSubmit}
234
+ onAttachFiles={jest.fn()}
235
+ attachments={[{ id: 'p1', name: 'a.png', status: 'ready' }]}
236
+ />
237
+ )
238
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'use this' } })
239
+ fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' })
240
+ expect(onSubmit).toHaveBeenCalledWith('use this')
241
+ })
242
+
243
+ it('routes dropped files to onAttachFiles', () => {
244
+ const onAttachFiles = jest.fn()
245
+ const { container } = render(
246
+ <ChatComposer onSubmit={jest.fn()} onAttachFiles={onAttachFiles} />
247
+ )
248
+ const root = container.firstChild as HTMLElement
249
+ fireEvent.drop(root, { dataTransfer: { files: [makeFile('dropped.png')] } })
250
+ expect(onAttachFiles).toHaveBeenCalledTimes(1)
251
+ expect(onAttachFiles.mock.calls[0][0][0].name).toBe('dropped.png')
252
+ })
253
+
254
+ it('routes pasted image/file data to onAttachFiles', () => {
255
+ const onAttachFiles = jest.fn()
256
+ render(<ChatComposer onSubmit={jest.fn()} onAttachFiles={onAttachFiles} />)
257
+ const textarea = screen.getByRole('textbox')
258
+ const file = makeFile('pasted.png')
259
+ fireEvent.paste(textarea, {
260
+ clipboardData: { files: [file], items: [{ kind: 'file', type: 'image/png' }] },
261
+ })
262
+ expect(onAttachFiles).toHaveBeenCalledTimes(1)
263
+ expect(onAttachFiles.mock.calls[0][0][0].name).toBe('pasted.png')
264
+ })
265
+
266
+ describe('self-managed uploader mode (startsim-dd9o)', () => {
267
+ beforeAll(() => {
268
+ if (!('createObjectURL' in URL)) {
269
+ // @ts-expect-error test shim
270
+ URL.createObjectURL = jest.fn(() => 'blob:preview')
271
+ }
272
+ if (!('revokeObjectURL' in URL)) {
273
+ // @ts-expect-error test shim
274
+ URL.revokeObjectURL = jest.fn()
275
+ }
276
+ })
277
+
278
+ function fakeUploader() {
279
+ return {
280
+ uploadAttachment: jest.fn(async (_id: string, file: File) => ({
281
+ id: `srv-${file.name}`,
282
+ fileUrl: `https://cdn/${file.name}`,
283
+ })),
284
+ deleteAttachment: jest.fn(async () => undefined),
285
+ }
286
+ }
287
+
288
+ it('uploads picked files through the uploader and shows a ready chip — no controlled props needed', async () => {
289
+ const uploader = fakeUploader()
290
+ render(<ChatComposer onSubmit={jest.fn()} uploader={uploader} documentId="doc-1" />)
291
+ const input = screen.getByTestId('chat-attach-input') as HTMLInputElement
292
+ fireEvent.change(input, { target: { files: [makeFile('hero.png')] } })
293
+ await waitFor(() =>
294
+ expect(uploader.uploadAttachment).toHaveBeenCalledWith('doc-1', expect.any(File), expect.any(Object))
295
+ )
296
+ await waitFor(() => {
297
+ const chips = screen.getByTestId('chat-attachment-chips')
298
+ expect(chips.querySelector('[data-status="ready"]')).toBeInTheDocument()
299
+ })
300
+ })
301
+
302
+ it('shows the attach affordance even without onAttachFiles when an uploader is present', () => {
303
+ render(<ChatComposer onSubmit={jest.fn()} uploader={fakeUploader()} documentId="doc-1" />)
304
+ expect(screen.getByRole('button', { name: /attach/i })).toBeInTheDocument()
305
+ })
306
+
307
+ it('still submits text in self-managed mode', () => {
308
+ const onSubmit = jest.fn()
309
+ render(<ChatComposer onSubmit={onSubmit} uploader={fakeUploader()} documentId="doc-1" />)
310
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'use these' } })
311
+ fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' })
312
+ expect(onSubmit).toHaveBeenCalledWith('use these')
313
+ })
314
+ })
315
+ })
129
316
  })
@@ -0,0 +1,48 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import {
3
+ DocumentStatusBadge,
4
+ DOCUMENT_STATUS_CONFIG,
5
+ type DocumentStatus,
6
+ } from '../badge/DocumentStatusBadge'
7
+
8
+ describe('DocumentStatusBadge', () => {
9
+ const cases: Array<{ status: DocumentStatus; label: string; tone: string }> = [
10
+ { status: 'draft', label: 'Draft', tone: 'bg-gray-100' },
11
+ { status: 'generating', label: 'Generating', tone: 'bg-amber-100' },
12
+ { status: 'ready', label: 'Ready', tone: 'bg-green-100' },
13
+ { status: 'error', label: 'Error', tone: 'bg-red-100' },
14
+ ]
15
+
16
+ it.each(cases)('renders $status with the spec label and tone', ({ status, label, tone }) => {
17
+ render(<DocumentStatusBadge status={status} />)
18
+ const badge = screen.getByText(label)
19
+ expect(badge).toBeInTheDocument()
20
+ expect(badge).toHaveClass(tone)
21
+ })
22
+
23
+ it('exposes the canonical config mapping (single source of truth)', () => {
24
+ expect(DOCUMENT_STATUS_CONFIG.ready.label).toBe('Ready')
25
+ expect(DOCUMENT_STATUS_CONFIG.generating.className).toContain('amber')
26
+ expect(DOCUMENT_STATUS_CONFIG.error.className).toContain('red')
27
+ expect(DOCUMENT_STATUS_CONFIG.draft.className).toContain('gray')
28
+ })
29
+
30
+ it('renders a status dot by default and omits it when showDot=false', () => {
31
+ const { container, rerender } = render(<DocumentStatusBadge status="ready" />)
32
+ expect(container.querySelector('span[aria-hidden="true"]')).not.toBeNull()
33
+
34
+ rerender(<DocumentStatusBadge status="ready" showDot={false} />)
35
+ expect(container.querySelector('span[aria-hidden="true"]')).toBeNull()
36
+ })
37
+
38
+ it('uses a pulsing dot for generating', () => {
39
+ const { container } = render(<DocumentStatusBadge status="generating" />)
40
+ const dot = container.querySelector('span[aria-hidden="true"]')
41
+ expect(dot?.className).toContain('animate-pulse')
42
+ })
43
+
44
+ it('honors the size prop', () => {
45
+ render(<DocumentStatusBadge status="ready" size="sm" />)
46
+ expect(screen.getByText('Ready')).toHaveClass('px-2')
47
+ })
48
+ })
@@ -0,0 +1,76 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { StatusBadge, type StatusBadgeConfig } from './StatusBadge'
5
+
6
+ /** Lifecycle status of a generated document (matches @startsimpli/api DocumentStatus). */
7
+ export type DocumentStatus = 'draft' | 'generating' | 'ready' | 'error'
8
+
9
+ /**
10
+ * Canonical color + label mapping for document lifecycle status — the single
11
+ * source of truth shared by the deck list cards and the reopen toolbar (per the
12
+ * present-web reopen-ux-spec §1.2). Generic enough that any documents-backed app
13
+ * (vault, market) can reuse it for a status pill.
14
+ */
15
+ export const DOCUMENT_STATUS_CONFIG: Record<DocumentStatus, StatusBadgeConfig> = {
16
+ draft: {
17
+ label: 'Draft',
18
+ className: 'gap-1.5 bg-gray-100 text-gray-600',
19
+ },
20
+ generating: {
21
+ label: 'Generating',
22
+ className: 'gap-1.5 bg-amber-100 text-amber-700',
23
+ },
24
+ ready: {
25
+ label: 'Ready',
26
+ className: 'gap-1.5 bg-green-100 text-green-700',
27
+ },
28
+ error: {
29
+ label: 'Error',
30
+ className: 'gap-1.5 bg-red-100 text-red-700',
31
+ },
32
+ }
33
+
34
+ /** Per-status dot treatment: hollow for draft, pulsing for generating, solid otherwise. */
35
+ const DOT_CLASS: Record<DocumentStatus, string> = {
36
+ draft: 'border border-current bg-transparent',
37
+ generating: 'bg-current animate-pulse',
38
+ ready: 'bg-current',
39
+ error: 'bg-current',
40
+ }
41
+
42
+ export interface DocumentStatusBadgeProps {
43
+ status: DocumentStatus
44
+ size?: 'sm' | 'md'
45
+ /** Show the leading status dot. Defaults to true. */
46
+ showDot?: boolean
47
+ className?: string
48
+ }
49
+
50
+ /**
51
+ * Status pill for a generated document. Wraps the generic `StatusBadge` with the
52
+ * canonical document-status config and a leading dot whose treatment encodes the
53
+ * state (hollow draft / pulsing generating / solid ready+error).
54
+ */
55
+ export function DocumentStatusBadge({
56
+ status,
57
+ size = 'md',
58
+ showDot = true,
59
+ className,
60
+ }: DocumentStatusBadgeProps) {
61
+ return (
62
+ <StatusBadge<DocumentStatus>
63
+ status={status}
64
+ config={DOCUMENT_STATUS_CONFIG}
65
+ size={size}
66
+ className={className}
67
+ >
68
+ {showDot ? (
69
+ <span
70
+ aria-hidden="true"
71
+ className={`mr-1 inline-block h-1.5 w-1.5 shrink-0 rounded-full ${DOT_CLASS[status] ?? 'bg-current'}`}
72
+ />
73
+ ) : null}
74
+ </StatusBadge>
75
+ )
76
+ }
@@ -11,6 +11,8 @@ export interface StatusBadgeProps<T extends string> {
11
11
  config: Record<T, StatusBadgeConfig>
12
12
  size?: 'sm' | 'md'
13
13
  className?: string
14
+ /** Optional leading content (e.g. a status dot) rendered before the label. */
15
+ children?: React.ReactNode
14
16
  }
15
17
 
16
18
  const sizeClasses = {
@@ -23,6 +25,7 @@ export function StatusBadge<T extends string>({
23
25
  config,
24
26
  size = 'md',
25
27
  className,
28
+ children,
26
29
  }: StatusBadgeProps<T>) {
27
30
  const entry = config[status]
28
31
 
@@ -35,6 +38,7 @@ export function StatusBadge<T extends string>({
35
38
  className
36
39
  )}
37
40
  >
41
+ {children}
38
42
  {status}
39
43
  </span>
40
44
  )
@@ -49,6 +53,7 @@ export function StatusBadge<T extends string>({
49
53
  className
50
54
  )}
51
55
  >
56
+ {children}
52
57
  {entry.label}
53
58
  </span>
54
59
  )
@@ -3,3 +3,6 @@ export type { StatusBadgeProps, StatusBadgeConfig } from './StatusBadge'
3
3
 
4
4
  export { StageBadge } from './StageBadge'
5
5
  export type { StageBadgeProps, StageBadgeConfig } from './StageBadge'
6
+
7
+ export { DocumentStatusBadge, DOCUMENT_STATUS_CONFIG } from './DocumentStatusBadge'
8
+ export type { DocumentStatusBadgeProps, DocumentStatus } from './DocumentStatusBadge'