@open-mercato/ui 0.4.6-develop-ce2a0728a5 → 0.4.6-develop-4d77832982

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.
Files changed (48) hide show
  1. package/AGENTS.md +16 -0
  2. package/dist/backend/CrudForm.js +138 -17
  3. package/dist/backend/CrudForm.js.map +3 -3
  4. package/dist/backend/DataTable.js +297 -24
  5. package/dist/backend/DataTable.js.map +3 -3
  6. package/dist/backend/detail/ActivitiesSection.js +11 -1
  7. package/dist/backend/detail/ActivitiesSection.js.map +2 -2
  8. package/dist/backend/detail/AddressesSection.js +11 -1
  9. package/dist/backend/detail/AddressesSection.js.map +2 -2
  10. package/dist/backend/detail/AttachmentsSection.js +11 -1
  11. package/dist/backend/detail/AttachmentsSection.js.map +2 -2
  12. package/dist/backend/detail/CustomDataSection.js +11 -1
  13. package/dist/backend/detail/CustomDataSection.js.map +2 -2
  14. package/dist/backend/detail/DetailFieldsSection.js +11 -1
  15. package/dist/backend/detail/DetailFieldsSection.js.map +2 -2
  16. package/dist/backend/detail/NotesSection.js +11 -1
  17. package/dist/backend/detail/NotesSection.js.map +2 -2
  18. package/dist/backend/detail/TagsSection.js +11 -1
  19. package/dist/backend/detail/TagsSection.js.map +2 -2
  20. package/dist/backend/injection/ComponentOverrideProvider.js +54 -0
  21. package/dist/backend/injection/ComponentOverrideProvider.js.map +7 -0
  22. package/dist/backend/injection/InjectedField.js +166 -0
  23. package/dist/backend/injection/InjectedField.js.map +7 -0
  24. package/dist/backend/injection/spotIds.js +5 -1
  25. package/dist/backend/injection/spotIds.js.map +2 -2
  26. package/dist/backend/injection/useRegisteredComponent.js +89 -0
  27. package/dist/backend/injection/useRegisteredComponent.js.map +7 -0
  28. package/dist/backend/injection/visibility-utils.js +29 -0
  29. package/dist/backend/injection/visibility-utils.js.map +7 -0
  30. package/package.json +2 -2
  31. package/src/backend/AGENTS.md +7 -0
  32. package/src/backend/CrudForm.tsx +144 -16
  33. package/src/backend/DataTable.tsx +342 -22
  34. package/src/backend/__tests__/DataTable.extensions.test.tsx +115 -0
  35. package/src/backend/__tests__/DataTable.namespaces.test.ts +32 -0
  36. package/src/backend/__tests__/component-replacement.test.tsx +232 -0
  37. package/src/backend/detail/ActivitiesSection.tsx +17 -1
  38. package/src/backend/detail/AddressesSection.tsx +17 -1
  39. package/src/backend/detail/AttachmentsSection.tsx +17 -1
  40. package/src/backend/detail/CustomDataSection.tsx +17 -1
  41. package/src/backend/detail/DetailFieldsSection.tsx +17 -1
  42. package/src/backend/detail/NotesSection.tsx +17 -1
  43. package/src/backend/detail/TagsSection.tsx +17 -1
  44. package/src/backend/injection/ComponentOverrideProvider.tsx +65 -0
  45. package/src/backend/injection/InjectedField.tsx +194 -0
  46. package/src/backend/injection/spotIds.ts +4 -0
  47. package/src/backend/injection/useRegisteredComponent.tsx +106 -0
  48. package/src/backend/injection/visibility-utils.ts +31 -0
@@ -0,0 +1,115 @@
1
+ import * as React from 'react'
2
+ import { renderToString } from 'react-dom/server'
3
+ import type { ColumnDef } from '@tanstack/react-table'
4
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
5
+ import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
6
+ import { DataTable } from '../DataTable'
7
+
8
+ jest.mock('next/navigation', () => ({
9
+ useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn(), refresh: jest.fn() }),
10
+ }))
11
+
12
+ const useInjectionDataWidgetsMock = jest.fn()
13
+ jest.mock('../injection/useInjectionDataWidgets', () => ({
14
+ useInjectionDataWidgets: (spotId: string) => useInjectionDataWidgetsMock(spotId),
15
+ }))
16
+
17
+ type Row = { id: string; name: string }
18
+
19
+ describe('DataTable extensions', () => {
20
+ beforeEach(() => {
21
+ useInjectionDataWidgetsMock.mockImplementation(() => ({ widgets: [], isLoading: false, error: null }))
22
+ })
23
+
24
+ it('renders injected columns from data-table extension surface', () => {
25
+ useInjectionDataWidgetsMock.mockImplementation((spotId: string) => {
26
+ if (spotId === 'data-table:customers.people:columns') {
27
+ return {
28
+ widgets: [
29
+ {
30
+ metadata: { id: 'test.columns' },
31
+ columns: [
32
+ {
33
+ id: 'ext_col',
34
+ header: 'Injected',
35
+ accessorKey: 'name',
36
+ sortable: false,
37
+ },
38
+ ],
39
+ },
40
+ ],
41
+ isLoading: false,
42
+ error: null,
43
+ }
44
+ }
45
+ return { widgets: [], isLoading: false, error: null }
46
+ })
47
+
48
+ const columns: ColumnDef<Row>[] = [{ accessorKey: 'name', header: 'Name' }]
49
+ const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } })
50
+ const html = renderToString(
51
+ React.createElement(
52
+ QueryClientProvider as any,
53
+ { client: queryClient },
54
+ React.createElement(
55
+ I18nProvider as any,
56
+ { locale: 'en', dict: {} },
57
+ React.createElement(DataTable as any, {
58
+ columns,
59
+ data: [{ id: 'r1', name: 'Alice' }],
60
+ injectionSpotId: 'data-table:customers.people',
61
+ }),
62
+ ),
63
+ ),
64
+ )
65
+
66
+ expect(html).toContain('Injected')
67
+ queryClient.clear()
68
+ })
69
+
70
+ it('renders injected bulk action button when bulk extension exists', () => {
71
+ useInjectionDataWidgetsMock.mockImplementation((spotId: string) => {
72
+ if (spotId === 'data-table:customers.people:bulk-actions') {
73
+ return {
74
+ widgets: [
75
+ {
76
+ metadata: { id: 'test.bulk-actions' },
77
+ bulkActions: [
78
+ {
79
+ id: 'bulk-normal',
80
+ label: 'Set normal',
81
+ onExecute: async () => ({ ok: true }),
82
+ },
83
+ ],
84
+ },
85
+ ],
86
+ isLoading: false,
87
+ error: null,
88
+ }
89
+ }
90
+ return { widgets: [], isLoading: false, error: null }
91
+ })
92
+
93
+ const columns: ColumnDef<Row>[] = [{ accessorKey: 'name', header: 'Name' }]
94
+ const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } })
95
+ const html = renderToString(
96
+ React.createElement(
97
+ QueryClientProvider as any,
98
+ { client: queryClient },
99
+ React.createElement(
100
+ I18nProvider as any,
101
+ { locale: 'en', dict: {} },
102
+ React.createElement(DataTable as any, {
103
+ columns,
104
+ data: [{ id: 'r1', name: 'Alice' }],
105
+ injectionSpotId: 'data-table:customers.people',
106
+ }),
107
+ ),
108
+ ),
109
+ )
110
+
111
+ expect(html).toContain('Set normal')
112
+ expect(html).toContain('Select all rows')
113
+ queryClient.clear()
114
+ })
115
+ })
@@ -0,0 +1,32 @@
1
+ import { withDataTableNamespaces } from '../DataTable'
2
+
3
+ describe('withDataTableNamespaces', () => {
4
+ it('preserves mapped row fields and appends namespaced payload fields', () => {
5
+ const row = { id: '1', name: 'Alice' }
6
+ const source = {
7
+ id: '1',
8
+ name: 'Alice',
9
+ _example: { priority: 'high' },
10
+ _other: { flag: true },
11
+ ignored: 'value',
12
+ }
13
+
14
+ expect(withDataTableNamespaces(row, source)).toEqual({
15
+ id: '1',
16
+ name: 'Alice',
17
+ _example: { priority: 'high' },
18
+ _other: { flag: true },
19
+ })
20
+ })
21
+
22
+ it('does not overwrite mapped values with non-namespaced source fields', () => {
23
+ const row = { id: '1', status: 'mapped' }
24
+ const source = { id: '1', status: 'source', plain: 123 }
25
+
26
+ expect(withDataTableNamespaces(row, source)).toEqual({
27
+ id: '1',
28
+ status: 'mapped',
29
+ })
30
+ })
31
+ })
32
+
@@ -0,0 +1,232 @@
1
+ /** @jest-environment jsdom */
2
+ import * as React from 'react'
3
+ import { render, screen } from '@testing-library/react'
4
+ import { z } from 'zod'
5
+ import {
6
+ registerComponent,
7
+ registerComponentOverrides,
8
+ ComponentReplacementHandles,
9
+ } from '@open-mercato/shared/modules/widgets/component-registry'
10
+ import { useRegisteredComponent } from '../injection/useRegisteredComponent'
11
+ import { DetailFieldsSection } from '../detail/DetailFieldsSection'
12
+
13
+ describe('component replacement', () => {
14
+ afterEach(() => {
15
+ registerComponentOverrides([])
16
+ })
17
+
18
+ it('falls back to passed component when no registry entry exists', () => {
19
+ const Fallback = ({ value }: { value: string }) => <div>{value}</div>
20
+
21
+ function Consumer() {
22
+ const Resolved = useRegisteredComponent<{ value: string }>('missing.component', Fallback)
23
+ return <Resolved value="fallback rendered" />
24
+ }
25
+
26
+ render(<Consumer />)
27
+ expect(screen.getByText('fallback rendered')).toBeInTheDocument()
28
+ })
29
+
30
+ it('applies wrapper override around registered component', () => {
31
+ const componentId = 'test.section'
32
+ const Base = ({ value }: { value: string }) => <span>{value}</span>
33
+
34
+ registerComponent({
35
+ id: componentId,
36
+ component: Base,
37
+ metadata: {
38
+ module: 'test',
39
+ },
40
+ })
41
+ registerComponentOverrides([
42
+ {
43
+ target: { componentId },
44
+ priority: 10,
45
+ metadata: { module: 'test' },
46
+ wrapper: (Original) => {
47
+ const Wrapped = (props: { value: string }) => (
48
+ <div data-testid="wrapped">
49
+ <Original {...props} />
50
+ </div>
51
+ )
52
+ Wrapped.displayName = 'Wrapped'
53
+ return Wrapped
54
+ },
55
+ },
56
+ ])
57
+
58
+ function Consumer() {
59
+ const Resolved = useRegisteredComponent<{ value: string }>(componentId)
60
+ return <Resolved value="wrapped rendered" />
61
+ }
62
+
63
+ render(<Consumer />)
64
+ expect(screen.getByTestId('wrapped')).toBeInTheDocument()
65
+ expect(screen.getByText('wrapped rendered')).toBeInTheDocument()
66
+ })
67
+
68
+ it('renders section handle for DetailFieldsSection', () => {
69
+ render(
70
+ <DetailFieldsSection
71
+ fields={[
72
+ {
73
+ key: 'name',
74
+ kind: 'custom',
75
+ label: 'Name',
76
+ emptyLabel: '-',
77
+ render: () => <span>Name</span>,
78
+ },
79
+ ]}
80
+ />,
81
+ )
82
+
83
+ const handle = ComponentReplacementHandles.section('ui.detail', 'DetailFieldsSection')
84
+ const wrapper = document.querySelector(`[data-component-handle="${handle}"]`)
85
+ expect(wrapper).not.toBeNull()
86
+ })
87
+
88
+ it('falls back to original component when replacement props schema validation fails', () => {
89
+ const componentId = 'test.replace.schema'
90
+ const Original = ({ value }: { value: string }) => <span>original:{value}</span>
91
+ const Replacement = ({ value }: { value: string }) => <span>replacement:{value}</span>
92
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
93
+
94
+ registerComponent({
95
+ id: componentId,
96
+ component: Original,
97
+ metadata: { module: 'test' },
98
+ })
99
+ registerComponentOverrides([
100
+ {
101
+ target: { componentId },
102
+ priority: 100,
103
+ metadata: { module: 'test' },
104
+ replacement: Replacement,
105
+ propsSchema: z.object({ value: z.string().min(4) }),
106
+ },
107
+ ])
108
+
109
+ function Consumer() {
110
+ const Resolved = useRegisteredComponent<{ value: string }>(componentId)
111
+ return <Resolved value="bad" />
112
+ }
113
+
114
+ render(<Consumer />)
115
+ expect(screen.getByText('original:bad')).toBeInTheDocument()
116
+ expect(screen.queryByText('replacement:bad')).toBeNull()
117
+ expect(consoleSpy).toHaveBeenCalled()
118
+ consoleSpy.mockRestore()
119
+ })
120
+
121
+ it('uses highest-priority replacement when multiple replacements are registered', () => {
122
+ const componentId = 'test.replace.priority'
123
+ const Original = ({ value }: { value: string }) => <span>original:{value}</span>
124
+ const LowPriorityReplacement = ({ value }: { value: string }) => <span>low:{value}</span>
125
+ const HighPriorityReplacement = ({ value }: { value: string }) => <span>high:{value}</span>
126
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
127
+
128
+ registerComponent({
129
+ id: componentId,
130
+ component: Original,
131
+ metadata: { module: 'test' },
132
+ })
133
+ registerComponentOverrides([
134
+ {
135
+ target: { componentId },
136
+ priority: 50,
137
+ metadata: { module: 'low-priority-module' },
138
+ replacement: LowPriorityReplacement,
139
+ propsSchema: z.object({ value: z.string() }),
140
+ },
141
+ {
142
+ target: { componentId },
143
+ priority: 100,
144
+ metadata: { module: 'high-priority-module' },
145
+ replacement: HighPriorityReplacement,
146
+ propsSchema: z.object({ value: z.string() }),
147
+ },
148
+ ])
149
+
150
+ function Consumer() {
151
+ const Resolved = useRegisteredComponent<{ value: string }>(componentId)
152
+ return <Resolved value="picked" />
153
+ }
154
+
155
+ render(<Consumer />)
156
+ expect(screen.queryByText('low:picked')).toBeNull()
157
+ expect(screen.getByText('high:picked')).toBeInTheDocument()
158
+ expect(warnSpy).toHaveBeenCalledWith(
159
+ `[UMES] Multiple replacements registered for "${componentId}". Highest-priority replacement is applied.`,
160
+ )
161
+ warnSpy.mockRestore()
162
+ })
163
+
164
+ it('applies propsTransform overrides before rendering', () => {
165
+ const componentId = 'test.props.transform'
166
+ const Base = ({ value, className }: { value: string; className?: string }) => (
167
+ <span data-testid="props-transform-target" className={className}>
168
+ {value}
169
+ </span>
170
+ )
171
+
172
+ registerComponent({
173
+ id: componentId,
174
+ component: Base,
175
+ metadata: { module: 'test' },
176
+ })
177
+ registerComponentOverrides([
178
+ {
179
+ target: { componentId },
180
+ priority: 50,
181
+ metadata: { module: 'props-transform-module' },
182
+ propsTransform: (props: { value: string; className?: string }) => ({
183
+ ...props,
184
+ className: 'injected-class',
185
+ }),
186
+ },
187
+ ])
188
+
189
+ function Consumer() {
190
+ const Resolved = useRegisteredComponent<{ value: string; className?: string }>(componentId)
191
+ return <Resolved value="props transformed" />
192
+ }
193
+
194
+ render(<Consumer />)
195
+ expect(screen.getByTestId('props-transform-target')).toHaveClass('injected-class')
196
+ expect(screen.getByText('props transformed')).toBeInTheDocument()
197
+ })
198
+
199
+ it('falls back to original component when replacement crashes at render time', () => {
200
+ const componentId = 'test.replace.crash'
201
+ const Original = ({ value }: { value: string }) => <span>original:{value}</span>
202
+ const CrashingReplacement = () => {
203
+ throw new Error('replacement render crash')
204
+ }
205
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
206
+
207
+ registerComponent({
208
+ id: componentId,
209
+ component: Original,
210
+ metadata: { module: 'test' },
211
+ })
212
+ registerComponentOverrides([
213
+ {
214
+ target: { componentId },
215
+ priority: 100,
216
+ metadata: { module: 'crashing-module' },
217
+ replacement: CrashingReplacement,
218
+ propsSchema: z.object({ value: z.string() }),
219
+ },
220
+ ])
221
+
222
+ function Consumer() {
223
+ const Resolved = useRegisteredComponent<{ value: string }>(componentId)
224
+ return <Resolved value="fallback" />
225
+ }
226
+
227
+ render(<Consumer />)
228
+ expect(screen.getByText('original:fallback')).toBeInTheDocument()
229
+ expect(errorSpy).toHaveBeenCalled()
230
+ errorSpy.mockRestore()
231
+ })
232
+ })
@@ -16,6 +16,8 @@ import { useT } from '@open-mercato/shared/lib/i18n/context'
16
16
  import { createTranslatorWithFallback } from '@open-mercato/shared/lib/i18n/translate'
17
17
  import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@open-mercato/ui/primitives/dialog'
18
18
  import { useConfirmDialog } from '../confirm-dialog'
19
+ import { ComponentReplacementHandles } from '@open-mercato/shared/modules/widgets/component-registry'
20
+ import { useRegisteredComponent } from '../injection/useRegisteredComponent'
19
21
 
20
22
  type Translator = (key: string, fallback?: string, params?: Record<string, string | number>) => string
21
23
 
@@ -692,7 +694,7 @@ export type ActivitiesSectionProps<C = unknown> = {
692
694
  manageHref?: string
693
695
  }
694
696
 
695
- export function ActivitiesSection<C = unknown>({
697
+ function ActivitiesSectionImpl<C = unknown>({
696
698
  entityId,
697
699
  dealId,
698
700
  addActionLabel,
@@ -1248,4 +1250,18 @@ export function ActivitiesSection<C = unknown>({
1248
1250
  )
1249
1251
  }
1250
1252
 
1253
+ export function ActivitiesSection<C = unknown>(props: ActivitiesSectionProps<C>) {
1254
+ const handle = ComponentReplacementHandles.section('ui.detail', 'ActivitiesSection')
1255
+ const Resolved = useRegisteredComponent<ActivitiesSectionProps<C>>(
1256
+ handle,
1257
+ ActivitiesSectionImpl as React.ComponentType<ActivitiesSectionProps<C>>,
1258
+ )
1259
+
1260
+ return (
1261
+ <div data-component-handle={handle}>
1262
+ <Resolved {...props} />
1263
+ </div>
1264
+ )
1265
+ }
1266
+
1251
1267
  export default ActivitiesSection
@@ -8,6 +8,8 @@ import { useT } from '@open-mercato/shared/lib/i18n/context'
8
8
  import AddressTiles, { type AddressInput, type AddressValue } from './AddressTiles'
9
9
  import type { AddressTypesAdapter } from './AddressEditor'
10
10
  import type { AddressFormatStrategy } from './addressFormat'
11
+ import { ComponentReplacementHandles } from '@open-mercato/shared/modules/widgets/component-registry'
12
+ import { useRegisteredComponent } from '../injection/useRegisteredComponent'
11
13
 
12
14
  type Translator = (key: string, fallback?: string, params?: Record<string, string | number>) => string
13
15
 
@@ -69,7 +71,7 @@ function generateTempId() {
69
71
  return `tmp_${Math.random().toString(36).slice(2)}`
70
72
  }
71
73
 
72
- export function AddressesSection<C = unknown>({
74
+ function AddressesSectionImpl<C = unknown>({
73
75
  entityId,
74
76
  emptyLabel,
75
77
  addActionLabel,
@@ -343,4 +345,18 @@ export function AddressesSection<C = unknown>({
343
345
  )
344
346
  }
345
347
 
348
+ export function AddressesSection<C = unknown>(props: AddressesSectionProps<C>) {
349
+ const handle = ComponentReplacementHandles.section('ui.detail', 'AddressesSection')
350
+ const Resolved = useRegisteredComponent<AddressesSectionProps<C>>(
351
+ handle,
352
+ AddressesSectionImpl as React.ComponentType<AddressesSectionProps<C>>,
353
+ )
354
+
355
+ return (
356
+ <div data-component-handle={handle}>
357
+ <Resolved {...props} />
358
+ </div>
359
+ )
360
+ }
361
+
346
362
  export default AddressesSection
@@ -9,6 +9,8 @@ import { cn } from '@open-mercato/shared/lib/utils'
9
9
  import { AttachmentVisualPreview, formatAttachmentFileSize } from './AttachmentVisualPreview'
10
10
  import { AttachmentDeleteDialog } from './AttachmentDeleteDialog'
11
11
  import { AttachmentMetadataDialog, type AttachmentItem, type AttachmentMetadataSavePayload } from './AttachmentMetadataDialog'
12
+ import { ComponentReplacementHandles } from '@open-mercato/shared/modules/widgets/component-registry'
13
+ import { useRegisteredComponent } from '../injection/useRegisteredComponent'
12
14
 
13
15
  type AttachmentsResponse = {
14
16
  items?: AttachmentItem[]
@@ -26,7 +28,7 @@ type Props = {
26
28
  onChanged?: () => void
27
29
  }
28
30
 
29
- export function AttachmentsSection({
31
+ function AttachmentsSectionImpl({
30
32
  entityId,
31
33
  recordId,
32
34
  title,
@@ -309,3 +311,17 @@ export function AttachmentsSection({
309
311
  </div>
310
312
  )
311
313
  }
314
+
315
+ export function AttachmentsSection(props: Props) {
316
+ const handle = ComponentReplacementHandles.section('ui.detail', 'AttachmentsSection')
317
+ const Resolved = useRegisteredComponent<Props>(
318
+ handle,
319
+ AttachmentsSectionImpl as React.ComponentType<Props>,
320
+ )
321
+
322
+ return (
323
+ <div data-component-handle={handle}>
324
+ <Resolved {...props} />
325
+ </div>
326
+ )
327
+ }
@@ -19,6 +19,8 @@ import {
19
19
  import { ensureDictionaryEntries } from '@open-mercato/core/modules/dictionaries/components/hooks/useDictionaryEntries'
20
20
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
21
21
  import { cn } from '@open-mercato/shared/lib/utils'
22
+ import { ComponentReplacementHandles } from '@open-mercato/shared/modules/widgets/component-registry'
23
+ import { useRegisteredComponent } from '../injection/useRegisteredComponent'
22
24
 
23
25
  type MarkdownPreviewProps = { children: string; className?: string; remarkPlugins?: PluggableList }
24
26
 
@@ -216,7 +218,7 @@ function formatFieldValue(
216
218
  return resolved
217
219
  }
218
220
 
219
- export function CustomDataSection({
221
+ function CustomDataSectionImpl({
220
222
  entityId,
221
223
  entityIds,
222
224
  values,
@@ -527,4 +529,18 @@ export function CustomDataSection({
527
529
  )
528
530
  }
529
531
 
532
+ export function CustomDataSection(props: CustomDataSectionProps) {
533
+ const handle = ComponentReplacementHandles.section('ui.detail', 'CustomDataSection')
534
+ const Resolved = useRegisteredComponent<CustomDataSectionProps>(
535
+ handle,
536
+ CustomDataSectionImpl as React.ComponentType<CustomDataSectionProps>,
537
+ )
538
+
539
+ return (
540
+ <div data-component-handle={handle}>
541
+ <Resolved {...props} />
542
+ </div>
543
+ )
544
+ }
545
+
530
546
  export default CustomDataSection
@@ -1,6 +1,8 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from 'react'
4
+ import { ComponentReplacementHandles } from '@open-mercato/shared/modules/widgets/component-registry'
5
+ import { useRegisteredComponent } from '../injection/useRegisteredComponent'
4
6
  import {
5
7
  InlineMultilineEditor,
6
8
  InlineSelectEditor,
@@ -66,7 +68,7 @@ export type DetailFieldsSectionProps = {
66
68
  className?: string
67
69
  }
68
70
 
69
- export function DetailFieldsSection({ fields, className }: DetailFieldsSectionProps) {
71
+ function DetailFieldsSectionImpl({ fields, className }: DetailFieldsSectionProps) {
70
72
  return (
71
73
  <div className={['grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3', className].filter(Boolean).join(' ')}>
72
74
  {fields.map((field) => {
@@ -145,3 +147,17 @@ export function DetailFieldsSection({ fields, className }: DetailFieldsSectionPr
145
147
  </div>
146
148
  )
147
149
  }
150
+
151
+ export function DetailFieldsSection(props: DetailFieldsSectionProps) {
152
+ const handle = ComponentReplacementHandles.section('ui.detail', 'DetailFieldsSection')
153
+ const Resolved = useRegisteredComponent<DetailFieldsSectionProps>(
154
+ handle,
155
+ DetailFieldsSectionImpl as React.ComponentType<DetailFieldsSectionProps>,
156
+ )
157
+
158
+ return (
159
+ <div data-component-handle={handle}>
160
+ <Resolved {...props} />
161
+ </div>
162
+ )
163
+ }
@@ -16,6 +16,8 @@ import { LoadingMessage } from './LoadingMessage'
16
16
  import { TabEmptyState } from './TabEmptyState'
17
17
  import { useConfirmDialog } from '../confirm-dialog'
18
18
  import { formatDateTime } from '@open-mercato/shared/lib/time'
19
+ import { ComponentReplacementHandles } from '@open-mercato/shared/modules/widgets/component-registry'
20
+ import { useRegisteredComponent } from '../injection/useRegisteredComponent'
19
21
  type Translator = (key: string, fallback?: string, params?: Record<string, string | number>) => string
20
22
 
21
23
  export type SectionAction = {
@@ -262,7 +264,7 @@ export function mapCommentSummary(input: unknown): CommentSummary {
262
264
  }
263
265
  }
264
266
 
265
- export function NotesSection<C = unknown>({
267
+ function NotesSectionImpl<C = unknown>({
266
268
  entityId,
267
269
  dealId,
268
270
  emptyLabel,
@@ -1246,3 +1248,17 @@ export function NotesSection<C = unknown>({
1246
1248
  </div>
1247
1249
  )
1248
1250
  }
1251
+
1252
+ export function NotesSection<C = unknown>(props: NotesSectionProps<C>) {
1253
+ const handle = ComponentReplacementHandles.section('ui.detail', 'NotesSection')
1254
+ const Resolved = useRegisteredComponent<NotesSectionProps<C>>(
1255
+ handle,
1256
+ NotesSectionImpl as React.ComponentType<NotesSectionProps<C>>,
1257
+ )
1258
+
1259
+ return (
1260
+ <div data-component-handle={handle}>
1261
+ <Resolved {...props} />
1262
+ </div>
1263
+ )
1264
+ }
@@ -6,6 +6,8 @@ import { Button } from '@open-mercato/ui/primitives/button'
6
6
  import { TagsInput } from '@open-mercato/ui/backend/inputs/TagsInput'
7
7
  import { DataLoader } from '@open-mercato/ui/primitives/DataLoader'
8
8
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
9
+ import { ComponentReplacementHandles } from '@open-mercato/shared/modules/widgets/component-registry'
10
+ import { useRegisteredComponent } from '../injection/useRegisteredComponent'
9
11
 
10
12
  export type TagOption = {
11
13
  id: string
@@ -44,7 +46,7 @@ export type TagsSectionProps = {
44
46
  labels: TagsSectionLabels
45
47
  }
46
48
 
47
- export function TagsSection({
49
+ function TagsSectionImpl({
48
50
  title,
49
51
  tags,
50
52
  onChange,
@@ -311,4 +313,18 @@ export function TagsSection({
311
313
  )
312
314
  }
313
315
 
316
+ export function TagsSection(props: TagsSectionProps) {
317
+ const handle = ComponentReplacementHandles.section('ui.detail', 'TagsSection')
318
+ const Resolved = useRegisteredComponent<TagsSectionProps>(
319
+ handle,
320
+ TagsSectionImpl as React.ComponentType<TagsSectionProps>,
321
+ )
322
+
323
+ return (
324
+ <div data-component-handle={handle}>
325
+ <Resolved {...props} />
326
+ </div>
327
+ )
328
+ }
329
+
314
330
  export default TagsSection