@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
|
@@ -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', () => {
|
|
@@ -11,7 +11,6 @@ export interface ChangePasswordFormProps {
|
|
|
11
11
|
onSubmit: (values: {
|
|
12
12
|
old_password: string
|
|
13
13
|
new_password: string
|
|
14
|
-
new_password_confirm: string
|
|
15
14
|
}) => Promise<void>
|
|
16
15
|
/** Called after a successful password change (e.g. to sign out) */
|
|
17
16
|
onSuccess?: () => void
|
|
@@ -21,38 +20,27 @@ export interface ChangePasswordFormProps {
|
|
|
21
20
|
export function ChangePasswordForm({ onSubmit, onSuccess, disabled }: ChangePasswordFormProps) {
|
|
22
21
|
const [oldPassword, setOldPassword] = useState('')
|
|
23
22
|
const [newPassword, setNewPassword] = useState('')
|
|
24
|
-
const [confirmPassword, setConfirmPassword] = useState('')
|
|
25
23
|
const [saving, setSaving] = useState(false)
|
|
26
24
|
const [error, setError] = useState<string | null>(null)
|
|
27
25
|
const [success, setSuccess] = useState(false)
|
|
28
26
|
|
|
29
|
-
const isValid =
|
|
30
|
-
oldPassword.length > 0 &&
|
|
31
|
-
newPassword.length >= 8 &&
|
|
32
|
-
newPassword === confirmPassword
|
|
27
|
+
const isValid = oldPassword.length > 0 && newPassword.length >= 8
|
|
33
28
|
|
|
34
29
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
35
30
|
e.preventDefault()
|
|
36
31
|
setError(null)
|
|
37
32
|
setSuccess(false)
|
|
38
33
|
|
|
39
|
-
if (newPassword !== confirmPassword) {
|
|
40
|
-
setError('New passwords do not match.')
|
|
41
|
-
return
|
|
42
|
-
}
|
|
43
|
-
|
|
44
34
|
setSaving(true)
|
|
45
35
|
|
|
46
36
|
try {
|
|
47
37
|
await onSubmit({
|
|
48
38
|
old_password: oldPassword,
|
|
49
39
|
new_password: newPassword,
|
|
50
|
-
new_password_confirm: confirmPassword,
|
|
51
40
|
})
|
|
52
41
|
setSuccess(true)
|
|
53
42
|
setOldPassword('')
|
|
54
43
|
setNewPassword('')
|
|
55
|
-
setConfirmPassword('')
|
|
56
44
|
onSuccess?.()
|
|
57
45
|
} catch (err) {
|
|
58
46
|
setError(err instanceof Error ? err.message : 'Failed to change password')
|
|
@@ -100,21 +88,6 @@ export function ChangePasswordForm({ onSubmit, onSuccess, disabled }: ChangePass
|
|
|
100
88
|
)}
|
|
101
89
|
</div>
|
|
102
90
|
|
|
103
|
-
<div className="space-y-2">
|
|
104
|
-
<Label htmlFor="cp-confirm-password">Confirm new password</Label>
|
|
105
|
-
<Input
|
|
106
|
-
id="cp-confirm-password"
|
|
107
|
-
type="password"
|
|
108
|
-
value={confirmPassword}
|
|
109
|
-
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
110
|
-
disabled={disabled || saving}
|
|
111
|
-
autoComplete="new-password"
|
|
112
|
-
/>
|
|
113
|
-
{confirmPassword.length > 0 && newPassword !== confirmPassword && (
|
|
114
|
-
<p className="text-xs text-destructive">Passwords do not match.</p>
|
|
115
|
-
)}
|
|
116
|
-
</div>
|
|
117
|
-
|
|
118
91
|
{error && (
|
|
119
92
|
<p className="text-sm text-destructive">{error}</p>
|
|
120
93
|
)}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CalendarView — shared month/week/day event grid.
|
|
5
|
+
*
|
|
6
|
+
* Wraps react-big-calendar with a date-fns localizer pre-wired and a
|
|
7
|
+
* simplified event shape so consumers don't have to reach for raw RBC types.
|
|
8
|
+
*
|
|
9
|
+
* IMPORTANT: react-big-calendar ships its own CSS. Consumers must import
|
|
10
|
+
* `react-big-calendar/lib/css/react-big-calendar.css` once at the app level
|
|
11
|
+
* (e.g. in _app.tsx / layout.tsx). This package does NOT auto-inject CSS so
|
|
12
|
+
* consumers can layer Tailwind overrides predictably.
|
|
13
|
+
*
|
|
14
|
+
* lifecycle:calendar-ui (raise-simpli-k4u)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as React from "react"
|
|
18
|
+
import {
|
|
19
|
+
Calendar,
|
|
20
|
+
dateFnsLocalizer,
|
|
21
|
+
Views,
|
|
22
|
+
type View,
|
|
23
|
+
type SlotInfo,
|
|
24
|
+
} from "react-big-calendar"
|
|
25
|
+
import { format, parse, startOfWeek, getDay } from "date-fns"
|
|
26
|
+
import { enUS } from "date-fns/locale"
|
|
27
|
+
|
|
28
|
+
const locales = { "en-US": enUS }
|
|
29
|
+
|
|
30
|
+
const localizer = dateFnsLocalizer({
|
|
31
|
+
format,
|
|
32
|
+
parse,
|
|
33
|
+
startOfWeek: () => startOfWeek(new Date(), { weekStartsOn: 0 }),
|
|
34
|
+
getDay,
|
|
35
|
+
locales,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
/** Domain-friendly event shape callers pass in. */
|
|
39
|
+
export interface CalendarEvent<T = unknown> {
|
|
40
|
+
/** Stable identifier — passed through to onEventClick payload */
|
|
41
|
+
id: string | number
|
|
42
|
+
/** Event title shown on the grid */
|
|
43
|
+
title: string
|
|
44
|
+
/** Event start (Date or ISO string) */
|
|
45
|
+
start: Date | string
|
|
46
|
+
/** Event end. If omitted, defaults to start + 30min */
|
|
47
|
+
end?: Date | string
|
|
48
|
+
/** Whether to render as all-day across the top of the day */
|
|
49
|
+
allDay?: boolean
|
|
50
|
+
/** Optional pass-through payload (the original ScheduledMeeting / etc.) */
|
|
51
|
+
resource?: T
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface InternalRBCEvent {
|
|
55
|
+
id: string | number
|
|
56
|
+
title: string
|
|
57
|
+
start: Date
|
|
58
|
+
end: Date
|
|
59
|
+
allDay: boolean
|
|
60
|
+
resource: unknown
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const DEFAULT_DURATION_MS = 30 * 60 * 1000
|
|
64
|
+
|
|
65
|
+
function toDate(value: Date | string): Date {
|
|
66
|
+
return value instanceof Date ? value : new Date(value)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeEvent(e: CalendarEvent): InternalRBCEvent {
|
|
70
|
+
const start = toDate(e.start)
|
|
71
|
+
const end = e.end ? toDate(e.end) : new Date(start.getTime() + DEFAULT_DURATION_MS)
|
|
72
|
+
return {
|
|
73
|
+
id: e.id,
|
|
74
|
+
title: e.title,
|
|
75
|
+
start,
|
|
76
|
+
end,
|
|
77
|
+
allDay: e.allDay ?? false,
|
|
78
|
+
resource: e.resource,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type CalendarViewMode = "month" | "week" | "day" | "agenda"
|
|
83
|
+
|
|
84
|
+
const VIEW_BY_MODE: Record<CalendarViewMode, View> = {
|
|
85
|
+
month: Views.MONTH,
|
|
86
|
+
week: Views.WEEK,
|
|
87
|
+
day: Views.DAY,
|
|
88
|
+
agenda: Views.AGENDA,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface CalendarViewProps<T = unknown> {
|
|
92
|
+
/** Events to render on the grid */
|
|
93
|
+
events: CalendarEvent<T>[]
|
|
94
|
+
/** Currently navigated date (controlled) */
|
|
95
|
+
date?: Date
|
|
96
|
+
/** Default date if uncontrolled */
|
|
97
|
+
defaultDate?: Date
|
|
98
|
+
/** Active view (controlled) */
|
|
99
|
+
view?: CalendarViewMode
|
|
100
|
+
/** Default view if uncontrolled */
|
|
101
|
+
defaultView?: CalendarViewMode
|
|
102
|
+
/** Allowed views the toolbar can switch between */
|
|
103
|
+
views?: CalendarViewMode[]
|
|
104
|
+
/** Fires when the user clicks an event. Receives the original event passed in. */
|
|
105
|
+
onEventClick?: (event: CalendarEvent<T>) => void
|
|
106
|
+
/** Fires when the user clicks an empty slot (or drags to select a range). */
|
|
107
|
+
onSlotClick?: (slot: { start: Date; end: Date; action: "click" | "select" }) => void
|
|
108
|
+
/** Fires when the user navigates to a new date. */
|
|
109
|
+
onNavigate?: (date: Date) => void
|
|
110
|
+
/** Fires when the user changes view. */
|
|
111
|
+
onViewChange?: (view: CalendarViewMode) => void
|
|
112
|
+
/** Allow drag-to-select empty time slots (defaults true). */
|
|
113
|
+
selectable?: boolean
|
|
114
|
+
/** Min height for the grid (default 600px). */
|
|
115
|
+
minHeight?: number
|
|
116
|
+
/** className passed to the wrapping div for Tailwind overrides. */
|
|
117
|
+
className?: string
|
|
118
|
+
/**
|
|
119
|
+
* Per-event styling. Receives the original CalendarEvent (with resource)
|
|
120
|
+
* and returns optional className / inline style applied to the rendered
|
|
121
|
+
* event block. Useful for color-coding by source (e.g. real Outlook event
|
|
122
|
+
* vs locally-scheduled meeting).
|
|
123
|
+
*/
|
|
124
|
+
eventPropGetter?: (event: CalendarEvent<T>) => {
|
|
125
|
+
className?: string
|
|
126
|
+
style?: React.CSSProperties
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* When the month grid runs out of vertical space, react-big-calendar
|
|
130
|
+
* collapses the overflow into a "+N more" link. Set true (default) to
|
|
131
|
+
* open the day's full event list as a popover when that link is clicked.
|
|
132
|
+
*/
|
|
133
|
+
popup?: boolean
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Shared calendar grid. Pre-wired with date-fns localizer.
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* import "react-big-calendar/lib/css/react-big-calendar.css"
|
|
141
|
+
* import { CalendarView } from "@startsimpli/ui"
|
|
142
|
+
*
|
|
143
|
+
* <CalendarView
|
|
144
|
+
* events={meetings.map(m => ({
|
|
145
|
+
* id: m.id,
|
|
146
|
+
* title: m.title,
|
|
147
|
+
* start: m.scheduled_at,
|
|
148
|
+
* end: addMinutes(new Date(m.scheduled_at), m.duration_minutes),
|
|
149
|
+
* resource: m,
|
|
150
|
+
* }))}
|
|
151
|
+
* onEventClick={e => openMeetingDialog(e.resource)}
|
|
152
|
+
* onSlotClick={s => openCreateMeetingDialog(s.start)}
|
|
153
|
+
* />
|
|
154
|
+
*/
|
|
155
|
+
export function CalendarView<T = unknown>({
|
|
156
|
+
events,
|
|
157
|
+
date,
|
|
158
|
+
defaultDate,
|
|
159
|
+
view,
|
|
160
|
+
defaultView,
|
|
161
|
+
views,
|
|
162
|
+
onEventClick,
|
|
163
|
+
onSlotClick,
|
|
164
|
+
onNavigate,
|
|
165
|
+
onViewChange,
|
|
166
|
+
selectable = true,
|
|
167
|
+
minHeight = 600,
|
|
168
|
+
className,
|
|
169
|
+
eventPropGetter,
|
|
170
|
+
popup = true,
|
|
171
|
+
}: CalendarViewProps<T>) {
|
|
172
|
+
const normalized = React.useMemo(() => events.map(normalizeEvent), [events])
|
|
173
|
+
|
|
174
|
+
// Map our public CalendarEvent[] back from the internal event RBC hands us
|
|
175
|
+
// on click — keeps the consumer API clean (they only see what they passed).
|
|
176
|
+
const eventByInternalId = React.useMemo(() => {
|
|
177
|
+
const map = new Map<string | number, CalendarEvent<T>>()
|
|
178
|
+
for (const e of events) map.set(e.id, e)
|
|
179
|
+
return map
|
|
180
|
+
}, [events])
|
|
181
|
+
|
|
182
|
+
const handleSelectEvent = React.useCallback(
|
|
183
|
+
(rbcEvent: object) => {
|
|
184
|
+
const id = (rbcEvent as InternalRBCEvent).id
|
|
185
|
+
const original = eventByInternalId.get(id)
|
|
186
|
+
if (original && onEventClick) onEventClick(original)
|
|
187
|
+
},
|
|
188
|
+
[eventByInternalId, onEventClick],
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
const handleSelectSlot = React.useCallback(
|
|
192
|
+
(slot: SlotInfo) => {
|
|
193
|
+
if (!onSlotClick) return
|
|
194
|
+
onSlotClick({
|
|
195
|
+
start: slot.start as Date,
|
|
196
|
+
end: slot.end as Date,
|
|
197
|
+
action: slot.action === "select" ? "select" : "click",
|
|
198
|
+
})
|
|
199
|
+
},
|
|
200
|
+
[onSlotClick],
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
const handleViewChange = React.useCallback(
|
|
204
|
+
(v: View) => {
|
|
205
|
+
if (!onViewChange) return
|
|
206
|
+
const mode = (Object.entries(VIEW_BY_MODE).find(([, val]) => val === v)?.[0] ?? "month") as CalendarViewMode
|
|
207
|
+
onViewChange(mode)
|
|
208
|
+
},
|
|
209
|
+
[onViewChange],
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
const allowedViews = React.useMemo<View[]>(
|
|
213
|
+
() => (views ?? ["month", "week", "day", "agenda"]).map(v => VIEW_BY_MODE[v]),
|
|
214
|
+
[views],
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
const rbcEventPropGetter = React.useCallback(
|
|
218
|
+
(rbcEvent: object) => {
|
|
219
|
+
if (!eventPropGetter) return {}
|
|
220
|
+
const id = (rbcEvent as InternalRBCEvent).id
|
|
221
|
+
const original = eventByInternalId.get(id)
|
|
222
|
+
if (!original) return {}
|
|
223
|
+
return eventPropGetter(original)
|
|
224
|
+
},
|
|
225
|
+
[eventPropGetter, eventByInternalId],
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<div className={className} style={{ minHeight }}>
|
|
230
|
+
<Calendar
|
|
231
|
+
localizer={localizer}
|
|
232
|
+
events={normalized}
|
|
233
|
+
startAccessor="start"
|
|
234
|
+
endAccessor="end"
|
|
235
|
+
titleAccessor="title"
|
|
236
|
+
allDayAccessor="allDay"
|
|
237
|
+
date={date}
|
|
238
|
+
defaultDate={defaultDate}
|
|
239
|
+
view={view ? VIEW_BY_MODE[view] : undefined}
|
|
240
|
+
defaultView={defaultView ? VIEW_BY_MODE[defaultView] : undefined}
|
|
241
|
+
views={allowedViews}
|
|
242
|
+
onSelectEvent={handleSelectEvent}
|
|
243
|
+
onSelectSlot={handleSelectSlot}
|
|
244
|
+
onNavigate={onNavigate}
|
|
245
|
+
onView={handleViewChange}
|
|
246
|
+
selectable={selectable}
|
|
247
|
+
popup={popup}
|
|
248
|
+
eventPropGetter={eventPropGetter ? rbcEventPropGetter : undefined}
|
|
249
|
+
style={{ height: "100%", minHeight }}
|
|
250
|
+
/>
|
|
251
|
+
</div>
|
|
252
|
+
)
|
|
253
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { CalendarView } from "./calendar-view"
|
|
2
|
+
export type {
|
|
3
|
+
CalendarEvent,
|
|
4
|
+
CalendarViewMode,
|
|
5
|
+
CalendarViewProps,
|
|
6
|
+
} from "./calendar-view"
|
|
7
|
+
|
|
8
|
+
export { UpcomingMeetings } from "./upcoming-meetings"
|
|
9
|
+
export type {
|
|
10
|
+
UpcomingMeeting,
|
|
11
|
+
UpcomingMeetingStatus,
|
|
12
|
+
UpcomingMeetingsProps,
|
|
13
|
+
} from "./upcoming-meetings"
|
|
14
|
+
|
|
15
|
+
export { MeetingsList } from "./meetings-list"
|
|
16
|
+
export type {
|
|
17
|
+
MeetingsListItem,
|
|
18
|
+
MeetingsListProps,
|
|
19
|
+
MeetingsListStatus,
|
|
20
|
+
} from "./meetings-list"
|