@tuturuuu/ui 0.13.0 → 0.16.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 +90 -0
- package/biome.json +1 -1
- package/package.json +62 -62
- package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +12 -0
- package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +93 -6
- package/src/components/ui/custom/loading-statistic-card.tsx +18 -0
- package/src/components/ui/custom/notification-popover-client.tsx +5 -1
- package/src/components/ui/custom/settings/keyboard-shortcuts-settings.test.tsx +34 -0
- package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +106 -0
- package/src/components/ui/custom/settings/sidebar-settings.tsx +18 -6
- package/src/components/ui/custom/settings/task-settings.tsx +55 -0
- package/src/components/ui/custom/settings-dialog-shell.tsx +18 -12
- package/src/components/ui/custom/sidebar-context.tsx +58 -3
- package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +5 -3
- package/src/components/ui/custom/statistic-card.tsx +104 -0
- package/src/components/ui/custom/structure.test.tsx +29 -0
- package/src/components/ui/custom/structure.tsx +103 -88
- package/src/components/ui/finance/invoices/civil-month.ts +224 -0
- package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.test.tsx +49 -0
- package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.tsx +81 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
- package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +90 -2
- package/src/components/ui/finance/invoices/new-invoice-page.tsx +40 -4
- package/src/components/ui/finance/invoices/pending-columns.test.ts +26 -0
- package/src/components/ui/finance/invoices/pending-columns.tsx +45 -14
- package/src/components/ui/finance/invoices/standard-invoice.test.ts +31 -0
- package/src/components/ui/finance/invoices/standard-invoice.tsx +33 -16
- package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +331 -0
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +53 -28
- package/src/components/ui/finance/invoices/utils.test.ts +84 -0
- package/src/components/ui/finance/invoices/utils.ts +56 -134
- package/src/components/ui/resizable.test.ts +15 -0
- package/src/components/ui/resizable.tsx +28 -6
- package/src/components/ui/tu-do/boards/boardId/board-column.tsx +4 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +62 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/auto-scroll.test.ts +118 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/auto-scroll.ts +144 -70
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +48 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +50 -6
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +6 -0
- package/src/components/ui/tu-do/boards/boardId/kanban.tsx +4 -2
- package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +41 -3
- package/src/components/ui/tu-do/boards/boardId/menus/task-due-date-menu.tsx +31 -10
- package/src/components/ui/tu-do/boards/boardId/menus/task-priority-menu.tsx +7 -5
- package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +7 -5
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.test.ts +44 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.ts +17 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +2 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +7 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +44 -30
- package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +54 -0
- package/src/components/ui/tu-do/my-tasks/task-list-with-completion.tsx +43 -1
- package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +33 -15
- package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +3 -3
- package/src/components/ui/tu-do/progress/task-progress-page.tsx +74 -28
- package/src/components/ui/tu-do/progress/task-progress-panels.tsx +12 -10
- package/src/components/ui/tu-do/projects/components/project-grid-card.tsx +22 -6
- package/src/components/ui/tu-do/projects/components/project-list-item.tsx +24 -8
- package/src/components/ui/tu-do/projects/projectId/components/__tests__/tasks-tab-layout.test.tsx +6 -0
- package/src/components/ui/tu-do/projects/projectId/components/tasks-tab.tsx +3 -1
- package/src/components/ui/tu-do/projects/projectId/task-project-detail-page-client.tsx +11 -3
- package/src/components/ui/tu-do/projects/projectId/task-project-detail.tsx +2 -0
- package/src/components/ui/tu-do/projects/projectId/types.ts +1 -0
- package/src/components/ui/tu-do/projects/task-projects-client.tsx +45 -1
- package/src/components/ui/tu-do/projects/types.ts +4 -1
- package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +10 -0
- package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +7 -1
- package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +87 -2
- package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +70 -0
- package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +27 -3
- package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +41 -3
- package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +54 -0
- package/src/components/ui/tu-do/shared/board-header.tsx +20 -0
- package/src/components/ui/tu-do/shared/board-views.tsx +33 -3
- package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
- package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +2 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +7 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-task-mutations.test.tsx +231 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +139 -68
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.test.tsx +68 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +17 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +45 -23
- package/src/components/ui/tu-do/shared/task-quick-create-target-list.ts +20 -0
- package/src/components/ui/tu-do/shared/task-row-actions-menu.test.tsx +138 -0
- package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +15 -9
- package/src/components/ui/tu-do/templates/client.tsx +97 -78
- package/src/components/ui/tu-do/templates/marketplace/client.tsx +171 -135
- package/src/components/ui/tu-do/templates/task-templates-hub.tsx +3 -0
- package/src/components/ui/tu-do/templates/templateId/client.tsx +24 -9
- package/src/components/ui/tu-do/templates/templateId/task-template-detail-page-client.tsx +8 -2
- package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -0
- package/src/hooks/__tests__/use-task-actions.test.tsx +14 -39
- package/src/hooks/task-actions-personal-external.ts +12 -6
- package/src/hooks/use-notifications.ts +14 -2
- package/src/hooks/use-user-config.ts +10 -2
- package/vitest.config.ts +4 -0
|
@@ -42,11 +42,13 @@ type SortOption = 'title-asc' | 'title-desc' | 'created-desc' | 'created-asc';
|
|
|
42
42
|
interface Props {
|
|
43
43
|
wsId: string;
|
|
44
44
|
templates: BoardTemplate[];
|
|
45
|
+
onOpenTemplate?: (template: BoardTemplate) => void;
|
|
45
46
|
templatesBasePath?: string;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
export default function MarketplaceClient({
|
|
49
50
|
wsId,
|
|
51
|
+
onOpenTemplate,
|
|
50
52
|
templates,
|
|
51
53
|
templatesBasePath = 'templates',
|
|
52
54
|
}: Props) {
|
|
@@ -189,6 +191,7 @@ export default function MarketplaceClient({
|
|
|
189
191
|
<FeaturedTemplateCard
|
|
190
192
|
key={template.id}
|
|
191
193
|
template={template}
|
|
194
|
+
onOpenTemplate={onOpenTemplate}
|
|
192
195
|
wsId={wsId}
|
|
193
196
|
index={idx}
|
|
194
197
|
templatesBasePath={templatesBasePath}
|
|
@@ -220,6 +223,7 @@ export default function MarketplaceClient({
|
|
|
220
223
|
<MarketplaceTemplateCard
|
|
221
224
|
key={template.id}
|
|
222
225
|
template={template}
|
|
226
|
+
onOpenTemplate={onOpenTemplate}
|
|
223
227
|
wsId={wsId}
|
|
224
228
|
templatesBasePath={templatesBasePath}
|
|
225
229
|
/>
|
|
@@ -233,176 +237,208 @@ export default function MarketplaceClient({
|
|
|
233
237
|
|
|
234
238
|
function FeaturedTemplateCard({
|
|
235
239
|
template,
|
|
240
|
+
onOpenTemplate,
|
|
236
241
|
wsId,
|
|
237
242
|
index,
|
|
238
243
|
templatesBasePath,
|
|
239
244
|
}: {
|
|
240
245
|
template: BoardTemplate;
|
|
246
|
+
onOpenTemplate?: (template: BoardTemplate) => void;
|
|
241
247
|
wsId: string;
|
|
242
248
|
index: number;
|
|
243
249
|
templatesBasePath: string;
|
|
244
250
|
}) {
|
|
245
|
-
|
|
246
|
-
<
|
|
247
|
-
<
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
<
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
251
|
+
const card = (
|
|
252
|
+
<Card className="group relative h-full overflow-hidden border-border/50 bg-linear-to-br from-background via-background to-muted/30 transition-all hover:border-primary/50 hover:shadow-xl">
|
|
253
|
+
<div className="aspect-video w-full overflow-hidden border-b bg-muted/50">
|
|
254
|
+
{template.backgroundUrl ? (
|
|
255
|
+
<Image
|
|
256
|
+
src={template.backgroundUrl}
|
|
257
|
+
alt={template.name}
|
|
258
|
+
width={600}
|
|
259
|
+
height={338}
|
|
260
|
+
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
261
|
+
/>
|
|
262
|
+
) : (
|
|
263
|
+
<div className="flex h-full w-full items-center justify-center bg-linear-to-br from-dynamic-blue/5 to-dynamic-purple/5">
|
|
264
|
+
<KanbanSquare className="h-12 w-12 text-muted-foreground/20" />
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
{/* Gradient overlay on hover */}
|
|
270
|
+
<div
|
|
271
|
+
className={cn(
|
|
272
|
+
'pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100',
|
|
273
|
+
index === 0
|
|
274
|
+
? 'bg-linear-to-br from-dynamic-purple/5 via-transparent to-dynamic-blue/5'
|
|
275
|
+
: 'bg-linear-to-br from-dynamic-blue/5 via-transparent to-dynamic-green/5'
|
|
276
|
+
)}
|
|
277
|
+
/>
|
|
278
|
+
|
|
279
|
+
<CardHeader className="relative space-y-4 pb-4">
|
|
280
|
+
<div className="flex items-center justify-between">
|
|
281
|
+
<Badge
|
|
282
|
+
variant="secondary"
|
|
283
|
+
className={cn(
|
|
284
|
+
'bg-background/80 font-semibold backdrop-blur-sm',
|
|
285
|
+
index === 0 ? 'text-dynamic-purple' : 'text-dynamic-blue'
|
|
286
|
+
)}
|
|
287
|
+
>
|
|
288
|
+
⭐ Featured
|
|
289
|
+
</Badge>
|
|
290
|
+
<span className="text-muted-foreground text-xs">
|
|
291
|
+
{formatDistanceToNow(new Date(template.createdAt), {
|
|
292
|
+
addSuffix: true,
|
|
293
|
+
})}
|
|
294
|
+
</span>
|
|
262
295
|
</div>
|
|
263
296
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
297
|
+
<div className="space-y-2">
|
|
298
|
+
<CardTitle className="font-bold text-2xl leading-tight transition-colors group-hover:text-primary">
|
|
299
|
+
{template.name}
|
|
300
|
+
</CardTitle>
|
|
301
|
+
{template.description && (
|
|
302
|
+
<CardDescription className="line-clamp-3 text-base leading-relaxed">
|
|
303
|
+
{template.description}
|
|
304
|
+
</CardDescription>
|
|
271
305
|
)}
|
|
272
|
-
|
|
306
|
+
</div>
|
|
307
|
+
</CardHeader>
|
|
273
308
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
)}
|
|
282
|
-
>
|
|
283
|
-
⭐ Featured
|
|
284
|
-
</Badge>
|
|
285
|
-
<span className="text-muted-foreground text-xs">
|
|
286
|
-
{formatDistanceToNow(new Date(template.createdAt), {
|
|
287
|
-
addSuffix: true,
|
|
288
|
-
})}
|
|
309
|
+
<CardContent className="relative">
|
|
310
|
+
<div className="flex flex-wrap items-center gap-4">
|
|
311
|
+
<div className="flex items-center gap-2 rounded-full bg-linear-to-r from-dynamic-blue/10 to-dynamic-cyan/10 px-3 py-1.5 text-sm">
|
|
312
|
+
<KanbanSquare className="h-4 w-4 text-dynamic-blue" />
|
|
313
|
+
<span className="font-medium">
|
|
314
|
+
{template.stats.lists}{' '}
|
|
315
|
+
<span className="text-muted-foreground">lists</span>
|
|
289
316
|
</span>
|
|
290
317
|
</div>
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
<
|
|
294
|
-
{template.
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
<CardDescription className="line-clamp-3 text-base leading-relaxed">
|
|
298
|
-
{template.description}
|
|
299
|
-
</CardDescription>
|
|
300
|
-
)}
|
|
318
|
+
<div className="flex items-center gap-2 rounded-full bg-linear-to-r from-dynamic-green/10 to-dynamic-teal/10 px-3 py-1.5 text-sm">
|
|
319
|
+
<ListTodo className="h-4 w-4 text-dynamic-green" />
|
|
320
|
+
<span className="font-medium">
|
|
321
|
+
{template.stats.tasks}{' '}
|
|
322
|
+
<span className="text-muted-foreground">tasks</span>
|
|
323
|
+
</span>
|
|
301
324
|
</div>
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
<div className="flex flex-wrap items-center gap-4">
|
|
306
|
-
<div className="flex items-center gap-2 rounded-full bg-linear-to-r from-dynamic-blue/10 to-dynamic-cyan/10 px-3 py-1.5 text-sm">
|
|
307
|
-
<KanbanSquare className="h-4 w-4 text-dynamic-blue" />
|
|
325
|
+
{template.stats.labels > 0 && (
|
|
326
|
+
<div className="flex items-center gap-2 rounded-full bg-linear-to-r from-dynamic-orange/10 to-dynamic-yellow/10 px-3 py-1.5 text-sm">
|
|
327
|
+
<Tags className="h-4 w-4 text-dynamic-orange" />
|
|
308
328
|
<span className="font-medium">
|
|
309
|
-
{template.stats.
|
|
310
|
-
<span className="text-muted-foreground">
|
|
329
|
+
{template.stats.labels}{' '}
|
|
330
|
+
<span className="text-muted-foreground">labels</span>
|
|
311
331
|
</span>
|
|
312
332
|
</div>
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
</Link>
|
|
333
|
+
)}
|
|
334
|
+
</div>
|
|
335
|
+
</CardContent>
|
|
336
|
+
</Card>
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
if (onOpenTemplate) {
|
|
340
|
+
return (
|
|
341
|
+
<button
|
|
342
|
+
type="button"
|
|
343
|
+
className="block h-full w-full text-left"
|
|
344
|
+
onClick={() => onOpenTemplate(template)}
|
|
345
|
+
>
|
|
346
|
+
{card}
|
|
347
|
+
</button>
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return (
|
|
352
|
+
<Link href={`/${wsId}/${templatesBasePath}/${template.id}`}>{card}</Link>
|
|
333
353
|
);
|
|
334
354
|
}
|
|
335
355
|
|
|
336
356
|
function MarketplaceTemplateCard({
|
|
337
357
|
template,
|
|
358
|
+
onOpenTemplate,
|
|
338
359
|
wsId,
|
|
339
360
|
templatesBasePath,
|
|
340
361
|
}: {
|
|
341
362
|
template: BoardTemplate;
|
|
363
|
+
onOpenTemplate?: (template: BoardTemplate) => void;
|
|
342
364
|
wsId: string;
|
|
343
365
|
templatesBasePath: string;
|
|
344
366
|
}) {
|
|
345
|
-
|
|
346
|
-
<
|
|
347
|
-
<
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
<
|
|
359
|
-
<KanbanSquare className="h-10 w-10 text-muted-foreground/20" />
|
|
360
|
-
</div>
|
|
361
|
-
)}
|
|
362
|
-
</div>
|
|
363
|
-
<CardHeader className="pb-3">
|
|
364
|
-
<div className="flex items-start gap-3">
|
|
365
|
-
<div className="rounded-lg bg-linear-to-br from-dynamic-purple/10 via-dynamic-pink/5 to-dynamic-blue/10 p-2.5 transition-colors group-hover:from-dynamic-purple/20 group-hover:to-dynamic-blue/20">
|
|
366
|
-
<Bookmark className="h-5 w-5 text-dynamic-purple" />
|
|
367
|
-
</div>
|
|
368
|
-
<div className="min-w-0 flex-1 space-y-1">
|
|
369
|
-
<CardTitle className="line-clamp-2 text-base leading-tight transition-colors group-hover:text-dynamic-blue">
|
|
370
|
-
{template.name}
|
|
371
|
-
</CardTitle>
|
|
372
|
-
<span className="text-muted-foreground text-xs">
|
|
373
|
-
{formatDistanceToNow(new Date(template.createdAt), {
|
|
374
|
-
addSuffix: true,
|
|
375
|
-
})}
|
|
376
|
-
</span>
|
|
377
|
-
</div>
|
|
367
|
+
const card = (
|
|
368
|
+
<Card className="group h-full overflow-hidden transition-all duration-200 hover:-translate-y-1 hover:border-primary/50 hover:shadow-lg">
|
|
369
|
+
<div className="aspect-video w-full overflow-hidden border-b bg-muted/50">
|
|
370
|
+
{template.backgroundUrl ? (
|
|
371
|
+
<Image
|
|
372
|
+
src={template.backgroundUrl}
|
|
373
|
+
alt={template.name}
|
|
374
|
+
width={400}
|
|
375
|
+
height={225}
|
|
376
|
+
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
377
|
+
/>
|
|
378
|
+
) : (
|
|
379
|
+
<div className="flex h-full w-full items-center justify-center bg-secondary/30">
|
|
380
|
+
<KanbanSquare className="h-10 w-10 text-muted-foreground/20" />
|
|
378
381
|
</div>
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
382
|
+
)}
|
|
383
|
+
</div>
|
|
384
|
+
<CardHeader className="pb-3">
|
|
385
|
+
<div className="flex items-start gap-3">
|
|
386
|
+
<div className="rounded-lg bg-linear-to-br from-dynamic-purple/10 via-dynamic-pink/5 to-dynamic-blue/10 p-2.5 transition-colors group-hover:from-dynamic-purple/20 group-hover:to-dynamic-blue/20">
|
|
387
|
+
<Bookmark className="h-5 w-5 text-dynamic-purple" />
|
|
388
|
+
</div>
|
|
389
|
+
<div className="min-w-0 flex-1 space-y-1">
|
|
390
|
+
<CardTitle className="line-clamp-2 text-base leading-tight transition-colors group-hover:text-dynamic-blue">
|
|
391
|
+
{template.name}
|
|
392
|
+
</CardTitle>
|
|
393
|
+
<span className="text-muted-foreground text-xs">
|
|
394
|
+
{formatDistanceToNow(new Date(template.createdAt), {
|
|
395
|
+
addSuffix: true,
|
|
396
|
+
})}
|
|
397
|
+
</span>
|
|
398
|
+
</div>
|
|
399
|
+
</div>
|
|
400
|
+
{template.description && (
|
|
401
|
+
<CardDescription className="line-clamp-2 pt-2 text-xs leading-relaxed">
|
|
402
|
+
{template.description}
|
|
403
|
+
</CardDescription>
|
|
404
|
+
)}
|
|
405
|
+
</CardHeader>
|
|
385
406
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
407
|
+
<CardContent className="pt-0">
|
|
408
|
+
<div className="flex flex-wrap items-center gap-3 border-border/50 border-t pt-3 text-muted-foreground text-xs">
|
|
409
|
+
<div className="flex items-center gap-1.5">
|
|
410
|
+
<KanbanSquare className="h-3.5 w-3.5 text-dynamic-blue" />
|
|
411
|
+
<span>{template.stats.lists}</span>
|
|
412
|
+
</div>
|
|
413
|
+
<div className="flex items-center gap-1.5">
|
|
414
|
+
<ListTodo className="h-3.5 w-3.5 text-dynamic-green" />
|
|
415
|
+
<span>{template.stats.tasks}</span>
|
|
416
|
+
</div>
|
|
417
|
+
{template.stats.labels > 0 && (
|
|
392
418
|
<div className="flex items-center gap-1.5">
|
|
393
|
-
<
|
|
394
|
-
<span>{template.stats.
|
|
419
|
+
<Tags className="h-3.5 w-3.5 text-dynamic-orange" />
|
|
420
|
+
<span>{template.stats.labels}</span>
|
|
395
421
|
</div>
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
422
|
+
)}
|
|
423
|
+
</div>
|
|
424
|
+
</CardContent>
|
|
425
|
+
</Card>
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
if (onOpenTemplate) {
|
|
429
|
+
return (
|
|
430
|
+
<button
|
|
431
|
+
type="button"
|
|
432
|
+
className="block h-full w-full text-left"
|
|
433
|
+
onClick={() => onOpenTemplate(template)}
|
|
434
|
+
>
|
|
435
|
+
{card}
|
|
436
|
+
</button>
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return (
|
|
441
|
+
<Link href={`/${wsId}/${templatesBasePath}/${template.id}`}>{card}</Link>
|
|
406
442
|
);
|
|
407
443
|
}
|
|
408
444
|
|
|
@@ -10,6 +10,7 @@ import type { BoardTemplate } from './types';
|
|
|
10
10
|
|
|
11
11
|
interface TaskTemplatesHubProps {
|
|
12
12
|
boardTemplates: BoardTemplate[];
|
|
13
|
+
onOpenBoardTemplate?: (template: BoardTemplate) => void;
|
|
13
14
|
taskTemplates?: WorkspaceTaskTemplate[];
|
|
14
15
|
templatesBasePath?: string;
|
|
15
16
|
wsId: string;
|
|
@@ -17,6 +18,7 @@ interface TaskTemplatesHubProps {
|
|
|
17
18
|
|
|
18
19
|
export function TaskTemplatesHub({
|
|
19
20
|
boardTemplates,
|
|
21
|
+
onOpenBoardTemplate,
|
|
20
22
|
taskTemplates = [],
|
|
21
23
|
templatesBasePath = 'templates',
|
|
22
24
|
wsId,
|
|
@@ -41,6 +43,7 @@ export function TaskTemplatesHub({
|
|
|
41
43
|
<TabsContent value="boards">
|
|
42
44
|
<TemplatesClient
|
|
43
45
|
initialTemplates={boardTemplates}
|
|
46
|
+
onOpenTemplate={onOpenBoardTemplate}
|
|
44
47
|
templatesBasePath={templatesBasePath}
|
|
45
48
|
wsId={wsId}
|
|
46
49
|
/>
|
|
@@ -54,6 +54,8 @@ import { UseTemplateDialog } from './use-template-dialog';
|
|
|
54
54
|
interface Props {
|
|
55
55
|
wsId: string;
|
|
56
56
|
template: BoardTemplateWithContent;
|
|
57
|
+
embedded?: boolean;
|
|
58
|
+
onClose?: () => void;
|
|
57
59
|
templatesBasePath?: string;
|
|
58
60
|
}
|
|
59
61
|
|
|
@@ -221,6 +223,8 @@ function TaskPreviewCard({ task }: { task: TemplateTask }) {
|
|
|
221
223
|
|
|
222
224
|
export default function TemplateDetailClient({
|
|
223
225
|
wsId,
|
|
226
|
+
embedded = false,
|
|
227
|
+
onClose,
|
|
224
228
|
template,
|
|
225
229
|
templatesBasePath = 'templates',
|
|
226
230
|
}: Props) {
|
|
@@ -248,7 +252,11 @@ export default function TemplateDetailClient({
|
|
|
248
252
|
}
|
|
249
253
|
|
|
250
254
|
toast.success(t('detail.delete_success'));
|
|
251
|
-
|
|
255
|
+
if (embedded) {
|
|
256
|
+
onClose?.();
|
|
257
|
+
} else {
|
|
258
|
+
router.push(`/${wsId}/${templatesBasePath}`);
|
|
259
|
+
}
|
|
252
260
|
} catch (error) {
|
|
253
261
|
console.error('Error deleting template:', error);
|
|
254
262
|
toast.error(
|
|
@@ -269,6 +277,11 @@ export default function TemplateDetailClient({
|
|
|
269
277
|
);
|
|
270
278
|
|
|
271
279
|
const handleBack = () => {
|
|
280
|
+
if (embedded) {
|
|
281
|
+
onClose?.();
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
272
285
|
// Check if there's history to go back to
|
|
273
286
|
if (window.history.length > 1) {
|
|
274
287
|
router.back();
|
|
@@ -281,14 +294,16 @@ export default function TemplateDetailClient({
|
|
|
281
294
|
return (
|
|
282
295
|
<div className="space-y-6">
|
|
283
296
|
{/* Back Button */}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
297
|
+
{!embedded && (
|
|
298
|
+
<Button
|
|
299
|
+
onClick={handleBack}
|
|
300
|
+
variant="link"
|
|
301
|
+
className="inline-flex items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground"
|
|
302
|
+
>
|
|
303
|
+
<ArrowLeft className="h-4 w-4" />
|
|
304
|
+
{t('detail.back_to_templates')}
|
|
305
|
+
</Button>
|
|
306
|
+
)}
|
|
292
307
|
|
|
293
308
|
{/* Header */}
|
|
294
309
|
<div className="grid gap-8 lg:grid-cols-3">
|
|
@@ -14,8 +14,10 @@ interface Props {
|
|
|
14
14
|
wsId: string;
|
|
15
15
|
templateId: string;
|
|
16
16
|
templatesBasePath: string;
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
embedded?: boolean;
|
|
18
|
+
initialTemplate?: InternalApiWorkspaceTemplate;
|
|
19
|
+
initialBackgroundUrl?: string | null;
|
|
20
|
+
onClose?: () => void;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
function mapTemplateToDetail(
|
|
@@ -74,8 +76,10 @@ export default function TaskTemplateDetailPageClient({
|
|
|
74
76
|
wsId,
|
|
75
77
|
templateId,
|
|
76
78
|
templatesBasePath,
|
|
79
|
+
embedded = false,
|
|
77
80
|
initialTemplate,
|
|
78
81
|
initialBackgroundUrl,
|
|
82
|
+
onClose,
|
|
79
83
|
}: Props) {
|
|
80
84
|
const { data: template, error } = useQuery({
|
|
81
85
|
queryKey: ['workspace-template', wsId, templateId],
|
|
@@ -112,6 +116,8 @@ export default function TaskTemplateDetailPageClient({
|
|
|
112
116
|
return (
|
|
113
117
|
<div>
|
|
114
118
|
<TemplateDetailClient
|
|
119
|
+
embedded={embedded}
|
|
120
|
+
onClose={onClose}
|
|
115
121
|
wsId={wsId}
|
|
116
122
|
template={mapTemplateToDetail(template, backgroundUrl ?? null)}
|
|
117
123
|
templatesBasePath={templatesBasePath}
|
|
@@ -8,7 +8,10 @@ import type { ReactNode } from 'react';
|
|
|
8
8
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
9
9
|
import {
|
|
10
10
|
__resetNotificationSubscriptionRegistryForTests,
|
|
11
|
+
UNREAD_COUNT_FALLBACK_INTERVAL_MS,
|
|
12
|
+
useInfiniteNotifications,
|
|
11
13
|
useNotificationSubscription,
|
|
14
|
+
useUnreadCount,
|
|
12
15
|
} from '../use-notifications';
|
|
13
16
|
|
|
14
17
|
type RealtimePayload = {
|
|
@@ -94,6 +97,37 @@ describe('useNotificationSubscription', () => {
|
|
|
94
97
|
|
|
95
98
|
afterEach(() => {
|
|
96
99
|
__resetNotificationSubscriptionRegistryForTests();
|
|
100
|
+
vi.unstubAllGlobals();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('defers notification list requests until the consumer enables them', () => {
|
|
104
|
+
const fetchMock = vi.fn();
|
|
105
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
106
|
+
const queryClient = createQueryClient();
|
|
107
|
+
|
|
108
|
+
renderHook(
|
|
109
|
+
() =>
|
|
110
|
+
useInfiniteNotifications({
|
|
111
|
+
enabled: false,
|
|
112
|
+
unreadOnly: true,
|
|
113
|
+
}),
|
|
114
|
+
{ wrapper: createWrapper(queryClient) }
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('uses realtime-first unread counts with a low-frequency fallback', () => {
|
|
121
|
+
const fetchMock = vi.fn();
|
|
122
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
123
|
+
const queryClient = createQueryClient();
|
|
124
|
+
|
|
125
|
+
renderHook(() => useUnreadCount(undefined, { enabled: false }), {
|
|
126
|
+
wrapper: createWrapper(queryClient),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
130
|
+
expect(UNREAD_COUNT_FALLBACK_INTERVAL_MS).toBe(5 * 60 * 1000);
|
|
97
131
|
});
|
|
98
132
|
|
|
99
133
|
it('shares one realtime channel across multiple consumers for the same user', async () => {
|
|
@@ -286,12 +286,9 @@ describe('useTaskActions', () => {
|
|
|
286
286
|
});
|
|
287
287
|
});
|
|
288
288
|
|
|
289
|
-
it('moves an external task to
|
|
289
|
+
it('moves an external task to done through terminal personal placement when checked', async () => {
|
|
290
290
|
const completedAt = '2026-05-07T03:00:00.000Z';
|
|
291
291
|
queryClient.setQueryData(['tasks', 'board-1'], [mockExternalTask]);
|
|
292
|
-
mockListWorkspaceTaskLists.mockResolvedValueOnce({
|
|
293
|
-
lists: mockSourceLists,
|
|
294
|
-
});
|
|
295
292
|
mockUpsertCurrentUserTaskPersonalPlacement.mockResolvedValueOnce({
|
|
296
293
|
task: {
|
|
297
294
|
...mockExternalTask,
|
|
@@ -299,14 +296,10 @@ describe('useTaskActions', () => {
|
|
|
299
296
|
personal_list_id: 'completion-list',
|
|
300
297
|
personal_sort_key: 1_500_000,
|
|
301
298
|
sort_key: 1_500_000,
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
mockUpdateWorkspaceTask.mockResolvedValueOnce({
|
|
305
|
-
task: {
|
|
306
|
-
...mockExternalTask,
|
|
307
|
-
list_id: 'source-done-list',
|
|
299
|
+
source_list_id: 'source-done-list',
|
|
300
|
+
source_list_status: 'done',
|
|
308
301
|
completed_at: completedAt,
|
|
309
|
-
closed_at:
|
|
302
|
+
closed_at: completedAt,
|
|
310
303
|
},
|
|
311
304
|
});
|
|
312
305
|
|
|
@@ -329,10 +322,7 @@ describe('useTaskActions', () => {
|
|
|
329
322
|
await result.current.handleArchiveToggle();
|
|
330
323
|
});
|
|
331
324
|
|
|
332
|
-
expect(mockListWorkspaceTaskLists).
|
|
333
|
-
'source-ws',
|
|
334
|
-
'source-board'
|
|
335
|
-
);
|
|
325
|
+
expect(mockListWorkspaceTaskLists).not.toHaveBeenCalled();
|
|
336
326
|
expect(mockUpsertCurrentUserTaskPersonalPlacement).toHaveBeenCalledWith(
|
|
337
327
|
'task-1',
|
|
338
328
|
expect.objectContaining({
|
|
@@ -340,15 +330,10 @@ describe('useTaskActions', () => {
|
|
|
340
330
|
personal_list_id: 'completion-list',
|
|
341
331
|
previous_task_id: null,
|
|
342
332
|
next_task_id: null,
|
|
333
|
+
terminal_status: 'done',
|
|
343
334
|
})
|
|
344
335
|
);
|
|
345
|
-
expect(mockUpdateWorkspaceTask).
|
|
346
|
-
'source-ws',
|
|
347
|
-
'task-1',
|
|
348
|
-
{
|
|
349
|
-
list_id: 'source-done-list',
|
|
350
|
-
}
|
|
351
|
-
);
|
|
336
|
+
expect(mockUpdateWorkspaceTask).not.toHaveBeenCalled();
|
|
352
337
|
expect(queryClient.getQueryData<Task[]>(['tasks', 'board-1'])).toEqual([
|
|
353
338
|
expect.objectContaining({
|
|
354
339
|
id: 'task-1',
|
|
@@ -356,6 +341,7 @@ describe('useTaskActions', () => {
|
|
|
356
341
|
personal_list_id: 'completion-list',
|
|
357
342
|
source_list_id: 'source-done-list',
|
|
358
343
|
completed_at: completedAt,
|
|
344
|
+
closed_at: completedAt,
|
|
359
345
|
_localMutationAt: expect.any(Number),
|
|
360
346
|
}),
|
|
361
347
|
]);
|
|
@@ -733,12 +719,9 @@ describe('useTaskActions', () => {
|
|
|
733
719
|
expect(setMenuOpen).toHaveBeenCalledWith(false);
|
|
734
720
|
});
|
|
735
721
|
|
|
736
|
-
it('moves an external task to
|
|
722
|
+
it('moves an external task to closed through terminal personal placement', async () => {
|
|
737
723
|
const closedAt = '2026-05-07T03:30:00.000Z';
|
|
738
724
|
queryClient.setQueryData(['tasks', 'board-1'], [mockExternalTask]);
|
|
739
|
-
mockListWorkspaceTaskLists.mockResolvedValueOnce({
|
|
740
|
-
lists: mockSourceLists,
|
|
741
|
-
});
|
|
742
725
|
mockUpsertCurrentUserTaskPersonalPlacement.mockResolvedValueOnce({
|
|
743
726
|
task: {
|
|
744
727
|
...mockExternalTask,
|
|
@@ -746,12 +729,8 @@ describe('useTaskActions', () => {
|
|
|
746
729
|
personal_list_id: 'closed-list',
|
|
747
730
|
personal_sort_key: 1_700_000,
|
|
748
731
|
sort_key: 1_700_000,
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
mockUpdateWorkspaceTask.mockResolvedValueOnce({
|
|
752
|
-
task: {
|
|
753
|
-
...mockExternalTask,
|
|
754
|
-
list_id: 'source-closed-list',
|
|
732
|
+
source_list_id: 'source-closed-list',
|
|
733
|
+
source_list_status: 'closed',
|
|
755
734
|
completed_at: null,
|
|
756
735
|
closed_at: closedAt,
|
|
757
736
|
},
|
|
@@ -781,15 +760,11 @@ describe('useTaskActions', () => {
|
|
|
781
760
|
expect.objectContaining({
|
|
782
761
|
personal_board_id: 'board-1',
|
|
783
762
|
personal_list_id: 'closed-list',
|
|
763
|
+
terminal_status: 'closed',
|
|
784
764
|
})
|
|
785
765
|
);
|
|
786
|
-
expect(
|
|
787
|
-
|
|
788
|
-
'task-1',
|
|
789
|
-
{
|
|
790
|
-
list_id: 'source-closed-list',
|
|
791
|
-
}
|
|
792
|
-
);
|
|
766
|
+
expect(mockListWorkspaceTaskLists).not.toHaveBeenCalled();
|
|
767
|
+
expect(mockUpdateWorkspaceTask).not.toHaveBeenCalled();
|
|
793
768
|
expect(queryClient.getQueryData<Task[]>(['tasks', 'board-1'])).toEqual([
|
|
794
769
|
expect.objectContaining({
|
|
795
770
|
id: 'task-1',
|