@open-mercato/ui 0.6.1-develop.3291.1.6fad645fd0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +2 -0
- package/dist/ai/AiAssistantLauncher.js +8 -8
- package/dist/ai/AiAssistantLauncher.js.map +2 -2
- package/dist/ai/AiChat.js +92 -93
- package/dist/ai/AiChat.js.map +2 -2
- package/dist/ai/AiIcon.js +17 -0
- package/dist/ai/AiIcon.js.map +7 -0
- package/dist/ai/LoopDisabledBanner.js +28 -0
- package/dist/ai/LoopDisabledBanner.js.map +7 -0
- package/dist/ai/LoopTracePanel.js +123 -0
- package/dist/ai/LoopTracePanel.js.map +7 -0
- package/dist/ai/ModelPicker.js.map +1 -1
- package/dist/ai/index.js +8 -0
- package/dist/ai/index.js.map +2 -2
- package/dist/ai/parts/ConfirmationCard.js +1 -5
- package/dist/ai/parts/ConfirmationCard.js.map +2 -2
- package/dist/ai/parts/FieldDiffCard.js +8 -15
- package/dist/ai/parts/FieldDiffCard.js.map +2 -2
- package/dist/ai/parts/MutationResultCard.js +1 -4
- package/dist/ai/parts/MutationResultCard.js.map +2 -2
- package/dist/ai/ui-parts/pending-phase3-placeholder.js +0 -2
- package/dist/ai/ui-parts/pending-phase3-placeholder.js.map +2 -2
- package/dist/ai/useAiChat.js +18 -4
- package/dist/ai/useAiChat.js.map +2 -2
- package/dist/backend/CrudForm.js +24 -2
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +2 -2
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/confirm-dialog/ConfirmDialog.js +102 -96
- package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
- package/dist/backend/detail/CustomDataSection.js +22 -3
- package/dist/backend/detail/CustomDataSection.js.map +2 -2
- package/dist/backend/filters/AdvancedFilterBuilder.js +2 -2
- package/dist/backend/filters/AdvancedFilterBuilder.js.map +2 -2
- package/dist/backend/injection/ComponentOverrideProvider.js +1 -1
- package/dist/backend/injection/ComponentOverrideProvider.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +25 -7
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/progress/useProgressPoll.js +58 -3
- package/dist/backend/progress/useProgressPoll.js.map +2 -2
- package/dist/backend/progress/useProgressSse.js +15 -2
- package/dist/backend/progress/useProgressSse.js.map +2 -2
- package/dist/backend/utils/bulkDelete.js +76 -0
- package/dist/backend/utils/bulkDelete.js.map +2 -2
- package/package.json +11 -6
- package/src/ai/AiAssistantLauncher.tsx +8 -8
- package/src/ai/AiChat.tsx +44 -38
- package/src/ai/AiIcon.tsx +12 -0
- package/src/ai/LoopDisabledBanner.tsx +38 -0
- package/src/ai/LoopTracePanel.tsx +184 -0
- package/src/ai/ModelPicker.tsx +1 -1
- package/src/ai/__tests__/AiChat.test.tsx +174 -5
- package/src/ai/index.ts +8 -0
- package/src/ai/parts/ConfirmationCard.tsx +1 -5
- package/src/ai/parts/FieldDiffCard.tsx +0 -3
- package/src/ai/parts/MutationResultCard.tsx +1 -4
- package/src/ai/parts/__tests__/MutationResultCard.test.tsx +5 -0
- package/src/ai/ui-parts/pending-phase3-placeholder.tsx +0 -2
- package/src/ai/useAiChat.ts +35 -5
- package/src/backend/CrudForm.tsx +30 -3
- package/src/backend/DataTable.tsx +2 -2
- package/src/backend/__tests__/CrudForm.custom-fields.test.tsx +72 -0
- package/src/backend/__tests__/CustomDataSection.test.tsx +76 -0
- package/src/backend/__tests__/DataTable.render.test.tsx +36 -0
- package/src/backend/__tests__/component-replacement.test.tsx +44 -0
- package/src/backend/confirm-dialog/ConfirmDialog.tsx +101 -91
- package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +19 -0
- package/src/backend/detail/CustomDataSection.tsx +26 -3
- package/src/backend/filters/AdvancedFilterBuilder.tsx +6 -6
- package/src/backend/injection/ComponentOverrideProvider.tsx +2 -1
- package/src/backend/inputs/ComboboxInput.tsx +29 -7
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +33 -2
- package/src/backend/progress/useProgressPoll.ts +68 -3
- package/src/backend/progress/useProgressSse.ts +16 -2
- package/src/backend/utils/__tests__/bulkDelete.test.ts +42 -0
- package/src/backend/utils/bulkDelete.ts +93 -0
|
@@ -233,6 +233,82 @@ describe('CustomDataSection relation display', () => {
|
|
|
233
233
|
expect(notesContent.className).toContain('text-sm')
|
|
234
234
|
})
|
|
235
235
|
|
|
236
|
+
it('renders select values from bare custom-field response keys', async () => {
|
|
237
|
+
const fields: CrudField[] = [
|
|
238
|
+
{
|
|
239
|
+
id: 'cf_buying_role',
|
|
240
|
+
label: 'Buying role',
|
|
241
|
+
type: 'select',
|
|
242
|
+
options: [
|
|
243
|
+
{ value: 'economic_buyer', label: 'Economic buyer' },
|
|
244
|
+
{ value: 'champion', label: 'Champion' },
|
|
245
|
+
{ value: 'technical_evaluator', label: 'Technical evaluator' },
|
|
246
|
+
{ value: 'influencer', label: 'Influencer' },
|
|
247
|
+
],
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
id: 'cf_relationship_health',
|
|
251
|
+
label: 'Relationship health',
|
|
252
|
+
type: 'select',
|
|
253
|
+
options: [
|
|
254
|
+
{ value: 'healthy', label: 'Healthy' },
|
|
255
|
+
{ value: 'monitor', label: 'Monitor' },
|
|
256
|
+
{ value: 'at_risk', label: 'At risk' },
|
|
257
|
+
],
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
id: 'cf_renewal_quarter',
|
|
261
|
+
label: 'Renewal quarter',
|
|
262
|
+
type: 'select',
|
|
263
|
+
options: [
|
|
264
|
+
{ value: 'Q1', label: 'Q1' },
|
|
265
|
+
{ value: 'Q2', label: 'Q2' },
|
|
266
|
+
{ value: 'Q3', label: 'Q3' },
|
|
267
|
+
{ value: 'Q4', label: 'Q4' },
|
|
268
|
+
],
|
|
269
|
+
},
|
|
270
|
+
]
|
|
271
|
+
const definitions: CustomFieldDefDto[] = [
|
|
272
|
+
{
|
|
273
|
+
key: 'buying_role',
|
|
274
|
+
kind: 'select',
|
|
275
|
+
label: 'Buying role',
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
key: 'relationship_health',
|
|
279
|
+
kind: 'select',
|
|
280
|
+
label: 'Relationship health',
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
key: 'renewal_quarter',
|
|
284
|
+
kind: 'select',
|
|
285
|
+
label: 'Renewal quarter',
|
|
286
|
+
},
|
|
287
|
+
]
|
|
288
|
+
|
|
289
|
+
renderWithProviders(
|
|
290
|
+
<CustomDataSection
|
|
291
|
+
entityIds={['customers:customer_person_profile', 'customers:customer_company_profile']}
|
|
292
|
+
values={{ buying_role: 'champion', relationship_health: 'monitor', renewal_quarter: 'Q4' }}
|
|
293
|
+
onSubmit={async () => {}}
|
|
294
|
+
title="Custom fields"
|
|
295
|
+
loadFields={async () => ({ fields, definitions })}
|
|
296
|
+
labels={{
|
|
297
|
+
loading: 'Loading custom data\u2026',
|
|
298
|
+
emptyValue: 'No value',
|
|
299
|
+
noFields: 'No fields',
|
|
300
|
+
saveShortcut: 'Save now',
|
|
301
|
+
edit: 'Edit',
|
|
302
|
+
cancel: 'Cancel',
|
|
303
|
+
}}
|
|
304
|
+
/>,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
expect(await screen.findByText('Champion')).toBeInTheDocument()
|
|
308
|
+
expect(await screen.findByText('Monitor')).toBeInTheDocument()
|
|
309
|
+
expect(screen.getByText('Q4')).toBeInTheDocument()
|
|
310
|
+
})
|
|
311
|
+
|
|
236
312
|
it('resolves customer_person_profile with entity_id route context', async () => {
|
|
237
313
|
const entityId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
238
314
|
const profileId = 'pppppppp-1111-2222-3333-444444444444'
|
|
@@ -45,4 +45,40 @@ describe('DataTable SSR render', () => {
|
|
|
45
45
|
queryClient.clear()
|
|
46
46
|
}
|
|
47
47
|
})
|
|
48
|
+
|
|
49
|
+
it('keeps rows-per-page controls on one line', () => {
|
|
50
|
+
const columns: ColumnDef<Row>[] = [
|
|
51
|
+
{ accessorKey: 'name', header: 'Name' },
|
|
52
|
+
]
|
|
53
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } })
|
|
54
|
+
try {
|
|
55
|
+
const html = renderToString(
|
|
56
|
+
React.createElement(
|
|
57
|
+
QueryClientProvider as any,
|
|
58
|
+
{ client: queryClient },
|
|
59
|
+
React.createElement(
|
|
60
|
+
I18nProvider as any,
|
|
61
|
+
{ locale: 'en', dict: {} },
|
|
62
|
+
React.createElement(DataTable as any, {
|
|
63
|
+
columns,
|
|
64
|
+
data: [{ id: '1', name: 'Ada' }],
|
|
65
|
+
pagination: {
|
|
66
|
+
page: 1,
|
|
67
|
+
pageSize: 20,
|
|
68
|
+
total: 1,
|
|
69
|
+
totalPages: 1,
|
|
70
|
+
onPageChange: () => {},
|
|
71
|
+
onPageSizeChange: () => {},
|
|
72
|
+
pageSizeOptions: [10, 20, 50],
|
|
73
|
+
},
|
|
74
|
+
}),
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
expect(html).toContain('whitespace-nowrap')
|
|
79
|
+
expect(html).toContain('per page')
|
|
80
|
+
} finally {
|
|
81
|
+
queryClient.clear()
|
|
82
|
+
}
|
|
83
|
+
})
|
|
48
84
|
})
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/** @jest-environment jsdom */
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import { render, screen } from '@testing-library/react'
|
|
4
|
+
import { renderToString } from 'react-dom/server'
|
|
4
5
|
import { z } from 'zod'
|
|
5
6
|
import {
|
|
6
7
|
registerComponent,
|
|
7
8
|
registerComponentOverrides,
|
|
8
9
|
ComponentReplacementHandles,
|
|
9
10
|
} from '@open-mercato/shared/modules/widgets/component-registry'
|
|
11
|
+
import { ComponentOverrideProvider } from '../injection/ComponentOverrideProvider'
|
|
10
12
|
import { useRegisteredComponent } from '../injection/useRegisteredComponent'
|
|
11
13
|
import { DetailFieldsSection } from '../detail/DetailFieldsSection'
|
|
12
14
|
|
|
@@ -65,6 +67,48 @@ describe('component replacement', () => {
|
|
|
65
67
|
expect(screen.getByText('wrapped rendered')).toBeInTheDocument()
|
|
66
68
|
})
|
|
67
69
|
|
|
70
|
+
it('applies provider overrides during the first render for hydration parity', () => {
|
|
71
|
+
const componentId = 'test.provider.first.render'
|
|
72
|
+
const Base = ({ value }: { value: string }) => <span>{value}</span>
|
|
73
|
+
|
|
74
|
+
registerComponent({
|
|
75
|
+
id: componentId,
|
|
76
|
+
component: Base,
|
|
77
|
+
metadata: { module: 'test' },
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
function Consumer() {
|
|
81
|
+
const Resolved = useRegisteredComponent<{ value: string }>(componentId)
|
|
82
|
+
return <Resolved value="first render" />
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const html = renderToString(
|
|
86
|
+
<ComponentOverrideProvider
|
|
87
|
+
overrides={[
|
|
88
|
+
{
|
|
89
|
+
target: { componentId },
|
|
90
|
+
priority: 10,
|
|
91
|
+
metadata: { module: 'test' },
|
|
92
|
+
wrapper: (Original) => {
|
|
93
|
+
const Wrapped = (props: { value: string }) => (
|
|
94
|
+
<div data-testid="provider-wrapped">
|
|
95
|
+
<Original {...props} />
|
|
96
|
+
</div>
|
|
97
|
+
)
|
|
98
|
+
Wrapped.displayName = 'ProviderWrapped'
|
|
99
|
+
return Wrapped
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
]}
|
|
103
|
+
>
|
|
104
|
+
<Consumer />
|
|
105
|
+
</ComponentOverrideProvider>,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
expect(html).toContain('data-testid="provider-wrapped"')
|
|
109
|
+
expect(html).toContain('first render')
|
|
110
|
+
})
|
|
111
|
+
|
|
68
112
|
it('ignores invalid override entries when resolving registered components', () => {
|
|
69
113
|
const componentId = 'test.invalid.override'
|
|
70
114
|
const Base = ({ value }: { value: string }) => <span>{value}</span>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import * as React from "react";
|
|
3
|
+
import { createPortal } from "react-dom";
|
|
3
4
|
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
4
5
|
import { Button } from "@open-mercato/ui/primitives/button";
|
|
5
6
|
import { IconButton } from "../../primitives/icon-button";
|
|
@@ -53,6 +54,7 @@ export function ConfirmDialog({
|
|
|
53
54
|
const [internalOpen, setInternalOpen] = React.useState(false);
|
|
54
55
|
const cancelButtonRef = React.useRef<HTMLButtonElement>(null);
|
|
55
56
|
const confirmButtonRef = React.useRef<HTMLButtonElement>(null);
|
|
57
|
+
const [portalTarget, setPortalTarget] = React.useState<HTMLElement | null>(null);
|
|
56
58
|
// Unique IDs so multiple ConfirmDialog instances on the same page don't
|
|
57
59
|
// collide and make `aria-labelledby` resolve to the wrong dialog's title.
|
|
58
60
|
const reactId = React.useId();
|
|
@@ -88,8 +90,12 @@ export function ConfirmDialog({
|
|
|
88
90
|
handleCancelCallback();
|
|
89
91
|
}, [setOpen, handleCancelCallback]);
|
|
90
92
|
|
|
93
|
+
React.useLayoutEffect(() => {
|
|
94
|
+
setPortalTarget(document.body);
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
91
97
|
// Handle dialog open/close with native showModal/close
|
|
92
|
-
React.
|
|
98
|
+
React.useLayoutEffect(() => {
|
|
93
99
|
const dialog = dialogRef.current;
|
|
94
100
|
if (!dialog) return;
|
|
95
101
|
|
|
@@ -110,7 +116,7 @@ export function ConfirmDialog({
|
|
|
110
116
|
dialog.close();
|
|
111
117
|
}
|
|
112
118
|
}
|
|
113
|
-
}, [open, resolvedCancelText]);
|
|
119
|
+
}, [open, resolvedCancelText, portalTarget]);
|
|
114
120
|
|
|
115
121
|
// Handle native cancel event (Escape key)
|
|
116
122
|
React.useEffect(() => {
|
|
@@ -172,109 +178,113 @@ export function ConfirmDialog({
|
|
|
172
178
|
}
|
|
173
179
|
};
|
|
174
180
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
{
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
+
const dialogElement = (
|
|
182
|
+
<dialog
|
|
183
|
+
ref={dialogRef}
|
|
184
|
+
role="alertdialog"
|
|
185
|
+
aria-labelledby={titleId}
|
|
186
|
+
aria-describedby={text ? descriptionId : undefined}
|
|
187
|
+
onClick={handleBackdropClick}
|
|
188
|
+
className={cn(
|
|
189
|
+
// Reset dialog defaults
|
|
190
|
+
"m-0 p-0 max-w-none bg-transparent border-none pointer-events-auto",
|
|
191
|
+
// Backdrop styling
|
|
192
|
+
"backdrop:bg-black/50 backdrop:backdrop-blur-sm backdrop:transition-opacity",
|
|
193
|
+
// Mobile: bottom sheet
|
|
194
|
+
"fixed inset-x-0 bottom-0 top-auto w-full",
|
|
195
|
+
// Desktop: centered
|
|
196
|
+
"sm:inset-auto sm:mx-auto sm:my-auto sm:max-w-md sm:top-1/2 sm:left-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2",
|
|
197
|
+
// Animation with reduced motion support
|
|
198
|
+
"motion-safe:open:animate-in motion-safe:open:fade-in-0 motion-safe:open:slide-in-from-bottom-4",
|
|
199
|
+
"sm:motion-safe:open:slide-in-from-bottom-0 sm:motion-safe:open:zoom-in-95",
|
|
200
|
+
// Duration
|
|
201
|
+
"motion-safe:open:duration-300"
|
|
181
202
|
)}
|
|
182
|
-
|
|
183
|
-
<
|
|
184
|
-
|
|
185
|
-
role="alertdialog"
|
|
186
|
-
aria-labelledby={titleId}
|
|
187
|
-
aria-describedby={text ? descriptionId : undefined}
|
|
188
|
-
onClick={handleBackdropClick}
|
|
203
|
+
>
|
|
204
|
+
<div
|
|
205
|
+
role="document"
|
|
189
206
|
className={cn(
|
|
190
|
-
//
|
|
191
|
-
"
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
"fixed inset-x-0 bottom-0 top-auto w-full",
|
|
196
|
-
// Desktop: centered
|
|
197
|
-
"sm:inset-auto sm:mx-auto sm:my-auto sm:max-w-md sm:top-1/2 sm:left-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2",
|
|
198
|
-
// Animation with reduced motion support
|
|
199
|
-
"motion-safe:open:animate-in motion-safe:open:fade-in-0 motion-safe:open:slide-in-from-bottom-4",
|
|
200
|
-
"sm:motion-safe:open:slide-in-from-bottom-0 sm:motion-safe:open:zoom-in-95",
|
|
201
|
-
// Duration
|
|
202
|
-
"motion-safe:open:duration-300"
|
|
207
|
+
// Panel container
|
|
208
|
+
"flex flex-col gap-4 rounded-t-2xl border-t bg-card p-6 text-foreground shadow-lg",
|
|
209
|
+
"sm:rounded-xl sm:border",
|
|
210
|
+
// Relative positioning for close button
|
|
211
|
+
"relative"
|
|
203
212
|
)}
|
|
204
213
|
>
|
|
205
|
-
|
|
206
|
-
|
|
214
|
+
{/* Close button */}
|
|
215
|
+
<IconButton
|
|
216
|
+
type="button"
|
|
217
|
+
variant="ghost"
|
|
218
|
+
size="sm"
|
|
219
|
+
onClick={handleCancel}
|
|
220
|
+
disabled={loading}
|
|
221
|
+
aria-label={closeAriaLabel}
|
|
222
|
+
className="absolute right-4 top-4 opacity-70 hover:opacity-100"
|
|
223
|
+
>
|
|
224
|
+
<X className="h-4 w-4" />
|
|
225
|
+
</IconButton>
|
|
226
|
+
|
|
227
|
+
{/* Title */}
|
|
228
|
+
<h2
|
|
229
|
+
id={titleId}
|
|
207
230
|
className={cn(
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
"
|
|
211
|
-
// Relative positioning for close button
|
|
212
|
-
"relative"
|
|
231
|
+
"text-sm font-medium leading-snug tracking-tight pr-6",
|
|
232
|
+
// Mobile: centered, Desktop: left-aligned
|
|
233
|
+
"text-center sm:text-left"
|
|
213
234
|
)}
|
|
214
235
|
>
|
|
215
|
-
{
|
|
216
|
-
|
|
217
|
-
type="button"
|
|
218
|
-
variant="ghost"
|
|
219
|
-
size="sm"
|
|
220
|
-
onClick={handleCancel}
|
|
221
|
-
disabled={loading}
|
|
222
|
-
aria-label={closeAriaLabel}
|
|
223
|
-
className="absolute right-4 top-4 opacity-70 hover:opacity-100"
|
|
224
|
-
>
|
|
225
|
-
<X className="h-4 w-4" />
|
|
226
|
-
</IconButton>
|
|
236
|
+
{resolvedTitle}
|
|
237
|
+
</h2>
|
|
227
238
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
// Mobile: centered, Desktop: left-aligned
|
|
234
|
-
"text-center sm:text-left"
|
|
235
|
-
)}
|
|
239
|
+
{/* Description (optional) */}
|
|
240
|
+
{text && (
|
|
241
|
+
<p
|
|
242
|
+
id={descriptionId}
|
|
243
|
+
className="text-sm font-medium leading-snug text-muted-foreground"
|
|
236
244
|
>
|
|
237
|
-
{
|
|
238
|
-
</
|
|
245
|
+
{text}
|
|
246
|
+
</p>
|
|
247
|
+
)}
|
|
239
248
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
249
|
+
{/* Actions */}
|
|
250
|
+
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
|
251
|
+
{resolvedCancelText !== false && (
|
|
252
|
+
<Button
|
|
253
|
+
ref={cancelButtonRef}
|
|
254
|
+
variant="outline"
|
|
255
|
+
onClick={handleCancel}
|
|
256
|
+
disabled={loading}
|
|
257
|
+
className="w-full sm:w-auto"
|
|
258
|
+
>
|
|
259
|
+
{resolvedCancelText}
|
|
260
|
+
</Button>
|
|
261
|
+
)}
|
|
262
|
+
{resolvedConfirmText !== false && (
|
|
263
|
+
<Button
|
|
264
|
+
ref={confirmButtonRef}
|
|
265
|
+
variant={variant === "destructive" ? "destructive" : "default"}
|
|
266
|
+
onClick={handleConfirm}
|
|
267
|
+
disabled={loading}
|
|
268
|
+
className="w-full sm:w-auto"
|
|
245
269
|
>
|
|
246
|
-
{
|
|
247
|
-
|
|
270
|
+
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
271
|
+
{resolvedConfirmText}
|
|
272
|
+
</Button>
|
|
248
273
|
)}
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
</dialog>
|
|
277
|
+
);
|
|
249
278
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
variant="outline"
|
|
256
|
-
onClick={handleCancel}
|
|
257
|
-
disabled={loading}
|
|
258
|
-
className="w-full sm:w-auto"
|
|
259
|
-
>
|
|
260
|
-
{resolvedCancelText}
|
|
261
|
-
</Button>
|
|
262
|
-
)}
|
|
263
|
-
{resolvedConfirmText !== false && (
|
|
264
|
-
<Button
|
|
265
|
-
ref={confirmButtonRef}
|
|
266
|
-
variant={variant === "destructive" ? "destructive" : "default"}
|
|
267
|
-
onClick={handleConfirm}
|
|
268
|
-
disabled={loading}
|
|
269
|
-
className="w-full sm:w-auto"
|
|
270
|
-
>
|
|
271
|
-
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
272
|
-
{resolvedConfirmText}
|
|
273
|
-
</Button>
|
|
274
|
-
)}
|
|
275
|
-
</div>
|
|
279
|
+
return (
|
|
280
|
+
<>
|
|
281
|
+
{trigger && (
|
|
282
|
+
<div onClick={handleTriggerClick} className="inline-block" role="presentation">
|
|
283
|
+
{trigger}
|
|
276
284
|
</div>
|
|
277
|
-
|
|
285
|
+
)}
|
|
286
|
+
|
|
287
|
+
{portalTarget ? createPortal(dialogElement, portalTarget) : null}
|
|
278
288
|
</>
|
|
279
289
|
);
|
|
280
290
|
}
|
|
@@ -41,4 +41,23 @@ describe('ConfirmDialog', () => {
|
|
|
41
41
|
|
|
42
42
|
expect(screen.getByRole('alertdialog').className).toEqual(expect.stringContaining('pointer-events-auto'))
|
|
43
43
|
})
|
|
44
|
+
|
|
45
|
+
it('portals the native dialog out of hidden ancestors', () => {
|
|
46
|
+
const { container } = renderWithProviders(
|
|
47
|
+
<div hidden>
|
|
48
|
+
<ConfirmDialog
|
|
49
|
+
open
|
|
50
|
+
onOpenChange={() => undefined}
|
|
51
|
+
onConfirm={() => undefined}
|
|
52
|
+
title="Discard unsaved changes?"
|
|
53
|
+
confirmText="Discard"
|
|
54
|
+
cancelText="Cancel"
|
|
55
|
+
/>
|
|
56
|
+
</div>,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
const dialog = screen.getByRole('alertdialog')
|
|
60
|
+
expect(container.contains(dialog)).toBe(false)
|
|
61
|
+
expect(dialog.parentElement).toBe(document.body)
|
|
62
|
+
})
|
|
44
63
|
})
|
|
@@ -75,6 +75,20 @@ function extractDictionaryValue(entry: unknown): string | null {
|
|
|
75
75
|
return null
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
function readCustomDataFieldValue(values: Record<string, unknown> | undefined, fieldId: string): unknown {
|
|
79
|
+
if (!values || !fieldId) return undefined
|
|
80
|
+
const candidates = fieldId.startsWith('cf_')
|
|
81
|
+
? [fieldId, `cf:${fieldId.slice(3)}`, fieldId.slice(3)]
|
|
82
|
+
: [fieldId, `cf_${fieldId}`, `cf:${fieldId}`]
|
|
83
|
+
|
|
84
|
+
for (const candidate of candidates) {
|
|
85
|
+
if (Object.prototype.hasOwnProperty.call(values, candidate)) {
|
|
86
|
+
return values[candidate]
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return undefined
|
|
90
|
+
}
|
|
91
|
+
|
|
78
92
|
export type CustomDataLabels = {
|
|
79
93
|
loading: string
|
|
80
94
|
emptyValue: string
|
|
@@ -312,6 +326,15 @@ function CustomDataSectionImpl({
|
|
|
312
326
|
() => customFieldFormsQuery.data?.definitions ?? [],
|
|
313
327
|
[customFieldFormsQuery.data],
|
|
314
328
|
)
|
|
329
|
+
const formInitialValues = React.useMemo(() => {
|
|
330
|
+
const next: Record<string, unknown> = { ...(values ?? {}) }
|
|
331
|
+
fields.forEach((field) => {
|
|
332
|
+
if (!field.id || Object.prototype.hasOwnProperty.call(next, field.id)) return
|
|
333
|
+
const resolved = readCustomDataFieldValue(values, field.id)
|
|
334
|
+
if (resolved !== undefined) next[field.id] = resolved
|
|
335
|
+
})
|
|
336
|
+
return next
|
|
337
|
+
}, [fields, values])
|
|
315
338
|
const [dictionaryLoading, setDictionaryLoading] = React.useState(false)
|
|
316
339
|
const [relationLoading, setRelationLoading] = React.useState(false)
|
|
317
340
|
const loading = customFieldFormsQuery.isLoading || dictionaryLoading || relationLoading
|
|
@@ -473,7 +496,7 @@ function CustomDataSectionImpl({
|
|
|
473
496
|
const normalizedKey = field.id.startsWith('cf_') ? field.id.slice(3) : field.id
|
|
474
497
|
const definition = definitionsByKey.get(normalizedKey.toLowerCase())
|
|
475
498
|
if (!definition || definition.kind !== 'relation') return null
|
|
476
|
-
const relationIds = collectRelationValueIds(values
|
|
499
|
+
const relationIds = collectRelationValueIds(readCustomDataFieldValue(values, field.id))
|
|
477
500
|
if (!relationIds.length) return null
|
|
478
501
|
return { field, definition, relationIds }
|
|
479
502
|
})
|
|
@@ -621,7 +644,7 @@ function CustomDataSectionImpl({
|
|
|
621
644
|
entityId={primaryEntityId}
|
|
622
645
|
entityIds={resolvedEntityIds}
|
|
623
646
|
fields={fields}
|
|
624
|
-
initialValues={
|
|
647
|
+
initialValues={formInitialValues}
|
|
625
648
|
onSubmit={handleSubmit}
|
|
626
649
|
submitLabel={labels.saveShortcut}
|
|
627
650
|
isLoading={loading}
|
|
@@ -659,7 +682,7 @@ function CustomDataSectionImpl({
|
|
|
659
682
|
<div className="text-sm break-words">
|
|
660
683
|
{formatFieldValue(
|
|
661
684
|
field,
|
|
662
|
-
values
|
|
685
|
+
readCustomDataFieldValue(values, field.id),
|
|
663
686
|
labels.emptyValue,
|
|
664
687
|
dictionaryMapsByField[field.id],
|
|
665
688
|
markdownPlugins,
|
|
@@ -290,7 +290,7 @@ function GroupView({
|
|
|
290
290
|
: 'space-y-3 rounded-lg border border-border bg-muted/30 p-3'
|
|
291
291
|
|
|
292
292
|
// Toolbar-first layout (Kendo React Filter pattern): one [And]/[Or] toggle per group
|
|
293
|
-
// is the only combinator UI. Mixing AND with OR requires
|
|
293
|
+
// is the only combinator UI. Mixing AND with OR requires `Add subgroup`. Matches
|
|
294
294
|
// `CompositeFilterDescriptor` semantics (one logic per group).
|
|
295
295
|
return (
|
|
296
296
|
<div className={containerClass}>
|
|
@@ -841,8 +841,8 @@ function toneDotClass(tone: NonNullable<FilterFieldDef['options']>[number]['tone
|
|
|
841
841
|
}
|
|
842
842
|
}
|
|
843
843
|
|
|
844
|
-
// Single Kendo-style toolbar per group: combinator toggle,
|
|
845
|
-
//
|
|
844
|
+
// Single Kendo-style toolbar per group: combinator toggle, `Add condition`,
|
|
845
|
+
// `Add subgroup` / `Add group`, plus a trailing delete for non-root groups.
|
|
846
846
|
function GroupToolbar({
|
|
847
847
|
group, level, tree, dispatch, defaultField, fields, isRoot, t,
|
|
848
848
|
}: {
|
|
@@ -886,8 +886,8 @@ function GroupToolbar({
|
|
|
886
886
|
: undefined
|
|
887
887
|
|
|
888
888
|
const addGroupLabel = level === 1
|
|
889
|
-
? t('ui.advancedFilter.addGroup', '
|
|
890
|
-
: t('ui.advancedFilter.addSubgroup', '
|
|
889
|
+
? t('ui.advancedFilter.addGroup', 'Add group')
|
|
890
|
+
: t('ui.advancedFilter.addSubgroup', 'Add subgroup')
|
|
891
891
|
|
|
892
892
|
// `text-brand-violet` overrides Button's default disabled token, so we have to
|
|
893
893
|
// re-state muted disabled colors here — otherwise disabled CTAs still look clickable.
|
|
@@ -912,7 +912,7 @@ function GroupToolbar({
|
|
|
912
912
|
className={ctaClass}
|
|
913
913
|
>
|
|
914
914
|
<Plus className="size-4" />
|
|
915
|
-
{t('ui.advancedFilter.addCondition', '
|
|
915
|
+
{t('ui.advancedFilter.addCondition', 'Add condition')}
|
|
916
916
|
</Button>
|
|
917
917
|
<FilterFieldPicker
|
|
918
918
|
fields={fields}
|
|
@@ -23,8 +23,9 @@ export function ComponentOverrideProvider({
|
|
|
23
23
|
}) {
|
|
24
24
|
const [userFeatures, setUserFeatures] = React.useState<readonly string[]>([])
|
|
25
25
|
|
|
26
|
+
registerComponentOverrides(overrides)
|
|
27
|
+
|
|
26
28
|
React.useEffect(() => {
|
|
27
|
-
registerComponentOverrides(overrides)
|
|
28
29
|
return () => {
|
|
29
30
|
registerComponentOverrides([])
|
|
30
31
|
}
|
|
@@ -50,6 +50,16 @@ function normalizeOptions(input?: Array<string | ComboboxOption>): ComboboxOptio
|
|
|
50
50
|
.filter((option): option is ComboboxOption => !!option)
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
function areOptionsEqual(a: ComboboxOption[], b: ComboboxOption[]): boolean {
|
|
54
|
+
if (a.length !== b.length) return false
|
|
55
|
+
return a.every((option, index) => {
|
|
56
|
+
const next = b[index]
|
|
57
|
+
return option.value === next.value
|
|
58
|
+
&& option.label === next.label
|
|
59
|
+
&& (option.description ?? null) === (next.description ?? null)
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
53
63
|
export function ComboboxInput({
|
|
54
64
|
value,
|
|
55
65
|
onChange,
|
|
@@ -75,6 +85,7 @@ export function ComboboxInput({
|
|
|
75
85
|
const [selectedIndex, setSelectedIndex] = React.useState(-1)
|
|
76
86
|
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
77
87
|
const suppressOpenOnFocusRef = React.useRef(Boolean(autoFocus && !disabled))
|
|
88
|
+
const eagerFallbackLoadedValueRef = React.useRef<string | null>(null)
|
|
78
89
|
|
|
79
90
|
const staticOptions = React.useMemo(
|
|
80
91
|
() => normalizeOptions([...(seedOptions ?? []), ...(suggestions ?? [])]),
|
|
@@ -91,6 +102,14 @@ export function ComboboxInput({
|
|
|
91
102
|
return set
|
|
92
103
|
}, [staticOptions, asyncOptions, resolvedOptions])
|
|
93
104
|
|
|
105
|
+
const coveredOptionValues = React.useMemo(() => {
|
|
106
|
+
const set = new Set<string>()
|
|
107
|
+
for (const option of [...staticOptions, ...asyncOptions, ...resolvedOptions]) {
|
|
108
|
+
set.add(option.value)
|
|
109
|
+
}
|
|
110
|
+
return set
|
|
111
|
+
}, [staticOptions, asyncOptions, resolvedOptions])
|
|
112
|
+
|
|
94
113
|
const optionMap = React.useMemo(() => {
|
|
95
114
|
const map = new Map<string, ComboboxOption>()
|
|
96
115
|
const register = (option: ComboboxOption) => {
|
|
@@ -106,16 +125,15 @@ export function ComboboxInput({
|
|
|
106
125
|
if (value) {
|
|
107
126
|
const existing = map.get(value)
|
|
108
127
|
if (!existing) {
|
|
109
|
-
const sync = typeof resolveLabel === 'function' ? resolveLabel(value) : undefined
|
|
110
128
|
map.set(value, {
|
|
111
129
|
value,
|
|
112
|
-
label:
|
|
130
|
+
label: value,
|
|
113
131
|
description: resolveDescription?.(value) ?? null,
|
|
114
132
|
})
|
|
115
133
|
}
|
|
116
134
|
}
|
|
117
135
|
return map
|
|
118
|
-
}, [asyncOptions, resolvedOptions, resolveDescription,
|
|
136
|
+
}, [asyncOptions, resolvedOptions, resolveDescription, staticOptions, value])
|
|
119
137
|
|
|
120
138
|
const availableOptions = React.useMemo(() => {
|
|
121
139
|
return Array.from(optionMap.values())
|
|
@@ -140,7 +158,8 @@ export function ComboboxInput({
|
|
|
140
158
|
try {
|
|
141
159
|
const items = await loadSuggestions(query)
|
|
142
160
|
if (!cancelled) {
|
|
143
|
-
|
|
161
|
+
const normalized = normalizeOptions(items)
|
|
162
|
+
setAsyncOptions((prev) => areOptionsEqual(prev, normalized) ? prev : normalized)
|
|
144
163
|
}
|
|
145
164
|
} finally {
|
|
146
165
|
if (!cancelled) setLoading(false)
|
|
@@ -157,7 +176,6 @@ export function ComboboxInput({
|
|
|
157
176
|
const eagerResolveLabel = typeof resolveLabel === 'function' ? resolveLabel : undefined
|
|
158
177
|
React.useEffect(() => {
|
|
159
178
|
if (!value || disabled) return
|
|
160
|
-
if (knownLabelValues.has(value)) return
|
|
161
179
|
let cancelled = false
|
|
162
180
|
const apply = (label?: string | null, description?: string | null) => {
|
|
163
181
|
const clean = typeof label === 'string' ? label.trim() : ''
|
|
@@ -168,12 +186,16 @@ export function ComboboxInput({
|
|
|
168
186
|
})
|
|
169
187
|
}
|
|
170
188
|
if (eagerResolveLabel) {
|
|
189
|
+
if (knownLabelValues.has(value)) return
|
|
171
190
|
Promise.resolve()
|
|
172
191
|
.then(() => eagerResolveLabel(value))
|
|
173
192
|
.then((label) => apply(label, resolveDescription?.(value)))
|
|
174
193
|
.catch(() => {})
|
|
175
194
|
return () => { cancelled = true }
|
|
176
195
|
}
|
|
196
|
+
if (coveredOptionValues.has(value)) return
|
|
197
|
+
if (eagerFallbackLoadedValueRef.current === value) return
|
|
198
|
+
eagerFallbackLoadedValueRef.current = value
|
|
177
199
|
// Fallback: pull the first page of async suggestions so a remount that lost
|
|
178
200
|
// its option cache can still recover the label without user interaction.
|
|
179
201
|
if (loadSuggestions) {
|
|
@@ -183,14 +205,14 @@ export function ComboboxInput({
|
|
|
183
205
|
.then((items) => {
|
|
184
206
|
if (cancelled) return
|
|
185
207
|
const normalized = normalizeOptions(items)
|
|
186
|
-
setAsyncOptions(normalized)
|
|
208
|
+
setAsyncOptions((prev) => areOptionsEqual(prev, normalized) ? prev : normalized)
|
|
187
209
|
})
|
|
188
210
|
.catch(() => {})
|
|
189
211
|
.finally(() => { if (!cancelled) setLoading(false) })
|
|
190
212
|
}
|
|
191
213
|
return () => { cancelled = true }
|
|
192
214
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
193
|
-
}, [value, disabled, knownLabelValues, eagerResolveLabel, loadSuggestions])
|
|
215
|
+
}, [value, disabled, knownLabelValues, coveredOptionValues, eagerResolveLabel, loadSuggestions])
|
|
194
216
|
|
|
195
217
|
// Sync input with value when value changes externally and input is not focused.
|
|
196
218
|
React.useEffect(() => {
|