@open-mercato/ui 0.6.6-develop.6410.1.2ad881013e → 0.6.6-develop.6412.1.e99f3c32d9

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.6410.1.2ad881013e",
3
+ "version": "0.6.6-develop.6412.1.e99f3c32d9",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -155,13 +155,13 @@
155
155
  "remark-gfm": "^4.0.1"
156
156
  },
157
157
  "peerDependencies": {
158
- "@open-mercato/shared": "0.6.6-develop.6410.1.2ad881013e",
158
+ "@open-mercato/shared": "0.6.6-develop.6412.1.e99f3c32d9",
159
159
  "react": ">=18.0.0",
160
160
  "react-dom": ">=18.0.0",
161
161
  "react-is": ">=18.0.0"
162
162
  },
163
163
  "devDependencies": {
164
- "@open-mercato/shared": "0.6.6-develop.6410.1.2ad881013e",
164
+ "@open-mercato/shared": "0.6.6-develop.6412.1.e99f3c32d9",
165
165
  "@testing-library/dom": "^10.4.1",
166
166
  "@testing-library/jest-dom": "^6.9.1",
167
167
  "@testing-library/react": "^16.3.1",
@@ -103,6 +103,7 @@ import { SortableGroupHandleProvider, type SortableGroupHandleProps } from './cr
103
103
  import { useGroupOrder } from './crud/useGroupOrder'
104
104
  import { InjectedField } from './injection/InjectedField'
105
105
  import type { InjectionFieldDefinition, FieldContext } from '@open-mercato/shared/modules/widgets/injection'
106
+ import { insertByInjectionPlacement } from '@open-mercato/shared/modules/widgets/injection-position'
106
107
  import { evaluateInjectedVisibility } from './injection/visibility-utils'
107
108
  import { ComponentReplacementHandles } from '@open-mercato/shared/modules/widgets/component-registry'
108
109
  import { RichEditor, type RichEditorLabels } from '../primitives/rich-editor'
@@ -1762,21 +1763,27 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1762
1763
  )
1763
1764
 
1764
1765
  const injectedCrudFields = React.useMemo<CrudField[]>(() => {
1765
- return injectedFieldDefinitions.map((definition) => ({
1766
- id: definition.id,
1767
- label: definition.label,
1768
- type: 'custom',
1769
- readOnly: definition.readOnly,
1770
- component: ({ value, setValue, values: formValues }) => (
1771
- <InjectedField
1772
- field={definition}
1773
- value={value}
1774
- onChange={(_, nextValue) => setValue(nextValue)}
1775
- context={injectedFieldContext}
1776
- formData={(formValues ?? values) as Record<string, unknown>}
1777
- />
1778
- ),
1779
- }))
1766
+ return injectedFieldDefinitions.map((definition) => {
1767
+ // InjectedField renders its own i18n-resolved <Label> for every field type
1768
+ // except a custom component (type 'custom' + customComponent). Suppress the
1769
+ // CrudForm row label in those cases so it is not rendered twice (#3047).
1770
+ const injectedFieldRendersOwnLabel = !(definition.type === 'custom' && definition.customComponent)
1771
+ return {
1772
+ id: definition.id,
1773
+ label: injectedFieldRendersOwnLabel ? '' : definition.label,
1774
+ type: 'custom',
1775
+ readOnly: definition.readOnly,
1776
+ component: ({ value, setValue, values: formValues }) => (
1777
+ <InjectedField
1778
+ field={definition}
1779
+ value={value}
1780
+ onChange={(_, nextValue) => setValue(nextValue)}
1781
+ context={injectedFieldContext}
1782
+ formData={(formValues ?? values) as Record<string, unknown>}
1783
+ />
1784
+ ),
1785
+ }
1786
+ })
1780
1787
  }, [injectedFieldContext, injectedFieldDefinitions, values])
1781
1788
 
1782
1789
  const allFields = React.useMemo(() => {
@@ -1997,10 +2004,18 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1997
2004
  }
1998
2005
  if (index < 0) continue
1999
2006
  const fieldEntries = cloned[index].fields ?? []
2000
- if (!fieldEntries.some((entry) => typeof entry === 'string' && entry === definition.id)) {
2001
- fieldEntries.push(definition.id)
2002
- }
2003
- cloned[index].fields = fieldEntries
2007
+ const alreadyPresent = fieldEntries.some((entry) => {
2008
+ const entryId = typeof entry === 'string' ? entry : entry.id
2009
+ return entryId === definition.id
2010
+ })
2011
+ cloned[index].fields = alreadyPresent
2012
+ ? fieldEntries
2013
+ : insertByInjectionPlacement(
2014
+ fieldEntries,
2015
+ definition.id,
2016
+ definition.placement,
2017
+ (entry) => (typeof entry === 'string' ? entry : entry.id),
2018
+ )
2004
2019
  }
2005
2020
  return cloned
2006
2021
  }, [groups, injectedFieldDefinitions])
@@ -0,0 +1,134 @@
1
+ /** @jest-environment jsdom */
2
+ jest.setTimeout(15000)
3
+
4
+ // Injected field definitions for the CrudForm `:fields` injection spot. The
5
+ // hook is mocked so the test drives `injectedFieldDefinitions` directly without
6
+ // standing up the full injection registry/bootstrap. See issue #3047.
7
+ let injectedFieldWidgets: Array<{ fields: unknown[] }> = []
8
+
9
+ jest.mock('next/navigation', () => ({
10
+ useRouter: () => ({ push: () => {} }),
11
+ usePathname: () => '/',
12
+ useSearchParams: () => new URLSearchParams(),
13
+ }))
14
+ jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
15
+ jest.mock('../injection/InjectionSpot', () => ({
16
+ __esModule: true,
17
+ InjectionSpot: () => null,
18
+ useInjectionWidgets: () => ({ widgets: [], loading: false, error: null }),
19
+ useInjectionSpotEvents: () => ({ triggerEvent: jest.fn(async () => ({ ok: true, data: {} })) }),
20
+ }))
21
+ jest.mock('../injection/useInjectionDataWidgets', () => ({
22
+ __esModule: true,
23
+ useInjectionDataWidgets: () => ({ widgets: injectedFieldWidgets, isLoading: false, error: null }),
24
+ }))
25
+
26
+ import * as React from 'react'
27
+ import { waitFor } from '@testing-library/react'
28
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
29
+ import { CrudForm, type CrudField, type CrudFormGroup } from '../CrudForm'
30
+ import { InjectionPosition } from '@open-mercato/shared/modules/widgets/injection-position'
31
+
32
+ const baseFields: CrudField[] = [
33
+ { id: 'firstName', label: 'First name', type: 'text' },
34
+ { id: 'lastName', label: 'Last name', type: 'text' },
35
+ // A trailing field so that "after lastName" is distinct from "end of group":
36
+ // the buggy unconditional push would land the injected field after `email`.
37
+ { id: 'email', label: 'Email', type: 'text' },
38
+ ]
39
+
40
+ const groups: CrudFormGroup[] = [
41
+ { id: 'personalData', label: 'Personal data', fields: ['firstName', 'lastName', 'email'] },
42
+ ]
43
+
44
+ function orderedFieldIds(container: HTMLElement): string[] {
45
+ const seen: string[] = []
46
+ container.querySelectorAll('[data-crud-field-id]').forEach((node) => {
47
+ const id = node.getAttribute('data-crud-field-id')
48
+ if (id && !seen.includes(id)) seen.push(id)
49
+ })
50
+ return seen
51
+ }
52
+
53
+ describe('CrudForm group field injection (#3047)', () => {
54
+ afterEach(() => {
55
+ injectedFieldWidgets = []
56
+ })
57
+
58
+ it('honors placement when injecting a field into an existing group', async () => {
59
+ injectedFieldWidgets = [
60
+ {
61
+ fields: [
62
+ {
63
+ id: 'cf:middle_name',
64
+ label: 'Middle name',
65
+ type: 'text',
66
+ group: 'personalData',
67
+ placement: { position: InjectionPosition.After, relativeTo: 'lastName' },
68
+ },
69
+ ],
70
+ },
71
+ ]
72
+
73
+ const { container } = renderWithProviders(
74
+ React.createElement(CrudForm as any, {
75
+ title: 'Form',
76
+ entityId: 'customers:person',
77
+ fields: baseFields,
78
+ groups,
79
+ onSubmit: () => {},
80
+ }),
81
+ )
82
+
83
+ await waitFor(() => {
84
+ expect(container.querySelector('[data-crud-field-id="cf:middle_name"]')).toBeTruthy()
85
+ })
86
+
87
+ const ids = orderedFieldIds(container)
88
+ const lastNameIdx = ids.indexOf('lastName')
89
+ const middleIdx = ids.indexOf('cf:middle_name')
90
+ const firstNameIdx = ids.indexOf('firstName')
91
+ const emailIdx = ids.indexOf('email')
92
+ expect(firstNameIdx).toBeGreaterThanOrEqual(0)
93
+ expect(lastNameIdx).toBeGreaterThan(firstNameIdx)
94
+ // Injected field lands directly after `lastName` (placement honored), not at
95
+ // the end of the group after `email` (the pre-fix append behavior).
96
+ expect(middleIdx).toBe(lastNameIdx + 1)
97
+ expect(emailIdx).toBe(middleIdx + 1)
98
+ })
99
+
100
+ it('renders the injected field label exactly once', async () => {
101
+ injectedFieldWidgets = [
102
+ {
103
+ fields: [
104
+ {
105
+ id: 'cf:middle_name',
106
+ label: 'Middle name',
107
+ type: 'text',
108
+ group: 'personalData',
109
+ placement: { position: InjectionPosition.After, relativeTo: 'lastName' },
110
+ },
111
+ ],
112
+ },
113
+ ]
114
+
115
+ const { container } = renderWithProviders(
116
+ React.createElement(CrudForm as any, {
117
+ title: 'Form',
118
+ entityId: 'customers:person',
119
+ fields: baseFields,
120
+ groups,
121
+ onSubmit: () => {},
122
+ }),
123
+ )
124
+
125
+ await waitFor(() => {
126
+ expect(container.querySelector('[data-crud-field-id="cf:middle_name"]')).toBeTruthy()
127
+ })
128
+
129
+ const labelMatches = Array.from(container.querySelectorAll('label')).filter(
130
+ (node) => node.textContent?.trim() === 'Middle name',
131
+ )
132
+ expect(labelMatches).toHaveLength(1)
133
+ })
134
+ })