@tuturuuu/ui 0.19.0 → 0.20.1
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 +46 -0
- package/biome.json +1 -1
- package/package.json +62 -50
- package/src/components/ui/badge.tsx +2 -0
- package/src/components/ui/button.tsx +2 -0
- package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +48 -4
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +22 -0
- package/src/components/ui/custom/animated-slot-text.tsx +20 -0
- package/src/components/ui/custom/common-footer.tsx +262 -237
- package/src/components/ui/custom/language-dropdown-item.tsx +3 -10
- package/src/components/ui/custom/language-toggle.test.tsx +30 -0
- package/src/components/ui/custom/language-toggle.tsx +11 -10
- package/src/components/ui/custom/locale-preference.ts +32 -0
- package/src/components/ui/custom/settings/appearance-settings.tsx +5 -13
- package/src/components/ui/custom/settings-dialog-shell.tsx +40 -9
- package/src/components/ui/custom/structure.tsx +12 -0
- package/src/components/ui/custom/system-language-dropdown-item.tsx +3 -6
- package/src/components/ui/custom/workspace-access/adapters.test.ts +10 -1
- package/src/components/ui/custom/workspace-access/adapters.ts +36 -4
- package/src/components/ui/custom/workspace-access/member-filter-utils.test.ts +14 -0
- package/src/components/ui/custom/workspace-access/member-filter-utils.ts +8 -0
- package/src/components/ui/custom/workspace-access/types.ts +20 -0
- package/src/components/ui/custom/workspace-access/workspace-access-context.test.tsx +111 -0
- package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +15 -5
- package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.tsx +155 -48
- package/src/components/ui/custom/workspace-access/workspace-access-member-profile-dialog.tsx +92 -0
- package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +171 -76
- package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +11 -2
- package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +11 -11
- package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +180 -22
- package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +29 -15
- package/src/components/ui/custom/workspace-access/workspace-access-permission-checklist.tsx +56 -10
- package/src/components/ui/custom/workspace-access/workspace-access-permission-preview.test.ts +33 -0
- package/src/components/ui/custom/workspace-access/workspace-access-permission-preview.tsx +24 -1
- package/src/components/ui/custom/workspace-access/workspace-access-responsive.test.ts +64 -0
- package/src/components/ui/custom/workspace-access/workspace-access-role-editor-dialog.tsx +28 -16
- package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +35 -11
- package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +23 -11
- package/src/components/ui/custom/workspace-select-helpers.ts +5 -3
- package/src/components/ui/custom/workspace-select.tsx +8 -2
- package/src/components/ui/finance/shared/charts/monthly-total-chart-client.tsx +1 -1
- package/src/components/ui/finance/shared/charts/monthly-total-chart.tsx +1 -1
- package/src/components/ui/storefront/cart-summary.tsx +12 -2
- package/src/components/ui/storefront/storefront-surface.test.tsx +21 -0
- package/src/components/ui/storefront/storefront-surface.tsx +6 -0
- package/src/components/ui/text-editor/__tests__/collaboration-binding.test.tsx +161 -0
- package/src/components/ui/text-editor/editor.tsx +267 -235
- package/src/globals.css +166 -0
- package/src/hooks/__tests__/use-workspace-identity-mutation.test.tsx +181 -0
- package/src/hooks/use-workspace-identity-mutation.ts +136 -0
- package/vendor/xlsx/LICENSE +201 -0
- package/vendor/xlsx/types/index.d.ts +1065 -0
- package/vendor/xlsx/xlsx.js +28103 -0
- package/vendor/xlsx/xlsx.mjs +28228 -0
- package/vendor/xlsx-0.20.3.tgz +0 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
function readWorkspaceAccessSource(fileName: string) {
|
|
6
|
+
const sourcePath = [
|
|
7
|
+
join(
|
|
8
|
+
process.cwd(),
|
|
9
|
+
'packages/ui/src/components/ui/custom/workspace-access',
|
|
10
|
+
fileName
|
|
11
|
+
),
|
|
12
|
+
join(process.cwd(), 'src/components/ui/custom/workspace-access', fileName),
|
|
13
|
+
].find((candidate) => existsSync(candidate));
|
|
14
|
+
|
|
15
|
+
if (!sourcePath) {
|
|
16
|
+
throw new Error(`Unable to locate ${fileName}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return readFileSync(sourcePath, 'utf8');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('workspace access responsive layout', () => {
|
|
23
|
+
it.each([
|
|
24
|
+
'workspace-access-invite-dialog.tsx',
|
|
25
|
+
'workspace-access-role-editor-dialog.tsx',
|
|
26
|
+
])('keeps %s within the mobile viewport with a scrollable body', (file) => {
|
|
27
|
+
const source = readWorkspaceAccessSource(file);
|
|
28
|
+
|
|
29
|
+
expect(source).toContain('max-h-[calc(100dvh-1rem)]');
|
|
30
|
+
expect(source).toContain('max-sm:bottom-0');
|
|
31
|
+
expect(source).toContain('overflow-y-auto overscroll-contain');
|
|
32
|
+
expect(source).toContain('<DialogFooter');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('uses compact navigation and controls on small screens', () => {
|
|
36
|
+
const toolbar = readWorkspaceAccessSource(
|
|
37
|
+
'workspace-access-tabs-toolbar.tsx'
|
|
38
|
+
);
|
|
39
|
+
const memberRow = readWorkspaceAccessSource(
|
|
40
|
+
'workspace-access-member-row.tsx'
|
|
41
|
+
);
|
|
42
|
+
const peopleFilters = readWorkspaceAccessSource(
|
|
43
|
+
'workspace-access-people-filters.tsx'
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
expect(toolbar).toContain('grid-cols-4');
|
|
47
|
+
expect(toolbar).toContain('shrink-0 grid-cols-4');
|
|
48
|
+
expect(toolbar).not.toContain('2xl:flex-row');
|
|
49
|
+
expect(toolbar).toContain('sr-only sm:hidden');
|
|
50
|
+
expect(memberRow).toContain('size-8 shrink-0');
|
|
51
|
+
expect(memberRow).toContain('<DropdownMenuContent');
|
|
52
|
+
expect(memberRow).not.toContain('sm:grid-cols-[1fr_auto]');
|
|
53
|
+
expect(peopleFilters).toContain('className="h-full min-h-0"');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('creates the recommended administrator role with only the admin grant', () => {
|
|
57
|
+
const page = readWorkspaceAccessSource('workspace-access-page.tsx');
|
|
58
|
+
|
|
59
|
+
expect(page).toContain("permissions: [{ enabled: true, id: 'admin' }]");
|
|
60
|
+
expect(page).not.toContain(
|
|
61
|
+
'permissions: permissionDefinitions.map((permission)'
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
4
|
+
import { ShieldCheck } from '@tuturuuu/icons';
|
|
4
5
|
import type { SupabaseUser } from '@tuturuuu/supabase/next/user';
|
|
5
6
|
import { Button } from '@tuturuuu/ui/button';
|
|
6
7
|
import {
|
|
7
8
|
Dialog,
|
|
8
9
|
DialogContent,
|
|
9
10
|
DialogDescription,
|
|
11
|
+
DialogFooter,
|
|
10
12
|
DialogHeader,
|
|
11
13
|
DialogTitle,
|
|
12
14
|
} from '@tuturuuu/ui/dialog';
|
|
@@ -131,13 +133,22 @@ export function WorkspaceAccessRoleEditorDialog({
|
|
|
131
133
|
|
|
132
134
|
return (
|
|
133
135
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
134
|
-
<DialogContent className="max-w-3xl">
|
|
135
|
-
<DialogHeader>
|
|
136
|
-
<
|
|
137
|
-
|
|
136
|
+
<DialogContent className="flex max-h-[calc(100dvh-1rem)] w-[calc(100%-1rem)] max-w-none flex-col gap-0 overflow-hidden rounded-b-none p-0 max-sm:top-auto max-sm:bottom-0 max-sm:left-0 max-sm:translate-x-0 max-sm:translate-y-0 sm:max-h-[min(90dvh,56rem)] sm:max-w-3xl sm:rounded-lg">
|
|
137
|
+
<DialogHeader className="shrink-0 gap-0 border-b p-4 pr-12 text-left sm:p-6 sm:pr-12">
|
|
138
|
+
<div className="flex items-start gap-3">
|
|
139
|
+
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-dynamic-purple/25 bg-dynamic-purple/10 text-dynamic-purple">
|
|
140
|
+
<ShieldCheck className="size-4" />
|
|
141
|
+
</div>
|
|
142
|
+
<div className="min-w-0 space-y-1">
|
|
143
|
+
<DialogTitle>{labels.title}</DialogTitle>
|
|
144
|
+
<DialogDescription className="leading-5">
|
|
145
|
+
{labels.description}
|
|
146
|
+
</DialogDescription>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
138
149
|
</DialogHeader>
|
|
139
150
|
|
|
140
|
-
<div className="space-y-4">
|
|
151
|
+
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain p-4 sm:p-6">
|
|
141
152
|
{state.mode !== 'default' ? (
|
|
142
153
|
<div className="grid gap-2">
|
|
143
154
|
<Label htmlFor="workspace-access-role-name">
|
|
@@ -152,9 +163,9 @@ export function WorkspaceAccessRoleEditorDialog({
|
|
|
152
163
|
</div>
|
|
153
164
|
) : null}
|
|
154
165
|
|
|
155
|
-
<div className="flex items-center justify-between rounded-lg border bg-muted/35 px-
|
|
166
|
+
<div className="flex items-center justify-between rounded-lg border bg-muted/35 px-3 py-2.5 text-sm sm:px-4 sm:py-3">
|
|
156
167
|
<span className="font-medium">{t('ws-roles.permissions')}</span>
|
|
157
|
-
<span className="tabular-nums">
|
|
168
|
+
<span className="rounded-full bg-background px-2 py-0.5 font-medium tabular-nums">
|
|
158
169
|
{selectedCount}/{totalCount}
|
|
159
170
|
</span>
|
|
160
171
|
</div>
|
|
@@ -164,16 +175,17 @@ export function WorkspaceAccessRoleEditorDialog({
|
|
|
164
175
|
onSelectedPermissionsChange={setSelectedPermissions}
|
|
165
176
|
selectedPermissions={selectedPermissions}
|
|
166
177
|
/>
|
|
167
|
-
|
|
168
|
-
<div className="flex justify-end">
|
|
169
|
-
<Button
|
|
170
|
-
disabled={disabled || saveMutation.isPending}
|
|
171
|
-
onClick={() => saveMutation.mutate()}
|
|
172
|
-
>
|
|
173
|
-
{saveMutation.isPending ? t('common.processing') : labels.save}
|
|
174
|
-
</Button>
|
|
175
|
-
</div>
|
|
176
178
|
</div>
|
|
179
|
+
|
|
180
|
+
<DialogFooter className="shrink-0 border-t bg-muted/20 p-3 sm:p-4">
|
|
181
|
+
<Button
|
|
182
|
+
className="w-full sm:w-auto"
|
|
183
|
+
disabled={disabled || saveMutation.isPending}
|
|
184
|
+
onClick={() => saveMutation.mutate()}
|
|
185
|
+
>
|
|
186
|
+
{saveMutation.isPending ? t('common.processing') : labels.save}
|
|
187
|
+
</Button>
|
|
188
|
+
</DialogFooter>
|
|
177
189
|
</DialogContent>
|
|
178
190
|
</Dialog>
|
|
179
191
|
);
|
|
@@ -49,7 +49,7 @@ export function WorkspaceAccessRoles({
|
|
|
49
49
|
|
|
50
50
|
return (
|
|
51
51
|
<div className="space-y-4">
|
|
52
|
-
<div className="flex
|
|
52
|
+
<div className="flex items-start justify-between gap-3">
|
|
53
53
|
<div>
|
|
54
54
|
<h2 className="font-semibold text-lg">{labels.accessLevelsLabel}</h2>
|
|
55
55
|
<p className="text-muted-foreground text-sm">
|
|
@@ -57,9 +57,13 @@ export function WorkspaceAccessRoles({
|
|
|
57
57
|
</p>
|
|
58
58
|
</div>
|
|
59
59
|
{canManageRoles ? (
|
|
60
|
-
<Button
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
<Button
|
|
61
|
+
onClick={onCreateRole}
|
|
62
|
+
className="size-9 shrink-0 px-0 sm:w-auto sm:px-4"
|
|
63
|
+
>
|
|
64
|
+
<Plus className="size-4 sm:mr-2" />
|
|
65
|
+
<span className="hidden sm:inline">{t('ws-roles.create')}</span>
|
|
66
|
+
<span className="sr-only sm:hidden">{t('ws-roles.create')}</span>
|
|
63
67
|
</Button>
|
|
64
68
|
) : null}
|
|
65
69
|
</div>
|
|
@@ -86,7 +90,10 @@ export function WorkspaceAccessRoles({
|
|
|
86
90
|
role.members && role.members.length > 0
|
|
87
91
|
? role.members
|
|
88
92
|
: assignedMembersForRole(role.id, members);
|
|
89
|
-
const enabled = enabledPermissionCount(role);
|
|
93
|
+
const enabled = enabledPermissionCount(role, permissionCount);
|
|
94
|
+
const isAdministrator = role.permissions.some(
|
|
95
|
+
(permission) => permission.id === 'admin' && permission.enabled
|
|
96
|
+
);
|
|
90
97
|
const pct =
|
|
91
98
|
permissionCount > 0
|
|
92
99
|
? Math.round((enabled / permissionCount) * 100)
|
|
@@ -95,7 +102,7 @@ export function WorkspaceAccessRoles({
|
|
|
95
102
|
return (
|
|
96
103
|
<div
|
|
97
104
|
key={role.id}
|
|
98
|
-
className="rounded-xl border border-border bg-background p-
|
|
105
|
+
className="rounded-xl border border-border bg-background p-4 transition-colors hover:border-foreground/20 sm:p-5"
|
|
99
106
|
>
|
|
100
107
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
101
108
|
<div className="flex min-w-0 gap-3">
|
|
@@ -133,22 +140,34 @@ export function WorkspaceAccessRoles({
|
|
|
133
140
|
</div>
|
|
134
141
|
|
|
135
142
|
{canManageRoles ? (
|
|
136
|
-
<div className="flex
|
|
143
|
+
<div className="flex shrink-0 gap-1.5 sm:gap-2">
|
|
137
144
|
<Button
|
|
138
145
|
variant="outline"
|
|
139
146
|
size="sm"
|
|
147
|
+
className="size-8 px-0 sm:w-auto sm:px-3"
|
|
140
148
|
onClick={() => onEditRole(role)}
|
|
141
149
|
>
|
|
142
|
-
<Pencil className="mr-2
|
|
143
|
-
|
|
150
|
+
<Pencil className="size-3.5 sm:mr-2" />
|
|
151
|
+
<span className="hidden sm:inline">
|
|
152
|
+
{t('common.edit')}
|
|
153
|
+
</span>
|
|
154
|
+
<span className="sr-only sm:hidden">
|
|
155
|
+
{t('common.edit')}
|
|
156
|
+
</span>
|
|
144
157
|
</Button>
|
|
145
158
|
<Button
|
|
146
159
|
variant="outline"
|
|
147
160
|
size="sm"
|
|
161
|
+
className="size-8 px-0 sm:w-auto sm:px-3"
|
|
148
162
|
onClick={() => onDeleteRole(role)}
|
|
149
163
|
>
|
|
150
|
-
<Trash2 className="mr-2
|
|
151
|
-
|
|
164
|
+
<Trash2 className="size-3.5 sm:mr-2" />
|
|
165
|
+
<span className="hidden sm:inline">
|
|
166
|
+
{t('common.delete')}
|
|
167
|
+
</span>
|
|
168
|
+
<span className="sr-only sm:hidden">
|
|
169
|
+
{t('common.delete')}
|
|
170
|
+
</span>
|
|
152
171
|
</Button>
|
|
153
172
|
</div>
|
|
154
173
|
) : null}
|
|
@@ -161,6 +180,11 @@ export function WorkspaceAccessRoles({
|
|
|
161
180
|
role={role}
|
|
162
181
|
/>
|
|
163
182
|
</div>
|
|
183
|
+
{isAdministrator ? (
|
|
184
|
+
<p className="mt-2 text-dynamic-green text-sm">
|
|
185
|
+
{t('ws-members.admin_has_all_permissions')}
|
|
186
|
+
</p>
|
|
187
|
+
) : null}
|
|
164
188
|
|
|
165
189
|
{assignedMembers.length > 0 ? (
|
|
166
190
|
<div className="mt-3 flex flex-wrap gap-1.5 border-border border-t pt-3">
|
|
@@ -27,7 +27,7 @@ type Props = {
|
|
|
27
27
|
};
|
|
28
28
|
|
|
29
29
|
const TAB_TRIGGER_CLASS =
|
|
30
|
-
'
|
|
30
|
+
'h-full min-w-0 w-full gap-1.5 overflow-hidden rounded-lg px-2 text-muted-foreground shadow-none transition-colors hover:text-foreground data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm sm:px-3';
|
|
31
31
|
|
|
32
32
|
export function WorkspaceAccessTabsToolbar({
|
|
33
33
|
activeTab,
|
|
@@ -73,8 +73,8 @@ export function WorkspaceAccessTabsToolbar({
|
|
|
73
73
|
];
|
|
74
74
|
|
|
75
75
|
return (
|
|
76
|
-
<div className="flex flex-col gap-3
|
|
77
|
-
<TabsList className="h-
|
|
76
|
+
<div className="flex flex-col gap-3">
|
|
77
|
+
<TabsList className="grid h-11 w-full shrink-0 grid-cols-4 gap-1 overflow-hidden rounded-xl border bg-muted/30 p-1">
|
|
78
78
|
{tabs.map((tab) => (
|
|
79
79
|
<TabsTrigger
|
|
80
80
|
key={tab.value}
|
|
@@ -83,13 +83,16 @@ export function WorkspaceAccessTabsToolbar({
|
|
|
83
83
|
className={TAB_TRIGGER_CLASS}
|
|
84
84
|
>
|
|
85
85
|
{tab.icon}
|
|
86
|
-
|
|
86
|
+
<span className="hidden min-w-0 truncate sm:inline">
|
|
87
|
+
{tab.label}
|
|
88
|
+
</span>
|
|
89
|
+
<span className="sr-only sm:hidden">{tab.label}</span>
|
|
87
90
|
</TabsTrigger>
|
|
88
91
|
))}
|
|
89
92
|
</TabsList>
|
|
90
93
|
|
|
91
|
-
<div className="flex w-full
|
|
92
|
-
<div className="relative min-w-0
|
|
94
|
+
<div className="flex w-full min-w-0 gap-2">
|
|
95
|
+
<div className="relative min-w-0 flex-1">
|
|
93
96
|
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
94
97
|
<Input
|
|
95
98
|
value={search}
|
|
@@ -99,11 +102,20 @@ export function WorkspaceAccessTabsToolbar({
|
|
|
99
102
|
/>
|
|
100
103
|
</div>
|
|
101
104
|
{activeTab === 'people' ? (
|
|
102
|
-
<Button
|
|
103
|
-
|
|
104
|
-
{
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
<Button
|
|
106
|
+
disabled={!canInvite}
|
|
107
|
+
onClick={onInviteClick}
|
|
108
|
+
className="shrink-0 px-3 sm:px-4"
|
|
109
|
+
>
|
|
110
|
+
<UserPlus className="size-4 sm:mr-2" />
|
|
111
|
+
<span className="hidden sm:inline">
|
|
112
|
+
{disableInvite
|
|
113
|
+
? t('ws-members.invite_member_disabled')
|
|
114
|
+
: t('ws-members.invite_member')}
|
|
115
|
+
</span>
|
|
116
|
+
<span className="sr-only sm:hidden">
|
|
117
|
+
{t('ws-members.invite_member')}
|
|
118
|
+
</span>
|
|
107
119
|
</Button>
|
|
108
120
|
) : null}
|
|
109
121
|
</div>
|
|
@@ -23,11 +23,13 @@ export function normalizeWorkspaceSwitchPath(
|
|
|
23
23
|
pathname: string,
|
|
24
24
|
nextSlug: string
|
|
25
25
|
) {
|
|
26
|
-
const
|
|
26
|
+
const taskBoardPaths = [`/${nextSlug}/boards`, `/${nextSlug}/tasks/boards`];
|
|
27
27
|
|
|
28
28
|
if (
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
taskBoardPaths.some(
|
|
30
|
+
(taskBoardsPath) =>
|
|
31
|
+
pathname === taskBoardsPath || pathname.startsWith(`${taskBoardsPath}/`)
|
|
32
|
+
)
|
|
31
33
|
) {
|
|
32
34
|
return `/${nextSlug}/tasks`;
|
|
33
35
|
}
|
|
@@ -156,6 +156,8 @@ export function WorkspaceSelect({
|
|
|
156
156
|
createWorkspaceDescription,
|
|
157
157
|
fallbackLogoUrl = TUTURUUU_LOGO_URL,
|
|
158
158
|
resolveNextPathname,
|
|
159
|
+
triggerClassName,
|
|
160
|
+
popoverModal = false,
|
|
159
161
|
}: {
|
|
160
162
|
wsId: string;
|
|
161
163
|
hideLeading?: boolean;
|
|
@@ -171,6 +173,9 @@ export function WorkspaceSelect({
|
|
|
171
173
|
currentPathname: string;
|
|
172
174
|
nextSlug: string;
|
|
173
175
|
}) => string;
|
|
176
|
+
triggerClassName?: string;
|
|
177
|
+
/** Keep the picker interactive and scrollable when rendered inside a modal. */
|
|
178
|
+
popoverModal?: boolean;
|
|
174
179
|
}) {
|
|
175
180
|
const t = useTranslations();
|
|
176
181
|
const router = useRouter();
|
|
@@ -554,7 +559,7 @@ export function WorkspaceSelect({
|
|
|
554
559
|
setShowNewWorkspaceDialog(open);
|
|
555
560
|
}}
|
|
556
561
|
>
|
|
557
|
-
<Popover open={open} onOpenChange={setOpen}>
|
|
562
|
+
<Popover modal={popoverModal} open={open} onOpenChange={setOpen}>
|
|
558
563
|
<PopoverTrigger asChild disabled={!hasSelectableWorkspaces}>
|
|
559
564
|
<Button
|
|
560
565
|
size="xs"
|
|
@@ -563,7 +568,8 @@ export function WorkspaceSelect({
|
|
|
563
568
|
aria-label="Select a workspace"
|
|
564
569
|
className={cn(
|
|
565
570
|
hideLeading ? 'justify-center p-0' : 'justify-start',
|
|
566
|
-
'w-full whitespace-normal text-start'
|
|
571
|
+
'w-full whitespace-normal text-start',
|
|
572
|
+
triggerClassName
|
|
567
573
|
)}
|
|
568
574
|
disabled={!hasSelectableWorkspaces}
|
|
569
575
|
>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { ArrowRight, ShoppingCart } from '@tuturuuu/icons';
|
|
4
4
|
import type { InventoryStorefront } from '@tuturuuu/internal-api/inventory';
|
|
5
5
|
import { cn } from '@tuturuuu/utils/format';
|
|
6
|
-
import type { FormEvent } from 'react';
|
|
6
|
+
import type { FormEvent, ReactNode } from 'react';
|
|
7
7
|
import { Badge } from '../badge';
|
|
8
8
|
import { AccentButton } from './accent-button';
|
|
9
9
|
import {
|
|
@@ -25,6 +25,8 @@ type StorefrontCartSummaryVariant = 'checkout' | 'panel' | 'popover';
|
|
|
25
25
|
export function StorefrontCartSummary({
|
|
26
26
|
buyerDefaults,
|
|
27
27
|
cartEntries,
|
|
28
|
+
checkoutBlocked = false,
|
|
29
|
+
checkoutFields,
|
|
28
30
|
checkoutHref,
|
|
29
31
|
className,
|
|
30
32
|
currency,
|
|
@@ -43,6 +45,8 @@ export function StorefrontCartSummary({
|
|
|
43
45
|
}: {
|
|
44
46
|
buyerDefaults?: StorefrontBuyerDefaults;
|
|
45
47
|
cartEntries: StorefrontCartEntry[];
|
|
48
|
+
checkoutBlocked?: boolean;
|
|
49
|
+
checkoutFields?: ReactNode;
|
|
46
50
|
checkoutHref?: string;
|
|
47
51
|
className?: string;
|
|
48
52
|
currency: string;
|
|
@@ -63,7 +67,11 @@ export function StorefrontCartSummary({
|
|
|
63
67
|
const hasCart = cartEntries.length > 0;
|
|
64
68
|
const isCheckoutDisabled = storefront.checkoutMode === 'disabled';
|
|
65
69
|
const submitDisabled =
|
|
66
|
-
!hasCart ||
|
|
70
|
+
!hasCart ||
|
|
71
|
+
isSubmitting ||
|
|
72
|
+
isCheckoutDisabled ||
|
|
73
|
+
checkoutBlocked ||
|
|
74
|
+
!onCheckoutSubmit;
|
|
67
75
|
const canOpenCheckout = hasCart && Boolean(checkoutHref || onCheckoutOpen);
|
|
68
76
|
|
|
69
77
|
if (presentation === 'checkout') {
|
|
@@ -101,6 +109,8 @@ export function StorefrontCartSummary({
|
|
|
101
109
|
/>
|
|
102
110
|
</CheckoutSection>
|
|
103
111
|
|
|
112
|
+
{checkoutFields}
|
|
113
|
+
|
|
104
114
|
<AccentButton disabled={submitDisabled} radius={radius}>
|
|
105
115
|
{isSubmitting ? labels.reserving : labels.reserve}
|
|
106
116
|
<ArrowRight className="size-4 shrink-0" />
|
|
@@ -485,6 +485,27 @@ describe('StorefrontSurface', () => {
|
|
|
485
485
|
expect(screen.getByLabelText('Email')).toBeEnabled();
|
|
486
486
|
});
|
|
487
487
|
|
|
488
|
+
it('shows checkout routing guidance and blocks dispatch until it is ready', () => {
|
|
489
|
+
render(
|
|
490
|
+
<StorefrontSurface
|
|
491
|
+
cartLines={[{ listingId: listing.id, quantity: 1 }]}
|
|
492
|
+
checkoutBlocked
|
|
493
|
+
checkoutFields={<div>Choose an approved payment station</div>}
|
|
494
|
+
listings={[listing]}
|
|
495
|
+
mode="checkout"
|
|
496
|
+
onCheckoutSubmit={() => undefined}
|
|
497
|
+
storefront={{ ...storefront, checkoutMode: 'square_terminal' }}
|
|
498
|
+
/>
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
expect(
|
|
502
|
+
screen.getByText('Choose an approved payment station')
|
|
503
|
+
).toBeInTheDocument();
|
|
504
|
+
expect(
|
|
505
|
+
screen.getByRole('button', { name: 'Reserve with Polar' })
|
|
506
|
+
).toBeDisabled();
|
|
507
|
+
});
|
|
508
|
+
|
|
488
509
|
it('keeps simulated storefront chrome customer-facing', () => {
|
|
489
510
|
render(
|
|
490
511
|
<StorefrontSurface
|
|
@@ -50,6 +50,8 @@ export function StorefrontSurface({
|
|
|
50
50
|
buyerDefaults,
|
|
51
51
|
cartLines = [],
|
|
52
52
|
cartHref,
|
|
53
|
+
checkoutBlocked = false,
|
|
54
|
+
checkoutFields,
|
|
53
55
|
checkoutOpen,
|
|
54
56
|
checkoutHref,
|
|
55
57
|
className,
|
|
@@ -83,6 +85,8 @@ export function StorefrontSurface({
|
|
|
83
85
|
buyerDefaults?: StorefrontBuyerDefaults;
|
|
84
86
|
cartLines?: StorefrontCartLine[];
|
|
85
87
|
cartHref?: string;
|
|
88
|
+
checkoutBlocked?: boolean;
|
|
89
|
+
checkoutFields?: ReactNode;
|
|
86
90
|
checkoutOpen?: boolean;
|
|
87
91
|
checkoutHref?: string;
|
|
88
92
|
className?: string;
|
|
@@ -269,6 +273,8 @@ export function StorefrontSurface({
|
|
|
269
273
|
<StorefrontCartSummary
|
|
270
274
|
buyerDefaults={buyerDefaults}
|
|
271
275
|
cartEntries={checkoutEntries}
|
|
276
|
+
checkoutBlocked={checkoutBlocked}
|
|
277
|
+
checkoutFields={checkoutFields}
|
|
272
278
|
checkoutHref={checkoutHref}
|
|
273
279
|
currency={currency}
|
|
274
280
|
isCheckout
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import * as Y from 'yjs';
|
|
4
|
+
import { RichTextEditor } from '../editor';
|
|
5
|
+
|
|
6
|
+
const useEditorMock = vi.hoisted(() => vi.fn((..._args: unknown[]) => null));
|
|
7
|
+
|
|
8
|
+
vi.mock('@tiptap/react', async (importOriginal) => ({
|
|
9
|
+
...(await importOriginal<typeof import('@tiptap/react')>()),
|
|
10
|
+
EditorContent: () => null,
|
|
11
|
+
useEditor: (...args: unknown[]) => useEditorMock(...args),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock('../tool-bar', () => ({
|
|
15
|
+
FixedToolbar: ({ className }: { className?: string }) => (
|
|
16
|
+
<div className={className} data-testid="fixed-toolbar" />
|
|
17
|
+
),
|
|
18
|
+
ToolBar: () => null,
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
type UseEditorCall = [
|
|
22
|
+
{ extensions: Array<{ name: string }> },
|
|
23
|
+
unknown[] | undefined,
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
function lastCall(): UseEditorCall {
|
|
27
|
+
const calls = useEditorMock.mock.calls as unknown as UseEditorCall[];
|
|
28
|
+
const call = calls.at(-1);
|
|
29
|
+
if (!call) throw new Error('useEditor was never called');
|
|
30
|
+
return call;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function extensionNames() {
|
|
34
|
+
return lastCall()[0].extensions.map((extension) => extension.name);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('RichTextEditor collaboration binding', () => {
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
useEditorMock.mockClear();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('omits the collaboration extension while collaboration is off', () => {
|
|
43
|
+
render(
|
|
44
|
+
<RichTextEditor
|
|
45
|
+
allowCollaboration={false}
|
|
46
|
+
content={null}
|
|
47
|
+
yjsDoc={undefined}
|
|
48
|
+
/>
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
expect(extensionNames()).not.toContain('collaboration');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Regression: `Editor.setOptions({ extensions })` does not rebuild the
|
|
55
|
+
// extension manager, so the only way to attach the Collaboration extension
|
|
56
|
+
// after mount is to recreate the editor. A task dialog opened from a deep link
|
|
57
|
+
// mounts before the task hydrates — i.e. with collaboration off — and used to
|
|
58
|
+
// stay non-collaborative forever, leaving the description permanently empty.
|
|
59
|
+
it('rebuilds the editor when collaboration turns on after mount', () => {
|
|
60
|
+
const doc = new Y.Doc();
|
|
61
|
+
const { rerender } = render(
|
|
62
|
+
<RichTextEditor allowCollaboration={false} content={null} yjsDoc={doc} />
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const initialDeps = lastCall()[1];
|
|
66
|
+
expect(extensionNames()).not.toContain('collaboration');
|
|
67
|
+
|
|
68
|
+
rerender(<RichTextEditor allowCollaboration content={null} yjsDoc={doc} />);
|
|
69
|
+
|
|
70
|
+
const collaborativeCall = lastCall();
|
|
71
|
+
expect(collaborativeCall[0].extensions.map((e) => e.name)).toContain(
|
|
72
|
+
'collaboration'
|
|
73
|
+
);
|
|
74
|
+
// A changed dependency array is what makes `useEditor` throw away the
|
|
75
|
+
// non-collaborative instance and build a new one.
|
|
76
|
+
expect(collaborativeCall[1]).not.toEqual(initialDeps);
|
|
77
|
+
expect(collaborativeCall[1]).toContain(doc);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('keeps a stable dependency array across unrelated re-renders', () => {
|
|
81
|
+
const doc = new Y.Doc();
|
|
82
|
+
const { rerender } = render(
|
|
83
|
+
<RichTextEditor allowCollaboration content={null} yjsDoc={doc} />
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const initialDeps = lastCall()[1];
|
|
87
|
+
|
|
88
|
+
// `collaborationUser` is a fresh object on every render in the task dialog;
|
|
89
|
+
// it must not be part of the dependency array or the editor would be
|
|
90
|
+
// rebuilt (and the caret reset) on every render.
|
|
91
|
+
rerender(
|
|
92
|
+
<RichTextEditor
|
|
93
|
+
allowCollaboration
|
|
94
|
+
collaborationUser={{ color: '#fff', name: 'Ada' }}
|
|
95
|
+
content={null}
|
|
96
|
+
yjsDoc={doc}
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(lastCall()[1]).toEqual(initialDeps);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// The provider only drives CollaborationCaret (cosmetic remote cursors).
|
|
104
|
+
// Rebuilding a live editor for it would tear down the ProseMirror view and
|
|
105
|
+
// re-run the Yjs binding over the whole document mid-session — expensive and
|
|
106
|
+
// user-visible on a large document, for no content benefit.
|
|
107
|
+
it('does not rebuild the editor when only the provider arrives', () => {
|
|
108
|
+
const doc = new Y.Doc();
|
|
109
|
+
const provider = { awareness: { setLocalStateField: () => {} } };
|
|
110
|
+
const { rerender } = render(
|
|
111
|
+
<RichTextEditor allowCollaboration content={null} yjsDoc={doc} />
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const initialDeps = lastCall()[1];
|
|
115
|
+
|
|
116
|
+
rerender(
|
|
117
|
+
<RichTextEditor
|
|
118
|
+
allowCollaboration
|
|
119
|
+
collaborationUser={{ color: '#fff', name: 'Ada' }}
|
|
120
|
+
content={null}
|
|
121
|
+
yjsDoc={doc}
|
|
122
|
+
yjsProvider={provider as never}
|
|
123
|
+
/>
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
expect(lastCall()[1]).toEqual(initialDeps);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('RichTextEditor toolbar reveal', () => {
|
|
131
|
+
beforeEach(() => {
|
|
132
|
+
useEditorMock.mockClear();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// The toolbar must reveal on focus *within* the editor wrapper, not on editor
|
|
136
|
+
// focus alone: clicking a toolbar button moves focus out of the text, so a
|
|
137
|
+
// stricter rule would hide the toolbar on mousedown and swallow the click.
|
|
138
|
+
it('hides the toolbar until focus lands inside the editor', () => {
|
|
139
|
+
const { container } = render(
|
|
140
|
+
<RichTextEditor content={null} revealToolbarOnFocus />
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const toolbar = container.querySelector('[data-testid="fixed-toolbar"]');
|
|
144
|
+
expect(toolbar?.className).toContain('opacity-0');
|
|
145
|
+
expect(toolbar?.className).toContain('group-focus-within:opacity-100');
|
|
146
|
+
// Inert while hidden, so it cannot show hover states or tooltips; the
|
|
147
|
+
// wrapper turns a press in that strip into editor focus instead.
|
|
148
|
+
expect(toolbar?.className).toContain('pointer-events-none');
|
|
149
|
+
expect(toolbar?.className).toContain(
|
|
150
|
+
'group-focus-within:pointer-events-auto'
|
|
151
|
+
);
|
|
152
|
+
expect(container.firstElementChild?.className).toContain('group');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('leaves the toolbar visible by default', () => {
|
|
156
|
+
const { container } = render(<RichTextEditor content={null} />);
|
|
157
|
+
|
|
158
|
+
const toolbar = container.querySelector('[data-testid="fixed-toolbar"]');
|
|
159
|
+
expect(toolbar?.className ?? '').not.toContain('opacity-0');
|
|
160
|
+
});
|
|
161
|
+
});
|