@tuturuuu/ui 0.11.0 → 0.12.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.
@@ -1,9 +1,10 @@
1
1
  import { fireEvent, render, screen } from '@testing-library/react';
2
2
  import type {
3
+ InventoryBundle,
3
4
  InventoryStorefront,
4
5
  InventoryStorefrontListing,
5
6
  } from '@tuturuuu/internal-api/inventory';
6
- import { describe, expect, it } from 'vitest';
7
+ import { describe, expect, it, vi } from 'vitest';
7
8
  import { StorefrontSurface } from './storefront-surface';
8
9
  import { formatStorefrontPrice, sanitizeStorefrontAccentColor } from './utils';
9
10
 
@@ -65,6 +66,95 @@ const secondListing: InventoryStorefrontListing = {
65
66
  unitId: 'unit-2',
66
67
  };
67
68
 
69
+ const categoryBundle: InventoryBundle = {
70
+ availableQuantity: 9,
71
+ categoryCandidateScope: 'all_stock',
72
+ categoryComponents: [
73
+ {
74
+ bundleId: 'bundle-keychains',
75
+ candidates: [
76
+ {
77
+ availableQuantity: 5,
78
+ componentId: 'component-keychains',
79
+ listingId: 'listing-keychain-a',
80
+ price: 1200,
81
+ productId: 'product-keychain-a',
82
+ selectionKind: 'listing',
83
+ title: 'Acrylic Keychain',
84
+ unitId: 'unit-1',
85
+ unitName: 'Each',
86
+ variantId: null,
87
+ warehouseId: 'warehouse-1',
88
+ warehouseName: 'Main',
89
+ },
90
+ {
91
+ availableQuantity: 5,
92
+ componentId: 'component-keychains',
93
+ listingId: 'listing-keychain-b',
94
+ price: 900,
95
+ productId: 'product-keychain-b',
96
+ selectionKind: 'listing',
97
+ title: 'Metal Keychain',
98
+ unitId: 'unit-1',
99
+ unitName: 'Each',
100
+ variantId: null,
101
+ warehouseId: 'warehouse-1',
102
+ warehouseName: 'Main',
103
+ },
104
+ {
105
+ availableQuantity: 5,
106
+ componentId: 'component-keychains',
107
+ listingId: 'listing-keychain-c',
108
+ price: 1500,
109
+ productId: 'product-keychain-c',
110
+ selectionKind: 'listing',
111
+ title: 'Charm Keychain',
112
+ unitId: 'unit-1',
113
+ unitName: 'Each',
114
+ variantId: null,
115
+ warehouseId: 'warehouse-1',
116
+ warehouseName: 'Main',
117
+ },
118
+ ],
119
+ categoryId: 'category-keychains',
120
+ categoryName: 'Keychains',
121
+ discountStrategy: 'cheapest_free',
122
+ freeQuantity: 1,
123
+ id: 'component-keychains',
124
+ quantityRequired: 3,
125
+ sortOrder: 0,
126
+ },
127
+ ],
128
+ components: [],
129
+ createdAt: '2026-07-03T00:00:00.000Z',
130
+ description: 'Choose any three keychains.',
131
+ id: 'bundle-keychains',
132
+ imageUrl: null,
133
+ maxPerOrder: 4,
134
+ name: 'Buy 2 Get 1 Keychains',
135
+ price: 0,
136
+ pricingMode: 'selected_items',
137
+ slug: 'buy-2-get-1-keychains',
138
+ status: 'active',
139
+ storefrontId: storefront.id,
140
+ updatedAt: '2026-07-03T00:00:00.000Z',
141
+ wsId: storefront.wsId,
142
+ };
143
+
144
+ const bundleListing: InventoryStorefrontListing = {
145
+ ...listing,
146
+ bundleId: categoryBundle.id,
147
+ id: 'listing-keychain-bundle',
148
+ listingType: 'bundle',
149
+ price: 0,
150
+ productId: null,
151
+ title: 'Buy 2 Get 1 Keychains',
152
+ unitId: null,
153
+ unitName: null,
154
+ warehouseId: null,
155
+ warehouseName: null,
156
+ };
157
+
68
158
  describe('StorefrontSurface', () => {
69
159
  it('sanitizes hex accent colors only', () => {
70
160
  expect(sanitizeStorefrontAccentColor('#abc')).toBe('#aabbcc');
@@ -143,6 +233,49 @@ describe('StorefrontSurface', () => {
143
233
  expect(screen.getByRole('button', { name: /Checkout/ })).toBeEnabled();
144
234
  });
145
235
 
236
+ it('opens category bundle selection and adds the configured selection to cart', () => {
237
+ const onAddCartLine = vi.fn();
238
+
239
+ render(
240
+ <StorefrontSurface
241
+ bundles={[categoryBundle]}
242
+ listings={[bundleListing]}
243
+ mode="store"
244
+ onAddCartLine={onAddCartLine}
245
+ storefront={storefront}
246
+ />
247
+ );
248
+
249
+ fireEvent.click(screen.getByRole('button', { name: /Select options/ }));
250
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
251
+ expect(screen.getByText('0 of 3 selected')).toBeInTheDocument();
252
+ expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled();
253
+
254
+ fireEvent.click(screen.getByRole('button', { name: /Acrylic Keychain/ }));
255
+ fireEvent.click(screen.getByRole('button', { name: /Metal Keychain/ }));
256
+ fireEvent.click(screen.getByRole('button', { name: /Charm Keychain/ }));
257
+
258
+ expect(screen.getByText('3 of 3 selected')).toBeInTheDocument();
259
+ expect(screen.getByText('$27.00')).toBeInTheDocument();
260
+ fireEvent.click(screen.getByRole('button', { name: 'Add' }));
261
+
262
+ expect(onAddCartLine).toHaveBeenCalledWith(
263
+ expect.objectContaining({
264
+ bundleSelections: {
265
+ 'component-keychains': [
266
+ expect.objectContaining({ listingId: 'listing-keychain-a' }),
267
+ expect.objectContaining({ listingId: 'listing-keychain-b' }),
268
+ expect.objectContaining({ listingId: 'listing-keychain-c' }),
269
+ ],
270
+ },
271
+ listingId: bundleListing.id,
272
+ quantity: 1,
273
+ selectionKey: expect.stringContaining('component-keychains='),
274
+ }),
275
+ 5
276
+ );
277
+ });
278
+
146
279
  it('keeps long item names and large totals inside the cart row bounds', () => {
147
280
  const largeListing: InventoryStorefrontListing = {
148
281
  ...listing,
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { ArrowLeft } from '@tuturuuu/icons';
4
4
  import type {
5
+ InventoryBundle,
5
6
  InventoryStorefront,
6
7
  InventoryStorefrontListing,
7
8
  } from '@tuturuuu/internal-api/inventory';
@@ -14,6 +15,7 @@ import {
14
15
  DialogHeader,
15
16
  DialogTitle,
16
17
  } from '../dialog';
18
+ import { StorefrontBundleSelectionDialog } from './bundle-selection-dialog';
17
19
  import { StorefrontCartPopover } from './cart-popover';
18
20
  import { StorefrontCartSummary } from './cart-summary';
19
21
  import { StorefrontCheckoutOverlay } from './checkout-overlay';
@@ -32,7 +34,7 @@ import type {
32
34
  import { mergeStorefrontSurfaceLabels } from './types';
33
35
  import {
34
36
  getAccentStyle,
35
- getStorefrontLinePrice,
37
+ getStorefrontCartLineSubtotal,
36
38
  getStorefrontListingLimit,
37
39
  getStorefrontVariantLimit,
38
40
  sanitizeStorefrontAccentColor,
@@ -42,6 +44,7 @@ import {
42
44
  } from './utils';
43
45
 
44
46
  export function StorefrontSurface({
47
+ bundles = [],
45
48
  buyerDefaults,
46
49
  cartLines = [],
47
50
  cartHref,
@@ -65,12 +68,14 @@ export function StorefrontSurface({
65
68
  onCheckoutSubmit,
66
69
  onDecrement,
67
70
  onDetailListingChange,
71
+ onAddCartLine,
68
72
  onIncrement,
69
73
  onInstantCheckout,
70
74
  selectedListingId,
71
75
  storefront,
72
76
  storefrontHref,
73
77
  }: {
78
+ bundles?: InventoryBundle[];
74
79
  buyerDefaults?: StorefrontBuyerDefaults;
75
80
  cartLines?: StorefrontCartLine[];
76
81
  cartHref?: string;
@@ -93,6 +98,7 @@ export function StorefrontSurface({
93
98
  onCheckoutOpenChange?: (open: boolean) => void;
94
99
  onCheckoutSubmit?: (formData: FormData) => void;
95
100
  onDecrement?: (listingId: string, variantId?: string | null) => void;
101
+ onAddCartLine?: (line: StorefrontCartLine, maxQuantity?: number) => void;
96
102
  onDetailListingChange?: (listingId: string | null) => void;
97
103
  onIncrement?: (
98
104
  listingId: string,
@@ -106,6 +112,9 @@ export function StorefrontSurface({
106
112
  }) {
107
113
  const labels = mergeStorefrontSurfaceLabels(labelOverrides);
108
114
  const [isCartPopoverOpen, setIsCartPopoverOpen] = useState(false);
115
+ const [bundleSelectionListingId, setBundleSelectionListingId] = useState<
116
+ string | null
117
+ >(null);
109
118
  const accentColor = sanitizeStorefrontAccentColor(storefront.accentColor);
110
119
  const radius = storefrontRadiusClasses[storefront.cornerStyle];
111
120
  const resolveVariant = (
@@ -119,7 +128,14 @@ export function StorefrontSurface({
119
128
  const listing = listings.find((item) => item.id === line.listingId);
120
129
  if (!listing) return [];
121
130
  return [
122
- { line, listing, variant: resolveVariant(listing, line.variantId) },
131
+ {
132
+ bundle: listing.bundleId
133
+ ? bundles.find((bundle) => bundle.id === listing.bundleId)
134
+ : undefined,
135
+ line,
136
+ listing,
137
+ variant: resolveVariant(listing, line.variantId),
138
+ },
123
139
  ];
124
140
  });
125
141
  const lineLimit = (entry: (typeof cartEntries)[number]) =>
@@ -132,7 +148,13 @@ export function StorefrontSurface({
132
148
  const total = checkoutEntries.reduce((sum, entry) => {
133
149
  const quantity = Math.min(entry.line.quantity, lineLimit(entry));
134
150
  return (
135
- sum + getStorefrontLinePrice(entry.listing, entry.variant) * quantity
151
+ sum +
152
+ getStorefrontCartLineSubtotal({
153
+ bundle: entry.bundle,
154
+ line: { ...entry.line, quantity },
155
+ listing: entry.listing,
156
+ variant: entry.variant,
157
+ })
136
158
  );
137
159
  }, 0);
138
160
  const cartQuantity = cartLines.reduce((sum, line) => sum + line.quantity, 0);
@@ -152,6 +174,14 @@ export function StorefrontSurface({
152
174
  const isPreview = mode === 'preview';
153
175
  const isCartPage = mode === 'cart';
154
176
  const listingRows = visibleListings;
177
+ const bundleSelectionListing = bundleSelectionListingId
178
+ ? listings.find((listing) => listing.id === bundleSelectionListingId)
179
+ : null;
180
+ const bundleSelectionBundle = bundleSelectionListing?.bundleId
181
+ ? (bundles.find(
182
+ (bundle) => bundle.id === bundleSelectionListing.bundleId
183
+ ) ?? null)
184
+ : null;
155
185
  const currency = storefront.currency ?? 'USD';
156
186
  const handleCheckoutOpen = () => {
157
187
  setIsCartPopoverOpen(false);
@@ -337,9 +367,14 @@ export function StorefrontSurface({
337
367
  />
338
368
  ) : (
339
369
  listingRows.map((listing) => {
340
- const line = cartLines.find(
341
- (item) => item.listingId === listing.id
342
- );
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);
343
378
 
344
379
  return (
345
380
  <StorefrontListingCard
@@ -349,13 +384,18 @@ export function StorefrontSurface({
349
384
  labels={labels}
350
385
  listing={listing}
351
386
  onDecrement={onDecrement}
387
+ onConfigureBundle={
388
+ listingBundle?.categoryComponents.length
389
+ ? () => setBundleSelectionListingId(listing.id)
390
+ : undefined
391
+ }
352
392
  onIncrement={onIncrement}
353
393
  onOpenDetail={
354
394
  onDetailListingChange
355
395
  ? (id) => onDetailListingChange(id)
356
396
  : undefined
357
397
  }
358
- quantity={line?.quantity ?? 0}
398
+ quantity={quantity}
359
399
  radius={radius}
360
400
  showInventoryBadges={storefront.showInventoryBadges}
361
401
  surfaceClassName={
@@ -390,6 +430,25 @@ export function StorefrontSurface({
390
430
  surfaceClassName={storefrontSurfaceClasses[storefront.surfaceStyle]}
391
431
  />
392
432
 
433
+ <StorefrontBundleSelectionDialog
434
+ bundle={bundleSelectionBundle}
435
+ currency={currency}
436
+ labels={labels}
437
+ listing={bundleSelectionListing ?? null}
438
+ onAdd={(line) => {
439
+ if (!bundleSelectionListing) return;
440
+ onAddCartLine?.(
441
+ line,
442
+ getStorefrontListingLimit(bundleSelectionListing)
443
+ );
444
+ }}
445
+ onOpenChange={(open) => {
446
+ if (!open) setBundleSelectionListingId(null);
447
+ }}
448
+ open={Boolean(bundleSelectionListing && bundleSelectionBundle)}
449
+ radius={radius}
450
+ />
451
+
393
452
  <Dialog
394
453
  onOpenChange={(open) => onCheckoutOpenChange?.(open)}
395
454
  open={isCheckoutDialogOpen}
@@ -1,15 +1,20 @@
1
1
  import type {
2
+ InventoryBundle,
3
+ InventoryCheckoutBundleSelections,
2
4
  InventoryListingVariant,
3
5
  InventoryStorefrontListing,
4
6
  } from '@tuturuuu/internal-api/inventory';
5
7
 
6
8
  export type StorefrontCartLine = {
7
9
  listingId: string;
10
+ bundleSelections?: InventoryCheckoutBundleSelections;
11
+ selectionKey?: string | null;
8
12
  variantId?: string | null;
9
13
  quantity: number;
10
14
  };
11
15
 
12
16
  export type StorefrontCartEntry = {
17
+ bundle?: InventoryBundle;
13
18
  line: StorefrontCartLine;
14
19
  listing: InventoryStorefrontListing;
15
20
  variant?: InventoryListingVariant;
@@ -32,8 +37,10 @@ export type StorefrontSurfaceLabels = {
32
37
  available: string;
33
38
  browse: string;
34
39
  bundle: string;
40
+ bundleSelectionTitle: string;
35
41
  buyNow: string;
36
42
  cart: string;
43
+ cheapestFreePreview: string;
37
44
  checkout: string;
38
45
  checkoutDisabled: string;
39
46
  checkoutDisabledBadge: string;
@@ -45,7 +52,10 @@ export type StorefrontSurfaceLabels = {
45
52
  instantCheckout: string;
46
53
  orderSummary: string;
47
54
  redirectingToCheckout: string;
55
+ requiredItems: string;
48
56
  selectOptions: string;
57
+ searchBundleItems: string;
58
+ selectedItems: string;
49
59
  viewDetails: string;
50
60
  emptyListingsDescription: string;
51
61
  emptyListingsTitle: string;
@@ -74,8 +84,10 @@ export const defaultStorefrontSurfaceLabels: StorefrontSurfaceLabels = {
74
84
  available: 'available',
75
85
  browse: 'Browse',
76
86
  bundle: 'Bundle',
87
+ bundleSelectionTitle: 'Build bundle',
77
88
  buyNow: 'Buy now',
78
89
  cart: 'Cart',
90
+ cheapestFreePreview: 'Cheapest eligible item is free.',
79
91
  checkout: 'Checkout',
80
92
  checkoutDisabled: 'Checkout is disabled in preview',
81
93
  checkoutDisabledBadge: 'Checkout disabled',
@@ -87,7 +99,10 @@ export const defaultStorefrontSurfaceLabels: StorefrontSurfaceLabels = {
87
99
  instantCheckout: 'Instant checkout',
88
100
  orderSummary: 'Order summary',
89
101
  redirectingToCheckout: 'Taking you to secure checkout…',
102
+ requiredItems: 'Select {count} items',
90
103
  selectOptions: 'Select options',
104
+ searchBundleItems: 'Search items',
105
+ selectedItems: '{selected} of {required} selected',
91
106
  viewDetails: 'View details',
92
107
  emptyListingsDescription:
93
108
  'Publish a listing to make this storefront ready for buyers.',
@@ -1,10 +1,14 @@
1
1
  import type {
2
+ InventoryBundle,
3
+ InventoryBundleCategoryCandidate,
4
+ InventoryBundleCategoryComponent,
2
5
  InventoryListingVariant,
3
6
  InventoryStorefront,
4
7
  InventoryStorefrontListing,
5
8
  } from '@tuturuuu/internal-api/inventory';
6
9
  import { formatMoneyFromMinor } from '@tuturuuu/utils/money';
7
10
  import type { CSSProperties } from 'react';
11
+ import type { StorefrontCartLine } from './types';
8
12
 
9
13
  // The storefront now ships a single, unified design language. The merchant
10
14
  // preset fields (cornerStyle/surfaceStyle/themePreset/layoutStyle) are retained
@@ -130,6 +134,163 @@ export function getStorefrontLinePrice(
130
134
  return variant ? variant.price : listing.price;
131
135
  }
132
136
 
137
+ function getCategorySelectionItems(
138
+ line: StorefrontCartLine,
139
+ component: InventoryBundleCategoryComponent
140
+ ) {
141
+ const selections = line.bundleSelections;
142
+ if (!selections) return [];
143
+
144
+ if (Array.isArray(selections)) {
145
+ return (
146
+ selections.find((selection) => selection.componentId === component.id)
147
+ ?.items ?? []
148
+ );
149
+ }
150
+
151
+ return selections[component.id] ?? [];
152
+ }
153
+
154
+ function getCandidateKey(candidate: InventoryBundleCategoryCandidate) {
155
+ return [
156
+ candidate.selectionKind,
157
+ candidate.listingId ?? '',
158
+ candidate.variantId ?? '',
159
+ candidate.productId,
160
+ candidate.unitId,
161
+ candidate.warehouseId,
162
+ ].join(':');
163
+ }
164
+
165
+ function resolveBundleCandidate(
166
+ component: InventoryBundleCategoryComponent,
167
+ item: ReturnType<typeof getCategorySelectionItems>[number]
168
+ ) {
169
+ return component.candidates?.find((candidate) => {
170
+ if (item.variantId) {
171
+ return (
172
+ candidate.variantId === item.variantId &&
173
+ candidate.listingId === item.listingId
174
+ );
175
+ }
176
+ if (item.listingId) {
177
+ return candidate.listingId === item.listingId && !candidate.variantId;
178
+ }
179
+ return (
180
+ candidate.productId === item.productId &&
181
+ candidate.unitId === item.unitId &&
182
+ candidate.warehouseId === item.warehouseId
183
+ );
184
+ });
185
+ }
186
+
187
+ export function getStorefrontBundleSelectionSubtotal(
188
+ bundle: InventoryBundle | undefined,
189
+ line: StorefrontCartLine
190
+ ) {
191
+ if (!bundle?.categoryComponents?.length || !line.bundleSelections) {
192
+ return null;
193
+ }
194
+
195
+ let subtotal = 0;
196
+ for (const component of bundle.categoryComponents) {
197
+ const pricedItems = getCategorySelectionItems(line, component).map(
198
+ (item) => {
199
+ const candidate = resolveBundleCandidate(component, item);
200
+ return candidate
201
+ ? {
202
+ candidate,
203
+ quantity: item.quantity ?? 1,
204
+ }
205
+ : null;
206
+ }
207
+ );
208
+
209
+ if (pricedItems.some((item) => !item)) return null;
210
+
211
+ const validPricedItems = pricedItems.filter(
212
+ (
213
+ item
214
+ ): item is {
215
+ candidate: InventoryBundleCategoryCandidate;
216
+ quantity: number;
217
+ } => Boolean(item)
218
+ );
219
+
220
+ let freeRemaining =
221
+ component.discountStrategy === 'cheapest_free'
222
+ ? component.freeQuantity * line.quantity
223
+ : 0;
224
+
225
+ for (const pricedItem of validPricedItems.sort(
226
+ (a, b) => a.candidate.price - b.candidate.price
227
+ )) {
228
+ const units = pricedItem.quantity * line.quantity;
229
+ const freeUnits = Math.min(units, freeRemaining);
230
+ freeRemaining -= freeUnits;
231
+ subtotal += (units - freeUnits) * pricedItem.candidate.price;
232
+ }
233
+ }
234
+
235
+ return subtotal;
236
+ }
237
+
238
+ export function getStorefrontBundleSelectionLabels(
239
+ bundle: InventoryBundle | undefined,
240
+ line: StorefrontCartLine
241
+ ) {
242
+ if (!bundle?.categoryComponents?.length || !line.bundleSelections) return [];
243
+
244
+ return bundle.categoryComponents.flatMap((component) =>
245
+ getCategorySelectionItems(line, component).flatMap((item) => {
246
+ const candidate = resolveBundleCandidate(component, item);
247
+ if (!candidate) return [];
248
+ const quantity = item.quantity ?? 1;
249
+ return quantity > 1 ? `${quantity}x ${candidate.title}` : candidate.title;
250
+ })
251
+ );
252
+ }
253
+
254
+ export function getStorefrontCartLineSubtotal({
255
+ bundle,
256
+ line,
257
+ listing,
258
+ variant,
259
+ }: {
260
+ bundle?: InventoryBundle;
261
+ line: StorefrontCartLine;
262
+ listing: InventoryStorefrontListing;
263
+ variant?: InventoryListingVariant | null;
264
+ }) {
265
+ const selectedSubtotal = getStorefrontBundleSelectionSubtotal(bundle, line);
266
+ if (selectedSubtotal != null) return selectedSubtotal;
267
+
268
+ return getStorefrontLinePrice(listing, variant) * line.quantity;
269
+ }
270
+
271
+ export function createStorefrontBundleSelectionKey(
272
+ bundle: InventoryBundle,
273
+ selections: NonNullable<StorefrontCartLine['bundleSelections']>
274
+ ) {
275
+ const chunks = bundle.categoryComponents.map((component) => {
276
+ const items = Array.isArray(selections)
277
+ ? (selections.find((selection) => selection.componentId === component.id)
278
+ ?.items ?? [])
279
+ : (selections[component.id] ?? []);
280
+ const keys = items
281
+ .map((item) => {
282
+ const candidate = resolveBundleCandidate(component, item);
283
+ return candidate
284
+ ? `${getCandidateKey(candidate)}@${item.quantity ?? 1}`
285
+ : JSON.stringify(item);
286
+ })
287
+ .sort();
288
+ return `${component.id}=${keys.join(',')}`;
289
+ });
290
+
291
+ return chunks.join('|');
292
+ }
293
+
133
294
  /** Lowest active-variant price, for a "from {price}" label on variant listings. */
134
295
  export function getStorefrontListingFromPrice(
135
296
  listing: InventoryStorefrontListing
@@ -145,9 +306,10 @@ export function getStorefrontListingFromPrice(
145
306
  /** Stable identity for a cart line so listing+variant combos stay distinct. */
146
307
  export function storefrontCartLineKey(
147
308
  listingId: string,
148
- variantId?: string | null
309
+ variantId?: string | null,
310
+ selectionKey?: string | null
149
311
  ) {
150
- return `${listingId}::${variantId ?? ''}`;
312
+ return `${listingId}::${variantId ?? ''}::${selectionKey ?? ''}`;
151
313
  }
152
314
 
153
315
  /** Composes a human label for the selected variant from its option values. */
@@ -1,5 +1,6 @@
1
1
  import { generateHTML, generateJSON } from '@tiptap/core';
2
2
  import type SupabaseProvider from '@tuturuuu/ui/hooks/supabase-provider';
3
+ import { parseTaskDescriptionInput } from '@tuturuuu/utils/task-description-codec';
3
4
  import { describe, expect, it } from 'vitest';
4
5
  import * as Y from 'yjs';
5
6
  import { getEditorExtensions } from '../extensions';
@@ -157,6 +158,27 @@ describe('text editor extensions', () => {
157
158
  expect(html).toContain('#FFF59D');
158
159
  });
159
160
 
161
+ it('renders shared markdown-codec table JSON with editor table extensions', () => {
162
+ const extensions = getEditorExtensions({ readOnly: true });
163
+ const content = parseTaskDescriptionInput(
164
+ ['| Field | Value |', '| --- | --- |', '| Owner | Platform |'].join('\n'),
165
+ 'markdown'
166
+ );
167
+
168
+ const html = generateHTML(content, extensions);
169
+
170
+ expect(html).toContain('<table');
171
+ expect(html).toContain('<th');
172
+ expect(html).toContain('<td');
173
+ expect(html).toContain('Platform');
174
+
175
+ const parsed = generateJSON(html, extensions);
176
+ expect(parsed.content?.[0]?.type).toBe('table');
177
+ expect(parsed.content?.[0]?.content?.[0]?.content?.[0]?.type).toBe(
178
+ 'tableHeader'
179
+ );
180
+ });
181
+
160
182
  it('round-trips task mention workspace metadata through HTML attrs', () => {
161
183
  const renderOutput = (Mention.config as any).renderHTML({
162
184
  HTMLAttributes: {
@@ -5,7 +5,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@tuturuuu/ui/popover';
5
5
  import { Tooltip, TooltipContent, TooltipTrigger } from '@tuturuuu/ui/tooltip';
6
6
  import { cn } from '@tuturuuu/utils/format';
7
7
  import { useTranslations } from 'next-intl';
8
- import { useMemo, useState } from 'react';
8
+ import { type ComponentProps, useMemo, useState } from 'react';
9
9
  import { CreateListDialog } from '../../create-list-dialog';
10
10
  import { translateTaskListNameForDisplay } from '../../utils/translate-task-list-display-name';
11
11
  import { TaskListPickerPanel } from './task-list-picker-panel';
@@ -23,7 +23,14 @@ interface TaskListSelectorProps {
23
23
  compact?: boolean;
24
24
  open?: boolean;
25
25
  onOpenChange?: (open: boolean) => void;
26
+ onPopoverCloseAutoFocus?: ComponentProps<
27
+ typeof PopoverContent
28
+ >['onCloseAutoFocus'];
29
+ onPopoverInteractOutside?: ComponentProps<
30
+ typeof PopoverContent
31
+ >['onInteractOutside'];
26
32
  onListChange: (listId: string) => void;
33
+ propertyPopoverId?: string;
27
34
  }
28
35
 
29
36
  export function TaskListSelector({
@@ -35,7 +42,10 @@ export function TaskListSelector({
35
42
  compact = false,
36
43
  open,
37
44
  onOpenChange,
45
+ onPopoverCloseAutoFocus,
46
+ onPopoverInteractOutside,
38
47
  onListChange,
48
+ propertyPopoverId,
39
49
  }: TaskListSelectorProps) {
40
50
  const t = useTranslations();
41
51
  const [uncontrolledPopoverOpen, setUncontrolledPopoverOpen] = useState(false);
@@ -80,6 +90,7 @@ export function TaskListSelector({
80
90
  const triggerButton = (
81
91
  <button
82
92
  type="button"
93
+ data-task-property-popover-trigger={propertyPopoverId}
83
94
  disabled={disabled}
84
95
  aria-label={compact ? triggerLabel : undefined}
85
96
  className={cn(
@@ -111,7 +122,12 @@ export function TaskListSelector({
111
122
  ) : (
112
123
  <PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
113
124
  )}
114
- <PopoverContent align="start" className="w-80 p-0">
125
+ <PopoverContent
126
+ align="start"
127
+ className="w-80 p-0"
128
+ onCloseAutoFocus={onPopoverCloseAutoFocus}
129
+ onInteractOutside={onPopoverInteractOutside}
130
+ >
115
131
  <TaskListPickerPanel
116
132
  selectedListId={selectedListId}
117
133
  availableLists={availableLists}