@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.
- package/CHANGELOG.md +37 -0
- package/biome.json +1 -1
- package/package.json +49 -48
- package/src/components/ui/button.tsx +12 -8
- package/src/components/ui/color-picker.test.tsx +23 -0
- package/src/components/ui/color-picker.tsx +1 -1
- package/src/components/ui/custom/combobox.tsx +4 -0
- package/src/components/ui/custom/notification-popover-client.tsx +6 -19
- package/src/components/ui/custom/notification-popover-trigger.test.tsx +37 -0
- package/src/components/ui/custom/notification-popover-trigger.tsx +44 -0
- package/src/components/ui/custom/workspace-access/adapters.test.ts +89 -1
- package/src/components/ui/custom/workspace-access/adapters.ts +67 -2
- package/src/components/ui/custom/workspace-access/types.ts +13 -0
- package/src/components/ui/custom/workspace-access/workspace-access-invitation-role-menu.test.tsx +79 -0
- package/src/components/ui/custom/workspace-access/workspace-access-invitation-role-menu.tsx +125 -0
- package/src/components/ui/custom/workspace-access/workspace-access-invite-access-picker.tsx +108 -0
- package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.test.tsx +134 -0
- package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.tsx +94 -113
- package/src/components/ui/custom/workspace-access/workspace-access-invite-pos-panel.tsx +75 -0
- package/src/components/ui/custom/workspace-access/workspace-access-invite-role-picker.tsx +151 -0
- package/src/components/ui/custom/workspace-access/workspace-access-labels.ts +1 -1
- package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +27 -2
- package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +10 -0
- package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +102 -3
- package/src/components/ui/custom/workspace-access/workspace-access-role-options.test.ts +35 -0
- package/src/components/ui/custom/workspace-access/workspace-access-role-options.ts +21 -0
- package/src/components/ui/custom/workspace-select-invitations.tsx +2 -1
- package/src/components/ui/text-editor/__tests__/editor-classes.test.ts +36 -0
- package/src/components/ui/text-editor/__tests__/inline-task-conversion.test.tsx +30 -0
- package/src/components/ui/text-editor/__tests__/keyboard.test.ts +173 -3
- package/src/components/ui/text-editor/__tests__/toggle-block-extension.test.ts +251 -0
- package/src/components/ui/text-editor/editor-classes.ts +100 -0
- package/src/components/ui/text-editor/editor.tsx +11 -153
- package/src/components/ui/text-editor/extensions.ts +19 -0
- package/src/components/ui/text-editor/keyboard.ts +47 -0
- package/src/components/ui/text-editor/toggle-block-extension.ts +153 -0
- package/src/components/ui/text-editor/tool-bar.tsx +24 -73
- package/src/components/ui/text-editor/toolbar-config.ts +67 -0
- package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -1
- package/src/hooks/use-board-actions.test.ts +23 -0
- package/src/hooks/use-board-actions.ts +48 -35
- package/src/hooks/use-calendar-sync.tsx +12 -12
- 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
|
-
|
|
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={
|
|
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
|
|
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:
|
|
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(
|
|
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 (
|
|
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
|
});
|