@tuturuuu/ui 0.26.1 → 0.28.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 (43) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/biome.json +1 -1
  3. package/package.json +49 -48
  4. package/src/components/ui/button.tsx +12 -8
  5. package/src/components/ui/color-picker.test.tsx +23 -0
  6. package/src/components/ui/color-picker.tsx +1 -1
  7. package/src/components/ui/custom/combobox.tsx +4 -0
  8. package/src/components/ui/custom/notification-popover-client.tsx +6 -19
  9. package/src/components/ui/custom/notification-popover-trigger.test.tsx +37 -0
  10. package/src/components/ui/custom/notification-popover-trigger.tsx +44 -0
  11. package/src/components/ui/custom/workspace-access/adapters.test.ts +89 -1
  12. package/src/components/ui/custom/workspace-access/adapters.ts +67 -2
  13. package/src/components/ui/custom/workspace-access/types.ts +13 -0
  14. package/src/components/ui/custom/workspace-access/workspace-access-invitation-role-menu.test.tsx +79 -0
  15. package/src/components/ui/custom/workspace-access/workspace-access-invitation-role-menu.tsx +125 -0
  16. package/src/components/ui/custom/workspace-access/workspace-access-invite-access-picker.tsx +108 -0
  17. package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.test.tsx +134 -0
  18. package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.tsx +94 -113
  19. package/src/components/ui/custom/workspace-access/workspace-access-invite-pos-panel.tsx +75 -0
  20. package/src/components/ui/custom/workspace-access/workspace-access-invite-role-picker.tsx +151 -0
  21. package/src/components/ui/custom/workspace-access/workspace-access-labels.ts +1 -1
  22. package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +27 -2
  23. package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +10 -0
  24. package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +102 -3
  25. package/src/components/ui/custom/workspace-access/workspace-access-role-options.test.ts +35 -0
  26. package/src/components/ui/custom/workspace-access/workspace-access-role-options.ts +21 -0
  27. package/src/components/ui/custom/workspace-select-invitations.tsx +2 -1
  28. package/src/components/ui/text-editor/__tests__/editor-classes.test.ts +36 -0
  29. package/src/components/ui/text-editor/__tests__/inline-task-conversion.test.tsx +30 -0
  30. package/src/components/ui/text-editor/__tests__/keyboard.test.ts +173 -3
  31. package/src/components/ui/text-editor/__tests__/toggle-block-extension.test.ts +251 -0
  32. package/src/components/ui/text-editor/editor-classes.ts +100 -0
  33. package/src/components/ui/text-editor/editor.tsx +11 -153
  34. package/src/components/ui/text-editor/extensions.ts +19 -0
  35. package/src/components/ui/text-editor/keyboard.ts +47 -0
  36. package/src/components/ui/text-editor/toggle-block-extension.ts +153 -0
  37. package/src/components/ui/text-editor/tool-bar.tsx +24 -73
  38. package/src/components/ui/text-editor/toolbar-config.ts +67 -0
  39. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -1
  40. package/src/hooks/use-board-actions.test.ts +23 -0
  41. package/src/hooks/use-board-actions.ts +48 -35
  42. package/src/hooks/use-calendar-sync.tsx +12 -12
  43. package/src/hooks/use-notifications.ts +4 -8
@@ -37,9 +37,16 @@ import { WorkspaceAccessMembers } from './workspace-access-members';
37
37
  import { WorkspaceAccessPageHeader } from './workspace-access-page-header';
38
38
  import { WorkspaceAccessPeopleFilters } from './workspace-access-people-filters';
39
39
  import { WorkspaceAccessRoleEditorDialog } from './workspace-access-role-editor-dialog';
40
+ import { listAllWorkspaceAccessRoles } from './workspace-access-role-options';
40
41
  import { WorkspaceAccessRoles } from './workspace-access-roles';
41
42
  import { WorkspaceAccessTabsToolbar } from './workspace-access-tabs-toolbar';
42
43
 
44
+ type InvitationRoleUpdate = {
45
+ email?: null | string;
46
+ roleIds: string[];
47
+ userId?: null | string;
48
+ };
49
+
43
50
  export function WorkspaceAccessPage({
44
51
  adapter,
45
52
  disableInvite = false,
@@ -57,6 +64,7 @@ export function WorkspaceAccessPage({
57
64
  const [inviteAccessPreset, setInviteAccessPreset] = useState<
58
65
  'guest' | 'member' | 'pos_operator'
59
66
  >('member');
67
+ const [inviteRoleIds, setInviteRoleIds] = useState<string[]>([]);
60
68
  const [confirmDefaultAdminMigration, setConfirmDefaultAdminMigration] =
61
69
  useState(false);
62
70
  const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
@@ -92,6 +100,7 @@ export function WorkspaceAccessPage({
92
100
  const workspaceId = context.workspaceId;
93
101
  const canManageMembers = context.canManageMembers;
94
102
  const canManageRoles = context.canManageRoles;
103
+ const canAssignInviteRoles = canManageRoles && mode === 'workspace';
95
104
  const canInvite = canManageMembers && !disableInvite;
96
105
  const permissionUser = useMemo(
97
106
  () =>
@@ -145,6 +154,12 @@ export function WorkspaceAccessPage({
145
154
  queryKey: ['workspace-access', workspaceId, 'roles', search],
146
155
  staleTime: 30_000,
147
156
  });
157
+ const inviteRolesQuery = useQuery({
158
+ enabled: inviteDialogOpen && canAssignInviteRoles,
159
+ queryFn: () => listAllWorkspaceAccessRoles(adapter, workspaceId),
160
+ queryKey: ['workspace-access', workspaceId, 'invite-roles'],
161
+ staleTime: 30_000,
162
+ });
148
163
  const memberDefaultQuery = useQuery({
149
164
  enabled: canManageRoles,
150
165
  queryFn: () => adapter.getDefaultRole(workspaceId, 'MEMBER'),
@@ -166,6 +181,9 @@ export function WorkspaceAccessPage({
166
181
  queryClient.invalidateQueries({
167
182
  queryKey: ['workspace-access', workspaceId, 'roles'],
168
183
  }),
184
+ queryClient.invalidateQueries({
185
+ queryKey: ['workspace-access', workspaceId, 'invite-roles'],
186
+ }),
169
187
  queryClient.invalidateQueries({
170
188
  queryKey: ['workspace-access', workspaceId, 'defaults'],
171
189
  }),
@@ -179,12 +197,17 @@ export function WorkspaceAccessPage({
179
197
  confirmDefaultAdminMigration,
180
198
  emails: parseInviteEmails(inviteEmails),
181
199
  memberType: inviteAccessPreset === 'guest' ? 'GUEST' : 'MEMBER',
200
+ roleIds:
201
+ canAssignInviteRoles && inviteAccessPreset === 'member'
202
+ ? inviteRoleIds
203
+ : [],
182
204
  }),
183
205
  onError: (error) =>
184
206
  toast.error(error instanceof Error ? error.message : t('common.error')),
185
207
  onSuccess: async (result) => {
186
208
  setInviteEmails('');
187
209
  setInviteAccessPreset('member');
210
+ setInviteRoleIds([]);
188
211
  setConfirmDefaultAdminMigration(false);
189
212
  setInviteDialogOpen(false);
190
213
  toast.success(result.message ?? t('ws-members.invitation-sent'));
@@ -247,6 +270,66 @@ export function WorkspaceAccessPage({
247
270
  await invalidateAccessData();
248
271
  },
249
272
  });
273
+ const invitationRoleMutation = useMutation({
274
+ mutationFn: (payload: InvitationRoleUpdate) => {
275
+ if (!adapter.updateInvitationRole) {
276
+ throw new Error(t('common.error'));
277
+ }
278
+ return adapter.updateInvitationRole(workspaceId, payload);
279
+ },
280
+ onMutate: async (payload) => {
281
+ const queryKey = ['workspace-access', workspaceId, 'members'] as const;
282
+ await queryClient.cancelQueries({ queryKey });
283
+ const previousMembers =
284
+ queryClient.getQueryData<InternalApiEnhancedWorkspaceMember[]>(
285
+ queryKey
286
+ );
287
+ const roleById = new Map(
288
+ (inviteRolesQuery.data?.data ?? rolesQuery.data?.data ?? []).map(
289
+ (role) => [role.id, role]
290
+ )
291
+ );
292
+ const nextRoles = payload.roleIds.flatMap((roleId) => {
293
+ const role = roleById.get(roleId);
294
+ return role ? [{ id: role.id, name: role.name, permissions: [] }] : [];
295
+ });
296
+ const normalizedEmail = payload.email?.trim().toLowerCase() ?? null;
297
+
298
+ queryClient.setQueryData<InternalApiEnhancedWorkspaceMember[]>(
299
+ queryKey,
300
+ (members = []) =>
301
+ members.map((member) => {
302
+ const matchesUser = Boolean(
303
+ payload.userId && member.id === payload.userId
304
+ );
305
+ const matchesEmail = Boolean(
306
+ normalizedEmail &&
307
+ member.email?.trim().toLowerCase() === normalizedEmail
308
+ );
309
+ if (!member.pending || (!matchesUser && !matchesEmail)) {
310
+ return member;
311
+ }
312
+
313
+ return {
314
+ ...member,
315
+ roles: nextRoles,
316
+ };
317
+ })
318
+ );
319
+
320
+ return { previousMembers, queryKey };
321
+ },
322
+ onError: (error, _payload, context) => {
323
+ if (context?.previousMembers) {
324
+ queryClient.setQueryData(context.queryKey, context.previousMembers);
325
+ }
326
+ toast.error(error instanceof Error ? error.message : t('common.error'));
327
+ },
328
+ onSettled: invalidateAccessData,
329
+ onSuccess: () => {
330
+ toast.success(t('ws-members.invitation_role_updated'));
331
+ },
332
+ });
250
333
  const deleteRoleMutation = useMutation({
251
334
  mutationFn: (roleId: string) => adapter.deleteRole(workspaceId, roleId),
252
335
  onError: (error) =>
@@ -394,11 +477,15 @@ export function WorkspaceAccessPage({
394
477
  canEditProfiles={Boolean(adapter.updateMemberProfile)}
395
478
  canManageMembers={canManageMembers}
396
479
  canManageRoles={canManageRoles}
480
+ canUpdateInvitationRoles={
481
+ canManageRoles && Boolean(adapter.updateInvitationRole)
482
+ }
397
483
  defaultAdminEnabled={defaultAdminEnabled}
398
484
  isLoading={membersQuery.isPending}
399
485
  isMutating={
400
486
  removeMemberMutation.isPending ||
401
487
  roleMembershipMutation.isPending ||
488
+ invitationRoleMutation.isPending ||
402
489
  updateMemberProfileMutation.isPending
403
490
  }
404
491
  labels={labels}
@@ -411,7 +498,10 @@ export function WorkspaceAccessPage({
411
498
  onRemoveRole={(payload) =>
412
499
  roleMembershipMutation.mutate({ ...payload, action: 'remove' })
413
500
  }
414
- roles={roles}
501
+ onUpdateInvitationRole={(payload) =>
502
+ invitationRoleMutation.mutate(payload)
503
+ }
504
+ roles={inviteRolesQuery.data?.data ?? roles}
415
505
  searchTerm={search}
416
506
  status={status}
417
507
  />
@@ -475,23 +565,32 @@ export function WorkspaceAccessPage({
475
565
 
476
566
  <WorkspaceAccessInviteDialog
477
567
  accessPreset={inviteAccessPreset}
478
- canManageRoles={canManageRoles}
568
+ canManageRoles={canAssignInviteRoles}
479
569
  confirmDefaultAdminMigration={confirmDefaultAdminMigration}
480
570
  defaultAdminEnabled={defaultAdminEnabled}
481
571
  emails={inviteEmails}
482
572
  isSubmitting={inviteMutation.isPending}
483
573
  joinedMemberCount={joinedCount}
574
+ noRoleLabel={t('ws-members.no_role_assigned')}
484
575
  onAccessPresetChange={(value) => {
485
576
  setInviteAccessPreset(value);
486
- if (value !== 'pos_operator') {
577
+ if (value === 'member') {
487
578
  setConfirmDefaultAdminMigration(false);
579
+ } else {
580
+ setInviteRoleIds([]);
581
+ if (value === 'guest') {
582
+ setConfirmDefaultAdminMigration(false);
583
+ }
488
584
  }
489
585
  }}
490
586
  onConfirmDefaultAdminMigrationChange={setConfirmDefaultAdminMigration}
491
587
  onEmailsChange={setInviteEmails}
492
588
  onOpenChange={setInviteDialogOpen}
589
+ onRoleIdsChange={setInviteRoleIds}
493
590
  onSubmit={() => inviteMutation.mutate()}
494
591
  open={inviteDialogOpen}
592
+ roleIds={inviteRoleIds}
593
+ roles={inviteRolesQuery.data?.data ?? []}
495
594
  />
496
595
 
497
596
  {profileMember ? (
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { WorkspaceAccessAdapter, WorkspaceAccessRole } from './types';
3
+ import { listAllWorkspaceAccessRoles } from './workspace-access-role-options';
4
+
5
+ function createRoles(start: number, count: number): WorkspaceAccessRole[] {
6
+ return Array.from({ length: count }, (_, index) => ({
7
+ id: `role-${start + index}`,
8
+ name: `Role ${start + index}`,
9
+ permissions: [],
10
+ }));
11
+ }
12
+
13
+ describe('listAllWorkspaceAccessRoles', () => {
14
+ it('paginates beyond the first 100 assignable roles', async () => {
15
+ const listRoleOptions = vi
16
+ .fn()
17
+ .mockResolvedValueOnce({ count: 125, data: createRoles(0, 100) })
18
+ .mockResolvedValueOnce({ count: 125, data: createRoles(100, 25) });
19
+
20
+ const result = await listAllWorkspaceAccessRoles(
21
+ { listRoleOptions } as unknown as WorkspaceAccessAdapter,
22
+ 'workspace-1'
23
+ );
24
+
25
+ expect(result.data).toHaveLength(125);
26
+ expect(listRoleOptions).toHaveBeenNthCalledWith(1, 'workspace-1', {
27
+ page: '1',
28
+ pageSize: '100',
29
+ });
30
+ expect(listRoleOptions).toHaveBeenNthCalledWith(2, 'workspace-1', {
31
+ page: '2',
32
+ pageSize: '100',
33
+ });
34
+ });
35
+ });
@@ -0,0 +1,21 @@
1
+ import type { WorkspaceAccessAdapter, WorkspaceAccessRole } from './types';
2
+
3
+ export async function listAllWorkspaceAccessRoles(
4
+ adapter: WorkspaceAccessAdapter,
5
+ workspaceId: string
6
+ ) {
7
+ const pageSize = 100;
8
+ const roles: WorkspaceAccessRole[] = [];
9
+
10
+ for (let page = 1; ; page += 1) {
11
+ const result = await adapter.listRoleOptions(workspaceId, {
12
+ page: String(page),
13
+ pageSize: String(pageSize),
14
+ });
15
+ roles.push(...result.data.map((role) => ({ ...role, permissions: [] })));
16
+
17
+ if (roles.length >= result.count || result.data.length < pageSize) {
18
+ return { count: result.count, data: roles };
19
+ }
20
+ }
21
+ }
@@ -31,8 +31,9 @@ export function useWorkspaceInvitations({
31
31
  queryKey: ['workspace-invitations', ...(cacheScope ? [cacheScope] : [])],
32
32
  queryFn: async () => (await listWorkspaceInvitations()).invitations,
33
33
  enabled,
34
+ gcTime: 30 * 60_000,
34
35
  retry: 1,
35
- staleTime: 30_000,
36
+ staleTime: 5 * 60_000,
36
37
  });
37
38
  const invitations = query.data ?? [];
38
39
  const mutation = useMutation({
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRichTextEditorClasses } from '../editor-classes';
3
+
4
+ describe('rich text editor classes', () => {
5
+ it('keeps regular and checklist nesting visibly indented', () => {
6
+ const classes = getRichTextEditorClasses({ readOnly: false });
7
+
8
+ expect(classes).toContain('[&_li_ul]:ml-6');
9
+ expect(classes).toContain('[&_li_ol]:ml-6');
10
+ expect(classes).toContain(
11
+ '[&_ul[data-type="taskList"]_ul[data-type="taskList"]]:ml-6'
12
+ );
13
+ expect(classes).not.toContain(
14
+ '[&_ul[data-type="taskList"]_ul[data-type="taskList"]]:ml-0'
15
+ );
16
+ });
17
+
18
+ it('styles toggle summaries by heading level and indents their body', () => {
19
+ const classes = getRichTextEditorClasses({ readOnly: false });
20
+
21
+ expect(classes).toContain('[&_summary[data-heading-level="1"]]:text-4xl');
22
+ expect(classes).toContain('[&_summary[data-heading-level="2"]]:text-3xl');
23
+ expect(classes).toContain('[&_summary[data-heading-level="3"]]:text-2xl');
24
+ expect(classes).toContain('[&_div[data-type="detailsContent"]]:border-l');
25
+ });
26
+
27
+ it('retains read-only interaction guards and custom classes', () => {
28
+ const classes = getRichTextEditorClasses({
29
+ className: 'task-description-editor',
30
+ readOnly: true,
31
+ });
32
+
33
+ expect(classes).toContain('task-description-editor');
34
+ expect(classes).toContain('[&_.task-list-checkbox]:!pointer-events-none');
35
+ });
36
+ });
@@ -19,6 +19,7 @@ function createEditorStub(): Editor {
19
19
  toggleSubscript: vi.fn(() => chain),
20
20
  toggleSuperscript: vi.fn(() => chain),
21
21
  toggleTaskListSmart: vi.fn(() => chain),
22
+ toggleDetailsBlock: vi.fn(() => chain),
22
23
  run: vi.fn(() => true),
23
24
  };
24
25
 
@@ -43,4 +44,33 @@ describe('inline task conversion toolbar', () => {
43
44
 
44
45
  expect(onConvertToTask).toHaveBeenCalledTimes(1);
45
46
  });
47
+
48
+ it('exposes toggle blocks and delegates the command through the editor chain', () => {
49
+ const editor = createEditorStub();
50
+ render(<FixedToolbar editor={editor} />);
51
+
52
+ fireEvent.click(
53
+ screen.getByRole('button', { name: 'Toggle List or Heading' })
54
+ );
55
+
56
+ const chain = vi.mocked(editor.chain).mock.results[0]?.value as {
57
+ toggleDetailsBlock: ReturnType<typeof vi.fn>;
58
+ run: ReturnType<typeof vi.fn>;
59
+ };
60
+ expect(chain.toggleDetailsBlock).toHaveBeenCalledTimes(1);
61
+ expect(chain.run).toHaveBeenCalled();
62
+ });
63
+
64
+ it('uses the localized toggle block label when provided', () => {
65
+ render(
66
+ <FixedToolbar
67
+ editor={createEditorStub()}
68
+ toggleBlockLabel="Liste ou titre repliable"
69
+ />
70
+ );
71
+
72
+ expect(
73
+ screen.getByRole('button', { name: 'Liste ou titre repliable' })
74
+ ).toBeInTheDocument();
75
+ });
46
76
  });
@@ -2,7 +2,7 @@ import { Schema } from '@tiptap/pm/model';
2
2
  import { EditorState, TextSelection } from '@tiptap/pm/state';
3
3
  import type { EditorView } from '@tiptap/pm/view';
4
4
  import { describe, expect, it, vi } from 'vitest';
5
- import { handlePlainEnterFallback } from '../keyboard';
5
+ import { handleListIndentation, handlePlainEnterFallback } from '../keyboard';
6
6
 
7
7
  const schema = new Schema({
8
8
  nodes: {
@@ -15,6 +15,10 @@ const schema = new Schema({
15
15
  group: 'block',
16
16
  content: 'listItem+',
17
17
  },
18
+ orderedList: {
19
+ group: 'block',
20
+ content: 'listItem+',
21
+ },
18
22
  listItem: {
19
23
  content: 'paragraph block*',
20
24
  defining: true,
@@ -37,11 +41,19 @@ const schema = new Schema({
37
41
  marks: {},
38
42
  });
39
43
 
40
- function stateWithSelection(doc: ReturnType<typeof schema.node>, offset = 1) {
44
+ function stateWithSelection(
45
+ doc: ReturnType<typeof schema.node>,
46
+ offset = 1,
47
+ targetText?: string
48
+ ) {
41
49
  let textStart: number | null = null;
42
50
 
43
51
  doc.descendants((node, pos) => {
44
- if (textStart === null && node.isText) {
52
+ if (
53
+ textStart === null &&
54
+ node.isText &&
55
+ (!targetText || node.text === targetText)
56
+ ) {
45
57
  textStart = pos;
46
58
  return false;
47
59
  }
@@ -69,6 +81,19 @@ function enterEvent(overrides: Partial<KeyboardEvent> = {}) {
69
81
  } as unknown as KeyboardEvent;
70
82
  }
71
83
 
84
+ function tabEvent(overrides: Partial<KeyboardEvent> = {}) {
85
+ return {
86
+ altKey: false,
87
+ ctrlKey: false,
88
+ key: 'Tab',
89
+ metaKey: false,
90
+ preventDefault: vi.fn(),
91
+ shiftKey: false,
92
+ stopPropagation: vi.fn(),
93
+ ...overrides,
94
+ } as unknown as KeyboardEvent;
95
+ }
96
+
72
97
  function viewForState(state: EditorState) {
73
98
  let currentState = state;
74
99
  const view = {
@@ -173,4 +198,149 @@ describe('text editor keyboard handling', () => {
173
198
  expect(handlePlainEnterFallback(view, event)).toBe(false);
174
199
  expect(event.preventDefault).not.toHaveBeenCalled();
175
200
  });
201
+
202
+ it.each(['bulletList', 'orderedList'] as const)(
203
+ 'indents and outdents a second %s item with Tab and Shift+Tab',
204
+ (listType) => {
205
+ const state = stateWithSelection(
206
+ schema.node('doc', null, [
207
+ schema.node(listType, null, [
208
+ schema.node('listItem', null, [
209
+ schema.node('paragraph', null, schema.text('first')),
210
+ ]),
211
+ schema.node('listItem', null, [
212
+ schema.node('paragraph', null, schema.text('second')),
213
+ ]),
214
+ ]),
215
+ ]),
216
+ 1,
217
+ 'second'
218
+ );
219
+ const { view, getState } = viewForState(state);
220
+ const indentEvent = tabEvent();
221
+
222
+ expect(handleListIndentation(view, indentEvent)).toBe(true);
223
+ expect(indentEvent.preventDefault).toHaveBeenCalledTimes(1);
224
+ expect(indentEvent.stopPropagation).toHaveBeenCalledTimes(1);
225
+ expect(getState().doc.toJSON()).toMatchObject({
226
+ content: [
227
+ {
228
+ type: listType,
229
+ content: [
230
+ {
231
+ type: 'listItem',
232
+ content: [
233
+ { type: 'paragraph' },
234
+ {
235
+ type: listType,
236
+ content: [{ type: 'listItem' }],
237
+ },
238
+ ],
239
+ },
240
+ ],
241
+ },
242
+ ],
243
+ });
244
+
245
+ const outdentEvent = tabEvent({ shiftKey: true });
246
+ expect(handleListIndentation(view, outdentEvent)).toBe(true);
247
+ expect(getState().doc.child(0).childCount).toBe(2);
248
+ }
249
+ );
250
+
251
+ it('indents and outdents checklist items', () => {
252
+ const state = stateWithSelection(
253
+ schema.node('doc', null, [
254
+ schema.node('taskList', null, [
255
+ schema.node('taskItem', null, [
256
+ schema.node('paragraph', null, schema.text('first')),
257
+ ]),
258
+ schema.node('taskItem', null, [
259
+ schema.node('paragraph', null, schema.text('second')),
260
+ ]),
261
+ ]),
262
+ ]),
263
+ 1,
264
+ 'second'
265
+ );
266
+ const { view, getState } = viewForState(state);
267
+
268
+ expect(handleListIndentation(view, tabEvent())).toBe(true);
269
+ expect(getState().doc.toJSON()).toMatchObject({
270
+ content: [
271
+ {
272
+ type: 'taskList',
273
+ content: [
274
+ {
275
+ type: 'taskItem',
276
+ content: [
277
+ { type: 'paragraph' },
278
+ {
279
+ type: 'taskList',
280
+ content: [{ type: 'taskItem' }],
281
+ },
282
+ ],
283
+ },
284
+ ],
285
+ },
286
+ ],
287
+ });
288
+
289
+ expect(handleListIndentation(view, tabEvent({ shiftKey: true }))).toBe(
290
+ true
291
+ );
292
+ expect(getState().doc.child(0).childCount).toBe(2);
293
+ });
294
+
295
+ it('does not consume Tab outside a list or when the first item cannot indent', () => {
296
+ const paragraph = viewForState(
297
+ stateWithSelection(
298
+ schema.node('doc', null, [
299
+ schema.node('paragraph', null, schema.text('plain')),
300
+ ])
301
+ )
302
+ );
303
+ const paragraphEvent = tabEvent();
304
+
305
+ expect(handleListIndentation(paragraph.view, paragraphEvent)).toBe(false);
306
+ expect(paragraphEvent.preventDefault).not.toHaveBeenCalled();
307
+
308
+ const firstItem = viewForState(
309
+ stateWithSelection(
310
+ schema.node('doc', null, [
311
+ schema.node('bulletList', null, [
312
+ schema.node('listItem', null, [
313
+ schema.node('paragraph', null, schema.text('only')),
314
+ ]),
315
+ ]),
316
+ ])
317
+ )
318
+ );
319
+ const firstItemEvent = tabEvent();
320
+
321
+ expect(handleListIndentation(firstItem.view, firstItemEvent)).toBe(false);
322
+ expect(firstItemEvent.preventDefault).not.toHaveBeenCalled();
323
+ });
324
+
325
+ it('does not hijack modified Tab shortcuts', () => {
326
+ const state = stateWithSelection(
327
+ schema.node('doc', null, [
328
+ schema.node('bulletList', null, [
329
+ schema.node('listItem', null, [
330
+ schema.node('paragraph', null, schema.text('first')),
331
+ ]),
332
+ schema.node('listItem', null, [
333
+ schema.node('paragraph', null, schema.text('second')),
334
+ ]),
335
+ ]),
336
+ ]),
337
+ 1,
338
+ 'second'
339
+ );
340
+ const { view } = viewForState(state);
341
+
342
+ expect(handleListIndentation(view, tabEvent({ metaKey: true }))).toBe(
343
+ false
344
+ );
345
+ });
176
346
  });