@open-mercato/ui 0.6.6-develop.5716.1.b108502d0d → 0.6.6-develop.5738.1.4182c2b2c7
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.5738.1.4182c2b2c7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -154,13 +154,13 @@
|
|
|
154
154
|
"remark-gfm": "^4.0.1"
|
|
155
155
|
},
|
|
156
156
|
"peerDependencies": {
|
|
157
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
157
|
+
"@open-mercato/shared": "0.6.6-develop.5738.1.4182c2b2c7",
|
|
158
158
|
"react": ">=18.0.0",
|
|
159
159
|
"react-dom": ">=18.0.0",
|
|
160
160
|
"react-is": ">=18.0.0"
|
|
161
161
|
},
|
|
162
162
|
"devDependencies": {
|
|
163
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
163
|
+
"@open-mercato/shared": "0.6.6-develop.5738.1.4182c2b2c7",
|
|
164
164
|
"@testing-library/dom": "^10.4.1",
|
|
165
165
|
"@testing-library/jest-dom": "^6.9.1",
|
|
166
166
|
"@testing-library/react": "^16.3.1",
|
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -403,6 +403,12 @@ export type CrudFormGroupComponentProps = {
|
|
|
403
403
|
values: Record<string, unknown>
|
|
404
404
|
setValue: (id: string, v: unknown) => void
|
|
405
405
|
errors: Record<string, string>
|
|
406
|
+
/**
|
|
407
|
+
* Field ids that active injection widgets declare as required (e.g. the SEO
|
|
408
|
+
* helper). Custom group components that render their own labels can use this
|
|
409
|
+
* to show a required marker that appears/disappears with the widget.
|
|
410
|
+
*/
|
|
411
|
+
requiredFieldIds?: ReadonlySet<string>
|
|
406
412
|
}
|
|
407
413
|
|
|
408
414
|
// Special group kind for automatic Custom Fields section
|
|
@@ -1069,6 +1075,19 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
1069
1075
|
const { triggerEvent: triggerInjectionEvent } = useInjectionSpotEvents(resolvedInjectionSpotId ?? '', injectionWidgets)
|
|
1070
1076
|
const extendedInjectionEventsEnabled = CRUDFORM_EXTENDED_EVENTS_ENABLED && Boolean(resolvedInjectionSpotId)
|
|
1071
1077
|
|
|
1078
|
+
// Fields that active injection widgets declare as required (e.g. the SEO helper
|
|
1079
|
+
// enforcing a description). The host renders a visual required marker for these;
|
|
1080
|
+
// enforcement stays in the widget's own onBeforeSave validation.
|
|
1081
|
+
const widgetRequiredFieldIds = React.useMemo(() => {
|
|
1082
|
+
const ids = new Set<string>()
|
|
1083
|
+
for (const widget of injectionWidgets ?? []) {
|
|
1084
|
+
const metadata = widget.module?.metadata
|
|
1085
|
+
if (!metadata || metadata.enabled === false) continue
|
|
1086
|
+
for (const fieldId of metadata.requiredFields ?? []) ids.add(fieldId)
|
|
1087
|
+
}
|
|
1088
|
+
return ids
|
|
1089
|
+
}, [injectionWidgets])
|
|
1090
|
+
|
|
1072
1091
|
const transformValidationErrors = React.useCallback(
|
|
1073
1092
|
async (fieldErrors: Record<string, string>): Promise<Record<string, string>> => {
|
|
1074
1093
|
if (!extendedInjectionEventsEnabled || !Object.keys(fieldErrors).length) return fieldErrors
|
|
@@ -2214,6 +2233,33 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2214
2233
|
}
|
|
2215
2234
|
}, [errors, formId])
|
|
2216
2235
|
|
|
2236
|
+
// When an injection widget blocks save (e.g. the SEO helper), the blocking
|
|
2237
|
+
// reason is easy to miss if the relevant field or the widget panel is off-screen.
|
|
2238
|
+
// Deliberately scroll to the first field-mapped error, falling back to the
|
|
2239
|
+
// injection widget region when the widget only returns a message.
|
|
2240
|
+
const scrollToInjectionBlockedTarget = React.useCallback(
|
|
2241
|
+
(fieldErrors?: Record<string, string>) => {
|
|
2242
|
+
if (typeof document === 'undefined') return
|
|
2243
|
+
const form = document.getElementById(formId)
|
|
2244
|
+
if (!form) return
|
|
2245
|
+
let target: HTMLElement | null = null
|
|
2246
|
+
for (const fieldId of fieldErrors ? Object.keys(fieldErrors) : []) {
|
|
2247
|
+
const fieldContainer = form.querySelector<HTMLElement>(`[data-crud-field-id="${fieldId}"]`)
|
|
2248
|
+
if (fieldContainer) {
|
|
2249
|
+
target = fieldContainer
|
|
2250
|
+
break
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
if (!target) {
|
|
2254
|
+
target = form.querySelector<HTMLElement>('[data-crud-injection-region]')
|
|
2255
|
+
}
|
|
2256
|
+
if (target && typeof target.scrollIntoView === 'function') {
|
|
2257
|
+
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
2258
|
+
}
|
|
2259
|
+
},
|
|
2260
|
+
[formId],
|
|
2261
|
+
)
|
|
2262
|
+
|
|
2217
2263
|
const updateEditedFieldMarker = React.useCallback((
|
|
2218
2264
|
id: string,
|
|
2219
2265
|
nextValue: unknown,
|
|
@@ -2717,6 +2763,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
2717
2763
|
}
|
|
2718
2764
|
const message = result.message || t('ui.forms.flash.saveBlocked', 'Save blocked by validation')
|
|
2719
2765
|
flash(message, 'error')
|
|
2766
|
+
scrollToInjectionBlockedTarget(result.fieldErrors)
|
|
2720
2767
|
setPending(false)
|
|
2721
2768
|
return
|
|
2722
2769
|
}
|
|
@@ -3007,6 +3054,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
3007
3054
|
wrapperClassName={wrapperClassName}
|
|
3008
3055
|
entityIdForField={primaryEntityId ?? undefined}
|
|
3009
3056
|
recordId={recordId}
|
|
3057
|
+
markRequired={widgetRequiredFieldIds.has(f.id)}
|
|
3010
3058
|
/>
|
|
3011
3059
|
)
|
|
3012
3060
|
})}
|
|
@@ -3299,7 +3347,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
3299
3347
|
if (g.component) {
|
|
3300
3348
|
customFieldsInnerNodes.push(
|
|
3301
3349
|
<div key={`${g.id}-component`} className="rounded-lg border bg-card px-4 py-3">
|
|
3302
|
-
{g.component({ values, setValue, errors })}
|
|
3350
|
+
{g.component({ values, setValue, errors, requiredFieldIds: widgetRequiredFieldIds })}
|
|
3303
3351
|
</div>,
|
|
3304
3352
|
)
|
|
3305
3353
|
}
|
|
@@ -3346,7 +3394,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
3346
3394
|
continue
|
|
3347
3395
|
}
|
|
3348
3396
|
|
|
3349
|
-
const componentNode = g.component ? g.component({ values, setValue, errors }) : null
|
|
3397
|
+
const componentNode = g.component ? g.component({ values, setValue, errors, requiredFieldIds: widgetRequiredFieldIds }) : null
|
|
3350
3398
|
if (g.bare) {
|
|
3351
3399
|
if (componentNode) {
|
|
3352
3400
|
nodes.push(<React.Fragment key={g.id}>{componentNode}</React.Fragment>)
|
|
@@ -3475,7 +3523,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
3475
3523
|
) : (
|
|
3476
3524
|
<div className="space-y-3">{col1Content}</div>
|
|
3477
3525
|
)}
|
|
3478
|
-
{hasSecondaryColumn ? <div className="space-y-3">{col2Content}</div> : null}
|
|
3526
|
+
{hasSecondaryColumn ? <div className="space-y-3" data-crud-injection-region>{col2Content}</div> : null}
|
|
3479
3527
|
</div>
|
|
3480
3528
|
{formError && !Object.keys(errors).length ? <div className="text-sm text-status-error-text">{formError}</div> : null}
|
|
3481
3529
|
{hideFooterActions || formReadOnly ? null : (
|
|
@@ -3569,6 +3617,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
3569
3617
|
wrapperClassName={wrapperClassName}
|
|
3570
3618
|
entityIdForField={primaryEntityId ?? undefined}
|
|
3571
3619
|
recordId={recordId}
|
|
3620
|
+
markRequired={widgetRequiredFieldIds.has(f.id)}
|
|
3572
3621
|
/>
|
|
3573
3622
|
)
|
|
3574
3623
|
})}
|
|
@@ -4005,6 +4054,7 @@ type FieldControlProps = {
|
|
|
4005
4054
|
wrapperClassName?: string
|
|
4006
4055
|
entityIdForField?: string
|
|
4007
4056
|
recordId?: string
|
|
4057
|
+
markRequired?: boolean
|
|
4008
4058
|
}
|
|
4009
4059
|
|
|
4010
4060
|
function supportsWrapperBlurValidation(field: CrudField): boolean {
|
|
@@ -4118,6 +4168,7 @@ const FieldControl = React.memo(function FieldControlImpl({
|
|
|
4118
4168
|
wrapperClassName,
|
|
4119
4169
|
entityIdForField,
|
|
4120
4170
|
recordId,
|
|
4171
|
+
markRequired,
|
|
4121
4172
|
}: FieldControlProps) {
|
|
4122
4173
|
const t = useT()
|
|
4123
4174
|
const fieldSetValue = React.useCallback(
|
|
@@ -4170,7 +4221,7 @@ const FieldControl = React.memo(function FieldControlImpl({
|
|
|
4170
4221
|
{field.type !== 'checkbox' && field.label.trim().length > 0 ? (
|
|
4171
4222
|
<label className="block text-sm font-medium">
|
|
4172
4223
|
{field.label}
|
|
4173
|
-
{field.required ? <span className="text-status-error-text"> *</span> : null}
|
|
4224
|
+
{field.required || markRequired ? <span className="text-status-error-text"> *</span> : null}
|
|
4174
4225
|
</label>
|
|
4175
4226
|
) : null}
|
|
4176
4227
|
{field.type === 'text' && (
|
|
@@ -4477,6 +4528,7 @@ const FieldControl = React.memo(function FieldControlImpl({
|
|
|
4477
4528
|
prev.field.label === next.field.label &&
|
|
4478
4529
|
prev.field.description === next.field.description &&
|
|
4479
4530
|
prev.field.required === next.field.required &&
|
|
4531
|
+
prev.markRequired === next.markRequired &&
|
|
4480
4532
|
prev.value === next.value &&
|
|
4481
4533
|
prev.error === next.error &&
|
|
4482
4534
|
prev.options === next.options &&
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
jest.setTimeout(15000)
|
|
3
|
+
|
|
4
|
+
const pushMock = jest.fn()
|
|
5
|
+
const confirmDialogMock = jest.fn()
|
|
6
|
+
const triggerEventMock = jest.fn()
|
|
7
|
+
|
|
8
|
+
jest.mock('next/navigation', () => ({
|
|
9
|
+
useRouter: () => ({ push: pushMock }),
|
|
10
|
+
usePathname: () => '/',
|
|
11
|
+
useSearchParams: () => new URLSearchParams(),
|
|
12
|
+
}))
|
|
13
|
+
jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
|
|
14
|
+
jest.mock('../confirm-dialog', () => ({
|
|
15
|
+
useConfirmDialog: () => ({
|
|
16
|
+
confirm: confirmDialogMock,
|
|
17
|
+
ConfirmDialogElement: null,
|
|
18
|
+
}),
|
|
19
|
+
}))
|
|
20
|
+
jest.mock('../injection/InjectionSpot', () => ({
|
|
21
|
+
__esModule: true,
|
|
22
|
+
InjectionSpot: () => null,
|
|
23
|
+
useInjectionWidgets: () => ({ widgets: [], loading: false, error: null }),
|
|
24
|
+
useInjectionSpotEvents: () => ({ triggerEvent: triggerEventMock }),
|
|
25
|
+
}))
|
|
26
|
+
jest.mock('../injection/useInjectionDataWidgets', () => ({
|
|
27
|
+
__esModule: true,
|
|
28
|
+
useInjectionDataWidgets: () => ({ widgets: [], isLoading: false, error: null }),
|
|
29
|
+
}))
|
|
30
|
+
|
|
31
|
+
import * as React from 'react'
|
|
32
|
+
import { act, fireEvent } from '@testing-library/react'
|
|
33
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
34
|
+
import { CrudForm, type CrudField } from '../CrudForm'
|
|
35
|
+
|
|
36
|
+
describe('CrudForm injection-block scroll behavior', () => {
|
|
37
|
+
const fields: CrudField[] = [
|
|
38
|
+
{ id: 'name', label: 'Name', type: 'text' },
|
|
39
|
+
{ id: 'description', label: 'Description', type: 'text' },
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
let scrollSpy: jest.Mock
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
pushMock.mockReset()
|
|
46
|
+
confirmDialogMock.mockReset()
|
|
47
|
+
confirmDialogMock.mockResolvedValue(true)
|
|
48
|
+
triggerEventMock.mockReset()
|
|
49
|
+
scrollSpy = jest.fn()
|
|
50
|
+
// jsdom does not implement scrollIntoView
|
|
51
|
+
;(Element.prototype as unknown as { scrollIntoView: unknown }).scrollIntoView = scrollSpy
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('scrolls to the first errored field when an injection widget blocks save', async () => {
|
|
55
|
+
triggerEventMock.mockImplementation(async (event: string, data: Record<string, unknown>) => {
|
|
56
|
+
if (event === 'onBeforeSave') {
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
message: 'SEO helper: Description is missing.',
|
|
60
|
+
fieldErrors: { description: 'Description is required.' },
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (event === 'transformValidation') return data
|
|
64
|
+
return { ok: true, data }
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const { container } = renderWithProviders(
|
|
68
|
+
<CrudForm
|
|
69
|
+
title="Form"
|
|
70
|
+
fields={fields}
|
|
71
|
+
initialValues={{ name: 'A valid product name', description: '' }}
|
|
72
|
+
injectionSpotId="crud-form:catalog.product"
|
|
73
|
+
onSubmit={() => {}}
|
|
74
|
+
/>,
|
|
75
|
+
{ dict: { 'ui.forms.actions.save': 'Save' } },
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
const form = container.querySelector('form') as HTMLFormElement
|
|
79
|
+
const descriptionContainer = container.querySelector(
|
|
80
|
+
'[data-crud-field-id="description"]',
|
|
81
|
+
) as HTMLElement
|
|
82
|
+
|
|
83
|
+
await act(async () => {
|
|
84
|
+
fireEvent.submit(form)
|
|
85
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
86
|
+
await Promise.resolve()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
expect(scrollSpy).toHaveBeenCalled()
|
|
90
|
+
expect(scrollSpy.mock.instances[0]).toBe(descriptionContainer)
|
|
91
|
+
expect(scrollSpy).toHaveBeenCalledWith(expect.objectContaining({ block: 'center' }))
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('does not scroll when the injection widget allows save', async () => {
|
|
95
|
+
triggerEventMock.mockImplementation(async (event: string, data: Record<string, unknown>) => {
|
|
96
|
+
if (event === 'onBeforeSave') return { ok: true }
|
|
97
|
+
if (event === 'transformValidation') return data
|
|
98
|
+
return { ok: true, data }
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
const { container } = renderWithProviders(
|
|
102
|
+
<CrudForm
|
|
103
|
+
title="Form"
|
|
104
|
+
fields={fields}
|
|
105
|
+
initialValues={{ name: 'A valid product name', description: 'Long enough description text here.' }}
|
|
106
|
+
injectionSpotId="crud-form:catalog.product"
|
|
107
|
+
onSubmit={() => {}}
|
|
108
|
+
/>,
|
|
109
|
+
{ dict: { 'ui.forms.actions.save': 'Save' } },
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
const form = container.querySelector('form') as HTMLFormElement
|
|
113
|
+
|
|
114
|
+
await act(async () => {
|
|
115
|
+
fireEvent.submit(form)
|
|
116
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
117
|
+
await Promise.resolve()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
expect(scrollSpy).not.toHaveBeenCalled()
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
jest.setTimeout(15000)
|
|
3
|
+
|
|
4
|
+
const pushMock = jest.fn()
|
|
5
|
+
const triggerEventMock = jest.fn(async () => ({ ok: true }))
|
|
6
|
+
|
|
7
|
+
const requiredWidget = {
|
|
8
|
+
widgetId: 'catalog.injection.product-seo',
|
|
9
|
+
moduleId: 'catalog',
|
|
10
|
+
module: {
|
|
11
|
+
metadata: {
|
|
12
|
+
id: 'catalog.injection.product-seo',
|
|
13
|
+
title: 'Product SEO Helper',
|
|
14
|
+
enabled: true,
|
|
15
|
+
requiredFields: ['description'],
|
|
16
|
+
},
|
|
17
|
+
Widget: () => null,
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
jest.mock('next/navigation', () => ({
|
|
22
|
+
useRouter: () => ({ push: pushMock }),
|
|
23
|
+
usePathname: () => '/',
|
|
24
|
+
useSearchParams: () => new URLSearchParams(),
|
|
25
|
+
}))
|
|
26
|
+
jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
|
|
27
|
+
jest.mock('../confirm-dialog', () => ({
|
|
28
|
+
useConfirmDialog: () => ({ confirm: jest.fn(), ConfirmDialogElement: null }),
|
|
29
|
+
}))
|
|
30
|
+
jest.mock('../injection/InjectionSpot', () => ({
|
|
31
|
+
__esModule: true,
|
|
32
|
+
InjectionSpot: () => null,
|
|
33
|
+
useInjectionWidgets: () => ({ widgets: [requiredWidget], loading: false, error: null }),
|
|
34
|
+
useInjectionSpotEvents: () => ({ triggerEvent: triggerEventMock }),
|
|
35
|
+
}))
|
|
36
|
+
jest.mock('../injection/useInjectionDataWidgets', () => ({
|
|
37
|
+
__esModule: true,
|
|
38
|
+
useInjectionDataWidgets: () => ({ widgets: [], isLoading: false, error: null }),
|
|
39
|
+
}))
|
|
40
|
+
|
|
41
|
+
import * as React from 'react'
|
|
42
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
43
|
+
import { CrudForm, type CrudField } from '../CrudForm'
|
|
44
|
+
|
|
45
|
+
describe('CrudForm required marker from injection widget metadata', () => {
|
|
46
|
+
const fields: CrudField[] = [
|
|
47
|
+
{ id: 'name', label: 'Name', type: 'text' },
|
|
48
|
+
{ id: 'description', label: 'Description', type: 'text' },
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
it('renders a required marker on fields declared in an active widget requiredFields', () => {
|
|
52
|
+
const { container } = renderWithProviders(
|
|
53
|
+
<CrudForm
|
|
54
|
+
title="Form"
|
|
55
|
+
fields={fields}
|
|
56
|
+
initialValues={{ name: '', description: '' }}
|
|
57
|
+
injectionSpotId="crud-form:catalog.product"
|
|
58
|
+
onSubmit={() => {}}
|
|
59
|
+
/>,
|
|
60
|
+
{ dict: { 'ui.forms.actions.save': 'Save' } },
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const descriptionMarker = container.querySelector(
|
|
64
|
+
'[data-crud-field-id="description"] .text-status-error-text',
|
|
65
|
+
)
|
|
66
|
+
const nameMarker = container.querySelector(
|
|
67
|
+
'[data-crud-field-id="name"] .text-status-error-text',
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
expect(descriptionMarker).not.toBeNull()
|
|
71
|
+
expect(descriptionMarker?.textContent).toContain('*')
|
|
72
|
+
expect(nameMarker).toBeNull()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('passes the active widget requiredFields to custom group components via requiredFieldIds', () => {
|
|
76
|
+
let received: ReadonlySet<string> | undefined
|
|
77
|
+
const groups = [
|
|
78
|
+
{
|
|
79
|
+
id: 'builder',
|
|
80
|
+
component: (ctx: { requiredFieldIds?: ReadonlySet<string> }) => {
|
|
81
|
+
received = ctx.requiredFieldIds
|
|
82
|
+
return <div data-testid="builder" />
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
renderWithProviders(
|
|
88
|
+
<CrudForm
|
|
89
|
+
title="Form"
|
|
90
|
+
fields={[]}
|
|
91
|
+
groups={groups}
|
|
92
|
+
initialValues={{ description: '' }}
|
|
93
|
+
injectionSpotId="crud-form:catalog.product"
|
|
94
|
+
onSubmit={() => {}}
|
|
95
|
+
/>,
|
|
96
|
+
{ dict: { 'ui.forms.actions.save': 'Save' } },
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
expect(received).toBeDefined()
|
|
100
|
+
expect(Array.from(received ?? [])).toContain('description')
|
|
101
|
+
})
|
|
102
|
+
})
|