@tuturuuu/ui 0.2.0 → 0.3.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 +53 -0
- package/package.json +79 -67
- package/src/components/ui/__tests__/avatar.test.tsx +8 -5
- package/src/components/ui/calendar-app/components/calendar-connections-compact.tsx +414 -0
- package/src/components/ui/calendar-app/components/calendar-connections-manager.tsx +5 -1
- package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +529 -0
- package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +26 -1429
- package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +711 -0
- package/src/components/ui/chart.test.tsx +29 -0
- package/src/components/ui/chart.tsx +12 -3
- package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +24 -1
- package/src/components/ui/custom/__tests__/tuturuuu-logo.test.ts +12 -3
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +39 -0
- package/src/components/ui/custom/common-footer.tsx +16 -1
- package/src/components/ui/custom/production-indicator.tsx +1 -1
- package/src/components/ui/custom/settings/sidebar-settings.tsx +1 -1
- package/src/components/ui/custom/settings/task-settings.tsx +18 -0
- package/src/components/ui/custom/settings-dialog-shell.tsx +38 -23
- package/src/components/ui/custom/sidebar-context-compile-graph.test.ts +60 -0
- package/src/components/ui/custom/sidebar-context.tsx +61 -61
- package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +123 -0
- package/src/components/ui/custom/tuturuuu-logo-urls.ts +6 -0
- package/src/components/ui/custom/tuturuuu-logo.tsx +25 -7
- package/src/components/ui/custom/workspace-select-helpers.ts +20 -0
- package/src/components/ui/custom/workspace-select.tsx +33 -12
- package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +7 -1
- package/src/components/ui/finance/invoices/components/invoice-payment-settings.tsx +3 -0
- package/src/components/ui/finance/invoices/components/invoice-products-permission-warning.tsx +58 -0
- package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +12 -20
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +10 -9
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +10 -5
- package/src/components/ui/finance/invoices/hooks.ts +75 -20
- package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +137 -0
- package/src/components/ui/finance/invoices/new-invoice-page.tsx +86 -37
- package/src/components/ui/finance/invoices/product-selection.test.tsx +8 -26
- package/src/components/ui/finance/invoices/product-selection.tsx +2 -10
- package/src/components/ui/finance/invoices/standard-invoice.tsx +88 -26
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +154 -46
- package/src/components/ui/finance/invoices/utils.test.ts +50 -0
- package/src/components/ui/finance/invoices/utils.ts +75 -17
- package/src/components/ui/finance/shared/finance-display-amount.tsx +3 -1
- package/src/components/ui/finance/shared/finance-permission-warning-dialog.test.tsx +34 -0
- package/src/components/ui/finance/shared/finance-permission-warning-dialog.tsx +157 -0
- package/src/components/ui/finance/transactions/form-basic-tab.tsx +8 -0
- package/src/components/ui/finance/transactions/form-more-tab.tsx +8 -0
- package/src/components/ui/finance/transactions/form-types.ts +2 -0
- package/src/components/ui/finance/transactions/form.test.tsx +43 -0
- package/src/components/ui/finance/transactions/form.tsx +60 -0
- package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +27 -0
- package/src/components/ui/finance/transactions/transactions-create-summary.tsx +13 -1
- package/src/components/ui/finance/transactions/transactions-infinite-page.tsx +4 -0
- package/src/components/ui/finance/transactions/transactions-page.tsx +23 -1
- package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +4 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +5 -0
- package/src/components/ui/legacy/calendar/calendar-content.tsx +9 -1
- package/src/components/ui/legacy/calendar/event-modal.tsx +146 -2
- package/src/components/ui/legacy/calendar/event-preview-popover.tsx +200 -0
- package/src/components/ui/legacy/calendar/smart-calendar.test.tsx +76 -0
- package/src/components/ui/legacy/calendar/smart-calendar.tsx +13 -1
- package/src/components/ui/legacy/meet/page.test.ts +180 -0
- package/src/components/ui/legacy/meet/page.tsx +87 -39
- package/src/components/ui/tu-do/boards/boardId/board-column.tsx +79 -25
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-external-workspaces.test.tsx +392 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-island.test.tsx +57 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-island.tsx +106 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-clear-delete.ts +106 -161
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-assignees.ts +96 -150
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-labels.ts +63 -79
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-projects.ts +64 -83
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-updates.ts +115 -155
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operation-utils.ts +319 -2
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operations.ts +8 -1
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +63 -37
- package/src/components/ui/tu-do/boards/boardId/kanban/kanban-column-collapse.ts +16 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +46 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +5 -3
- package/src/components/ui/tu-do/boards/boardId/kanban.tsx +19 -7
- package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +181 -2
- package/src/components/ui/tu-do/boards/boardId/menus/index.ts +1 -0
- package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +463 -0
- package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-utils.ts +109 -0
- package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +4 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/TaskCardCheckbox.tsx +6 -3
- package/src/components/ui/tu-do/boards/boardId/task-card/TaskCardDates.tsx +26 -9
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-checkbox-style.ts +39 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.test.ts +43 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.ts +33 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-completion-checkbox-visibility.test.ts +31 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-completion-checkbox-visibility.ts +9 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-identifier-row.test.tsx +124 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-identifier-row.tsx +88 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +151 -76
- package/src/components/ui/tu-do/boards/boardId/task-card/task-scheduling-badge.tsx +174 -0
- package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +34 -13
- package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +54 -1
- package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +158 -0
- package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +5 -2
- package/src/components/ui/tu-do/shared/board-client.tsx +12 -2
- package/src/components/ui/tu-do/shared/board-views.tsx +195 -328
- package/src/components/ui/tu-do/shared/list-view.tsx +18 -8
- package/src/components/ui/tu-do/shared/task-due-date-visibility.test.ts +72 -0
- package/src/components/ui/tu-do/shared/task-due-date-visibility.ts +38 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +6 -3
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +2 -2
- package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +33 -0
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +74 -3
- package/src/hooks/__tests__/use-task-actions.test.tsx +118 -0
- package/src/hooks/__tests__/use-user-config.test.tsx +65 -0
- package/src/hooks/__tests__/use-workspace-presence.test.tsx +1 -1
- package/src/hooks/use-calendar-sync.tsx +22 -277
- package/src/hooks/use-calendar.tsx +95 -525
- package/src/hooks/use-task-actions.ts +43 -117
- package/src/hooks/use-user-config.ts +1 -1
- package/src/hooks/use-workspace-config.ts +6 -2
- package/src/hooks/use-workspace-presence.ts +1 -1
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-bar.tsx +0 -94
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { renderToString } from 'react-dom/server';
|
|
2
|
+
import { Bar, BarChart } from 'recharts';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { ChartContainer } from './chart';
|
|
5
|
+
|
|
6
|
+
describe('ChartContainer', () => {
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
vi.restoreAllMocks();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('does not emit Recharts dimension warnings during server rendering', () => {
|
|
12
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
13
|
+
|
|
14
|
+
renderToString(
|
|
15
|
+
<ChartContainer
|
|
16
|
+
className="h-64 w-full"
|
|
17
|
+
config={{ clicks: { color: 'var(--primary)', label: 'Clicks' } }}
|
|
18
|
+
>
|
|
19
|
+
<BarChart data={[{ clicks: 1, day: 'Mon' }]}>
|
|
20
|
+
<Bar dataKey="clicks" />
|
|
21
|
+
</BarChart>
|
|
22
|
+
</ChartContainer>
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
expect(warn).not.toHaveBeenCalledWith(
|
|
26
|
+
expect.stringContaining('The width(-1) and height(-1) of chart')
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -73,6 +73,11 @@ const ChartContainer = React.forwardRef<
|
|
|
73
73
|
>(({ id, className, children, config, ...props }, ref) => {
|
|
74
74
|
const uniqueId = React.useId();
|
|
75
75
|
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
|
|
76
|
+
const [mounted, setMounted] = React.useState(false);
|
|
77
|
+
|
|
78
|
+
React.useEffect(() => {
|
|
79
|
+
setMounted(true);
|
|
80
|
+
}, []);
|
|
76
81
|
|
|
77
82
|
return (
|
|
78
83
|
<ChartContext.Provider value={{ config }}>
|
|
@@ -87,9 +92,13 @@ const ChartContainer = React.forwardRef<
|
|
|
87
92
|
{...props}
|
|
88
93
|
>
|
|
89
94
|
<ChartStyle id={chartId} config={config} />
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
95
|
+
{mounted ? (
|
|
96
|
+
<RechartsPrimitive.ResponsiveContainer>
|
|
97
|
+
{children}
|
|
98
|
+
</RechartsPrimitive.ResponsiveContainer>
|
|
99
|
+
) : (
|
|
100
|
+
<div aria-hidden className="h-full w-full" />
|
|
101
|
+
)}
|
|
93
102
|
</div>
|
|
94
103
|
</ChartContext.Provider>
|
|
95
104
|
);
|
|
@@ -88,11 +88,34 @@ describe('SettingsDialogShell keyboard navigation', () => {
|
|
|
88
88
|
}));
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
+
it('renders the fullscreen settings sheet chrome', () => {
|
|
92
|
+
renderShell();
|
|
93
|
+
|
|
94
|
+
const dialog = screen.getByRole('dialog');
|
|
95
|
+
|
|
96
|
+
expect(dialog).toHaveClass('h-dvh');
|
|
97
|
+
expect(dialog).toHaveClass('w-screen');
|
|
98
|
+
expect(dialog).toHaveClass('rounded-none');
|
|
99
|
+
expect(
|
|
100
|
+
screen.getAllByRole('button', { name: 'settings.back_to_app' }).length
|
|
101
|
+
).toBeGreaterThan(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('keeps settings groups expanded by default', () => {
|
|
105
|
+
renderShell();
|
|
106
|
+
|
|
107
|
+
expect(screen.getAllByText('Profile').length).toBeGreaterThan(0);
|
|
108
|
+
expect(screen.getByText('Appearance')).toBeVisible();
|
|
109
|
+
expect(screen.getByText('Forms')).toBeVisible();
|
|
110
|
+
});
|
|
111
|
+
|
|
91
112
|
it('focuses settings search with slash and modifier search shortcuts', () => {
|
|
92
113
|
renderShell();
|
|
93
114
|
|
|
94
115
|
const dialog = screen.getByRole('dialog');
|
|
95
|
-
const searchInput = screen.getByPlaceholderText(
|
|
116
|
+
const searchInput = screen.getByPlaceholderText(
|
|
117
|
+
'settings.search_settings_placeholder'
|
|
118
|
+
);
|
|
96
119
|
|
|
97
120
|
fireEvent.keyDown(dialog, { key: '/' });
|
|
98
121
|
expect(searchInput).toHaveFocus();
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
TUTURUUU_LOCAL_LOGO_URL,
|
|
4
|
+
TUTURUUU_LOGO_URL,
|
|
5
|
+
TUTURUUU_REMOTE_LOGO_URL,
|
|
6
|
+
} from '../tuturuuu-logo';
|
|
3
7
|
|
|
4
8
|
describe('Tuturuuu logo asset URL', () => {
|
|
5
|
-
it('
|
|
6
|
-
expect(
|
|
9
|
+
it('keeps the canonical hosted logo as the shared default', () => {
|
|
10
|
+
expect(TUTURUUU_REMOTE_LOGO_URL).toBe(
|
|
7
11
|
'https://tuturuuu.com/media/logos/transparent.png'
|
|
8
12
|
);
|
|
13
|
+
expect(TUTURUUU_LOGO_URL).toBe(TUTURUUU_REMOTE_LOGO_URL);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('exports the same-origin public logo path for apps that ship the asset', () => {
|
|
17
|
+
expect(TUTURUUU_LOCAL_LOGO_URL).toBe('/media/logos/transparent.png');
|
|
9
18
|
});
|
|
10
19
|
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { InternalApiWorkspaceSummary } from '@tuturuuu/types';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { mergeWorkspaceSelectWorkspaces } from '../workspace-select-helpers';
|
|
4
|
+
|
|
5
|
+
describe('mergeWorkspaceSelectWorkspaces', () => {
|
|
6
|
+
it('uses the current workspace fallback when the workspace list is unavailable', () => {
|
|
7
|
+
const fallback: InternalApiWorkspaceSummary = {
|
|
8
|
+
access_type: 'guest',
|
|
9
|
+
avatar_url: null,
|
|
10
|
+
guest_board_count: 1,
|
|
11
|
+
guest_highest_permission: 'edit',
|
|
12
|
+
guest_landing_path: '/tasks/boards/board-1',
|
|
13
|
+
guest_products: ['tasks'],
|
|
14
|
+
id: 'guest-workspace',
|
|
15
|
+
logo_url: null,
|
|
16
|
+
name: 'Shared workspace',
|
|
17
|
+
personal: false,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
expect(mergeWorkspaceSelectWorkspaces(undefined, fallback)).toEqual([
|
|
21
|
+
fallback,
|
|
22
|
+
]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('does not duplicate the current workspace when the list already includes it', () => {
|
|
26
|
+
const workspace: InternalApiWorkspaceSummary = {
|
|
27
|
+
access_type: 'member',
|
|
28
|
+
avatar_url: null,
|
|
29
|
+
id: 'workspace-1',
|
|
30
|
+
logo_url: null,
|
|
31
|
+
name: 'Workspace',
|
|
32
|
+
personal: false,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
expect(mergeWorkspaceSelectWorkspaces([workspace], workspace)).toEqual([
|
|
36
|
+
workspace,
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -3,7 +3,15 @@ import Link from 'next/link';
|
|
|
3
3
|
import { Separator } from '../separator';
|
|
4
4
|
import { TuturuuLogo } from './tuturuuu-logo';
|
|
5
5
|
|
|
6
|
-
export function CommonFooter({
|
|
6
|
+
export function CommonFooter({
|
|
7
|
+
t,
|
|
8
|
+
devMode,
|
|
9
|
+
logoSrc,
|
|
10
|
+
}: {
|
|
11
|
+
t: any;
|
|
12
|
+
devMode: boolean;
|
|
13
|
+
logoSrc?: string;
|
|
14
|
+
}) {
|
|
7
15
|
const TUTURUUU_URL = devMode
|
|
8
16
|
? getLocalInternalAppUrl('platform', 'http://localhost:7803')
|
|
9
17
|
: 'https://tuturuuu.com';
|
|
@@ -35,6 +43,7 @@ export function CommonFooter({ t, devMode }: { t: any; devMode: boolean }) {
|
|
|
35
43
|
width={64}
|
|
36
44
|
height={64}
|
|
37
45
|
alt="logo"
|
|
46
|
+
src={logoSrc}
|
|
38
47
|
className="h-12 w-12"
|
|
39
48
|
/>
|
|
40
49
|
<div className="font-semibold text-4xl">Tuturuuu</div>
|
|
@@ -166,6 +175,12 @@ export function CommonFooter({ t, devMode }: { t: any; devMode: boolean }) {
|
|
|
166
175
|
>
|
|
167
176
|
{t('common.branding')}
|
|
168
177
|
</Link>
|
|
178
|
+
<Link
|
|
179
|
+
href={`${TUTURUUU_URL}/ui`}
|
|
180
|
+
className="text-foreground/80 text-sm hover:text-foreground hover:underline md:w-fit"
|
|
181
|
+
>
|
|
182
|
+
{t('common.ui')}
|
|
183
|
+
</Link>
|
|
169
184
|
</div>
|
|
170
185
|
|
|
171
186
|
<div className="grid gap-1 md:items-start">
|
|
@@ -34,7 +34,7 @@ export default function SidebarSettings({ useSidebar }: SidebarSettingsProps) {
|
|
|
34
34
|
value: expandSettingsAccordions,
|
|
35
35
|
setValue: setExpandSettingsAccordions,
|
|
36
36
|
isLoading: isLoadingExpandAccordions,
|
|
37
|
-
} = useUserBooleanConfig('EXPAND_SETTINGS_ACCORDIONS',
|
|
37
|
+
} = useUserBooleanConfig('EXPAND_SETTINGS_ACCORDIONS', true);
|
|
38
38
|
|
|
39
39
|
return (
|
|
40
40
|
<div className="space-y-8">
|
|
@@ -20,6 +20,7 @@ import { toast } from '@tuturuuu/ui/sonner';
|
|
|
20
20
|
import { Switch } from '@tuturuuu/ui/switch';
|
|
21
21
|
import { useTranslations } from 'next-intl';
|
|
22
22
|
import { useEffect } from 'react';
|
|
23
|
+
import { TASKS_SHOW_REVIEW_DUE_DATES_CONFIG_ID } from '../../tu-do/shared/task-due-date-visibility';
|
|
23
24
|
|
|
24
25
|
interface TaskSettingsData {
|
|
25
26
|
task_auto_assign_to_self: boolean;
|
|
@@ -67,6 +68,12 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
|
|
|
67
68
|
isLoading: openDefaultBoardLoading,
|
|
68
69
|
isPending: openDefaultBoardPending,
|
|
69
70
|
} = useUserBooleanConfig('TASKS_OPEN_DEFAULT_BOARD', true);
|
|
71
|
+
const {
|
|
72
|
+
value: showReviewDueDates,
|
|
73
|
+
setValue: setShowReviewDueDates,
|
|
74
|
+
isLoading: showReviewDueDatesLoading,
|
|
75
|
+
isPending: showReviewDueDatesPending,
|
|
76
|
+
} = useUserBooleanConfig(TASKS_SHOW_REVIEW_DUE_DATES_CONFIG_ID, false);
|
|
70
77
|
|
|
71
78
|
const { data: submitShortcut, isLoading: submitShortcutLoading } =
|
|
72
79
|
useUserConfig('TASK_SUBMIT_SHORTCUT', 'enter');
|
|
@@ -202,6 +209,17 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
|
|
|
202
209
|
/>
|
|
203
210
|
</SettingItemTab>
|
|
204
211
|
<Separator />
|
|
212
|
+
<SettingItemTab
|
|
213
|
+
title={t('show_review_due_dates')}
|
|
214
|
+
description={t('show_review_due_dates_description')}
|
|
215
|
+
>
|
|
216
|
+
<Switch
|
|
217
|
+
checked={showReviewDueDates}
|
|
218
|
+
onCheckedChange={setShowReviewDueDates}
|
|
219
|
+
disabled={showReviewDueDatesLoading || showReviewDueDatesPending}
|
|
220
|
+
/>
|
|
221
|
+
</SettingItemTab>
|
|
222
|
+
<Separator />
|
|
205
223
|
<SettingItemTab
|
|
206
224
|
title={t('submit_shortcut')}
|
|
207
225
|
description={t('submit_shortcut_description')}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { ChevronDown, ChevronRight, Search } from '@tuturuuu/icons';
|
|
3
|
+
import { ArrowLeft, ChevronDown, ChevronRight, Search } from '@tuturuuu/icons';
|
|
4
4
|
import {
|
|
5
5
|
Breadcrumb,
|
|
6
6
|
BreadcrumbItem,
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
CommandList,
|
|
25
25
|
} from '@tuturuuu/ui/command';
|
|
26
26
|
import {
|
|
27
|
+
DialogClose,
|
|
27
28
|
DialogContent,
|
|
28
29
|
DialogDescription,
|
|
29
30
|
DialogTitle,
|
|
@@ -52,7 +53,6 @@ import {
|
|
|
52
53
|
SidebarProvider,
|
|
53
54
|
} from '@tuturuuu/ui/sidebar';
|
|
54
55
|
import { cn } from '@tuturuuu/utils/format';
|
|
55
|
-
import { usePlatform } from '@tuturuuu/utils/hooks/use-platform';
|
|
56
56
|
import { removeAccents } from '@tuturuuu/utils/text-helper';
|
|
57
57
|
import { useTranslations } from 'next-intl';
|
|
58
58
|
import type { ComponentType, KeyboardEvent, ReactNode } from 'react';
|
|
@@ -81,11 +81,11 @@ export interface SettingsDialogShellProps {
|
|
|
81
81
|
onActiveTabChange: (tab: string) => void;
|
|
82
82
|
/**
|
|
83
83
|
* Group labels that should be expanded by default.
|
|
84
|
-
*
|
|
85
|
-
* If not provided, only the first group expands
|
|
84
|
+
* Used when expandAllAccordions is false.
|
|
85
|
+
* If not provided in that mode, only the first group expands.
|
|
86
86
|
*/
|
|
87
87
|
primaryGroupLabels?: string[];
|
|
88
|
-
/** Override to expand all accordions (user preference) */
|
|
88
|
+
/** Override to expand all accordions (user preference). Defaults to expanded. */
|
|
89
89
|
expandAllAccordions?: boolean;
|
|
90
90
|
/** Enable dialog-scoped keyboard shortcuts for search and tab navigation */
|
|
91
91
|
keyboardNavigation?: boolean;
|
|
@@ -111,21 +111,19 @@ function isEditableShortcutTarget(target: EventTarget | null) {
|
|
|
111
111
|
*
|
|
112
112
|
* Each app provides its own `navItems` (ordered by priority) and
|
|
113
113
|
* renders tab-specific content via `children`. The `primaryGroupLabels`
|
|
114
|
-
* prop controls which groups expand
|
|
115
|
-
* highlight their domain
|
|
116
|
-
* in a future calendar app).
|
|
114
|
+
* prop controls which groups expand when `expandAllAccordions` is false,
|
|
115
|
+
* enabling apps to highlight their domain while preserving a compact mode.
|
|
117
116
|
*/
|
|
118
117
|
export function SettingsDialogShell({
|
|
119
118
|
navItems,
|
|
120
119
|
activeTab,
|
|
121
120
|
onActiveTabChange,
|
|
122
121
|
primaryGroupLabels,
|
|
123
|
-
expandAllAccordions =
|
|
122
|
+
expandAllAccordions = true,
|
|
124
123
|
keyboardNavigation = false,
|
|
125
124
|
children,
|
|
126
125
|
}: SettingsDialogShellProps) {
|
|
127
126
|
const t = useTranslations();
|
|
128
|
-
const { isMac, modKey } = usePlatform();
|
|
129
127
|
const isMobile = useIsMobile();
|
|
130
128
|
const desktopSearchInputRef = useRef<HTMLInputElement>(null);
|
|
131
129
|
const mobileSearchInputRef = useRef<HTMLInputElement>(null);
|
|
@@ -275,8 +273,9 @@ export function SettingsDialogShell({
|
|
|
275
273
|
|
|
276
274
|
return (
|
|
277
275
|
<DialogContent
|
|
278
|
-
className="flex h-
|
|
276
|
+
className="top-0 left-0 flex h-dvh max-h-dvh w-screen max-w-none translate-x-0 translate-y-0 flex-col gap-0 overflow-hidden rounded-none border-0 p-0 shadow-none sm:max-w-none"
|
|
279
277
|
onKeyDown={handleKeyboardNavigation}
|
|
278
|
+
showCloseButton={false}
|
|
280
279
|
>
|
|
281
280
|
<DialogTitle className="sr-only">{t('common.settings')}</DialogTitle>
|
|
282
281
|
<DialogDescription className="sr-only">
|
|
@@ -285,24 +284,29 @@ export function SettingsDialogShell({
|
|
|
285
284
|
<SidebarProvider className="flex h-full min-h-0 items-start">
|
|
286
285
|
<Sidebar
|
|
287
286
|
collapsible="none"
|
|
288
|
-
className="hidden h-full w-
|
|
287
|
+
className="hidden h-full w-72 flex-col border-r bg-muted/30 md:flex"
|
|
289
288
|
>
|
|
290
|
-
<SidebarHeader className="z-10 p-4 pb-0">
|
|
291
|
-
<
|
|
289
|
+
<SidebarHeader className="z-10 gap-3 p-4 pb-0">
|
|
290
|
+
<DialogClose asChild>
|
|
291
|
+
<Button
|
|
292
|
+
type="button"
|
|
293
|
+
variant="ghost"
|
|
294
|
+
className="h-9 justify-start px-2 text-muted-foreground"
|
|
295
|
+
>
|
|
296
|
+
<ArrowLeft className="h-4 w-4" />
|
|
297
|
+
{t('settings.back_to_app')}
|
|
298
|
+
</Button>
|
|
299
|
+
</DialogClose>
|
|
300
|
+
<div className="relative">
|
|
292
301
|
<Search className="absolute top-2.5 left-2 h-4 w-4 text-muted-foreground" />
|
|
293
302
|
<SidebarInput
|
|
294
303
|
ref={desktopSearchInputRef}
|
|
295
|
-
placeholder={t('
|
|
304
|
+
placeholder={t('settings.search_settings_placeholder')}
|
|
296
305
|
className="bg-background pl-8"
|
|
297
306
|
value={searchQuery}
|
|
298
307
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
299
308
|
/>
|
|
300
309
|
</div>
|
|
301
|
-
<div className="flex items-center justify-between px-1">
|
|
302
|
-
<span className="text-[10px] text-muted-foreground uppercase tracking-wider">
|
|
303
|
-
Detected OS: {isMac ? 'macOS' : 'Windows/Linux'} ({modKey})
|
|
304
|
-
</span>
|
|
305
|
-
</div>
|
|
306
310
|
</SidebarHeader>
|
|
307
311
|
<SidebarContent className="overflow-y-auto p-4">
|
|
308
312
|
{filteredNavItems.map((group, index) => (
|
|
@@ -358,7 +362,18 @@ export function SettingsDialogShell({
|
|
|
358
362
|
</SidebarContent>
|
|
359
363
|
</Sidebar>
|
|
360
364
|
<main className="flex h-full flex-1 flex-col overflow-hidden bg-background">
|
|
361
|
-
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4
|
|
365
|
+
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-6">
|
|
366
|
+
<DialogClose asChild>
|
|
367
|
+
<Button
|
|
368
|
+
type="button"
|
|
369
|
+
variant="ghost"
|
|
370
|
+
size="icon"
|
|
371
|
+
className="shrink-0 text-muted-foreground md:hidden"
|
|
372
|
+
>
|
|
373
|
+
<ArrowLeft className="h-4 w-4" />
|
|
374
|
+
<span className="sr-only">{t('settings.back_to_app')}</span>
|
|
375
|
+
</Button>
|
|
376
|
+
</DialogClose>
|
|
362
377
|
<div className="flex flex-1 items-center gap-2 md:flex-initial">
|
|
363
378
|
{isMobile && (
|
|
364
379
|
<Drawer open={mobileNavOpen} onOpenChange={setMobileNavOpen}>
|
|
@@ -390,7 +405,7 @@ export function SettingsDialogShell({
|
|
|
390
405
|
<Command className="rounded-none border-0">
|
|
391
406
|
<CommandInput
|
|
392
407
|
ref={mobileSearchInputRef}
|
|
393
|
-
placeholder={t('
|
|
408
|
+
placeholder={t('settings.search_settings_placeholder')}
|
|
394
409
|
/>
|
|
395
410
|
<CommandList className="max-h-[50vh]">
|
|
396
411
|
<CommandEmpty>
|
|
@@ -458,7 +473,7 @@ export function SettingsDialogShell({
|
|
|
458
473
|
</div>
|
|
459
474
|
</header>
|
|
460
475
|
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-6">
|
|
461
|
-
<div className="mx-auto w-full max-w-
|
|
476
|
+
<div className="mx-auto w-full max-w-4xl space-y-6">
|
|
462
477
|
<div className="space-y-1">
|
|
463
478
|
<h2 className="font-semibold text-lg tracking-tight">
|
|
464
479
|
{activeItem?.label}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
function readSidebarSource(fileName: string) {
|
|
6
|
+
const candidates = [
|
|
7
|
+
join(process.cwd(), 'src/components/ui/custom', fileName),
|
|
8
|
+
join(process.cwd(), 'packages/ui/src/components/ui/custom', fileName),
|
|
9
|
+
];
|
|
10
|
+
const sourcePath = candidates.find((candidate) => existsSync(candidate));
|
|
11
|
+
|
|
12
|
+
if (!sourcePath) {
|
|
13
|
+
throw new Error(`Unable to resolve ${fileName} from ${process.cwd()}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return readFileSync(sourcePath, 'utf8');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const sidebarContextSource = readSidebarSource('sidebar-context.tsx');
|
|
20
|
+
const sidebarRemoteBehaviorBridgeSource = readSidebarSource(
|
|
21
|
+
'sidebar-remote-behavior-bridge.tsx'
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
function staticImportPattern(modulePath: string) {
|
|
25
|
+
const escapedModulePath = modulePath.replace(
|
|
26
|
+
/[.*+?^${}()|[\]\\]/gu,
|
|
27
|
+
String.raw`\$&`
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
return new RegExp(
|
|
31
|
+
String.raw`^\s*import\s+(?!type\b)[\s\S]*?\sfrom\s+['"]${escapedModulePath}['"];`,
|
|
32
|
+
'mu'
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function dynamicImportPattern(modulePath: string) {
|
|
37
|
+
const escapedModulePath = modulePath.replace(
|
|
38
|
+
/[.*+?^${}()|[\]\\]/gu,
|
|
39
|
+
String.raw`\$&`
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
return new RegExp(
|
|
43
|
+
String.raw`import\s*\(\s*['"]${escapedModulePath}['"]\s*\)`,
|
|
44
|
+
'mu'
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('sidebar context compile graph', () => {
|
|
49
|
+
it('loads remote user-config sync after hydration', () => {
|
|
50
|
+
expect(sidebarContextSource).not.toMatch(
|
|
51
|
+
staticImportPattern('@tuturuuu/ui/hooks/use-user-config')
|
|
52
|
+
);
|
|
53
|
+
expect(sidebarContextSource).toMatch(
|
|
54
|
+
dynamicImportPattern('./sidebar-remote-behavior-bridge')
|
|
55
|
+
);
|
|
56
|
+
expect(sidebarRemoteBehaviorBridgeSource).toMatch(
|
|
57
|
+
staticImportPattern('@tuturuuu/ui/hooks/use-user-config')
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useLocalStorage } from '@tuturuuu/ui/hooks/use-local-storage';
|
|
4
|
-
import {
|
|
5
|
-
useUpdateUserConfig,
|
|
6
|
-
useUserConfig,
|
|
7
|
-
} from '@tuturuuu/ui/hooks/use-user-config';
|
|
8
4
|
import { setCookie } from 'cookies-next';
|
|
9
5
|
import {
|
|
6
|
+
type ComponentType,
|
|
10
7
|
createContext,
|
|
11
8
|
type Dispatch,
|
|
12
9
|
type ReactNode,
|
|
@@ -14,7 +11,6 @@ import {
|
|
|
14
11
|
useCallback,
|
|
15
12
|
useContext,
|
|
16
13
|
useEffect,
|
|
17
|
-
useRef,
|
|
18
14
|
useState,
|
|
19
15
|
} from 'react';
|
|
20
16
|
|
|
@@ -23,9 +19,6 @@ export const SIDEBAR_BEHAVIOR_CONFIG_KEY = 'SIDEBAR_BEHAVIOR';
|
|
|
23
19
|
|
|
24
20
|
export type SidebarBehavior = 'expanded' | 'collapsed' | 'hover';
|
|
25
21
|
|
|
26
|
-
const isValidBehavior = (value: string | undefined): value is SidebarBehavior =>
|
|
27
|
-
value === 'expanded' || value === 'collapsed' || value === 'hover';
|
|
28
|
-
|
|
29
22
|
interface SidebarContextProps {
|
|
30
23
|
behavior: SidebarBehavior;
|
|
31
24
|
setBehavior: Dispatch<SetStateAction<SidebarBehavior>>;
|
|
@@ -42,6 +35,38 @@ export const SidebarContext = createContext<SidebarContextProps | undefined>(
|
|
|
42
35
|
// Persistent cookie options — ensures setting survives browser restarts
|
|
43
36
|
const COOKIE_OPTIONS = { maxAge: 365 * 24 * 60 * 60, path: '/' } as const;
|
|
44
37
|
|
|
38
|
+
type SidebarRemoteBehaviorBridgeComponent = ComponentType<{
|
|
39
|
+
behavior: SidebarBehavior;
|
|
40
|
+
localOverride: boolean;
|
|
41
|
+
localOverrideVersion: number;
|
|
42
|
+
onApplyRemoteBehavior: (newBehavior: SidebarBehavior) => void;
|
|
43
|
+
onRemoteBehaviorAvailable: (remoteBehavior: SidebarBehavior) => void;
|
|
44
|
+
userChangeVersion: number;
|
|
45
|
+
}>;
|
|
46
|
+
|
|
47
|
+
function useSidebarRemoteBehaviorBridge() {
|
|
48
|
+
const [RemoteBehaviorBridge, setRemoteBehaviorBridge] =
|
|
49
|
+
useState<SidebarRemoteBehaviorBridgeComponent | null>(null);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
let active = true;
|
|
53
|
+
|
|
54
|
+
// biome-ignore lint/suspicious/noTsIgnore: NodeNext requires .js, but Next/Turbopack resolves workspace TypeScript source here before package emit.
|
|
55
|
+
// @ts-ignore
|
|
56
|
+
void import('./sidebar-remote-behavior-bridge').then((module) => {
|
|
57
|
+
if (active) {
|
|
58
|
+
setRemoteBehaviorBridge(() => module.SidebarRemoteBehaviorBridge);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return () => {
|
|
63
|
+
active = false;
|
|
64
|
+
};
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
return RemoteBehaviorBridge;
|
|
68
|
+
}
|
|
69
|
+
|
|
45
70
|
export const SidebarProvider = ({
|
|
46
71
|
children,
|
|
47
72
|
initialBehavior,
|
|
@@ -54,74 +79,39 @@ export const SidebarProvider = ({
|
|
|
54
79
|
'sidebar-local-override',
|
|
55
80
|
false
|
|
56
81
|
);
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
const userChangedRef = useRef(false);
|
|
60
|
-
// Allows reading current behavior inside the sync effect without
|
|
61
|
-
// adding `behavior` to its dependency array
|
|
62
|
-
const behaviorRef = useRef(behavior);
|
|
63
|
-
behaviorRef.current = behavior;
|
|
64
|
-
|
|
65
|
-
// Fetch account-wide preference
|
|
66
|
-
const { data: remoteBehavior, isSuccess: remoteLoaded } = useUserConfig(
|
|
67
|
-
SIDEBAR_BEHAVIOR_CONFIG_KEY,
|
|
68
|
-
'expanded'
|
|
82
|
+
const [remoteBehavior, setRemoteBehavior] = useState<SidebarBehavior | null>(
|
|
83
|
+
null
|
|
69
84
|
);
|
|
85
|
+
const [userChangeVersion, setUserChangeVersion] = useState(0);
|
|
86
|
+
const [localOverrideVersion, setLocalOverrideVersion] = useState(0);
|
|
87
|
+
const RemoteBehaviorBridge = useSidebarRemoteBehaviorBridge();
|
|
70
88
|
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (
|
|
76
|
-
!remoteLoaded ||
|
|
77
|
-
localOverride ||
|
|
78
|
-
hasAppliedRemote.current ||
|
|
79
|
-
userChangedRef.current
|
|
80
|
-
)
|
|
81
|
-
return;
|
|
82
|
-
hasAppliedRemote.current = true;
|
|
83
|
-
if (
|
|
84
|
-
isValidBehavior(remoteBehavior) &&
|
|
85
|
-
remoteBehavior !== behaviorRef.current
|
|
86
|
-
) {
|
|
87
|
-
setBehavior(remoteBehavior);
|
|
88
|
-
setCookie(SIDEBAR_BEHAVIOR_COOKIE_NAME, remoteBehavior, COOKIE_OPTIONS);
|
|
89
|
-
}
|
|
90
|
-
}, [remoteLoaded, remoteBehavior, localOverride]);
|
|
89
|
+
const applyBehavior = useCallback((newBehavior: SidebarBehavior) => {
|
|
90
|
+
setBehavior(newBehavior);
|
|
91
|
+
setCookie(SIDEBAR_BEHAVIOR_COOKIE_NAME, newBehavior, COOKIE_OPTIONS);
|
|
92
|
+
}, []);
|
|
91
93
|
|
|
92
94
|
const handleBehaviorChange = useCallback(
|
|
93
95
|
(newBehavior: SidebarBehavior) => {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
// Always update cookie for SSR
|
|
97
|
-
setCookie(SIDEBAR_BEHAVIOR_COOKIE_NAME, newBehavior, COOKIE_OPTIONS);
|
|
98
|
-
// Save to user_configs (account-wide) unless locally overridden
|
|
96
|
+
applyBehavior(newBehavior);
|
|
97
|
+
|
|
99
98
|
if (!localOverride) {
|
|
100
|
-
|
|
101
|
-
configId: SIDEBAR_BEHAVIOR_CONFIG_KEY,
|
|
102
|
-
value: newBehavior,
|
|
103
|
-
});
|
|
99
|
+
setUserChangeVersion((version) => version + 1);
|
|
104
100
|
}
|
|
105
101
|
},
|
|
106
|
-
[
|
|
102
|
+
[applyBehavior, localOverride]
|
|
107
103
|
);
|
|
108
104
|
|
|
109
105
|
const setLocalOverride = useCallback(
|
|
110
106
|
(enabled: boolean) => {
|
|
111
107
|
setLocalOverrideRaw(enabled);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
} else if (enabled) {
|
|
117
|
-
// Turning on local override — save current behavior to account-wide first
|
|
118
|
-
updateConfig.mutate({
|
|
119
|
-
configId: SIDEBAR_BEHAVIOR_CONFIG_KEY,
|
|
120
|
-
value: behavior,
|
|
121
|
-
});
|
|
108
|
+
setLocalOverrideVersion((version) => version + 1);
|
|
109
|
+
|
|
110
|
+
if (!enabled && remoteBehavior) {
|
|
111
|
+
applyBehavior(remoteBehavior);
|
|
122
112
|
}
|
|
123
113
|
},
|
|
124
|
-
[
|
|
114
|
+
[applyBehavior, remoteBehavior, setLocalOverrideRaw]
|
|
125
115
|
);
|
|
126
116
|
|
|
127
117
|
return (
|
|
@@ -134,6 +124,16 @@ export const SidebarProvider = ({
|
|
|
134
124
|
setLocalOverride,
|
|
135
125
|
}}
|
|
136
126
|
>
|
|
127
|
+
{RemoteBehaviorBridge && (
|
|
128
|
+
<RemoteBehaviorBridge
|
|
129
|
+
behavior={behavior}
|
|
130
|
+
localOverride={localOverride}
|
|
131
|
+
localOverrideVersion={localOverrideVersion}
|
|
132
|
+
onApplyRemoteBehavior={applyBehavior}
|
|
133
|
+
onRemoteBehaviorAvailable={setRemoteBehavior}
|
|
134
|
+
userChangeVersion={userChangeVersion}
|
|
135
|
+
/>
|
|
136
|
+
)}
|
|
137
137
|
{children}
|
|
138
138
|
</SidebarContext.Provider>
|
|
139
139
|
);
|