@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.5716.1.b108502d0d",
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.5716.1.b108502d0d",
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.5716.1.b108502d0d",
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",
@@ -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
+ })