@startsimpli/ui 0.4.14 → 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 +18 -13
- package/src/components/__tests__/calendar-view-popup.test.tsx +42 -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__/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 +31 -0
- package/src/components/calendar/index.ts +7 -0
- package/src/components/calendar/meetings-list.tsx +202 -0
- package/src/components/calendar/upcoming-meetings.tsx +5 -5
- 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 +13 -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",
|
|
@@ -80,6 +74,7 @@
|
|
|
80
74
|
"@testing-library/react": "^16.3.2",
|
|
81
75
|
"@testing-library/user-event": "^14.6.1",
|
|
82
76
|
"@types/jest": "^30.0.0",
|
|
77
|
+
"@types/dagre": "^0.7.52",
|
|
83
78
|
"@types/node": "^20.19.39",
|
|
84
79
|
"@types/react": "^19.2.14",
|
|
85
80
|
"@types/react-big-calendar": "^1.16.3",
|
|
@@ -99,5 +94,15 @@
|
|
|
99
94
|
"react",
|
|
100
95
|
"nextjs",
|
|
101
96
|
"startsimpli"
|
|
102
|
-
]
|
|
103
|
-
|
|
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,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,106 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
2
|
+
import { SplitPane } from '../workspace/SplitPane'
|
|
3
|
+
import { DualPaneWorkspace } from '../workspace/DualPaneWorkspace'
|
|
4
|
+
|
|
5
|
+
describe('SplitPane', () => {
|
|
6
|
+
it('renders both panes', () => {
|
|
7
|
+
render(<SplitPane first={<div>LEFT</div>} second={<div>RIGHT</div>} />)
|
|
8
|
+
expect(screen.getByText('LEFT')).toBeInTheDocument()
|
|
9
|
+
expect(screen.getByText('RIGHT')).toBeInTheDocument()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('exposes an accessible resizer separator with aria values', () => {
|
|
13
|
+
render(
|
|
14
|
+
<SplitPane
|
|
15
|
+
first={<div>L</div>}
|
|
16
|
+
second={<div>R</div>}
|
|
17
|
+
initialSize={40}
|
|
18
|
+
minSize={20}
|
|
19
|
+
maxSize={80}
|
|
20
|
+
/>
|
|
21
|
+
)
|
|
22
|
+
const sep = screen.getByRole('separator')
|
|
23
|
+
expect(sep).toHaveAttribute('aria-orientation', 'vertical')
|
|
24
|
+
expect(sep).toHaveAttribute('aria-valuenow', '40')
|
|
25
|
+
expect(sep).toHaveAttribute('aria-valuemin', '20')
|
|
26
|
+
expect(sep).toHaveAttribute('aria-valuemax', '80')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('grows the first pane on ArrowRight and shrinks on ArrowLeft', () => {
|
|
30
|
+
const onResize = jest.fn()
|
|
31
|
+
render(
|
|
32
|
+
<SplitPane first={<div>L</div>} second={<div>R</div>} initialSize={50} onResize={onResize} />
|
|
33
|
+
)
|
|
34
|
+
const sep = screen.getByRole('separator')
|
|
35
|
+
fireEvent.keyDown(sep, { key: 'ArrowRight' })
|
|
36
|
+
expect(Number(sep.getAttribute('aria-valuenow'))).toBeGreaterThan(50)
|
|
37
|
+
fireEvent.keyDown(sep, { key: 'ArrowLeft' })
|
|
38
|
+
fireEvent.keyDown(sep, { key: 'ArrowLeft' })
|
|
39
|
+
expect(Number(sep.getAttribute('aria-valuenow'))).toBeLessThan(50)
|
|
40
|
+
expect(onResize).toHaveBeenCalled()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('clamps size to min/max', () => {
|
|
44
|
+
render(<SplitPane first={<div>L</div>} second={<div>R</div>} initialSize={22} minSize={20} maxSize={80} />)
|
|
45
|
+
const sep = screen.getByRole('separator')
|
|
46
|
+
// hammer left many times — must not drop below min
|
|
47
|
+
for (let i = 0; i < 20; i++) fireEvent.keyDown(sep, { key: 'ArrowLeft' })
|
|
48
|
+
expect(Number(sep.getAttribute('aria-valuenow'))).toBe(20)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('persists size to localStorage when storageKey is set', () => {
|
|
52
|
+
window.localStorage.clear()
|
|
53
|
+
render(<SplitPane first={<div>L</div>} second={<div>R</div>} initialSize={50} storageKey="sp-test" />)
|
|
54
|
+
const sep = screen.getByRole('separator')
|
|
55
|
+
fireEvent.keyDown(sep, { key: 'ArrowRight' })
|
|
56
|
+
expect(window.localStorage.getItem('sp-test')).not.toBeNull()
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('DualPaneWorkspace', () => {
|
|
61
|
+
it('renders left and right content with labels and an optional toolbar', () => {
|
|
62
|
+
render(
|
|
63
|
+
<DualPaneWorkspace
|
|
64
|
+
leftLabel="Chat"
|
|
65
|
+
rightLabel="Slides"
|
|
66
|
+
toolbar={<div>TOOLBAR</div>}
|
|
67
|
+
left={<div>CHAT_PANE</div>}
|
|
68
|
+
right={<div>SLIDES_PANE</div>}
|
|
69
|
+
layout="split"
|
|
70
|
+
/>
|
|
71
|
+
)
|
|
72
|
+
expect(screen.getByText('CHAT_PANE')).toBeInTheDocument()
|
|
73
|
+
expect(screen.getByText('SLIDES_PANE')).toBeInTheDocument()
|
|
74
|
+
expect(screen.getByText('TOOLBAR')).toBeInTheDocument()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('stacks panes when layout="stack"', () => {
|
|
78
|
+
const { container } = render(
|
|
79
|
+
<DualPaneWorkspace left={<div>L</div>} right={<div>R</div>} layout="stack" />
|
|
80
|
+
)
|
|
81
|
+
const root = container.querySelector('[data-layout="stack"]')
|
|
82
|
+
expect(root).toBeInTheDocument()
|
|
83
|
+
// both panes still rendered when stacked
|
|
84
|
+
expect(screen.getByText('L')).toBeInTheDocument()
|
|
85
|
+
expect(screen.getByText('R')).toBeInTheDocument()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('collapses a pane when collapsible and the collapse control is used', () => {
|
|
89
|
+
render(
|
|
90
|
+
<DualPaneWorkspace
|
|
91
|
+
leftLabel="Chat"
|
|
92
|
+
rightLabel="Slides"
|
|
93
|
+
left={<div>CHAT_PANE</div>}
|
|
94
|
+
right={<div>SLIDES_PANE</div>}
|
|
95
|
+
layout="split"
|
|
96
|
+
collapsible
|
|
97
|
+
/>
|
|
98
|
+
)
|
|
99
|
+
const collapseLeft = screen.getByRole('button', { name: /collapse chat/i })
|
|
100
|
+
fireEvent.click(collapseLeft)
|
|
101
|
+
expect(screen.queryByText('CHAT_PANE')).not.toBeInTheDocument()
|
|
102
|
+
// right still visible, and an expand affordance appears
|
|
103
|
+
expect(screen.getByText('SLIDES_PANE')).toBeInTheDocument()
|
|
104
|
+
expect(screen.getByRole('button', { name: /expand chat/i })).toBeInTheDocument()
|
|
105
|
+
})
|
|
106
|
+
})
|
|
@@ -148,11 +148,11 @@ describe('ChangePasswordForm', () => {
|
|
|
148
148
|
jest.clearAllMocks()
|
|
149
149
|
})
|
|
150
150
|
|
|
151
|
-
it('renders
|
|
151
|
+
it('renders both password fields (current + new, no confirm — startsim-nbq)', () => {
|
|
152
152
|
render(<ChangePasswordForm {...defaultPasswordProps} />)
|
|
153
153
|
expect(screen.getByLabelText('Current password')).toBeInTheDocument()
|
|
154
154
|
expect(screen.getByLabelText('New password')).toBeInTheDocument()
|
|
155
|
-
expect(screen.
|
|
155
|
+
expect(screen.queryByLabelText('Confirm new password')).not.toBeInTheDocument()
|
|
156
156
|
})
|
|
157
157
|
|
|
158
158
|
it('submit button is disabled when fields are empty', () => {
|
|
@@ -165,7 +165,6 @@ describe('ChangePasswordForm', () => {
|
|
|
165
165
|
|
|
166
166
|
changeInput(screen.getByLabelText('Current password'), 'oldpassword')
|
|
167
167
|
changeInput(screen.getByLabelText('New password'), 'short')
|
|
168
|
-
changeInput(screen.getByLabelText('Confirm new password'), 'short')
|
|
169
168
|
|
|
170
169
|
expect(screen.getByRole('button', { name: /change password/i })).toBeDisabled()
|
|
171
170
|
})
|
|
@@ -182,40 +181,21 @@ describe('ChangePasswordForm', () => {
|
|
|
182
181
|
expect(screen.queryByText('Must be at least 8 characters.')).not.toBeInTheDocument()
|
|
183
182
|
})
|
|
184
183
|
|
|
185
|
-
it('submit button is
|
|
184
|
+
it('submit button is enabled when current + new (>=8) are filled', () => {
|
|
186
185
|
render(<ChangePasswordForm {...defaultPasswordProps} />)
|
|
187
186
|
|
|
188
187
|
changeInput(screen.getByLabelText('Current password'), 'oldpassword')
|
|
189
188
|
changeInput(screen.getByLabelText('New password'), 'newpassword1')
|
|
190
|
-
changeInput(screen.getByLabelText('Confirm new password'), 'different123')
|
|
191
|
-
|
|
192
|
-
expect(screen.getByRole('button', { name: /change password/i })).toBeDisabled()
|
|
193
|
-
})
|
|
194
|
-
|
|
195
|
-
it('shows mismatch hint when confirm does not match', () => {
|
|
196
|
-
render(<ChangePasswordForm {...defaultPasswordProps} />)
|
|
197
|
-
changeInput(screen.getByLabelText('New password'), 'newpassword1')
|
|
198
|
-
changeInput(screen.getByLabelText('Confirm new password'), 'different123')
|
|
199
|
-
expect(screen.getByText('Passwords do not match.')).toBeInTheDocument()
|
|
200
|
-
})
|
|
201
|
-
|
|
202
|
-
it('submit button is enabled when all fields are valid', () => {
|
|
203
|
-
render(<ChangePasswordForm {...defaultPasswordProps} />)
|
|
204
|
-
|
|
205
|
-
changeInput(screen.getByLabelText('Current password'), 'oldpassword')
|
|
206
|
-
changeInput(screen.getByLabelText('New password'), 'newpassword1')
|
|
207
|
-
changeInput(screen.getByLabelText('Confirm new password'), 'newpassword1')
|
|
208
189
|
|
|
209
190
|
expect(screen.getByRole('button', { name: /change password/i })).toBeEnabled()
|
|
210
191
|
})
|
|
211
192
|
|
|
212
|
-
it('calls onSubmit with
|
|
193
|
+
it('calls onSubmit with { old_password, new_password } — no confirm', async () => {
|
|
213
194
|
const onSubmit = jest.fn().mockResolvedValue(undefined)
|
|
214
195
|
render(<ChangePasswordForm onSubmit={onSubmit} />)
|
|
215
196
|
|
|
216
197
|
changeInput(screen.getByLabelText('Current password'), 'oldpassword')
|
|
217
198
|
changeInput(screen.getByLabelText('New password'), 'newpassword1')
|
|
218
|
-
changeInput(screen.getByLabelText('Confirm new password'), 'newpassword1')
|
|
219
199
|
|
|
220
200
|
fireEvent.click(screen.getByRole('button', { name: /change password/i }))
|
|
221
201
|
|
|
@@ -223,7 +203,6 @@ describe('ChangePasswordForm', () => {
|
|
|
223
203
|
expect(onSubmit).toHaveBeenCalledWith({
|
|
224
204
|
old_password: 'oldpassword',
|
|
225
205
|
new_password: 'newpassword1',
|
|
226
|
-
new_password_confirm: 'newpassword1',
|
|
227
206
|
})
|
|
228
207
|
})
|
|
229
208
|
})
|
|
@@ -234,7 +213,6 @@ describe('ChangePasswordForm', () => {
|
|
|
234
213
|
|
|
235
214
|
changeInput(screen.getByLabelText('Current password'), 'oldpassword')
|
|
236
215
|
changeInput(screen.getByLabelText('New password'), 'newpassword1')
|
|
237
|
-
changeInput(screen.getByLabelText('Confirm new password'), 'newpassword1')
|
|
238
216
|
|
|
239
217
|
fireEvent.click(screen.getByRole('button', { name: /change password/i }))
|
|
240
218
|
|
|
@@ -244,7 +222,6 @@ describe('ChangePasswordForm', () => {
|
|
|
244
222
|
|
|
245
223
|
expect(screen.getByLabelText('Current password')).toHaveValue('')
|
|
246
224
|
expect(screen.getByLabelText('New password')).toHaveValue('')
|
|
247
|
-
expect(screen.getByLabelText('Confirm new password')).toHaveValue('')
|
|
248
225
|
})
|
|
249
226
|
|
|
250
227
|
it('calls onSuccess callback after successful submit', async () => {
|
|
@@ -254,7 +231,6 @@ describe('ChangePasswordForm', () => {
|
|
|
254
231
|
|
|
255
232
|
changeInput(screen.getByLabelText('Current password'), 'oldpassword')
|
|
256
233
|
changeInput(screen.getByLabelText('New password'), 'newpassword1')
|
|
257
|
-
changeInput(screen.getByLabelText('Confirm new password'), 'newpassword1')
|
|
258
234
|
|
|
259
235
|
fireEvent.click(screen.getByRole('button', { name: /change password/i }))
|
|
260
236
|
|
|
@@ -269,7 +245,6 @@ describe('ChangePasswordForm', () => {
|
|
|
269
245
|
|
|
270
246
|
changeInput(screen.getByLabelText('Current password'), 'wrongold')
|
|
271
247
|
changeInput(screen.getByLabelText('New password'), 'newpassword1')
|
|
272
|
-
changeInput(screen.getByLabelText('Confirm new password'), 'newpassword1')
|
|
273
248
|
|
|
274
249
|
fireEvent.click(screen.getByRole('button', { name: /change password/i }))
|
|
275
250
|
|
|
@@ -284,7 +259,6 @@ describe('ChangePasswordForm', () => {
|
|
|
284
259
|
|
|
285
260
|
changeInput(screen.getByLabelText('Current password'), 'wrongold')
|
|
286
261
|
changeInput(screen.getByLabelText('New password'), 'newpassword1')
|
|
287
|
-
changeInput(screen.getByLabelText('Confirm new password'), 'newpassword1')
|
|
288
262
|
|
|
289
263
|
fireEvent.click(screen.getByRole('button', { name: /change password/i }))
|
|
290
264
|
|
|
@@ -293,11 +267,10 @@ describe('ChangePasswordForm', () => {
|
|
|
293
267
|
})
|
|
294
268
|
})
|
|
295
269
|
|
|
296
|
-
it('disables
|
|
270
|
+
it('disables both inputs when disabled prop is true', () => {
|
|
297
271
|
render(<ChangePasswordForm {...defaultPasswordProps} disabled />)
|
|
298
272
|
expect(screen.getByLabelText('Current password')).toBeDisabled()
|
|
299
273
|
expect(screen.getByLabelText('New password')).toBeDisabled()
|
|
300
|
-
expect(screen.getByLabelText('Confirm new password')).toBeDisabled()
|
|
301
274
|
})
|
|
302
275
|
|
|
303
276
|
it('renders card heading', () => {
|