@startsimpli/ui 0.4.13 → 0.4.15
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/README.md +457 -398
- package/package.json +20 -13
- package/src/components/__tests__/calendar-view-popup.test.tsx +42 -0
- package/src/components/__tests__/calendar-view.test.tsx +97 -0
- package/src/components/__tests__/chat.test.tsx +129 -0
- package/src/components/__tests__/meetings-list.test.tsx +114 -0
- package/src/components/__tests__/slide-deck-viewer.test.tsx +82 -0
- package/src/components/__tests__/upcoming-meetings.test.tsx +104 -0
- package/src/components/__tests__/workspace.test.tsx +106 -0
- package/src/components/account/__tests__/account.test.tsx +5 -32
- package/src/components/account/change-password-form.tsx +1 -28
- package/src/components/calendar/calendar-view.tsx +253 -0
- package/src/components/calendar/index.ts +20 -0
- package/src/components/calendar/meetings-list.tsx +202 -0
- package/src/components/calendar/upcoming-meetings.tsx +211 -0
- package/src/components/chat/ChatComposer.tsx +113 -0
- package/src/components/chat/ChatMessage.tsx +81 -0
- package/src/components/chat/ChatThread.tsx +57 -0
- package/src/components/chat/index.ts +12 -0
- package/src/components/chat/types.ts +20 -0
- package/src/components/index.ts +16 -0
- package/src/components/slide-deck/SlideCanvas.tsx +68 -0
- package/src/components/slide-deck/SlideDeckViewer.tsx +144 -0
- package/src/components/slide-deck/SlideFilmstrip.tsx +73 -0
- package/src/components/slide-deck/index.ts +7 -0
- package/src/components/slide-deck/types.ts +18 -0
- package/src/components/team/DomainClaimCard.tsx +170 -0
- package/src/components/team/InviteMemberDialog.tsx +182 -0
- package/src/components/team/LeaveTeamDialog.tsx +130 -0
- package/src/components/team/MembersTable.tsx +138 -0
- package/src/components/team/OrgSwitcher.tsx +68 -0
- package/src/components/team/PendingInvitationCallout.tsx +106 -0
- package/src/components/team/RoleSelector.tsx +68 -0
- package/src/components/team/__tests__/team-components.test.tsx +352 -0
- package/src/components/team/domain-claim-card-default-class-names.ts +45 -0
- package/src/components/team/index.ts +57 -0
- package/src/components/team/invite-member-dialog-default-class-names.ts +41 -0
- package/src/components/team/leave-team-dialog-default-class-names.ts +33 -0
- package/src/components/team/members-table-default-class-names.ts +39 -0
- package/src/components/team/org-switcher-default-class-names.ts +13 -0
- package/src/components/team/pending-invitation-callout-default-class-names.ts +22 -0
- package/src/components/team/role-selector-default-class-names.ts +11 -0
- package/src/components/team/types.ts +97 -0
- package/src/components/workflows/ExecNodeDetails.tsx +83 -0
- package/src/components/workflows/ExecutionTimeline.tsx +146 -0
- package/src/components/workflows/NodeInspector.tsx +257 -0
- package/src/components/workflows/NodePalette.tsx +119 -0
- package/src/components/workflows/WorkflowCanvas.tsx +113 -0
- package/src/components/workflows/WorkflowEdge.tsx +65 -0
- package/src/components/workflows/WorkflowEditor.tsx +130 -0
- package/src/components/workflows/WorkflowNode.tsx +198 -0
- package/src/components/workflows/WorkflowRunViewer.tsx +81 -0
- package/src/components/workflows/__tests__/ExecutionTimeline.test.tsx +99 -0
- package/src/components/workflows/__tests__/NodeInspector.test.tsx +74 -0
- package/src/components/workflows/__tests__/NodePalette.test.tsx +46 -0
- package/src/components/workflows/__tests__/WorkflowCanvas.test.tsx +59 -0
- package/src/components/workflows/__tests__/WorkflowNode.test.tsx +92 -0
- package/src/components/workflows/__tests__/WorkflowRunViewer.test.tsx +138 -0
- package/src/components/workflows/__tests__/auto-layout.test.ts +107 -0
- package/src/components/workflows/__tests__/serialization.test.ts +278 -0
- package/src/components/workflows/exec-status.ts +90 -0
- package/src/components/workflows/hooks/useCanvasGraph.ts +70 -0
- package/src/components/workflows/hooks/useNodeStatusOverlay.ts +47 -0
- package/src/components/workflows/index.ts +78 -0
- package/src/components/workflows/layout/auto-layout.ts +142 -0
- package/src/components/workflows/node-icons.ts +31 -0
- package/src/components/workflows/serialization.ts +171 -0
- package/src/components/workflows/theme/categories.ts +96 -0
- package/src/components/workflows/types.ts +231 -0
- package/src/components/workflows/workflows.css +29 -0
- package/src/components/workspace/DualPaneWorkspace.tsx +187 -0
- package/src/components/workspace/SplitPane.tsx +174 -0
- package/src/components/workspace/index.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.15",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
"./theme": "./src/theme/index.ts",
|
|
15
15
|
"./theme/contract": "./theme/contract.css",
|
|
16
16
|
"./email-editor": "./src/components/email-editor/index.ts",
|
|
17
|
+
"./workflows": "./src/components/workflows/index.ts",
|
|
18
|
+
"./workflows/styles": "./src/components/workflows/workflows.css",
|
|
17
19
|
"./tailwind": "./tailwind.preset.js"
|
|
18
20
|
},
|
|
19
21
|
"files": [
|
|
@@ -24,16 +26,6 @@
|
|
|
24
26
|
"publishConfig": {
|
|
25
27
|
"access": "public"
|
|
26
28
|
},
|
|
27
|
-
"scripts": {
|
|
28
|
-
"build": "tsup",
|
|
29
|
-
"dev": "tsup --watch",
|
|
30
|
-
"type-check": "tsc --noEmit",
|
|
31
|
-
"test": "jest",
|
|
32
|
-
"test:watch": "jest --watch",
|
|
33
|
-
"test:coverage": "jest --coverage",
|
|
34
|
-
"lint": "eslint src/**/*.{ts,tsx}",
|
|
35
|
-
"clean": "rm -rf dist"
|
|
36
|
-
},
|
|
37
29
|
"peerDependencies": {
|
|
38
30
|
"@tanstack/react-query": ">=5.0.0",
|
|
39
31
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
@@ -47,6 +39,8 @@
|
|
|
47
39
|
},
|
|
48
40
|
"dependencies": {
|
|
49
41
|
"@hello-pangea/dnd": "^18.0.1",
|
|
42
|
+
"@xyflow/react": "^12.10.2",
|
|
43
|
+
"dagre": "^0.8.5",
|
|
50
44
|
"@radix-ui/react-accordion": "^1.2.12",
|
|
51
45
|
"@radix-ui/react-checkbox": "^1.3.3",
|
|
52
46
|
"@radix-ui/react-collapsible": "^1.1.12",
|
|
@@ -66,6 +60,7 @@
|
|
|
66
60
|
"date-fns": "^3.6.0",
|
|
67
61
|
"dompurify": "^3.4.1",
|
|
68
62
|
"lucide-react": "^0.408.0",
|
|
63
|
+
"react-big-calendar": "^1.19.4",
|
|
69
64
|
"react-day-picker": "^9.14.0",
|
|
70
65
|
"tailwind-merge": "^2.6.1",
|
|
71
66
|
"tailwindcss-animate": "^1.0.7",
|
|
@@ -79,8 +74,10 @@
|
|
|
79
74
|
"@testing-library/react": "^16.3.2",
|
|
80
75
|
"@testing-library/user-event": "^14.6.1",
|
|
81
76
|
"@types/jest": "^30.0.0",
|
|
77
|
+
"@types/dagre": "^0.7.52",
|
|
82
78
|
"@types/node": "^20.19.39",
|
|
83
79
|
"@types/react": "^19.2.14",
|
|
80
|
+
"@types/react-big-calendar": "^1.16.3",
|
|
84
81
|
"@types/react-dom": "^19.2.3",
|
|
85
82
|
"eslint": "^8.57.1",
|
|
86
83
|
"identity-obj-proxy": "^3.0.0",
|
|
@@ -97,5 +94,15 @@
|
|
|
97
94
|
"react",
|
|
98
95
|
"nextjs",
|
|
99
96
|
"startsimpli"
|
|
100
|
-
]
|
|
101
|
-
|
|
97
|
+
],
|
|
98
|
+
"scripts": {
|
|
99
|
+
"build": "tsup",
|
|
100
|
+
"dev": "tsup --watch",
|
|
101
|
+
"type-check": "tsc --noEmit",
|
|
102
|
+
"test": "jest",
|
|
103
|
+
"test:watch": "jest --watch",
|
|
104
|
+
"test:coverage": "jest --coverage",
|
|
105
|
+
"lint": "eslint src/**/*.{ts,tsx}",
|
|
106
|
+
"clean": "rm -rf dist"
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifies CalendarView forwards the `popup` prop to react-big-calendar so the
|
|
3
|
+
* month grid's "+N more" overflow link opens a popover instead of being inert
|
|
4
|
+
* (raise-simpli-3er). RBC's overflow rendering depends on real cell layout
|
|
5
|
+
* heights, which jsdom does not compute, so we mock RBC's Calendar with a
|
|
6
|
+
* passthrough that surfaces props into the DOM.
|
|
7
|
+
*/
|
|
8
|
+
import { render } from '@testing-library/react'
|
|
9
|
+
|
|
10
|
+
const calendarPropSpy = jest.fn()
|
|
11
|
+
|
|
12
|
+
jest.mock('react-big-calendar', () => {
|
|
13
|
+
const actual = jest.requireActual('react-big-calendar')
|
|
14
|
+
return {
|
|
15
|
+
...actual,
|
|
16
|
+
Calendar: (props: Record<string, unknown>) => {
|
|
17
|
+
calendarPropSpy(props)
|
|
18
|
+
return <div data-testid="rbc-calendar-stub" data-popup={String(props.popup)} />
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
import { CalendarView } from '../calendar/calendar-view'
|
|
24
|
+
|
|
25
|
+
const FIXED_DATE = new Date('2026-04-15T12:00:00Z')
|
|
26
|
+
|
|
27
|
+
describe('CalendarView popup prop', () => {
|
|
28
|
+
beforeEach(() => calendarPropSpy.mockClear())
|
|
29
|
+
|
|
30
|
+
it('defaults popup to true so the "+N more" overflow link opens a popover', () => {
|
|
31
|
+
render(<CalendarView events={[]} defaultDate={FIXED_DATE} defaultView="month" />)
|
|
32
|
+
expect(calendarPropSpy).toHaveBeenCalledTimes(1)
|
|
33
|
+
expect(calendarPropSpy.mock.calls[0][0].popup).toBe(true)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('forwards popup={false} when callers opt out', () => {
|
|
37
|
+
render(
|
|
38
|
+
<CalendarView events={[]} defaultDate={FIXED_DATE} defaultView="month" popup={false} />,
|
|
39
|
+
)
|
|
40
|
+
expect(calendarPropSpy.mock.calls[0][0].popup).toBe(false)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
2
|
+
import { CalendarView, type CalendarEvent } from '../calendar/calendar-view'
|
|
3
|
+
|
|
4
|
+
const FIXED_DATE = new Date('2026-04-15T12:00:00Z')
|
|
5
|
+
|
|
6
|
+
const sampleEvents: CalendarEvent[] = [
|
|
7
|
+
{
|
|
8
|
+
id: 'meet-1',
|
|
9
|
+
title: 'Investor Sync — Sequoia',
|
|
10
|
+
start: new Date('2026-04-15T16:00:00Z'),
|
|
11
|
+
end: new Date('2026-04-15T16:30:00Z'),
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
id: 'meet-2',
|
|
15
|
+
title: 'Demo with Acme',
|
|
16
|
+
start: '2026-04-16T17:00:00Z',
|
|
17
|
+
end: '2026-04-16T17:45:00Z',
|
|
18
|
+
},
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
describe('CalendarView', () => {
|
|
22
|
+
it('renders without crashing in month view', () => {
|
|
23
|
+
render(
|
|
24
|
+
<CalendarView
|
|
25
|
+
events={sampleEvents}
|
|
26
|
+
defaultDate={FIXED_DATE}
|
|
27
|
+
defaultView="month"
|
|
28
|
+
/>
|
|
29
|
+
)
|
|
30
|
+
// The toolbar's "Today" button is always rendered by react-big-calendar
|
|
31
|
+
expect(screen.getByText('Today')).toBeInTheDocument()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('renders event titles on the grid', () => {
|
|
35
|
+
render(
|
|
36
|
+
<CalendarView
|
|
37
|
+
events={sampleEvents}
|
|
38
|
+
defaultDate={FIXED_DATE}
|
|
39
|
+
defaultView="month"
|
|
40
|
+
/>
|
|
41
|
+
)
|
|
42
|
+
expect(screen.getByText(/Investor Sync — Sequoia/)).toBeInTheDocument()
|
|
43
|
+
expect(screen.getByText(/Demo with Acme/)).toBeInTheDocument()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('fires onEventClick with the original event when an event is clicked', () => {
|
|
47
|
+
const onEventClick = jest.fn()
|
|
48
|
+
render(
|
|
49
|
+
<CalendarView
|
|
50
|
+
events={sampleEvents}
|
|
51
|
+
defaultDate={FIXED_DATE}
|
|
52
|
+
defaultView="month"
|
|
53
|
+
onEventClick={onEventClick}
|
|
54
|
+
/>
|
|
55
|
+
)
|
|
56
|
+
fireEvent.click(screen.getByText(/Investor Sync — Sequoia/))
|
|
57
|
+
expect(onEventClick).toHaveBeenCalledTimes(1)
|
|
58
|
+
expect(onEventClick.mock.calls[0][0].id).toBe('meet-1')
|
|
59
|
+
expect(onEventClick.mock.calls[0][0].title).toBe('Investor Sync — Sequoia')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('accepts string dates in events and normalizes them', () => {
|
|
63
|
+
// Verifies the toDate / normalizeEvent path
|
|
64
|
+
render(
|
|
65
|
+
<CalendarView
|
|
66
|
+
events={[
|
|
67
|
+
{ id: 'x', title: 'Stringy', start: '2026-04-15T16:00:00Z' },
|
|
68
|
+
]}
|
|
69
|
+
defaultDate={FIXED_DATE}
|
|
70
|
+
defaultView="month"
|
|
71
|
+
/>
|
|
72
|
+
)
|
|
73
|
+
expect(screen.getByText(/Stringy/)).toBeInTheDocument()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('passes through resource payload to onEventClick', () => {
|
|
77
|
+
interface MyMeeting {
|
|
78
|
+
uuid: string
|
|
79
|
+
attendees: number
|
|
80
|
+
}
|
|
81
|
+
const meeting: MyMeeting = { uuid: 'abc-123', attendees: 5 }
|
|
82
|
+
const onEventClick = jest.fn()
|
|
83
|
+
render(
|
|
84
|
+
<CalendarView<MyMeeting>
|
|
85
|
+
events={[
|
|
86
|
+
{ id: 'r1', title: 'WithResource', start: FIXED_DATE, resource: meeting },
|
|
87
|
+
]}
|
|
88
|
+
defaultDate={FIXED_DATE}
|
|
89
|
+
defaultView="month"
|
|
90
|
+
onEventClick={onEventClick}
|
|
91
|
+
/>
|
|
92
|
+
)
|
|
93
|
+
fireEvent.click(screen.getByText(/WithResource/))
|
|
94
|
+
expect(onEventClick).toHaveBeenCalledTimes(1)
|
|
95
|
+
expect(onEventClick.mock.calls[0][0].resource).toEqual(meeting)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
2
|
+
import { ChatMessage } from '../chat/ChatMessage'
|
|
3
|
+
import { ChatThread } from '../chat/ChatThread'
|
|
4
|
+
import { ChatComposer } from '../chat/ChatComposer'
|
|
5
|
+
import type { ChatMessageData } from '../chat/types'
|
|
6
|
+
|
|
7
|
+
beforeAll(() => {
|
|
8
|
+
// jsdom has no layout; stub scrollIntoView used by autoscroll
|
|
9
|
+
Element.prototype.scrollIntoView = jest.fn()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
describe('ChatMessage', () => {
|
|
13
|
+
it('renders text content and tags the role', () => {
|
|
14
|
+
const { container } = render(
|
|
15
|
+
<ChatMessage message={{ id: '1', role: 'user', content: 'Hello there' }} />
|
|
16
|
+
)
|
|
17
|
+
expect(screen.getByText('Hello there')).toBeInTheDocument()
|
|
18
|
+
expect(container.querySelector('[data-role="user"]')).toBeInTheDocument()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('marks a streaming assistant message as busy', () => {
|
|
22
|
+
const { container } = render(
|
|
23
|
+
<ChatMessage message={{ id: '2', role: 'assistant', content: 'thinking', status: 'streaming' }} />
|
|
24
|
+
)
|
|
25
|
+
const el = container.querySelector('[data-status="streaming"]')
|
|
26
|
+
expect(el).toBeInTheDocument()
|
|
27
|
+
expect(el).toHaveAttribute('aria-busy', 'true')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('renders a tool/status message with its tool name', () => {
|
|
31
|
+
render(
|
|
32
|
+
<ChatMessage
|
|
33
|
+
message={{ id: '3', role: 'tool', kind: 'tool', toolName: 'generateSlide', content: 'Generating slide 1…' }}
|
|
34
|
+
/>
|
|
35
|
+
)
|
|
36
|
+
expect(screen.getByText(/generateSlide/i)).toBeInTheDocument()
|
|
37
|
+
expect(screen.getByText('Generating slide 1…')).toBeInTheDocument()
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('ChatThread', () => {
|
|
42
|
+
const messages: ChatMessageData[] = [
|
|
43
|
+
{ id: 'a', role: 'user', content: 'First' },
|
|
44
|
+
{ id: 'b', role: 'assistant', content: 'Second' },
|
|
45
|
+
{ id: 'c', role: 'assistant', content: 'Third' },
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
it('renders all messages in order', () => {
|
|
49
|
+
render(<ChatThread messages={messages} />)
|
|
50
|
+
expect(screen.getByText('First')).toBeInTheDocument()
|
|
51
|
+
expect(screen.getByText('Second')).toBeInTheDocument()
|
|
52
|
+
expect(screen.getByText('Third')).toBeInTheDocument()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('shows an empty state placeholder when there are no messages', () => {
|
|
56
|
+
render(<ChatThread messages={[]} emptyState={<div>Start chatting</div>} />)
|
|
57
|
+
expect(screen.getByText('Start chatting')).toBeInTheDocument()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('autoscrolls to the bottom when messages change', () => {
|
|
61
|
+
const { rerender } = render(<ChatThread messages={messages} />)
|
|
62
|
+
rerender(<ChatThread messages={[...messages, { id: 'd', role: 'user', content: 'Fourth' }]} />)
|
|
63
|
+
expect(Element.prototype.scrollIntoView).toHaveBeenCalled()
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('ChatComposer', () => {
|
|
68
|
+
it('submits trimmed text on Enter and clears (uncontrolled)', () => {
|
|
69
|
+
const onSubmit = jest.fn()
|
|
70
|
+
render(<ChatComposer onSubmit={onSubmit} />)
|
|
71
|
+
const textarea = screen.getByRole('textbox')
|
|
72
|
+
fireEvent.change(textarea, { target: { value: ' paste my outline ' } })
|
|
73
|
+
fireEvent.keyDown(textarea, { key: 'Enter' })
|
|
74
|
+
expect(onSubmit).toHaveBeenCalledWith('paste my outline')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('does NOT submit on Shift+Enter (newline)', () => {
|
|
78
|
+
const onSubmit = jest.fn()
|
|
79
|
+
render(<ChatComposer onSubmit={onSubmit} />)
|
|
80
|
+
const textarea = screen.getByRole('textbox')
|
|
81
|
+
fireEvent.change(textarea, { target: { value: 'line one' } })
|
|
82
|
+
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true })
|
|
83
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('does NOT submit on bare Enter when the content is multi-line (pasted outline stays intact)', () => {
|
|
87
|
+
const onSubmit = jest.fn()
|
|
88
|
+
render(<ChatComposer onSubmit={onSubmit} />)
|
|
89
|
+
const textarea = screen.getByRole('textbox')
|
|
90
|
+
fireEvent.change(textarea, { target: { value: 'My Deck\nSlide 1: Problem\nSlide 2: Solution' } })
|
|
91
|
+
fireEvent.keyDown(textarea, { key: 'Enter' })
|
|
92
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('submits multi-line content on Cmd/Ctrl+Enter', () => {
|
|
96
|
+
const onSubmit = jest.fn()
|
|
97
|
+
render(<ChatComposer onSubmit={onSubmit} />)
|
|
98
|
+
const textarea = screen.getByRole('textbox')
|
|
99
|
+
fireEvent.change(textarea, { target: { value: 'My Deck\nSlide 1: Problem' } })
|
|
100
|
+
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
|
|
101
|
+
expect(onSubmit).toHaveBeenCalledWith('My Deck\nSlide 1: Problem')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('does not submit empty/whitespace', () => {
|
|
105
|
+
const onSubmit = jest.fn()
|
|
106
|
+
render(<ChatComposer onSubmit={onSubmit} />)
|
|
107
|
+
const textarea = screen.getByRole('textbox')
|
|
108
|
+
fireEvent.change(textarea, { target: { value: ' ' } })
|
|
109
|
+
fireEvent.keyDown(textarea, { key: 'Enter' })
|
|
110
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('submits via the send button', () => {
|
|
114
|
+
const onSubmit = jest.fn()
|
|
115
|
+
render(<ChatComposer onSubmit={onSubmit} />)
|
|
116
|
+
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'go' } })
|
|
117
|
+
fireEvent.click(screen.getByRole('button', { name: /send/i }))
|
|
118
|
+
expect(onSubmit).toHaveBeenCalledWith('go')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('disables submission while busy', () => {
|
|
122
|
+
const onSubmit = jest.fn()
|
|
123
|
+
render(<ChatComposer onSubmit={onSubmit} busy />)
|
|
124
|
+
const textarea = screen.getByRole('textbox')
|
|
125
|
+
fireEvent.change(textarea, { target: { value: 'hi' } })
|
|
126
|
+
fireEvent.keyDown(textarea, { key: 'Enter' })
|
|
127
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
128
|
+
})
|
|
129
|
+
})
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
2
|
+
import { MeetingsList, type MeetingsListItem } from '../calendar/meetings-list'
|
|
3
|
+
|
|
4
|
+
const sampleMeetings: MeetingsListItem[] = [
|
|
5
|
+
{
|
|
6
|
+
id: 1,
|
|
7
|
+
title: 'Sequoia partner sync',
|
|
8
|
+
scheduled_at: '2026-04-29T16:00:00Z',
|
|
9
|
+
status: 'confirmed',
|
|
10
|
+
duration_minutes: 30,
|
|
11
|
+
investor_name: 'Roelof',
|
|
12
|
+
investor_email: 'roelof@sequoia.com',
|
|
13
|
+
meeting_link: 'https://meet.google.com/abc',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
id: 2,
|
|
17
|
+
title: 'Acme intro',
|
|
18
|
+
scheduled_at: '2026-04-30T14:00:00Z',
|
|
19
|
+
status: 'pending',
|
|
20
|
+
duration_minutes: 45,
|
|
21
|
+
investor_email: 'partner@acme.vc',
|
|
22
|
+
},
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
describe('MeetingsList', () => {
|
|
26
|
+
it('renders each meeting title', () => {
|
|
27
|
+
render(<MeetingsList meetings={sampleMeetings} />)
|
|
28
|
+
expect(screen.getByText('Sequoia partner sync')).toBeInTheDocument()
|
|
29
|
+
expect(screen.getByText('Acme intro')).toBeInTheDocument()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('shows status badges per meeting', () => {
|
|
33
|
+
render(<MeetingsList meetings={sampleMeetings} />)
|
|
34
|
+
expect(screen.getByText('Confirmed')).toBeInTheDocument()
|
|
35
|
+
expect(screen.getByText('Pending')).toBeInTheDocument()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('shows duration when provided', () => {
|
|
39
|
+
render(<MeetingsList meetings={sampleMeetings} />)
|
|
40
|
+
expect(screen.getByText(/· 30min/)).toBeInTheDocument()
|
|
41
|
+
expect(screen.getByText(/· 45min/)).toBeInTheDocument()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('renders skeletons when loading', () => {
|
|
45
|
+
const { container } = render(
|
|
46
|
+
<MeetingsList meetings={[]} loading loadingRowCount={6} />
|
|
47
|
+
)
|
|
48
|
+
expect(container.querySelectorAll('.animate-pulse')).toHaveLength(6)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('renders empty state with custom message', () => {
|
|
52
|
+
render(<MeetingsList meetings={[]} emptyMessage="Nothing yet" />)
|
|
53
|
+
expect(screen.getByText('Nothing yet')).toBeInTheDocument()
|
|
54
|
+
// No CTA without onSchedule
|
|
55
|
+
expect(screen.queryByText('Schedule First Meeting')).not.toBeInTheDocument()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('shows the Schedule CTA when onSchedule provided + fires it', () => {
|
|
59
|
+
const onSchedule = jest.fn()
|
|
60
|
+
render(<MeetingsList meetings={[]} onSchedule={onSchedule} />)
|
|
61
|
+
fireEvent.click(screen.getByText('Schedule First Meeting'))
|
|
62
|
+
expect(onSchedule).toHaveBeenCalledTimes(1)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('does NOT render delete button when onDelete is absent', () => {
|
|
66
|
+
render(<MeetingsList meetings={sampleMeetings} />)
|
|
67
|
+
expect(screen.queryByLabelText('Delete meeting')).not.toBeInTheDocument()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('renders delete buttons + fires onDelete with the meeting id', () => {
|
|
71
|
+
const onDelete = jest.fn()
|
|
72
|
+
render(<MeetingsList meetings={sampleMeetings} onDelete={onDelete} />)
|
|
73
|
+
const deleteButtons = screen.getAllByLabelText('Delete meeting')
|
|
74
|
+
expect(deleteButtons).toHaveLength(2)
|
|
75
|
+
fireEvent.click(deleteButtons[0])
|
|
76
|
+
expect(onDelete).toHaveBeenCalledWith(1)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('fires onRowClick with the meeting payload', () => {
|
|
80
|
+
const onRowClick = jest.fn()
|
|
81
|
+
render(<MeetingsList meetings={sampleMeetings} onRowClick={onRowClick} />)
|
|
82
|
+
fireEvent.click(screen.getByText('Sequoia partner sync'))
|
|
83
|
+
expect(onRowClick).toHaveBeenCalledTimes(1)
|
|
84
|
+
expect(onRowClick.mock.calls[0][0].id).toBe(1)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('does not fire onRowClick when delete button is clicked (event bubbling guard)', () => {
|
|
88
|
+
const onRowClick = jest.fn()
|
|
89
|
+
const onDelete = jest.fn()
|
|
90
|
+
render(
|
|
91
|
+
<MeetingsList
|
|
92
|
+
meetings={sampleMeetings}
|
|
93
|
+
onRowClick={onRowClick}
|
|
94
|
+
onDelete={onDelete}
|
|
95
|
+
/>
|
|
96
|
+
)
|
|
97
|
+
fireEvent.click(screen.getAllByLabelText('Delete meeting')[0])
|
|
98
|
+
expect(onDelete).toHaveBeenCalledTimes(1)
|
|
99
|
+
expect(onRowClick).not.toHaveBeenCalled()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('renders Join link when meeting_link is set', () => {
|
|
103
|
+
render(<MeetingsList meetings={sampleMeetings} />)
|
|
104
|
+
const joinLink = screen.getByTitle('Join meeting')
|
|
105
|
+
expect(joinLink).toHaveAttribute('href', 'https://meet.google.com/abc')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it("does NOT propagate row click when join link is clicked", () => {
|
|
109
|
+
const onRowClick = jest.fn()
|
|
110
|
+
render(<MeetingsList meetings={sampleMeetings} onRowClick={onRowClick} />)
|
|
111
|
+
fireEvent.click(screen.getByTitle('Join meeting'))
|
|
112
|
+
expect(onRowClick).not.toHaveBeenCalled()
|
|
113
|
+
})
|
|
114
|
+
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
2
|
+
import { SlideCanvas } from '../slide-deck/SlideCanvas'
|
|
3
|
+
import { SlideFilmstrip } from '../slide-deck/SlideFilmstrip'
|
|
4
|
+
import { SlideDeckViewer } from '../slide-deck/SlideDeckViewer'
|
|
5
|
+
import type { SlideData } from '../slide-deck/types'
|
|
6
|
+
|
|
7
|
+
const slides: SlideData[] = [
|
|
8
|
+
{ id: 's1', slideNumber: 1, title: 'Title', imageUrl: 'https://x/1.png', status: 'ready' },
|
|
9
|
+
{ id: 's2', slideNumber: 2, title: 'Problem', imageUrl: 'https://x/2.png', status: 'ready' },
|
|
10
|
+
{ id: 's3', slideNumber: 3, title: 'Solution', status: 'generating' },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
describe('SlideCanvas', () => {
|
|
14
|
+
it('renders a full-bleed image in image render mode', () => {
|
|
15
|
+
render(<SlideCanvas slide={slides[0]} />)
|
|
16
|
+
const img = screen.getByRole('img')
|
|
17
|
+
expect(img).toHaveAttribute('src', 'https://x/1.png')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('shows a generating state while the slide is in progress', () => {
|
|
21
|
+
const { container } = render(<SlideCanvas slide={slides[2]} />)
|
|
22
|
+
const el = container.querySelector('[data-status="generating"]')
|
|
23
|
+
expect(el).toBeInTheDocument()
|
|
24
|
+
expect(el).toHaveAttribute('aria-busy', 'true')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('shows an error state with a retry control', () => {
|
|
28
|
+
const onRetry = jest.fn()
|
|
29
|
+
render(<SlideCanvas slide={{ id: 'e', slideNumber: 1, status: 'error' }} onRetry={onRetry} />)
|
|
30
|
+
const retry = screen.getByRole('button', { name: /retry|regenerate/i })
|
|
31
|
+
fireEvent.click(retry)
|
|
32
|
+
expect(onRetry).toHaveBeenCalled()
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('SlideFilmstrip', () => {
|
|
37
|
+
it('renders a thumbnail per slide and marks the active one', () => {
|
|
38
|
+
render(<SlideFilmstrip slides={slides} activeId="s2" onSelect={() => {}} />)
|
|
39
|
+
const thumbs = screen.getAllByRole('button')
|
|
40
|
+
expect(thumbs).toHaveLength(3)
|
|
41
|
+
const active = thumbs.find((t) => t.getAttribute('aria-current') === 'true')
|
|
42
|
+
expect(active).toBeTruthy()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('calls onSelect with the slide id when a thumbnail is clicked', () => {
|
|
46
|
+
const onSelect = jest.fn()
|
|
47
|
+
render(<SlideFilmstrip slides={slides} activeId="s1" onSelect={onSelect} />)
|
|
48
|
+
fireEvent.click(screen.getAllByRole('button')[2])
|
|
49
|
+
expect(onSelect).toHaveBeenCalledWith('s3')
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('SlideDeckViewer', () => {
|
|
54
|
+
it('renders the active slide and the position indicator', () => {
|
|
55
|
+
const { container } = render(<SlideDeckViewer slides={slides} activeId="s2" />)
|
|
56
|
+
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
|
57
|
+
const stage = container.querySelector('[data-testid="slide-deck-stage"]') as HTMLElement
|
|
58
|
+
expect(stage.querySelector('img')).toHaveAttribute('src', 'https://x/2.png')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('advances the active slide with ArrowRight', () => {
|
|
62
|
+
const onActiveChange = jest.fn()
|
|
63
|
+
const { container } = render(
|
|
64
|
+
<SlideDeckViewer slides={slides} activeId="s1" onActiveChange={onActiveChange} />
|
|
65
|
+
)
|
|
66
|
+
const stage = container.querySelector('[data-testid="slide-deck-stage"]') as HTMLElement
|
|
67
|
+
fireEvent.keyDown(stage, { key: 'ArrowRight' })
|
|
68
|
+
expect(onActiveChange).toHaveBeenCalledWith('s2')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('renders an empty state when there are no slides', () => {
|
|
72
|
+
render(<SlideDeckViewer slides={[]} emptyState={<div>No slides yet</div>} />)
|
|
73
|
+
expect(screen.getByText('No slides yet')).toBeInTheDocument()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('invokes onRegenerate for the active slide', () => {
|
|
77
|
+
const onRegenerate = jest.fn()
|
|
78
|
+
render(<SlideDeckViewer slides={slides} activeId="s1" onRegenerate={onRegenerate} />)
|
|
79
|
+
fireEvent.click(screen.getByRole('button', { name: /regenerate/i }))
|
|
80
|
+
expect(onRegenerate).toHaveBeenCalledWith(slides[0])
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
2
|
+
import { UpcomingMeetings, type UpcomingMeeting } from '../calendar/upcoming-meetings'
|
|
3
|
+
|
|
4
|
+
const sampleMeetings: UpcomingMeeting[] = [
|
|
5
|
+
{
|
|
6
|
+
id: 1,
|
|
7
|
+
title: 'Sequoia partner sync',
|
|
8
|
+
scheduled_at: '2026-04-29T16:00:00Z',
|
|
9
|
+
status: 'confirmed',
|
|
10
|
+
investor_name: 'Roelof Botha',
|
|
11
|
+
investor_email: 'roelof@sequoia.com',
|
|
12
|
+
meeting_link: 'https://meet.google.com/abc',
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
id: 2,
|
|
16
|
+
title: 'Acme partner intro',
|
|
17
|
+
scheduled_at: '2026-04-30T14:00:00Z',
|
|
18
|
+
status: 'pending',
|
|
19
|
+
investor_email: 'partner@acme.vc',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: 3,
|
|
23
|
+
title: 'Cancelled call',
|
|
24
|
+
scheduled_at: '2026-05-01T10:00:00Z',
|
|
25
|
+
status: 'cancelled',
|
|
26
|
+
},
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
describe('UpcomingMeetings', () => {
|
|
30
|
+
it('renders the title and each meeting title', () => {
|
|
31
|
+
render(<UpcomingMeetings meetings={sampleMeetings} />)
|
|
32
|
+
expect(screen.getByText('Upcoming Meetings')).toBeInTheDocument()
|
|
33
|
+
expect(screen.getByText('Sequoia partner sync')).toBeInTheDocument()
|
|
34
|
+
expect(screen.getByText('Acme partner intro')).toBeInTheDocument()
|
|
35
|
+
expect(screen.getByText('Cancelled call')).toBeInTheDocument()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('shows the right status badge per meeting', () => {
|
|
39
|
+
render(<UpcomingMeetings meetings={sampleMeetings} />)
|
|
40
|
+
expect(screen.getByText('Confirmed')).toBeInTheDocument()
|
|
41
|
+
expect(screen.getByText('Pending')).toBeInTheDocument()
|
|
42
|
+
expect(screen.getByText('Cancelled')).toBeInTheDocument()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('renders skeletons in loading state', () => {
|
|
46
|
+
const { container } = render(
|
|
47
|
+
<UpcomingMeetings meetings={[]} loading loadingRowCount={5} />
|
|
48
|
+
)
|
|
49
|
+
// Skeletons render as empty divs with animate-pulse — count them by class.
|
|
50
|
+
const skeletons = container.querySelectorAll('.animate-pulse')
|
|
51
|
+
expect(skeletons).toHaveLength(5)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('renders empty state when no meetings', () => {
|
|
55
|
+
render(<UpcomingMeetings meetings={[]} />)
|
|
56
|
+
expect(screen.getByText('No upcoming meetings')).toBeInTheDocument()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('does not render Schedule CTA when onSchedule is not provided', () => {
|
|
60
|
+
render(<UpcomingMeetings meetings={[]} />)
|
|
61
|
+
expect(screen.queryByText('Schedule one')).not.toBeInTheDocument()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('renders Schedule CTA when onSchedule is provided and fires it on click', () => {
|
|
65
|
+
const onSchedule = jest.fn()
|
|
66
|
+
render(<UpcomingMeetings meetings={[]} onSchedule={onSchedule} />)
|
|
67
|
+
const cta = screen.getByText('Schedule one')
|
|
68
|
+
expect(cta).toBeInTheDocument()
|
|
69
|
+
fireEvent.click(cta)
|
|
70
|
+
expect(onSchedule).toHaveBeenCalledTimes(1)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('fires onMeetingClick with the original meeting payload', () => {
|
|
74
|
+
const onMeetingClick = jest.fn()
|
|
75
|
+
render(<UpcomingMeetings meetings={sampleMeetings} onMeetingClick={onMeetingClick} />)
|
|
76
|
+
fireEvent.click(screen.getByText('Sequoia partner sync'))
|
|
77
|
+
expect(onMeetingClick).toHaveBeenCalledTimes(1)
|
|
78
|
+
expect(onMeetingClick.mock.calls[0][0].id).toBe(1)
|
|
79
|
+
expect(onMeetingClick.mock.calls[0][0].investor_name).toBe('Roelof Botha')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('renders View all link when viewAllHref is provided', () => {
|
|
83
|
+
render(<UpcomingMeetings meetings={sampleMeetings} viewAllHref="/calendar" />)
|
|
84
|
+
const link = screen.getByText('View all')
|
|
85
|
+
expect(link.closest('a')).toHaveAttribute('href', '/calendar')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('renders Join link when meeting_link is set', () => {
|
|
89
|
+
render(<UpcomingMeetings meetings={sampleMeetings} />)
|
|
90
|
+
const join = screen.getByText('Join')
|
|
91
|
+
expect(join.closest('a')).toHaveAttribute('href', 'https://meet.google.com/abc')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("falls back to investor_email when investor_name is absent", () => {
|
|
95
|
+
render(<UpcomingMeetings meetings={sampleMeetings} />)
|
|
96
|
+
expect(screen.getByText('partner@acme.vc')).toBeInTheDocument()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('uses a custom title when title prop is provided', () => {
|
|
100
|
+
render(<UpcomingMeetings meetings={[]} title="This Week's Calls" />)
|
|
101
|
+
expect(screen.getByText("This Week's Calls")).toBeInTheDocument()
|
|
102
|
+
expect(screen.queryByText('Upcoming Meetings')).not.toBeInTheDocument()
|
|
103
|
+
})
|
|
104
|
+
})
|