@tuturuuu/ui 0.5.0 → 0.6.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 +29 -0
- package/package.json +41 -34
- package/src/components/ui/currency-input.tsx +65 -23
- package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +64 -0
- package/src/components/ui/custom/__tests__/sidebar-remote-behavior-bridge.test.tsx +109 -0
- package/src/components/ui/custom/combobox.test.tsx +141 -0
- package/src/components/ui/custom/combobox.tsx +105 -36
- package/src/components/ui/custom/settings/task-settings.tsx +50 -0
- package/src/components/ui/custom/settings/task-sound-settings.test.tsx +21 -1
- package/src/components/ui/custom/sidebar-context.tsx +68 -6
- package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +21 -2
- package/src/components/ui/finance/finance-layout.tsx +2 -4
- package/src/components/ui/finance/shared/balance-mode-toggle.tsx +35 -0
- package/src/components/ui/finance/shared/finance-layout-controls.tsx +43 -0
- package/src/components/ui/finance/shared/quick-actions.tsx +14 -6
- package/src/components/ui/finance/shared/use-finance-balance-mode.ts +72 -0
- package/src/components/ui/finance/shared/wallet-balance-mode.test.ts +66 -0
- package/src/components/ui/finance/shared/wallet-balance-mode.ts +42 -0
- package/src/components/ui/finance/transactions/form-types.ts +23 -0
- package/src/components/ui/finance/transactions/form.tsx +81 -22
- package/src/components/ui/finance/transactions/wallet-filter.tsx +21 -2
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +73 -26
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-history-dialog.tsx +617 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-panel.tsx +2 -1
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-sections.tsx +4 -4
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +298 -34
- package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +219 -46
- package/src/components/ui/finance/wallets/columns-rendering.test.tsx +125 -0
- package/src/components/ui/finance/wallets/columns.test.ts +56 -0
- package/src/components/ui/finance/wallets/columns.tsx +196 -43
- package/src/components/ui/finance/wallets/form.test.tsx +79 -14
- package/src/components/ui/finance/wallets/form.tsx +41 -197
- package/src/components/ui/finance/wallets/query-invalidation.ts +1 -0
- package/src/components/ui/finance/wallets/wallet-basics-fields.tsx +141 -0
- package/src/components/ui/finance/wallets/wallet-credit-fields.tsx +136 -0
- package/src/components/ui/finance/wallets/walletId/credit-wallet-summary.tsx +143 -68
- package/src/components/ui/finance/wallets/walletId/wallet-details-actions.test.tsx +105 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +120 -16
- package/src/components/ui/finance/wallets/walletId/wallet-details-amount.test.tsx +64 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-amount.tsx +226 -6
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +64 -2
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +42 -35
- package/src/components/ui/finance/wallets/wallets-data-table.test.tsx +171 -0
- package/src/components/ui/finance/wallets/wallets-data-table.tsx +132 -29
- package/src/components/ui/finance/wallets/wallets-page.test.tsx +111 -37
- package/src/components/ui/finance/wallets/wallets-page.tsx +38 -78
- package/src/components/ui/storefront/accent-button.tsx +33 -0
- package/src/components/ui/storefront/cart-summary.tsx +140 -0
- package/src/components/ui/storefront/empty-listings.tsx +32 -0
- package/src/components/ui/storefront/hero-panel.tsx +70 -0
- package/src/components/ui/storefront/image-panel.tsx +40 -0
- package/src/components/ui/storefront/index.ts +12 -0
- package/src/components/ui/storefront/listing-card.tsx +129 -0
- package/src/components/ui/storefront/storefront-surface.test.tsx +85 -0
- package/src/components/ui/storefront/storefront-surface.tsx +235 -0
- package/src/components/ui/storefront/types.ts +99 -0
- package/src/components/ui/storefront/utils.ts +90 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +134 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +127 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +17 -42
- package/src/components/ui/tu-do/boards/boardId/timeline-board-open-task.test.tsx +164 -0
- package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +25 -16
- package/src/components/ui/tu-do/hooks/useTaskDialog.ts +15 -1
- package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +2 -0
- package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +114 -7
- package/src/components/ui/tu-do/providers/__tests__/task-dialog-provider.test.tsx +217 -5
- package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +180 -35
- package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +222 -26
- package/src/components/ui/tu-do/shared/board-client.tsx +1 -3
- package/src/components/ui/tu-do/shared/list-view-context-menu.test.tsx +55 -2
- package/src/components/ui/tu-do/shared/list-view.tsx +23 -16
- package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +93 -76
- package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +11 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +128 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +104 -69
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.test.tsx +129 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.tsx +358 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +1 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +6 -2
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.test.tsx +17 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.tsx +151 -111
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-form-reset.ts +18 -2
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-realtime-sync.ts +1 -2
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +5 -3
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +584 -53
- package/src/hooks/useBoardRealtime.ts +54 -1
- package/src/hooks/useBoardRealtimeEventHandler.ts +169 -4
- package/src/hooks/useTaskUserRealtime.ts +338 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import '@testing-library/jest-dom/vitest';
|
|
6
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
7
|
+
import type { WorkspaceTaskSuggestionTask } from '@tuturuuu/internal-api/tasks';
|
|
8
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
9
|
+
import {
|
|
10
|
+
SmartTaskSuggestionsButton,
|
|
11
|
+
SmartTaskSuggestionsPanel,
|
|
12
|
+
} from './smart-task-suggestions-panel';
|
|
13
|
+
|
|
14
|
+
vi.mock('next-intl', () => ({
|
|
15
|
+
useTranslations: () => (key: string, values?: Record<string, unknown>) =>
|
|
16
|
+
values ? `${key}:${JSON.stringify(values)}` : key,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
const baseSuggestion: WorkspaceTaskSuggestionTask = {
|
|
20
|
+
id: 'suggestion-1',
|
|
21
|
+
title: 'Prepare launch checklist',
|
|
22
|
+
description: 'Confirm owners and deadlines.',
|
|
23
|
+
priority: 'high',
|
|
24
|
+
listId: 'list-1',
|
|
25
|
+
listName: 'Inbox',
|
|
26
|
+
labelIds: ['label-1'],
|
|
27
|
+
labels: [
|
|
28
|
+
{
|
|
29
|
+
id: 'label-1',
|
|
30
|
+
name: 'Launch',
|
|
31
|
+
color: 'blue',
|
|
32
|
+
created_at: '2026-06-11T00:00:00.000Z',
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
projectIds: ['project-1'],
|
|
36
|
+
projects: [{ id: 'project-1', name: 'Website', status: 'active' }],
|
|
37
|
+
endDate: '2026-06-12T16:59:59.000Z',
|
|
38
|
+
estimationPoints: 3,
|
|
39
|
+
durationMinutes: 90,
|
|
40
|
+
isSplittable: true,
|
|
41
|
+
minSplitDurationMinutes: 30,
|
|
42
|
+
maxSplitDurationMinutes: 60,
|
|
43
|
+
calendarHours: 'work_hours',
|
|
44
|
+
autoSchedule: true,
|
|
45
|
+
reason: 'Launch work with a deadline.',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
describe('SmartTaskSuggestionsPanel', () => {
|
|
49
|
+
it('renders loading state from the sparkle button and panel', () => {
|
|
50
|
+
render(
|
|
51
|
+
<>
|
|
52
|
+
<SmartTaskSuggestionsButton isLoading={true} onClick={vi.fn()} />
|
|
53
|
+
<SmartTaskSuggestionsPanel
|
|
54
|
+
suggestions={[]}
|
|
55
|
+
selectedSuggestionIds={[]}
|
|
56
|
+
isLoading={true}
|
|
57
|
+
onApplyFirst={vi.fn()}
|
|
58
|
+
onApplySuggestion={vi.fn()}
|
|
59
|
+
onClose={vi.fn()}
|
|
60
|
+
onCreateSelected={vi.fn()}
|
|
61
|
+
onRetry={vi.fn()}
|
|
62
|
+
onToggleSuggestion={vi.fn()}
|
|
63
|
+
/>
|
|
64
|
+
</>
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
expect(screen.getByLabelText('smart_suggest')).toBeDisabled();
|
|
68
|
+
expect(screen.getByText('smart_generating')).toBeInTheDocument();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('applies a single suggestion without creating it immediately', () => {
|
|
72
|
+
const onApplySuggestion = vi.fn();
|
|
73
|
+
|
|
74
|
+
render(
|
|
75
|
+
<SmartTaskSuggestionsPanel
|
|
76
|
+
suggestions={[baseSuggestion]}
|
|
77
|
+
selectedSuggestionIds={['suggestion-1']}
|
|
78
|
+
onApplyFirst={vi.fn()}
|
|
79
|
+
onApplySuggestion={onApplySuggestion}
|
|
80
|
+
onClose={vi.fn()}
|
|
81
|
+
onCreateSelected={vi.fn()}
|
|
82
|
+
onRetry={vi.fn()}
|
|
83
|
+
onToggleSuggestion={vi.fn()}
|
|
84
|
+
/>
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
fireEvent.click(
|
|
88
|
+
screen.getByRole('button', { name: 'smart_apply_suggestion' })
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(onApplySuggestion).toHaveBeenCalledWith(baseSuggestion);
|
|
92
|
+
expect(
|
|
93
|
+
screen.queryByRole('button', { name: /smart_create_selected/ })
|
|
94
|
+
).not.toBeInTheDocument();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('creates selected tasks from multiple suggestions', () => {
|
|
98
|
+
const onCreateSelected = vi.fn();
|
|
99
|
+
const onToggleSuggestion = vi.fn();
|
|
100
|
+
const secondSuggestion = {
|
|
101
|
+
...baseSuggestion,
|
|
102
|
+
id: 'suggestion-2',
|
|
103
|
+
title: 'Draft launch note',
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
render(
|
|
107
|
+
<SmartTaskSuggestionsPanel
|
|
108
|
+
suggestions={[baseSuggestion, secondSuggestion]}
|
|
109
|
+
selectedSuggestionIds={['suggestion-1']}
|
|
110
|
+
onApplyFirst={vi.fn()}
|
|
111
|
+
onApplySuggestion={vi.fn()}
|
|
112
|
+
onClose={vi.fn()}
|
|
113
|
+
onCreateSelected={onCreateSelected}
|
|
114
|
+
onRetry={vi.fn()}
|
|
115
|
+
onToggleSuggestion={onToggleSuggestion}
|
|
116
|
+
/>
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
fireEvent.click(screen.getAllByLabelText('smart_select_suggestion')[1]!);
|
|
120
|
+
fireEvent.click(
|
|
121
|
+
screen.getByRole('button', {
|
|
122
|
+
name: 'smart_create_selected:{"count":1}',
|
|
123
|
+
})
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
expect(onToggleSuggestion).toHaveBeenCalledWith('suggestion-2');
|
|
127
|
+
expect(onCreateSelected).toHaveBeenCalledTimes(1);
|
|
128
|
+
});
|
|
129
|
+
});
|
package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.tsx
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AlertCircle,
|
|
5
|
+
Calendar,
|
|
6
|
+
CheckCircle2,
|
|
7
|
+
Clock,
|
|
8
|
+
Flag,
|
|
9
|
+
FolderKanban,
|
|
10
|
+
ListTodo,
|
|
11
|
+
Loader2,
|
|
12
|
+
Sparkles,
|
|
13
|
+
Tag,
|
|
14
|
+
Timer,
|
|
15
|
+
X,
|
|
16
|
+
} from '@tuturuuu/icons';
|
|
17
|
+
import type { WorkspaceTaskSuggestionTask } from '@tuturuuu/internal-api/tasks';
|
|
18
|
+
import { Badge } from '@tuturuuu/ui/badge';
|
|
19
|
+
import { Button } from '@tuturuuu/ui/button';
|
|
20
|
+
import { Checkbox } from '@tuturuuu/ui/checkbox';
|
|
21
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from '@tuturuuu/ui/tooltip';
|
|
22
|
+
import { cn } from '@tuturuuu/utils/format';
|
|
23
|
+
import dayjs from 'dayjs';
|
|
24
|
+
import { useTranslations } from 'next-intl';
|
|
25
|
+
|
|
26
|
+
interface SmartTaskSuggestionsButtonProps {
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
isLoading?: boolean;
|
|
29
|
+
onClick: () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface SmartTaskSuggestionsPanelProps {
|
|
33
|
+
suggestions: WorkspaceTaskSuggestionTask[];
|
|
34
|
+
selectedSuggestionIds: string[];
|
|
35
|
+
createErrors?: Record<string, string>;
|
|
36
|
+
creatingSuggestionIds?: string[];
|
|
37
|
+
errorMessage?: string | null;
|
|
38
|
+
isCreatingSelected?: boolean;
|
|
39
|
+
isLoading?: boolean;
|
|
40
|
+
onApplyFirst: () => void;
|
|
41
|
+
onApplySuggestion: (suggestion: WorkspaceTaskSuggestionTask) => void;
|
|
42
|
+
onClose: () => void;
|
|
43
|
+
onCreateSelected: () => void;
|
|
44
|
+
onRetry: () => void;
|
|
45
|
+
onToggleSuggestion: (suggestionId: string) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function formatDuration(minutes: number) {
|
|
49
|
+
if (minutes < 60) {
|
|
50
|
+
return `${minutes}m`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const hours = Math.floor(minutes / 60);
|
|
54
|
+
const remainder = minutes % 60;
|
|
55
|
+
|
|
56
|
+
return remainder ? `${hours}h ${remainder}m` : `${hours}h`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function SuggestionChip({
|
|
60
|
+
children,
|
|
61
|
+
icon,
|
|
62
|
+
}: {
|
|
63
|
+
children: React.ReactNode;
|
|
64
|
+
icon: React.ReactNode;
|
|
65
|
+
}) {
|
|
66
|
+
return (
|
|
67
|
+
<Badge
|
|
68
|
+
variant="secondary"
|
|
69
|
+
className="h-6 gap-1 rounded-md px-1.5 font-normal text-xs"
|
|
70
|
+
>
|
|
71
|
+
{icon}
|
|
72
|
+
<span className="max-w-28 truncate">{children}</span>
|
|
73
|
+
</Badge>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function SuggestionCard({
|
|
78
|
+
creating,
|
|
79
|
+
error,
|
|
80
|
+
multi,
|
|
81
|
+
onApply,
|
|
82
|
+
onToggle,
|
|
83
|
+
selected,
|
|
84
|
+
suggestion,
|
|
85
|
+
}: {
|
|
86
|
+
creating?: boolean;
|
|
87
|
+
error?: string;
|
|
88
|
+
multi: boolean;
|
|
89
|
+
onApply: () => void;
|
|
90
|
+
onToggle: () => void;
|
|
91
|
+
selected: boolean;
|
|
92
|
+
suggestion: WorkspaceTaskSuggestionTask;
|
|
93
|
+
}) {
|
|
94
|
+
const t = useTranslations('ws-task-boards.dialog');
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div
|
|
98
|
+
className={cn(
|
|
99
|
+
'rounded-lg border bg-background p-3 transition-colors',
|
|
100
|
+
selected && 'border-dynamic-blue/60 bg-dynamic-blue/5'
|
|
101
|
+
)}
|
|
102
|
+
>
|
|
103
|
+
<div className="flex items-start gap-2">
|
|
104
|
+
{multi && (
|
|
105
|
+
<Checkbox
|
|
106
|
+
aria-label={t('smart_select_suggestion')}
|
|
107
|
+
checked={selected}
|
|
108
|
+
className="mt-0.5"
|
|
109
|
+
onCheckedChange={onToggle}
|
|
110
|
+
/>
|
|
111
|
+
)}
|
|
112
|
+
<div className="min-w-0 flex-1 space-y-2">
|
|
113
|
+
<div className="flex items-start justify-between gap-2">
|
|
114
|
+
<div className="min-w-0">
|
|
115
|
+
<div className="truncate font-medium text-sm">
|
|
116
|
+
{suggestion.title}
|
|
117
|
+
</div>
|
|
118
|
+
{suggestion.description && (
|
|
119
|
+
<div className="line-clamp-2 text-muted-foreground text-xs">
|
|
120
|
+
{suggestion.description}
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
{!multi && (
|
|
125
|
+
<Button size="sm" type="button" onClick={onApply}>
|
|
126
|
+
<Sparkles className="h-4 w-4" />
|
|
127
|
+
{t('smart_apply_suggestion')}
|
|
128
|
+
</Button>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<div className="flex flex-wrap gap-1.5">
|
|
133
|
+
{suggestion.listName && (
|
|
134
|
+
<SuggestionChip icon={<ListTodo className="h-3 w-3" />}>
|
|
135
|
+
{suggestion.listName}
|
|
136
|
+
</SuggestionChip>
|
|
137
|
+
)}
|
|
138
|
+
{suggestion.priority && (
|
|
139
|
+
<SuggestionChip icon={<Flag className="h-3 w-3" />}>
|
|
140
|
+
{t(`priority.${suggestion.priority}`)}
|
|
141
|
+
</SuggestionChip>
|
|
142
|
+
)}
|
|
143
|
+
{suggestion.endDate && (
|
|
144
|
+
<SuggestionChip icon={<Calendar className="h-3 w-3" />}>
|
|
145
|
+
{dayjs(suggestion.endDate).format('MMM D')}
|
|
146
|
+
</SuggestionChip>
|
|
147
|
+
)}
|
|
148
|
+
{suggestion.durationMinutes && (
|
|
149
|
+
<SuggestionChip icon={<Clock className="h-3 w-3" />}>
|
|
150
|
+
{formatDuration(suggestion.durationMinutes)}
|
|
151
|
+
</SuggestionChip>
|
|
152
|
+
)}
|
|
153
|
+
{suggestion.estimationPoints != null && (
|
|
154
|
+
<SuggestionChip icon={<Timer className="h-3 w-3" />}>
|
|
155
|
+
{suggestion.estimationPoints}
|
|
156
|
+
</SuggestionChip>
|
|
157
|
+
)}
|
|
158
|
+
{suggestion.labels.map((label) => (
|
|
159
|
+
<SuggestionChip key={label.id} icon={<Tag className="h-3 w-3" />}>
|
|
160
|
+
{label.name}
|
|
161
|
+
</SuggestionChip>
|
|
162
|
+
))}
|
|
163
|
+
{suggestion.projects.map((project) => (
|
|
164
|
+
<SuggestionChip
|
|
165
|
+
key={project.id}
|
|
166
|
+
icon={<FolderKanban className="h-3 w-3" />}
|
|
167
|
+
>
|
|
168
|
+
{project.name}
|
|
169
|
+
</SuggestionChip>
|
|
170
|
+
))}
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
{suggestion.reason && (
|
|
174
|
+
<div className="text-muted-foreground text-xs">
|
|
175
|
+
{suggestion.reason}
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
|
|
179
|
+
{error && (
|
|
180
|
+
<div className="flex items-center gap-1.5 text-destructive text-xs">
|
|
181
|
+
<AlertCircle className="h-3.5 w-3.5" />
|
|
182
|
+
{error}
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
|
|
186
|
+
{creating && (
|
|
187
|
+
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
|
|
188
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
189
|
+
{t('smart_creating_task')}
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function SmartTaskSuggestionsButton({
|
|
199
|
+
disabled,
|
|
200
|
+
isLoading,
|
|
201
|
+
onClick,
|
|
202
|
+
}: SmartTaskSuggestionsButtonProps) {
|
|
203
|
+
const t = useTranslations('ws-task-boards.dialog');
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<Tooltip>
|
|
207
|
+
<TooltipTrigger asChild>
|
|
208
|
+
<Button
|
|
209
|
+
type="button"
|
|
210
|
+
variant="ghost"
|
|
211
|
+
size="icon"
|
|
212
|
+
aria-label={t('smart_suggest')}
|
|
213
|
+
disabled={disabled || isLoading}
|
|
214
|
+
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
|
215
|
+
onClick={onClick}
|
|
216
|
+
>
|
|
217
|
+
{isLoading ? (
|
|
218
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
219
|
+
) : (
|
|
220
|
+
<Sparkles className="h-4 w-4" />
|
|
221
|
+
)}
|
|
222
|
+
</Button>
|
|
223
|
+
</TooltipTrigger>
|
|
224
|
+
<TooltipContent side="bottom">{t('smart_suggest')}</TooltipContent>
|
|
225
|
+
</Tooltip>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function SmartTaskSuggestionsPanel({
|
|
230
|
+
suggestions,
|
|
231
|
+
selectedSuggestionIds,
|
|
232
|
+
createErrors = {},
|
|
233
|
+
creatingSuggestionIds = [],
|
|
234
|
+
errorMessage,
|
|
235
|
+
isCreatingSelected,
|
|
236
|
+
isLoading,
|
|
237
|
+
onApplyFirst,
|
|
238
|
+
onApplySuggestion,
|
|
239
|
+
onClose,
|
|
240
|
+
onCreateSelected,
|
|
241
|
+
onRetry,
|
|
242
|
+
onToggleSuggestion,
|
|
243
|
+
}: SmartTaskSuggestionsPanelProps) {
|
|
244
|
+
const t = useTranslations('ws-task-boards.dialog');
|
|
245
|
+
const selectedCount = selectedSuggestionIds.length;
|
|
246
|
+
|
|
247
|
+
if (isLoading) {
|
|
248
|
+
return (
|
|
249
|
+
<div className="rounded-lg border bg-muted/30 p-3">
|
|
250
|
+
<div className="flex items-center gap-2 text-sm">
|
|
251
|
+
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
252
|
+
<span>{t('smart_generating')}</span>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (errorMessage) {
|
|
259
|
+
return (
|
|
260
|
+
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3">
|
|
261
|
+
<div className="flex items-start justify-between gap-2">
|
|
262
|
+
<div className="flex min-w-0 gap-2">
|
|
263
|
+
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
|
264
|
+
<div className="min-w-0 space-y-1">
|
|
265
|
+
<div className="font-medium text-sm">
|
|
266
|
+
{t('smart_suggestions_failed')}
|
|
267
|
+
</div>
|
|
268
|
+
<div className="text-muted-foreground text-xs">
|
|
269
|
+
{errorMessage}
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
<Button size="sm" type="button" variant="ghost" onClick={onRetry}>
|
|
274
|
+
{t('retry')}
|
|
275
|
+
</Button>
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (!suggestions.length) {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const multi = suggestions.length > 1;
|
|
286
|
+
|
|
287
|
+
return (
|
|
288
|
+
<div className="rounded-lg border bg-muted/20 p-3">
|
|
289
|
+
<div className="mb-3 flex items-start justify-between gap-2">
|
|
290
|
+
<div className="min-w-0">
|
|
291
|
+
<div className="flex items-center gap-1.5 font-medium text-sm">
|
|
292
|
+
<Sparkles className="h-4 w-4" />
|
|
293
|
+
{multi
|
|
294
|
+
? t('smart_multiple_suggestions')
|
|
295
|
+
: t('smart_one_suggestion')}
|
|
296
|
+
</div>
|
|
297
|
+
<div className="text-muted-foreground text-xs">
|
|
298
|
+
{multi
|
|
299
|
+
? t('smart_multiple_suggestions_description')
|
|
300
|
+
: t('smart_one_suggestion_description')}
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
<Button
|
|
304
|
+
type="button"
|
|
305
|
+
variant="ghost"
|
|
306
|
+
size="icon"
|
|
307
|
+
aria-label={t('smart_dismiss')}
|
|
308
|
+
className="h-7 w-7 shrink-0"
|
|
309
|
+
onClick={onClose}
|
|
310
|
+
>
|
|
311
|
+
<X className="h-3.5 w-3.5" />
|
|
312
|
+
</Button>
|
|
313
|
+
</div>
|
|
314
|
+
|
|
315
|
+
<div className="space-y-2">
|
|
316
|
+
{suggestions.map((suggestion) => (
|
|
317
|
+
<SuggestionCard
|
|
318
|
+
key={suggestion.id}
|
|
319
|
+
creating={creatingSuggestionIds.includes(suggestion.id)}
|
|
320
|
+
error={createErrors[suggestion.id]}
|
|
321
|
+
multi={multi}
|
|
322
|
+
selected={selectedSuggestionIds.includes(suggestion.id)}
|
|
323
|
+
suggestion={suggestion}
|
|
324
|
+
onApply={() => onApplySuggestion(suggestion)}
|
|
325
|
+
onToggle={() => onToggleSuggestion(suggestion.id)}
|
|
326
|
+
/>
|
|
327
|
+
))}
|
|
328
|
+
</div>
|
|
329
|
+
|
|
330
|
+
{multi && (
|
|
331
|
+
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
|
|
332
|
+
<Button
|
|
333
|
+
type="button"
|
|
334
|
+
size="sm"
|
|
335
|
+
variant="secondary"
|
|
336
|
+
onClick={onApplyFirst}
|
|
337
|
+
>
|
|
338
|
+
<CheckCircle2 className="h-4 w-4" />
|
|
339
|
+
{t('smart_apply_first')}
|
|
340
|
+
</Button>
|
|
341
|
+
<Button
|
|
342
|
+
type="button"
|
|
343
|
+
size="sm"
|
|
344
|
+
disabled={!selectedCount || isCreatingSelected}
|
|
345
|
+
onClick={onCreateSelected}
|
|
346
|
+
>
|
|
347
|
+
{isCreatingSelected ? (
|
|
348
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
349
|
+
) : (
|
|
350
|
+
<Sparkles className="h-4 w-4" />
|
|
351
|
+
)}
|
|
352
|
+
{t('smart_create_selected', { count: selectedCount })}
|
|
353
|
+
</Button>
|
|
354
|
+
</div>
|
|
355
|
+
)}
|
|
356
|
+
</div>
|
|
357
|
+
);
|
|
358
|
+
}
|
package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx
CHANGED
|
@@ -36,7 +36,7 @@ export interface TaskDescriptionEditorProps {
|
|
|
36
36
|
// Refs
|
|
37
37
|
editorRef: React.RefObject<HTMLDivElement | null>;
|
|
38
38
|
richTextEditorRef: React.RefObject<HTMLDivElement | null>;
|
|
39
|
-
titleInputRef: React.RefObject<HTMLInputElement | null>;
|
|
39
|
+
titleInputRef: React.RefObject<HTMLInputElement | HTMLTextAreaElement | null>;
|
|
40
40
|
lastCursorPositionRef: React.RefObject<number | null>;
|
|
41
41
|
targetEditorCursorRef: React.RefObject<number | null>;
|
|
42
42
|
flushEditorPendingRef: React.RefObject<
|
|
@@ -222,6 +222,8 @@ interface TaskDialogHeaderProps {
|
|
|
222
222
|
onOpenShareDialog?: () => void;
|
|
223
223
|
/** Whether the dialog is in read-only mode */
|
|
224
224
|
disabled?: boolean;
|
|
225
|
+
/** Whether task-dependent actions should be disabled while the task hydrates. */
|
|
226
|
+
controlsDisabled?: boolean;
|
|
225
227
|
/** Callback to scroll the editor to a collaborator's cursor position */
|
|
226
228
|
onScrollToUserCursor?: (userId: string, displayName: string) => void;
|
|
227
229
|
}
|
|
@@ -262,6 +264,7 @@ export function TaskDialogHeader({
|
|
|
262
264
|
isPersonalWorkspace = false,
|
|
263
265
|
onOpenShareDialog,
|
|
264
266
|
disabled = false,
|
|
267
|
+
controlsDisabled = false,
|
|
265
268
|
onScrollToUserCursor,
|
|
266
269
|
}: TaskDialogHeaderProps) {
|
|
267
270
|
const t = useTranslations();
|
|
@@ -448,7 +451,7 @@ export function TaskDialogHeader({
|
|
|
448
451
|
)}
|
|
449
452
|
|
|
450
453
|
{/* Quick Settings */}
|
|
451
|
-
{!
|
|
454
|
+
{!controlsDisabled && (
|
|
452
455
|
<QuickSettingsPopover isPersonalWorkspace={isPersonalWorkspace} />
|
|
453
456
|
)}
|
|
454
457
|
|
|
@@ -467,10 +470,11 @@ export function TaskDialogHeader({
|
|
|
467
470
|
onNavigateBack={onNavigateBack}
|
|
468
471
|
onOpenShareDialog={onOpenShareDialog}
|
|
469
472
|
disabled={disabled}
|
|
473
|
+
controlsDisabled={controlsDisabled}
|
|
470
474
|
/>
|
|
471
475
|
|
|
472
476
|
{/* Hide save button in edit mode when realtime is enabled (either cursors or Yjs sync) */}
|
|
473
|
-
{!
|
|
477
|
+
{!controlsDisabled &&
|
|
474
478
|
(isCreateMode || (!collaborationMode && !realtimeEnabled)) && (
|
|
475
479
|
<Tooltip>
|
|
476
480
|
<TooltipTrigger asChild>
|
|
@@ -27,7 +27,9 @@ function renderTaskNameInput({
|
|
|
27
27
|
editorElement.tabIndex = -1;
|
|
28
28
|
editorWrapper.append(editorElement);
|
|
29
29
|
|
|
30
|
-
const titleInputRef = {
|
|
30
|
+
const titleInputRef = {
|
|
31
|
+
current: null as HTMLInputElement | HTMLTextAreaElement | null,
|
|
32
|
+
};
|
|
31
33
|
const editorRef = { current: editorWrapper };
|
|
32
34
|
const lastCursorPositionRef = { current: null };
|
|
33
35
|
const targetEditorCursorRef = { current: targetCursor };
|
|
@@ -66,6 +68,18 @@ describe('TaskNameInput', () => {
|
|
|
66
68
|
vi.restoreAllMocks();
|
|
67
69
|
});
|
|
68
70
|
|
|
71
|
+
it.each([
|
|
72
|
+
'fullscreen',
|
|
73
|
+
'compact',
|
|
74
|
+
] as const)('places the initial %s title caret at the end', (variant) => {
|
|
75
|
+
const { input } = renderTaskNameInput({ variant });
|
|
76
|
+
const expectedCursorPosition = 'APIs for Agent Pi'.length;
|
|
77
|
+
|
|
78
|
+
expect(input).toHaveFocus();
|
|
79
|
+
expect(input).toHaveProperty('selectionStart', expectedCursorPosition);
|
|
80
|
+
expect(input).toHaveProperty('selectionEnd', expectedCursorPosition);
|
|
81
|
+
});
|
|
82
|
+
|
|
69
83
|
it('defers focusing the description editor after Enter', () => {
|
|
70
84
|
const { focusEditor, targetEditorCursorRef, input } = renderTaskNameInput();
|
|
71
85
|
|
|
@@ -153,9 +167,11 @@ describe('TaskNameInput', () => {
|
|
|
153
167
|
|
|
154
168
|
const eventAllowed = fireEvent.keyDown(input, { key: 'Enter' });
|
|
155
169
|
|
|
170
|
+
expect(input.tagName).toBe('TEXTAREA');
|
|
156
171
|
expect(eventAllowed).toBe(false);
|
|
157
172
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
158
173
|
expect(targetEditorCursorRef.current).toBe(12);
|
|
174
|
+
expect(input).toHaveProperty('value', 'APIs for Agent Pi');
|
|
159
175
|
|
|
160
176
|
vi.runOnlyPendingTimers();
|
|
161
177
|
|