@tuturuuu/ui 0.14.0 → 0.17.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.
Files changed (101) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/biome.json +1 -1
  3. package/package.json +63 -62
  4. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +12 -0
  5. package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +93 -6
  6. package/src/components/ui/custom/loading-statistic-card.tsx +18 -0
  7. package/src/components/ui/custom/notification-popover-client.tsx +5 -1
  8. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.test.tsx +34 -0
  9. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +106 -0
  10. package/src/components/ui/custom/settings/sidebar-settings.tsx +18 -6
  11. package/src/components/ui/custom/settings-dialog-shell.tsx +18 -12
  12. package/src/components/ui/custom/sidebar-context.tsx +58 -3
  13. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +5 -3
  14. package/src/components/ui/custom/statistic-card.tsx +104 -0
  15. package/src/components/ui/custom/structure.test.tsx +29 -0
  16. package/src/components/ui/custom/structure.tsx +103 -88
  17. package/src/components/ui/finance/invoices/civil-month.ts +224 -0
  18. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.test.tsx +49 -0
  19. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.tsx +81 -0
  20. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  21. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +90 -2
  22. package/src/components/ui/finance/invoices/new-invoice-page.tsx +40 -4
  23. package/src/components/ui/finance/invoices/pending-columns.test.ts +26 -0
  24. package/src/components/ui/finance/invoices/pending-columns.tsx +45 -14
  25. package/src/components/ui/finance/invoices/standard-invoice.test.ts +31 -0
  26. package/src/components/ui/finance/invoices/standard-invoice.tsx +33 -16
  27. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +331 -0
  28. package/src/components/ui/finance/invoices/subscription-invoice.tsx +53 -28
  29. package/src/components/ui/finance/invoices/utils.test.ts +84 -0
  30. package/src/components/ui/finance/invoices/utils.ts +56 -134
  31. package/src/components/ui/resizable.test.ts +15 -0
  32. package/src/components/ui/resizable.tsx +28 -6
  33. package/src/components/ui/storefront/browse-panel.tsx +161 -0
  34. package/src/components/ui/storefront/bundle-selection-dialog.tsx +1 -1
  35. package/src/components/ui/storefront/cart-popover.tsx +1 -1
  36. package/src/components/ui/storefront/hero-panel.tsx +35 -30
  37. package/src/components/ui/storefront/listing-card.tsx +10 -5
  38. package/src/components/ui/storefront/merch-sections.tsx +7 -5
  39. package/src/components/ui/storefront/product-detail.tsx +79 -6
  40. package/src/components/ui/storefront/product-dialog.tsx +5 -1
  41. package/src/components/ui/storefront/storefront-surface.test.tsx +72 -0
  42. package/src/components/ui/storefront/storefront-surface.tsx +67 -63
  43. package/src/components/ui/storefront/types.ts +28 -0
  44. package/src/components/ui/storefront/utils.ts +4 -5
  45. package/src/components/ui/tu-do/boards/boardId/board-column-external-retry.test.tsx +59 -1
  46. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +37 -8
  47. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +62 -0
  48. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +48 -0
  49. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +32 -2
  50. package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +41 -3
  51. package/src/components/ui/tu-do/boards/boardId/menus/task-due-date-menu.tsx +31 -10
  52. package/src/components/ui/tu-do/boards/boardId/menus/task-priority-menu.tsx +7 -5
  53. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +7 -5
  54. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.test.ts +44 -0
  55. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.ts +17 -0
  56. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +2 -0
  57. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +7 -0
  58. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +44 -30
  59. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +54 -0
  60. package/src/components/ui/tu-do/my-tasks/task-list-with-completion.tsx +43 -1
  61. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +33 -15
  62. package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +3 -3
  63. package/src/components/ui/tu-do/progress/task-progress-page.tsx +60 -26
  64. package/src/components/ui/tu-do/progress/task-progress-panels.tsx +12 -10
  65. package/src/components/ui/tu-do/projects/components/project-grid-card.tsx +22 -6
  66. package/src/components/ui/tu-do/projects/components/project-list-item.tsx +24 -8
  67. package/src/components/ui/tu-do/projects/projectId/components/__tests__/tasks-tab-layout.test.tsx +6 -0
  68. package/src/components/ui/tu-do/projects/projectId/components/tasks-tab.tsx +3 -1
  69. package/src/components/ui/tu-do/projects/projectId/task-project-detail-page-client.tsx +11 -3
  70. package/src/components/ui/tu-do/projects/projectId/task-project-detail.tsx +2 -0
  71. package/src/components/ui/tu-do/projects/projectId/types.ts +1 -0
  72. package/src/components/ui/tu-do/projects/task-projects-client.tsx +45 -1
  73. package/src/components/ui/tu-do/projects/types.ts +4 -1
  74. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +10 -0
  75. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +7 -1
  76. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +87 -2
  77. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +27 -3
  78. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +41 -3
  79. package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +54 -0
  80. package/src/components/ui/tu-do/shared/board-header.tsx +20 -0
  81. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
  82. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +2 -0
  83. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +7 -0
  84. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-task-mutations.test.tsx +231 -0
  85. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +139 -68
  86. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.test.tsx +68 -0
  87. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +17 -0
  88. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +45 -23
  89. package/src/components/ui/tu-do/shared/task-row-actions-menu.test.tsx +138 -0
  90. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +15 -9
  91. package/src/components/ui/tu-do/templates/client.tsx +97 -78
  92. package/src/components/ui/tu-do/templates/marketplace/client.tsx +171 -135
  93. package/src/components/ui/tu-do/templates/task-templates-hub.tsx +3 -0
  94. package/src/components/ui/tu-do/templates/templateId/client.tsx +24 -9
  95. package/src/components/ui/tu-do/templates/templateId/task-template-detail-page-client.tsx +8 -2
  96. package/src/globals.css +1 -0
  97. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -0
  98. package/src/hooks/__tests__/use-task-actions.test.tsx +14 -39
  99. package/src/hooks/task-actions-personal-external.ts +12 -6
  100. package/src/hooks/use-notifications.ts +14 -2
  101. package/vitest.config.ts +4 -0
@@ -15,6 +15,7 @@ import {
15
15
  DialogHeader,
16
16
  DialogTitle,
17
17
  } from '../dialog';
18
+ import { StorefrontBrowsePanel } from './browse-panel';
18
19
  import { StorefrontBundleSelectionDialog } from './bundle-selection-dialog';
19
20
  import { StorefrontCartPopover } from './cart-popover';
20
21
  import { StorefrontCartSummary } from './cart-summary';
@@ -173,7 +174,6 @@ export function StorefrontSurface({
173
174
  const isCheckoutDialogOpen = checkoutOpen ?? isCheckout;
174
175
  const isPreview = mode === 'preview';
175
176
  const isCartPage = mode === 'cart';
176
- const listingRows = visibleListings;
177
177
  const bundleSelectionListing = bundleSelectionListingId
178
178
  ? listings.find((listing) => listing.id === bundleSelectionListingId)
179
179
  : null;
@@ -256,27 +256,36 @@ export function StorefrontSurface({
256
256
  </div>
257
257
  ) : null}
258
258
 
259
- <header className="sticky top-0 z-30 border-border border-b bg-background/80 backdrop-blur-md supports-[backdrop-filter]:bg-background/65">
260
- <div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-3 px-4 py-3">
259
+ <header className="sticky top-0 z-30 border-border border-b bg-background/95 backdrop-blur-md supports-[backdrop-filter]:bg-background/85">
260
+ <div className="mx-auto flex max-w-7xl items-center justify-between gap-4 px-5 py-3 sm:px-6">
261
261
  <div className="min-w-0">
262
- <h1 className="truncate font-semibold text-xl">
262
+ <h1 className="truncate font-semibold text-base tracking-tight">
263
263
  {storefrontHref ? (
264
264
  <a
265
- className="block truncate rounded-sm transition hover:text-[var(--storefront-accent-text,var(--primary))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
265
+ className="flex min-w-0 items-center gap-3 rounded-sm transition hover:text-[var(--storefront-accent-text,var(--primary))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
266
266
  href={storefrontHref}
267
267
  title={storefront.name}
268
268
  >
269
- {storefront.name}
269
+ <span
270
+ aria-hidden
271
+ className="grid size-8 shrink-0 place-items-center rounded-lg border border-border bg-muted/35 font-mono text-xs uppercase"
272
+ >
273
+ {storefront.name.slice(0, 1)}
274
+ </span>
275
+ <span className="truncate">{storefront.name}</span>
270
276
  </a>
271
277
  ) : (
272
- storefront.name
278
+ <span className="flex min-w-0 items-center gap-3">
279
+ <span
280
+ aria-hidden
281
+ className="grid size-8 shrink-0 place-items-center rounded-lg border border-border bg-muted/35 font-mono text-xs uppercase"
282
+ >
283
+ {storefront.name.slice(0, 1)}
284
+ </span>
285
+ <span className="truncate">{storefront.name}</span>
286
+ </span>
273
287
  )}
274
288
  </h1>
275
- {storefront.description ? (
276
- <p className="mt-0.5 line-clamp-1 text-muted-foreground text-sm">
277
- {storefront.description}
278
- </p>
279
- ) : null}
280
289
  </div>
281
290
  <div className="flex items-center gap-2">
282
291
  {headerActions}
@@ -295,7 +304,7 @@ export function StorefrontSurface({
295
304
 
296
305
  <section
297
306
  className={cn(
298
- 'mx-auto max-w-7xl px-4 py-5',
307
+ 'mx-auto max-w-7xl px-5 py-8 sm:px-6 sm:py-10',
299
308
  compactLayout ? 'max-w-5xl' : null
300
309
  )}
301
310
  >
@@ -317,6 +326,7 @@ export function StorefrontSurface({
317
326
  <StorefrontProductDetail
318
327
  cartHref={cartHref}
319
328
  cartLines={cartLines}
329
+ checkoutMode={storefront.checkoutMode}
320
330
  currency={currency}
321
331
  isSubmitting={isSubmitting}
322
332
  labels={labels}
@@ -351,61 +361,54 @@ export function StorefrontSurface({
351
361
  sections={storefront.sections ?? []}
352
362
  />
353
363
 
354
- <div
355
- className={cn(
356
- 'mt-4 grid gap-4',
357
- compactLayout
358
- ? 'sm:grid-cols-2'
359
- : 'sm:grid-cols-2 xl:grid-cols-3'
360
- )}
361
- >
362
- {listingRows.length === 0 ? (
364
+ <StorefrontBrowsePanel
365
+ compactLayout={compactLayout}
366
+ emptyListings={
363
367
  <StorefrontEmptyListings
364
368
  action={emptyAction}
365
369
  labels={labels}
366
370
  radius={radius}
367
371
  />
368
- ) : (
369
- listingRows.map((listing) => {
370
- const listingBundle = listing.bundleId
371
- ? bundles.find(
372
- (bundle) => bundle.id === listing.bundleId
373
- )
374
- : undefined;
375
- const quantity = cartLines
376
- .filter((item) => item.listingId === listing.id)
377
- .reduce((sum, item) => sum + item.quantity, 0);
372
+ }
373
+ labels={labels}
374
+ listings={visibleListings}
375
+ renderListing={(listing) => {
376
+ const listingBundle = listing.bundleId
377
+ ? bundles.find((bundle) => bundle.id === listing.bundleId)
378
+ : undefined;
379
+ const quantity = cartLines
380
+ .filter((item) => item.listingId === listing.id)
381
+ .reduce((sum, item) => sum + item.quantity, 0);
378
382
 
379
- return (
380
- <StorefrontListingCard
381
- currency={currency}
382
- isList={false}
383
- key={listing.id}
384
- labels={labels}
385
- listing={listing}
386
- onDecrement={onDecrement}
387
- onConfigureBundle={
388
- listingBundle?.categoryComponents.length
389
- ? () => setBundleSelectionListingId(listing.id)
390
- : undefined
391
- }
392
- onIncrement={onIncrement}
393
- onOpenDetail={
394
- onDetailListingChange
395
- ? (id) => onDetailListingChange(id)
396
- : undefined
397
- }
398
- quantity={quantity}
399
- radius={radius}
400
- showInventoryBadges={storefront.showInventoryBadges}
401
- surfaceClassName={
402
- storefrontSurfaceClasses[storefront.surfaceStyle]
403
- }
404
- />
405
- );
406
- })
407
- )}
408
- </div>
383
+ return (
384
+ <StorefrontListingCard
385
+ currency={currency}
386
+ isList={false}
387
+ key={listing.id}
388
+ labels={labels}
389
+ listing={listing}
390
+ onDecrement={onDecrement}
391
+ onConfigureBundle={
392
+ listingBundle?.categoryComponents.length
393
+ ? () => setBundleSelectionListingId(listing.id)
394
+ : undefined
395
+ }
396
+ onIncrement={onIncrement}
397
+ onOpenDetail={
398
+ onDetailListingChange
399
+ ? (id) => onDetailListingChange(id)
400
+ : undefined
401
+ }
402
+ quantity={quantity}
403
+ radius={radius}
404
+ showInventoryBadges={storefront.showInventoryBadges}
405
+ surfaceClassName={
406
+ storefrontSurfaceClasses[storefront.surfaceStyle]
407
+ }
408
+ />
409
+ );
410
+ }}
411
+ />
409
412
  </>
410
413
  )}
411
414
  </div>
@@ -415,6 +418,7 @@ export function StorefrontSurface({
415
418
  <StorefrontProductDialog
416
419
  cartHref={cartHref}
417
420
  cartLines={cartLines}
421
+ checkoutMode={storefront.checkoutMode}
418
422
  currency={currency}
419
423
  isSubmitting={isSubmitting}
420
424
  labels={labels}
@@ -453,7 +457,7 @@ export function StorefrontSurface({
453
457
  onOpenChange={(open) => onCheckoutOpenChange?.(open)}
454
458
  open={isCheckoutDialogOpen}
455
459
  >
456
- <DialogContent className="grid max-h-[92dvh] max-w-[min(42rem,calc(100vw-1rem))] grid-rows-[auto_minmax(0,1fr)] gap-0 overflow-hidden border-border/60 p-0 shadow-2xl sm:rounded-2xl">
460
+ <DialogContent className="grid max-h-[92dvh] max-w-[min(42rem,calc(100vw-1rem))] grid-rows-[auto_minmax(0,1fr)] gap-0 overflow-hidden border-border p-0 shadow-sm sm:rounded-xl">
457
461
  <DialogHeader className="px-5 pt-5 pb-2 text-left sm:px-6">
458
462
  <DialogTitle>{labels.checkout}</DialogTitle>
459
463
  <DialogDescription>{labels.reservedCopy}</DialogDescription>
@@ -34,9 +34,11 @@ export type StorefrontSurfaceMode =
34
34
 
35
35
  export type StorefrontSurfaceLabels = {
36
36
  add: string;
37
+ allItems: string;
37
38
  available: string;
38
39
  browse: string;
39
40
  bundle: string;
41
+ bundles: string;
40
42
  bundleSelectionTitle: string;
41
43
  buyNow: string;
42
44
  cart: string;
@@ -44,6 +46,7 @@ export type StorefrontSurfaceLabels = {
44
46
  checkout: string;
45
47
  checkoutDisabled: string;
46
48
  checkoutDisabledBadge: string;
49
+ clearFilters: string;
47
50
  contactDetails: string;
48
51
  couponNote: string;
49
52
  demoBadge: string;
@@ -51,15 +54,20 @@ export type StorefrontSurfaceLabels = {
51
54
  fromPrice: string;
52
55
  instantCheckout: string;
53
56
  orderSummary: string;
57
+ onlineCheckout: string;
58
+ onlineCheckoutDescription: string;
54
59
  redirectingToCheckout: string;
55
60
  requiredItems: string;
56
61
  selectOptions: string;
57
62
  searchBundleItems: string;
63
+ searchStore: string;
58
64
  selectedItems: string;
59
65
  viewDetails: string;
60
66
  emptyListingsDescription: string;
61
67
  emptyListingsTitle: string;
62
68
  fallbackDescription: string;
69
+ noResultsDescription: string;
70
+ noResultsTitle: string;
63
71
  form: {
64
72
  email: string;
65
73
  name: string;
@@ -69,6 +77,7 @@ export type StorefrontSurfaceLabels = {
69
77
  privateStore: string;
70
78
  previewBadge: string;
71
79
  product: string;
80
+ products: string;
72
81
  publicStore: string;
73
82
  quantity: string;
74
83
  reserve: string;
@@ -76,14 +85,20 @@ export type StorefrontSurfaceLabels = {
76
85
  reservedCopy: string;
77
86
  simulatedBadge: string;
78
87
  soldOut: string;
88
+ squareTerminal: string;
89
+ squareTerminalDescription: string;
90
+ shopTitle: string;
79
91
  total: string;
92
+ visibleItems: string;
80
93
  };
81
94
 
82
95
  export const defaultStorefrontSurfaceLabels: StorefrontSurfaceLabels = {
83
96
  add: 'Add',
97
+ allItems: 'All',
84
98
  available: 'available',
85
99
  browse: 'Browse',
86
100
  bundle: 'Bundle',
101
+ bundles: 'Bundles',
87
102
  bundleSelectionTitle: 'Build bundle',
88
103
  buyNow: 'Buy now',
89
104
  cart: 'Cart',
@@ -91,6 +106,7 @@ export const defaultStorefrontSurfaceLabels: StorefrontSurfaceLabels = {
91
106
  checkout: 'Checkout',
92
107
  checkoutDisabled: 'Checkout is disabled in preview',
93
108
  checkoutDisabledBadge: 'Checkout disabled',
109
+ clearFilters: 'Clear filters',
94
110
  contactDetails: 'Contact details',
95
111
  couponNote: 'Have a coupon? You can apply it at checkout.',
96
112
  demoBadge: 'Demo',
@@ -98,16 +114,22 @@ export const defaultStorefrontSurfaceLabels: StorefrontSurfaceLabels = {
98
114
  fromPrice: 'From',
99
115
  instantCheckout: 'Instant checkout',
100
116
  orderSummary: 'Order summary',
117
+ onlineCheckout: 'Secure online checkout',
118
+ onlineCheckoutDescription:
119
+ 'Continue to Polar to complete payment securely online.',
101
120
  redirectingToCheckout: 'Taking you to secure checkout…',
102
121
  requiredItems: 'Select {count} items',
103
122
  selectOptions: 'Select options',
104
123
  searchBundleItems: 'Search items',
124
+ searchStore: 'Search this store',
105
125
  selectedItems: '{selected} of {required} selected',
106
126
  viewDetails: 'View details',
107
127
  emptyListingsDescription:
108
128
  'Publish a listing to make this storefront ready for buyers.',
109
129
  emptyListingsTitle: 'No listings yet',
110
130
  fallbackDescription: 'This listing is available for checkout.',
131
+ noResultsDescription: 'Try another search or show every item.',
132
+ noResultsTitle: 'No matching items',
111
133
  form: {
112
134
  email: 'Email',
113
135
  name: 'Name',
@@ -117,6 +139,7 @@ export const defaultStorefrontSurfaceLabels: StorefrontSurfaceLabels = {
117
139
  privateStore: 'Private',
118
140
  previewBadge: 'Preview',
119
141
  product: 'Product',
142
+ products: 'Products',
120
143
  publicStore: 'Public',
121
144
  quantity: 'Qty',
122
145
  reserve: 'Reserve with Polar',
@@ -125,7 +148,12 @@ export const defaultStorefrontSurfaceLabels: StorefrontSurfaceLabels = {
125
148
  'Review your cart, then continue with the available checkout mode.',
126
149
  simulatedBadge: 'Simulated checkout',
127
150
  soldOut: 'Sold out',
151
+ squareTerminal: 'Square Terminal',
152
+ squareTerminalDescription:
153
+ 'Complete payment in person on the paired terminal at the counter.',
154
+ shopTitle: 'Shop',
128
155
  total: 'Total',
156
+ visibleItems: '{count} items',
129
157
  };
130
158
 
131
159
  export function mergeStorefrontSurfaceLabels(
@@ -15,8 +15,8 @@ import type { StorefrontCartLine } from './types';
15
15
  // in the data model for backwards compatibility, but every value resolves to the
16
16
  // same refined look so the experience is consistent across all storefronts.
17
17
 
18
- /** One soft, modern corner radius for every surface. */
19
- export const STOREFRONT_RADIUS = 'rounded-2xl';
18
+ /** One restrained corner radius for every storefront surface. */
19
+ export const STOREFRONT_RADIUS = 'rounded-xl';
20
20
 
21
21
  export const storefrontRadiusClasses: Record<
22
22
  InventoryStorefront['cornerStyle'],
@@ -27,9 +27,8 @@ export const storefrontRadiusClasses: Record<
27
27
  soft: STOREFRONT_RADIUS,
28
28
  };
29
29
 
30
- /** One elevated card surface for every storefront. */
31
- export const STOREFRONT_SURFACE =
32
- 'border border-border/60 bg-card shadow-sm shadow-foreground/5';
30
+ /** One quiet, border-led card surface for every storefront. */
31
+ export const STOREFRONT_SURFACE = 'border border-border bg-card';
33
32
 
34
33
  export const storefrontSurfaceClasses: Record<
35
34
  InventoryStorefront['surfaceStyle'],
@@ -2,7 +2,14 @@
2
2
  * @vitest-environment jsdom
3
3
  */
4
4
 
5
- import { act, render, waitFor } from '@testing-library/react';
5
+ import {
6
+ act,
7
+ fireEvent,
8
+ render,
9
+ screen,
10
+ waitFor,
11
+ } from '@testing-library/react';
12
+ import type { Task } from '@tuturuuu/types/primitives/Task';
6
13
  import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
7
14
  import { beforeEach, describe, expect, it, vi } from 'vitest';
8
15
  import type { ListPaginationState } from '../../shared/progressive-loader-context';
@@ -82,6 +89,19 @@ const loadedExternalState: ListPaginationState = {
82
89
  totalCount: 0,
83
90
  };
84
91
 
92
+ const regularColumn: TaskList = {
93
+ ...externalColumn,
94
+ color: 'BLUE',
95
+ id: 'list-1',
96
+ is_external_staging: false,
97
+ name: 'In progress',
98
+ };
99
+
100
+ const regularTasks = [
101
+ { id: 'task-1', list_id: regularColumn.id, name: 'First task' },
102
+ { id: 'task-2', list_id: regularColumn.id, name: 'Second task' },
103
+ ] as Task[];
104
+
85
105
  function renderExternalColumn() {
86
106
  return (
87
107
  <BoardColumn
@@ -124,4 +144,42 @@ describe('BoardColumn external lane retry behavior', () => {
124
144
  externalSortBy: 'created-desc',
125
145
  });
126
146
  });
147
+
148
+ it('replaces the status icon with a stable select-all checkbox in bulk mode', () => {
149
+ mocks.pagination = {
150
+ [regularColumn.id]: {
151
+ ...loadedExternalState,
152
+ hasMore: false,
153
+ totalCount: regularTasks.length,
154
+ },
155
+ };
156
+ const onTaskSelect = vi.fn();
157
+
158
+ render(
159
+ <BoardColumn
160
+ boardId="board-1"
161
+ column={regularColumn}
162
+ isMultiSelectMode
163
+ onTaskSelect={onTaskSelect}
164
+ selectedTasks={new Set(['task-1'])}
165
+ setIsMultiSelectMode={vi.fn()}
166
+ tasks={regularTasks}
167
+ wsId="workspace-1"
168
+ />
169
+ );
170
+
171
+ const checkbox = screen.getByRole('checkbox', {
172
+ name: 'select_all_tasks',
173
+ });
174
+ expect(checkbox).toHaveAttribute('data-state', 'indeterminate');
175
+ expect(checkbox).toHaveClass('size-4');
176
+
177
+ fireEvent.click(checkbox);
178
+
179
+ expect(onTaskSelect).toHaveBeenCalledTimes(1);
180
+ expect(onTaskSelect).toHaveBeenCalledWith(
181
+ 'task-2',
182
+ expect.objectContaining({ shiftKey: false })
183
+ );
184
+ });
127
185
  });
@@ -22,6 +22,7 @@ import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
22
22
  import { Badge } from '@tuturuuu/ui/badge';
23
23
  import { Button } from '@tuturuuu/ui/button';
24
24
  import { Card } from '@tuturuuu/ui/card';
25
+ import { Checkbox } from '@tuturuuu/ui/checkbox';
25
26
  import {
26
27
  DropdownMenu,
27
28
  DropdownMenuCheckboxItem,
@@ -469,6 +470,14 @@ export function BoardColumn({
469
470
  ? Math.max(listState.totalCount, visibleTasks.length)
470
471
  : visibleTasks.length
471
472
  : visibleTasks.length;
473
+ const selectedVisibleTaskCount = visibleTasks.reduce(
474
+ (count, task) => count + (selectedTasks?.has(task.id) ? 1 : 0),
475
+ 0
476
+ );
477
+ const allVisibleTasksSelected =
478
+ visibleTasks.length > 0 && selectedVisibleTaskCount === visibleTasks.length;
479
+ const someVisibleTasksSelected =
480
+ selectedVisibleTaskCount > 0 && !allVisibleTasksSelected;
472
481
  const externalFilterCount =
473
482
  (externalIncludeDocuments ? 1 : 0) + (externalIncludeDoneClosed ? 1 : 0);
474
483
  const pinListLabel = specialPinned
@@ -496,13 +505,15 @@ export function BoardColumn({
496
505
  [attributes, listeners, isDragging, isOverlay, t]
497
506
  );
498
507
 
499
- const handleSelectAll = () => {
500
- if (!onTaskSelect || !setIsMultiSelectMode || tasks.length === 0) return;
508
+ const handleToggleAll = () => {
509
+ if (!onTaskSelect || !setIsMultiSelectMode || visibleTasks.length === 0)
510
+ return;
511
+
512
+ if (!allVisibleTasksSelected) setIsMultiSelectMode(true);
501
513
 
502
- setIsMultiSelectMode(true);
503
- // Select all tasks in this list
504
- tasks.forEach((task) => {
505
- if (selectedTasks?.has(task.id)) return;
514
+ visibleTasks.forEach((task) => {
515
+ const isSelected = selectedTasks?.has(task.id) ?? false;
516
+ if (allVisibleTasksSelected ? !isSelected : isSelected) return;
506
517
 
507
518
  const fakeEvent = {
508
519
  shiftKey: false,
@@ -606,7 +617,25 @@ export function BoardColumn({
606
617
  <div className="flex items-center gap-2 rounded-t-xl border-b p-3">
607
618
  {!readOnly && !isExternalStaging && DragHandle}
608
619
  <div className="flex flex-1 items-center gap-2">
609
- <span className="text-sm">{statusIcon}</span>
620
+ {isMultiSelectMode && !readOnly && visibleTasks.length > 0 ? (
621
+ <Checkbox
622
+ aria-label={t('select_all_tasks')}
623
+ checked={
624
+ allVisibleTasksSelected
625
+ ? true
626
+ : someVisibleTasksSelected
627
+ ? 'indeterminate'
628
+ : false
629
+ }
630
+ className="size-4"
631
+ onCheckedChange={handleToggleAll}
632
+ title={t('select_all_tasks')}
633
+ />
634
+ ) : (
635
+ <span className="inline-flex size-4 items-center justify-center text-sm">
636
+ {statusIcon}
637
+ </span>
638
+ )}
610
639
  <h3
611
640
  className={cn(
612
641
  'font-semibold text-foreground/90 text-sm',
@@ -830,7 +859,7 @@ export function BoardColumn({
830
859
  boardId={boardId}
831
860
  wsId={wsId}
832
861
  onUpdate={handleUpdate}
833
- onSelectAll={handleSelectAll}
862
+ onSelectAll={handleToggleAll}
834
863
  isEditOpen={isEditOpen}
835
864
  onEditOpenChange={setIsEditOpen}
836
865
  />
@@ -187,4 +187,66 @@ describe('bulk move mutations', () => {
187
187
  intensity: 1.2,
188
188
  });
189
189
  });
190
+
191
+ it('bulk-moves selected external tasks to done through terminal personal placement', async () => {
192
+ const doneList = {
193
+ ...targetList,
194
+ id: 'done-list',
195
+ name: 'Done',
196
+ status: 'done',
197
+ } as unknown as TaskList;
198
+
199
+ queryClient.setQueryData(['tasks', 'board-1'], [localTask, externalTask]);
200
+ mockUpsertCurrentUserTaskPersonalPlacement.mockResolvedValueOnce({
201
+ task: {
202
+ ...externalTask,
203
+ list_id: 'done-list',
204
+ personal_list_id: 'done-list',
205
+ source_list_id: 'source-done-list',
206
+ source_list_status: 'done',
207
+ },
208
+ });
209
+
210
+ const { result } = renderHook(
211
+ () =>
212
+ useBulkOperations({
213
+ queryClient,
214
+ wsId: 'personal-ws',
215
+ boardId: 'board-1',
216
+ selectedTasks: new Set(['local-task', 'external-task']),
217
+ columns: [sourceList, doneList],
218
+ workspaceLabels: [],
219
+ workspaceProjects: [],
220
+ workspaceMembers: [],
221
+ setBulkWorking: vi.fn(),
222
+ clearSelection: vi.fn(),
223
+ setBulkDeleteOpen: vi.fn(),
224
+ }),
225
+ { wrapper }
226
+ );
227
+
228
+ await act(async () => {
229
+ await result.current.bulkMoveToList('done-list', 'Done');
230
+ });
231
+
232
+ expect(mockBulkWorkspaceTasks).toHaveBeenCalledWith(
233
+ 'personal-ws',
234
+ {
235
+ taskIds: ['local-task'],
236
+ operation: {
237
+ type: 'move_to_list',
238
+ listId: 'done-list',
239
+ },
240
+ },
241
+ expect.anything()
242
+ );
243
+ expect(mockUpsertCurrentUserTaskPersonalPlacement).toHaveBeenCalledWith(
244
+ 'external-task',
245
+ expect.objectContaining({
246
+ personal_board_id: 'board-1',
247
+ personal_list_id: 'done-list',
248
+ terminal_status: 'done',
249
+ })
250
+ );
251
+ });
190
252
  });
@@ -20,6 +20,7 @@ vi.mock('@tuturuuu/utils/task-helper', () => {
20
20
 
21
21
  import {
22
22
  applyTaskDropPreviewToCache,
23
+ getPersonalPlacementTargetBoardId,
23
24
  getProjectedTaskDropOrderFromPreview,
24
25
  getTaskDropEndPreviewFromRects,
25
26
  getTaskDropPositionFromRects,
@@ -178,6 +179,53 @@ describe('usesPersonalPlacement', () => {
178
179
  });
179
180
  });
180
181
 
182
+ describe('getPersonalPlacementTargetBoardId', () => {
183
+ it('uses the target list board id for real-list moves', () => {
184
+ expect(
185
+ getPersonalPlacementTargetBoardId({
186
+ boardId: 'fallback-board',
187
+ columns: [
188
+ createList({
189
+ board_id: 'canonical-board',
190
+ id: 'target-list',
191
+ } as Partial<TaskList>),
192
+ ],
193
+ targetListId: 'target-list',
194
+ })
195
+ ).toBe('canonical-board');
196
+ });
197
+
198
+ it('keeps staging board ids from virtual staging lanes', () => {
199
+ expect(
200
+ getPersonalPlacementTargetBoardId({
201
+ boardId: 'fallback-board',
202
+ columns: [
203
+ createList({
204
+ board_id: 'canonical-board',
205
+ id: 'target-list',
206
+ } as Partial<TaskList>),
207
+ ],
208
+ targetListId: 'personal-external-staging:staging-board',
209
+ })
210
+ ).toBe('staging-board');
211
+ });
212
+
213
+ it('falls back to the current board id when list metadata is absent', () => {
214
+ expect(
215
+ getPersonalPlacementTargetBoardId({
216
+ boardId: 'fallback-board',
217
+ columns: [
218
+ createList({
219
+ board_id: 'other-board',
220
+ id: 'other-list',
221
+ } as Partial<TaskList>),
222
+ ],
223
+ targetListId: 'target-list',
224
+ })
225
+ ).toBe('fallback-board');
226
+ });
227
+ });
228
+
181
229
  describe('mergeTaskIntoBoardTaskCache', () => {
182
230
  it('updates an existing task in the board cache', () => {
183
231
  const existingTask = createTask({ list_id: 'source-list' });