@tuturuuu/ui 0.37.1 → 0.38.0

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.
Files changed (64) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/package.json +10 -7
  3. package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +267 -0
  4. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +24 -38
  5. package/src/components/ui/custom/education-todo.test.tsx +131 -0
  6. package/src/components/ui/custom/education-todo.tsx +191 -0
  7. package/src/components/ui/custom/hidden-workspaces-settings.tsx +49 -0
  8. package/src/components/ui/custom/mobile-header-height.ts +16 -0
  9. package/src/components/ui/custom/satellite-notification-polling.test.tsx +61 -0
  10. package/src/components/ui/custom/satellite-notification-popover.tsx +7 -3
  11. package/src/components/ui/custom/structure.tsx +37 -5
  12. package/src/components/ui/custom/user-filters.tsx +2 -1
  13. package/src/components/ui/custom/visible-workspace-filter.tsx +41 -0
  14. package/src/components/ui/custom/workspace-picker-content.tsx +356 -0
  15. package/src/components/ui/custom/workspace-select-helpers.ts +11 -0
  16. package/src/components/ui/custom/workspace-select-invitations.tsx +3 -0
  17. package/src/components/ui/custom/workspace-select.tsx +95 -359
  18. package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
  19. package/src/components/ui/legacy/calendar/event-modal.tsx +77 -137
  20. package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
  21. package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
  22. package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
  23. package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
  24. package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
  25. package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
  26. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
  27. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
  28. package/src/components/ui/legacy/calendar/settings/calendar-sync-dashboard.tsx +4 -23
  29. package/src/components/ui/legacy/calendar/settings/sync-logs-table.tsx +3 -6
  30. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
  31. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
  32. package/src/components/ui/popover.tsx +3 -1
  33. package/src/components/ui/resizable.tsx +2 -1
  34. package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
  35. package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
  36. package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
  37. package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
  38. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
  39. package/src/components/ui/text-editor/editor.tsx +8 -3
  40. package/src/components/ui/text-editor/extensions.ts +8 -2
  41. package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
  42. package/src/components/ui/text-editor/markdown.ts +11 -0
  43. package/src/components/ui/text-editor/tool-bar.tsx +43 -44
  44. package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
  45. package/src/hooks/__tests__/supabase-provider.test.ts +9 -0
  46. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +12 -2
  47. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
  48. package/src/hooks/__tests__/use-visible-workspaces.test.tsx +76 -0
  49. package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
  50. package/src/hooks/__tests__/use-workspace-visibility.test.tsx +155 -0
  51. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +108 -0
  52. package/src/hooks/__tests__/workspace-actor-lifetime.test.tsx +103 -0
  53. package/src/hooks/calendar-creation-request.test.ts +62 -0
  54. package/src/hooks/calendar-creation-request.ts +34 -0
  55. package/src/hooks/calendar-event-write-payload.ts +24 -0
  56. package/src/hooks/notification-subscription.ts +5 -2
  57. package/src/hooks/supabase-provider.ts +38 -72
  58. package/src/hooks/use-calendar.tsx +21 -45
  59. package/src/hooks/use-exchange-rates.ts +4 -15
  60. package/src/hooks/use-visible-workspaces.ts +57 -0
  61. package/src/hooks/use-workspace-presence.ts +9 -9
  62. package/src/hooks/use-workspace-user.ts +15 -3
  63. package/src/hooks/use-workspace-visibility.ts +234 -0
  64. package/src/hooks/use-yjs-collaboration.ts +45 -27
package/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.38.0](https://github.com/tutur3u/platform/compare/ui-v0.37.2...ui-v0.38.0) (2026-10-04)
4
+
5
+
6
+ ### Features
7
+
8
+ * **calendar:** support full Google color choices and scoped edits ([#5661](https://github.com/tutur3u/platform/issues/5661)) ([8cafac5](https://github.com/tutur3u/platform/commit/8cafac53438b504eafa8bcc8213d18448e4a17bc))
9
+ * **education:** add personal to do destinations to Teach and Learn ([9587942](https://github.com/tutur3u/platform/commit/9587942deeebcb6aca3a774f4289178771bcabd6))
10
+ * **education:** add personal To do in Teach and Learn ([#5785](https://github.com/tutur3u/platform/issues/5785)) ([0a5e302](https://github.com/tutur3u/platform/commit/0a5e302fa9b09c140c717751005bbda23a1dc0b1))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **calendar:** gate uncertain creation retries on durable capability ([4a0a088](https://github.com/tutur3u/platform/commit/4a0a0883ddfff464be0880afd13758908e65203f))
16
+ * **calendar:** recover edited drafts and align provider color parity ([d97142a](https://github.com/tutur3u/platform/commit/d97142abe22188b6a31a4a6b0e7b3a97d35d07f5))
17
+ * **finance:** authenticate exchange rates with satellite sessions ([33368f1](https://github.com/tutur3u/platform/commit/33368f1c00f86f71b4a66702899af262a90122e2)) ([#5760](https://github.com/tutur3u/platform/issues/5760)) ([759b1e5](https://github.com/tutur3u/platform/commit/759b1e52f8867aced2d64288af43b861d5ebccdc))
18
+ * **identity:** integrate main and order unapplied creator migrations ([5512e24](https://github.com/tutur3u/platform/commit/5512e2414abaa0c78b68cbdd3186a1d4bab861e7))
19
+ * **notifications:** preserve Colab polling policy ([ed09b9d](https://github.com/tutur3u/platform/commit/ed09b9ddaabd9b1865ca11e0459f23b0141137df))
20
+ * **ui:** close workspace popover without callback ([b0693b8](https://github.com/tutur3u/platform/commit/b0693b869721b9333d81d791554864291bec769d))
21
+ * **ui:** default browser workspace selectors to dropdowns ([9a8da02](https://github.com/tutur3u/platform/commit/9a8da02ceb7c03ee7eb05fb6cdc729144da6192a))
22
+ * **ui:** refine workspace dropdown recovery and viewport bounds ([06eeee7](https://github.com/tutur3u/platform/commit/06eeee77a26c1fc632907b72a5e0218d5d3fb49d))
23
+ * **web:** restore anchored workspace dropdown ([2df0661](https://github.com/tutur3u/platform/commit/2df0661f911eaec319cacca5d29242804d0e76ee))
24
+ * **web:** restore standard workspace dropdown ([#5739](https://github.com/tutur3u/platform/issues/5739)) ([f84ae74](https://github.com/tutur3u/platform/commit/f84ae741f2abab2a21aadfacda56bd7eba623466))
25
+
26
+
27
+ ### Performance Improvements
28
+
29
+ * **notifications:** reduce idle API polling ([32ee541](https://github.com/tutur3u/platform/commit/32ee5410e51d900698a06724b440b63df363fee9)) ([#5773](https://github.com/tutur3u/platform/issues/5773)) ([55089e0](https://github.com/tutur3u/platform/commit/55089e06ed6728f4bd6543787dcae48de3cfd734))
30
+
31
+ ## [0.37.2](https://github.com/tutur3u/platform/compare/ui-v0.37.1...ui-v0.37.2) (2026-10-02)
32
+
33
+
34
+ ### Bug Fixes
35
+
36
+ * **calendar:** bind source color hydration to persisted identity ([00269c3](https://github.com/tutur3u/platform/commit/00269c315cd2ac8e1c9607f450100db8537cada2))
37
+ * **calendar:** preserve month hover color contrast ([6fd591c](https://github.com/tutur3u/platform/commit/6fd591c46f4c90d6f4c685c63b140728dd0339fe))
38
+ * **calendar:** preserve opaque rendering cues and hydrate source colors ([33847dd](https://github.com/tutur3u/platform/commit/33847ddcd8a3cf2b5cef8ebf50ca4c1700424a27))
39
+ * **calendar:** preserve resolved contrast during event hover ([79001b3](https://github.com/tutur3u/platform/commit/79001b32ad1c813119f8f7df934e7142bbbd7585))
40
+ * **calendar:** render opaque readable event colors ([#5694](https://github.com/tutur3u/platform/issues/5694)) ([6e3d81d](https://github.com/tutur3u/platform/commit/6e3d81d6fdd6f8155f12e36ede58d31fde0ac362))
41
+ * **calendar:** restore timer restart and metadata color refresh ([58c17ff](https://github.com/tutur3u/platform/commit/58c17ff6a89624678cc207cd9551bc54f15f53d2)) ([#5722](https://github.com/tutur3u/platform/issues/5722)) ([8954e54](https://github.com/tutur3u/platform/commit/8954e5476fd04ada4cb846e909fa26b8a669cda3))
42
+
3
43
  ## [0.37.1](https://github.com/tutur3u/platform/compare/ui-v0.37.0...ui-v0.37.1) (2026-10-01)
4
44
 
5
45
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuturuuu/ui",
3
- "version": "0.37.1",
3
+ "version": "0.38.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -87,13 +87,14 @@
87
87
  "@tiptap/pm": "3.31.3",
88
88
  "@tiptap/react": "3.31.3",
89
89
  "@tiptap/starter-kit": "3.31.3",
90
- "@tuturuuu/ai": "0.16.1",
90
+ "@tuturuuu/ai": "0.17.0",
91
91
  "@tuturuuu/apis": "0.13.0",
92
92
  "@tuturuuu/hooks": "0.1.2",
93
93
  "@tuturuuu/icons": "0.1.1",
94
- "@tuturuuu/internal-api": "0.48.0",
94
+ "@tuturuuu/internal-api": "0.49.0",
95
+ "@tuturuuu/realtime": "0.10.0",
95
96
  "@tuturuuu/supabase": "0.5.2",
96
- "@tuturuuu/utils": "0.33.2",
97
+ "@tuturuuu/utils": "0.34.0",
97
98
  "@types/debug": "^4.1.13",
98
99
  "browser-image-compression": "^2.0.2",
99
100
  "class-variance-authority": "^0.7.1",
@@ -144,7 +145,7 @@
144
145
  "use-debounce": "^10.1.1",
145
146
  "vaul": "^1.1.2",
146
147
  "y-protocols": "^1.0.7",
147
- "yjs": "^13.6.32",
148
+ "yjs": "^13.6.33",
148
149
  "zod": "^4.6.2"
149
150
  },
150
151
  "devDependencies": {
@@ -154,7 +155,7 @@
154
155
  "@tanstack/react-table": "^9.2.4",
155
156
  "@testing-library/jest-dom": "^7.0.1",
156
157
  "@testing-library/react": "^16.3.3",
157
- "@tuturuuu/types": "0.33.1",
158
+ "@tuturuuu/types": "0.34.0",
158
159
  "@tuturuuu/typescript-config": "0.1.2",
159
160
  "@types/html2canvas": "^1.0.0",
160
161
  "@types/lodash": "^4.17.25",
@@ -179,6 +180,7 @@
179
180
  "@tanstack/react-table": "^9.0.0"
180
181
  },
181
182
  "exports": {
183
+ "./custom/education-todo": "./src/components/ui/custom/education-todo.tsx",
182
184
  "./globals.css": "./src/globals.css",
183
185
  "./marketing": "./src/components/ui/marketing/index.ts",
184
186
  "./postcss.config": "./postcss.config.mjs",
@@ -497,7 +499,8 @@
497
499
  "./custom/satellite-notification-popover": "./src/components/ui/custom/satellite-notification-popover.tsx",
498
500
  "./custom/notification-runtime": "./src/components/ui/custom/notification-runtime.tsx",
499
501
  "./finance/invoices/query-invalidation": "./src/components/ui/finance/invoices/query-invalidation.ts",
500
- "./finance/shared/charts/use-finance-confidential-visibility": "./src/components/ui/finance/shared/charts/use-finance-confidential-visibility.ts"
502
+ "./finance/shared/charts/use-finance-confidential-visibility": "./src/components/ui/finance/shared/charts/use-finance-confidential-visibility.ts",
503
+ "./text-editor/collaboration-carets": "./src/components/ui/text-editor/collaboration-carets.ts"
501
504
  },
502
505
  "packageManager": "bun@1.4.1"
503
506
  }
@@ -0,0 +1,267 @@
1
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import type { InternalApiWorkspaceSummary } from '@tuturuuu/types';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import type { useWorkspaceVisibility } from '../../../../hooks/use-workspace-visibility';
5
+ import { Dialog } from '../../dialog';
6
+ import { Popover, PopoverTrigger } from '../../popover';
7
+ import { WorkspacePickerContent } from '../workspace-picker-content';
8
+
9
+ vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
10
+ vi.mock('../workspace-select-icon', () => ({
11
+ WorkspaceIcon: () => <span aria-hidden="true" />,
12
+ }));
13
+ const one: InternalApiWorkspaceSummary = {
14
+ id: 'a',
15
+ name: 'Repeated name',
16
+ personal: false,
17
+ avatar_url: null,
18
+ logo_url: null,
19
+ tier: 'PRO',
20
+ };
21
+ const two: InternalApiWorkspaceSummary = { ...one, id: 'b' };
22
+ const other: InternalApiWorkspaceSummary = {
23
+ ...one,
24
+ id: 'z',
25
+ name: 'Another workspace',
26
+ };
27
+ function visibility(overrides = {}) {
28
+ return {
29
+ known: true,
30
+ hiddenIds: [],
31
+ pending: new Set(),
32
+ isError: false,
33
+ updateError: null,
34
+ setHidden: vi.fn().mockResolvedValue(undefined),
35
+ refetch: vi.fn(),
36
+ ...overrides,
37
+ } as unknown as ReturnType<typeof useWorkspaceVisibility>;
38
+ }
39
+ describe('workspace sheet rendered identities', () => {
40
+ it('keeps floating create and search actions separate from workspace selection', () => {
41
+ const onCreate = vi.fn();
42
+ const onJoin = vi.fn();
43
+ const onSelect = vi.fn();
44
+ render(
45
+ <Dialog open>
46
+ <WorkspacePickerContent
47
+ workspaces={[one]}
48
+ visibility={visibility()}
49
+ onSelect={onSelect}
50
+ onCreate={onCreate}
51
+ onJoin={onJoin}
52
+ />
53
+ </Dialog>
54
+ );
55
+ const create = screen.getByRole('button', {
56
+ name: 'create_workspace_action',
57
+ });
58
+ const search = screen.getByRole('button', { name: 'search_workspace' });
59
+ expect(create.parentElement).toBe(search.parentElement);
60
+ expect(create.parentElement).toHaveClass('absolute');
61
+ fireEvent.click(create);
62
+ fireEvent.click(
63
+ screen.getByRole('button', { name: 'join_workspace_action' })
64
+ );
65
+ expect(onCreate).toHaveBeenCalledOnce();
66
+ expect(onJoin).toHaveBeenCalledOnce();
67
+ expect(onSelect).not.toHaveBeenCalled();
68
+ });
69
+ it('focuses search when opened and clears the current filtered query', async () => {
70
+ render(
71
+ <Dialog open>
72
+ <WorkspacePickerContent
73
+ workspaces={[one, other]}
74
+ visibility={visibility()}
75
+ onSelect={vi.fn()}
76
+ />
77
+ </Dialog>
78
+ );
79
+ fireEvent.click(screen.getByRole('button', { name: 'search_workspace' }));
80
+ const input = screen.getByRole('textbox', { name: 'search_workspace' });
81
+ await waitFor(() => expect(document.activeElement).toBe(input));
82
+ fireEvent.change(input, { target: { value: 'Repeated' } });
83
+ expect(screen.queryByText('Another workspace')).toBeNull();
84
+ fireEvent.click(screen.getByRole('button', { name: 'clear_search' }));
85
+ expect(input).toHaveValue('');
86
+ expect(document.activeElement).toBe(input);
87
+ expect(screen.getByText('Another workspace')).toBeInTheDocument();
88
+ });
89
+ it('provides fullscreen scrollable modal content with trapped focus and Escape close', async () => {
90
+ const onOpenChange = vi.fn();
91
+ render(
92
+ <>
93
+ <input aria-label="Outside picker" />
94
+ <Dialog open onOpenChange={onOpenChange}>
95
+ <WorkspacePickerContent
96
+ workspaces={[one]}
97
+ visibility={visibility()}
98
+ onSelect={vi.fn()}
99
+ />
100
+ </Dialog>
101
+ </>
102
+ );
103
+ const dialog = screen.getByRole('dialog');
104
+ expect(dialog).toHaveClass('inset-0', 'h-dvh', 'overflow-hidden');
105
+ const row = screen.getByRole('button', { name: 'Repeated name PRO' });
106
+ expect(row.closest('.overflow-y-auto')).not.toBeNull();
107
+ const outside = screen.getByLabelText('Outside picker');
108
+ outside.focus();
109
+ await waitFor(() =>
110
+ expect(dialog.contains(document.activeElement)).toBe(true)
111
+ );
112
+ fireEvent.keyDown(document.activeElement ?? dialog, { key: 'Escape' });
113
+ await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false));
114
+ });
115
+ it('selects refreshed filtered objects with duplicate names and reordered input', () => {
116
+ const onSelect = vi.fn();
117
+ const state = visibility();
118
+ const { rerender } = render(
119
+ <Dialog open>
120
+ <WorkspacePickerContent
121
+ workspaces={[other, two, one]}
122
+ visibility={state}
123
+ onSelect={onSelect}
124
+ />
125
+ </Dialog>
126
+ );
127
+ fireEvent.click(screen.getByRole('button', { name: 'search_workspace' }));
128
+ fireEvent.change(screen.getByRole('textbox'), {
129
+ target: { value: 'Repeated' },
130
+ });
131
+ expect(screen.queryByText('Another workspace')).toBeNull();
132
+ const updated = { ...two, tier: 'ENTERPRISE' as const };
133
+ rerender(
134
+ <Dialog open>
135
+ <WorkspacePickerContent
136
+ workspaces={[one, other, updated]}
137
+ visibility={state}
138
+ onSelect={onSelect}
139
+ />
140
+ </Dialog>
141
+ );
142
+ fireEvent.click(
143
+ screen.getByRole('button', { name: 'Repeated name ENTERPRISE' })
144
+ );
145
+ expect(onSelect).toHaveBeenCalledWith(updated);
146
+ });
147
+ it('restores only the hidden ID without selecting a current/default scope', () => {
148
+ const state = visibility({ hiddenIds: ['b'] });
149
+ const onSelect = vi.fn();
150
+ render(
151
+ <Dialog open>
152
+ <WorkspacePickerContent
153
+ restoreOnly
154
+ workspaces={[one, two, other]}
155
+ visibility={state}
156
+ onSelect={onSelect}
157
+ />
158
+ </Dialog>
159
+ );
160
+ fireEvent.click(
161
+ screen.getByRole('button', { name: 'restore_workspace: Repeated name' })
162
+ );
163
+ expect(state.setHidden).toHaveBeenCalledWith('b', false);
164
+ expect(onSelect).not.toHaveBeenCalled();
165
+ expect(screen.queryByText('Another workspace')).toBeNull();
166
+ });
167
+ it('does not expose unfiltered choices while the private list is unknown', () => {
168
+ render(
169
+ <Dialog open>
170
+ <WorkspacePickerContent
171
+ workspaces={[one, two]}
172
+ visibility={visibility({ known: false, isError: true })}
173
+ onSelect={vi.fn()}
174
+ />
175
+ </Dialog>
176
+ );
177
+ expect(screen.queryByText('Repeated name')).toBeNull();
178
+ expect(screen.getByRole('status').textContent).toBe(
179
+ 'hidden_workspaces_load_error'
180
+ );
181
+ });
182
+ });
183
+
184
+ describe('workspace browser dropdown', () => {
185
+ it('keeps search and Hidden recovery in an anchored scrolling dropdown', async () => {
186
+ const onSelect = vi.fn();
187
+ const onDefault = vi.fn();
188
+ const onCreate = vi.fn();
189
+ const state = visibility({ hiddenIds: [other.id] });
190
+ render(
191
+ <Popover defaultOpen>
192
+ <PopoverTrigger>Choose workspace</PopoverTrigger>
193
+ <WorkspacePickerContent
194
+ presentation="dropdown"
195
+ workspaces={[one, other]}
196
+ visibility={state}
197
+ onSelect={onSelect}
198
+ onDefault={onDefault}
199
+ onCreate={onCreate}
200
+ />
201
+ </Popover>
202
+ );
203
+ const dropdown = screen.getByRole('dialog', { name: 'workspaces' });
204
+ expect(dropdown).toHaveAttribute('data-slot', 'popover-content');
205
+ expect(dropdown).not.toHaveClass('inset-0');
206
+ expect(dropdown).not.toHaveClass('h-dvh');
207
+ expect(dropdown).toHaveClass(
208
+ 'max-h-[min(32rem,var(--radix-popover-content-available-height))]'
209
+ );
210
+ expect(dropdown).toHaveClass('overflow-hidden', 'flex-col');
211
+ const input = screen.getByRole('textbox', { name: 'search_workspace' });
212
+ const row = screen.getByRole('button', { name: 'Repeated name PRO' });
213
+ expect(row.closest('.overflow-y-auto')).not.toBeNull();
214
+ expect(screen.queryByText('Another workspace')).toBeNull();
215
+ fireEvent.click(row);
216
+ expect(onSelect).toHaveBeenCalledWith(one);
217
+ fireEvent.click(screen.getByRole('button', { name: 'default_workspace' }));
218
+ expect(onDefault).toHaveBeenCalledWith(one.id);
219
+ const create = screen.getByRole('button', {
220
+ name: 'create_workspace_action',
221
+ });
222
+ const createFooter = create.parentElement;
223
+ expect(createFooter).not.toHaveClass('absolute');
224
+ fireEvent.click(create);
225
+ expect(onCreate).toHaveBeenCalledOnce();
226
+ fireEvent.change(input, { target: { value: 'missing' } });
227
+ expect(
228
+ screen.queryByRole('button', { name: 'Repeated name PRO' })
229
+ ).toBeNull();
230
+ fireEvent.click(screen.getByRole('button', { name: 'hidden_workspaces' }));
231
+ expect(input).toHaveValue('');
232
+ expect(dropdown).toHaveAccessibleName('hidden_workspaces');
233
+ expect(createFooter).toHaveClass('hidden');
234
+ fireEvent.click(
235
+ screen.getByRole('button', {
236
+ name: 'restore_workspace: Another workspace',
237
+ })
238
+ );
239
+ expect(state.setHidden).toHaveBeenCalledWith(other.id, false);
240
+ expect(onSelect).toHaveBeenCalledTimes(1);
241
+ fireEvent.keyDown(dropdown, { key: 'Escape' });
242
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
243
+ });
244
+ });
245
+
246
+ it.each([false, true])(
247
+ 'closes its popover with an optional callback: %s',
248
+ async (withCallback) => {
249
+ const onClose = vi.fn();
250
+ render(
251
+ <Popover defaultOpen>
252
+ <PopoverTrigger>Choose workspace</PopoverTrigger>
253
+ <WorkspacePickerContent
254
+ presentation="dropdown"
255
+ workspaces={[one]}
256
+ visibility={visibility()}
257
+ onSelect={vi.fn()}
258
+ onDefault={vi.fn()}
259
+ onClose={withCallback ? onClose : undefined}
260
+ />
261
+ </Popover>
262
+ );
263
+ fireEvent.click(screen.getByRole('button', { name: 'close' }));
264
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
265
+ expect(onClose).toHaveBeenCalledTimes(withCallback ? 1 : 0);
266
+ }
267
+ );
@@ -6,6 +6,7 @@ import {
6
6
  buildWorkspaceSetupHandoffUrl,
7
7
  mergeWorkspaceSelectWorkspaces,
8
8
  normalizeWorkspaceSwitchPath,
9
+ resolveGuestWorkspaceLanding,
9
10
  resolveWorkspaceAvatarUrl,
10
11
  } from '../workspace-select-helpers';
11
12
 
@@ -126,44 +127,6 @@ describe('mergeWorkspaceSelectWorkspaces', () => {
126
127
  /<AvatarFallback[\s\S]*<AvatarImage/u
127
128
  );
128
129
  });
129
-
130
- it('lays out the short create and join actions in the command item grid', () => {
131
- const workspaceSelectSource = readFileSync(
132
- join(process.cwd(), 'src/components/ui/custom/workspace-select.tsx'),
133
- 'utf8'
134
- );
135
-
136
- expect(workspaceSelectSource).toContain(
137
- '[&_[cmdk-group-items]]:grid-cols-2'
138
- );
139
- expect(workspaceSelectSource).toContain(
140
- "t('common.create_workspace_action')"
141
- );
142
- expect(workspaceSelectSource).toContain(
143
- "t('common.join_workspace_action')"
144
- );
145
- });
146
-
147
- it('supports a modal popover so nested settings pickers remain scrollable', () => {
148
- const workspaceSelectSource = readFileSync(
149
- join(process.cwd(), 'src/components/ui/custom/workspace-select.tsx'),
150
- 'utf8'
151
- );
152
-
153
- expect(workspaceSelectSource).toContain('popoverModal = false');
154
- expect(workspaceSelectSource).toContain('<Popover modal={popoverModal}');
155
- });
156
-
157
- it('focuses workspace search as soon as the picker opens', () => {
158
- const workspaceSelectSource = readFileSync(
159
- join(process.cwd(), 'src/components/ui/custom/workspace-select.tsx'),
160
- 'utf8'
161
- );
162
-
163
- expect(workspaceSelectSource).toContain(
164
- '<CommandInput autoFocus placeholder="Search workspace..." />'
165
- );
166
- });
167
130
  });
168
131
 
169
132
  describe('normalizeWorkspaceSwitchPath', () => {
@@ -200,3 +163,26 @@ describe('normalizeWorkspaceSwitchPath', () => {
200
163
  ).toBe('/workspace-1/users');
201
164
  });
202
165
  });
166
+
167
+ it.each([null, ''])(
168
+ 'preserves the boards fallback for a guest without a landing path (%s)',
169
+ (guest_landing_path) => {
170
+ expect(
171
+ resolveGuestWorkspaceLanding({ access_type: 'guest', guest_landing_path })
172
+ ).toBe('/tasks/boards');
173
+ }
174
+ );
175
+ it('preserves explicit guest landing while members retain normal path resolution', () => {
176
+ expect(
177
+ resolveGuestWorkspaceLanding({
178
+ access_type: 'guest',
179
+ guest_landing_path: '/tasks/boards/shared-board',
180
+ })
181
+ ).toBe('/tasks/boards/shared-board');
182
+ expect(
183
+ resolveGuestWorkspaceLanding({
184
+ access_type: 'member',
185
+ guest_landing_path: null,
186
+ })
187
+ ).toBeNull();
188
+ });
@@ -0,0 +1,131 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { EducationTodo } from './education-todo';
4
+
5
+ const state = vi.hoisted(() => ({
6
+ query: vi.fn(),
7
+ refetch: vi.fn(),
8
+ error: false,
9
+ }));
10
+ vi.mock('@tanstack/react-query', () => ({
11
+ useQuery: (options: unknown) => {
12
+ state.query(options);
13
+ return {
14
+ isError: state.error,
15
+ isPending: false,
16
+ refetch: state.refetch,
17
+ data: {
18
+ count: 30,
19
+ totalPages: 2,
20
+ data: [
21
+ {
22
+ id: 'done',
23
+ title: 'Completed synthetic work',
24
+ courseId: 'course',
25
+ completed: true,
26
+ },
27
+ {
28
+ id: 'open',
29
+ title: 'Open synthetic work',
30
+ courseId: 'course',
31
+ completed: false,
32
+ },
33
+ ],
34
+ },
35
+ };
36
+ },
37
+ }));
38
+ vi.mock('@tuturuuu/internal-api/teach', () => ({ listEducationTodo: vi.fn() }));
39
+ const translate = (key: string) => key;
40
+ describe('personal education work controls', () => {
41
+ beforeEach(() => {
42
+ vi.clearAllMocks();
43
+ state.error = false;
44
+ });
45
+ it('separates actor caches, paginates and resets page on category switch', () => {
46
+ const { rerender } = render(
47
+ <EducationTodo
48
+ app="learn"
49
+ wsId="workspace"
50
+ actorId="actor-one"
51
+ t={translate}
52
+ />
53
+ );
54
+ expect(state.query).toHaveBeenLastCalledWith(
55
+ expect.objectContaining({
56
+ queryKey: [
57
+ 'education-todo',
58
+ 'learn',
59
+ 'workspace',
60
+ 'actor-one',
61
+ 'tutoring',
62
+ 1,
63
+ ],
64
+ })
65
+ );
66
+ fireEvent.click(screen.getByRole('button', { name: 'next' }));
67
+ expect(state.query).toHaveBeenLastCalledWith(
68
+ expect.objectContaining({
69
+ queryKey: [
70
+ 'education-todo',
71
+ 'learn',
72
+ 'workspace',
73
+ 'actor-one',
74
+ 'tutoring',
75
+ 2,
76
+ ],
77
+ })
78
+ );
79
+ fireEvent.click(screen.getByRole('button', { name: 'assignments' }));
80
+ expect(screen.queryByText('Completed synthetic work')).toBeNull();
81
+ expect(screen.getByText('Open synthetic work')).toBeTruthy();
82
+ expect(state.query).toHaveBeenLastCalledWith(
83
+ expect.objectContaining({
84
+ queryKey: [
85
+ 'education-todo',
86
+ 'learn',
87
+ 'workspace',
88
+ 'actor-one',
89
+ 'assignments',
90
+ 1,
91
+ ],
92
+ })
93
+ );
94
+ fireEvent.click(screen.getByRole('checkbox'));
95
+ expect(screen.getByText('Completed synthetic work')).toBeTruthy();
96
+ rerender(
97
+ <EducationTodo
98
+ app="learn"
99
+ wsId="workspace"
100
+ actorId="actor-two"
101
+ t={translate}
102
+ />
103
+ );
104
+ expect(state.query).toHaveBeenLastCalledWith(
105
+ expect.objectContaining({
106
+ queryKey: [
107
+ 'education-todo',
108
+ 'learn',
109
+ 'workspace',
110
+ 'actor-two',
111
+ 'assignments',
112
+ 1,
113
+ ],
114
+ })
115
+ );
116
+ });
117
+ it('offers retry on errors without rendering stale assigned rows', () => {
118
+ state.error = true;
119
+ render(
120
+ <EducationTodo
121
+ app="teach"
122
+ wsId="workspace"
123
+ actorId="actor"
124
+ t={translate}
125
+ />
126
+ );
127
+ expect(screen.queryByText('Open synthetic work')).toBeNull();
128
+ fireEvent.click(screen.getByRole('button', { name: 'retry' }));
129
+ expect(state.refetch).toHaveBeenCalledOnce();
130
+ });
131
+ });