@asteby/metacore-runtime-react 23.2.0 → 23.3.0

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.
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-row-actions.d.ts","sourceRoot":"","sources":["../src/dynamic-row-actions.tsx"],"names":[],"mappings":"AAoCA,OAAO,KAAK,EAAE,aAAa,EAAkB,MAAM,SAAS,CAAA;AAE5D,MAAM,WAAW,0BAA0B;IACvC,6DAA6D;IAC7D,KAAK,EAAE,MAAM,CAAA;IACb;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,2EAA2E;IAC3E,QAAQ,EAAE,aAAa,GAAG,IAAI,CAAA;IAC9B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7C,4EAA4E;IAC5E,SAAS,EAAE,MAAM,IAAI,CAAA;CACxB;AAED,MAAM,WAAW,iBAAiB;IAC9B,+CAA+C;IAC/C,oBAAoB,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IACjE;;;OAGG;IACH,OAAO,EAAE,KAAK,CAAC,YAAY,CAAA;CAC9B;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,EACjC,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,QAAQ,EACR,SAAS,GACZ,EAAE,0BAA0B,GAAG,iBAAiB,CAmHhD"}
1
+ {"version":3,"file":"dynamic-row-actions.d.ts","sourceRoot":"","sources":["../src/dynamic-row-actions.tsx"],"names":[],"mappings":"AAoCA,OAAO,KAAK,EAAE,aAAa,EAAkB,MAAM,SAAS,CAAA;AAE5D,MAAM,WAAW,0BAA0B;IACvC,6DAA6D;IAC7D,KAAK,EAAE,MAAM,CAAA;IACb;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,2EAA2E;IAC3E,QAAQ,EAAE,aAAa,GAAG,IAAI,CAAA;IAC9B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7C,4EAA4E;IAC5E,SAAS,EAAE,MAAM,IAAI,CAAA;CACxB;AAED,MAAM,WAAW,iBAAiB;IAC9B,+CAA+C;IAC/C,oBAAoB,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IACjE;;;OAGG;IACH,OAAO,EAAE,KAAK,CAAC,YAAY,CAAA;CAC9B;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,EACjC,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,QAAQ,EACR,SAAS,GACZ,EAAE,0BAA0B,GAAG,iBAAiB,CAqHhD"}
@@ -89,16 +89,18 @@ export function useDynamicRowActions({ model, endpoint, metadata, onAction, onRe
89
89
  try {
90
90
  const deleteEndpoint = endpoint ? `${endpoint}/${rowToDelete.id}` : `/data/${model}/${rowToDelete.id}`;
91
91
  const res = await api.delete(deleteEndpoint);
92
+ // CRUD estándar: no usar res.data.message (el endpoint dinámico
93
+ // devuelve texto en inglés que se filtraría al toast). String localizado.
92
94
  if (res.data.success) {
93
- toast.success(res.data.message || 'Eliminado correctamente');
95
+ toast.success(t('dynamic.delete_success', { defaultValue: 'Registro eliminado correctamente' }));
94
96
  onRefresh();
95
97
  }
96
98
  else
97
- toast.error(res.data.message || 'Error al eliminar');
99
+ toast.error(t('dynamic.delete_error', { defaultValue: 'No se pudo eliminar el registro' }));
98
100
  }
99
101
  catch (error) {
100
102
  console.error('Error al eliminar', error);
101
- toast.error('Error al eliminar el registro');
103
+ toast.error(t('dynamic.delete_error', { defaultValue: 'No se pudo eliminar el registro' }));
102
104
  }
103
105
  finally {
104
106
  setIsDeleting(false);
@@ -545,9 +545,9 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
545
545
  setBulkDeleteTotal(0);
546
546
  setRowSelection({});
547
547
  if (successCount > 0)
548
- toast.success(`${successCount} registro(s) eliminado(s) correctamente`);
548
+ toast.success(t('dynamic.bulk_delete_success', { count: successCount, defaultValue: '{{count}} registro(s) eliminado(s) correctamente' }));
549
549
  if (errorCount > 0)
550
- toast.error(`${errorCount} registro(s) no pudieron ser eliminados`);
550
+ toast.error(t('dynamic.bulk_delete_error', { count: errorCount, defaultValue: '{{count}} registro(s) no pudieron ser eliminados' }));
551
551
  handleRefresh();
552
552
  };
553
553
  const handleDynamicFilterChange = useCallback((filterKey, values) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "23.2.0",
3
+ "version": "23.3.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,97 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // `readonly` field handling in the generic record dialog + action modal.
4
+ //
5
+ // A `readonly` field is server/system-generated (e.g. the GitHub addon's
6
+ // `number`/`github_url`, filled by the API after the outbound create). Kernel
7
+ // v0.64.0 stamps `FieldDef.readonly` on such fields served by DeriveFormFields.
8
+ // UI semantics:
9
+ // - CREATE: the field is excluded from the form entirely (`filterVisibleFields`)
10
+ // — the user can't set a value the server will overwrite.
11
+ // - EDIT: the field is shown, but as a disabled/muted input
12
+ // (`ReadonlyEditField`) — the value is visible, not editable.
13
+ // - VIEW: unchanged (rich read-only renderer).
14
+ //
15
+ // The last block covers action fields with a materialized `options_source`: ops
16
+ // turns them into `type:'select'` with server-populated `options`, and the modal
17
+ // must render a <Select> for them. That decision lives in `resolveWidget`.
18
+ import { afterEach, describe, expect, it, vi } from 'vitest'
19
+ import { cleanup, render, screen } from '@testing-library/react'
20
+
21
+ vi.mock('react-i18next', () => ({
22
+ useTranslation: () => ({
23
+ t: (k: string, o?: any) => o?.defaultValue ?? k,
24
+ i18n: { language: 'es' },
25
+ }),
26
+ }))
27
+
28
+ import { filterVisibleFields, ReadonlyEditField } from '../dialogs/dynamic-record'
29
+ import { resolveWidget } from '../dynamic-form-schema'
30
+
31
+ afterEach(cleanup)
32
+
33
+ const fields = [
34
+ { key: 'name', label: 'Nombre', type: 'text' },
35
+ // System-generated: filled by the API after the outbound create.
36
+ { key: 'number', label: 'Número', type: 'text', readonly: true },
37
+ { key: 'secret', label: 'Secret', type: 'text', hidden: true },
38
+ ]
39
+
40
+ describe('filterVisibleFields — readonly exclusion per mode', () => {
41
+ it('excludes a readonly field on CREATE (and always excludes hidden)', () => {
42
+ const keys = filterVisibleFields(fields as any, 'create').map(f => f.key)
43
+ expect(keys).toEqual(['name'])
44
+ })
45
+
46
+ it('keeps a readonly field visible on EDIT and VIEW (still drops hidden)', () => {
47
+ expect(filterVisibleFields(fields as any, 'edit').map(f => f.key)).toEqual(['name', 'number'])
48
+ expect(filterVisibleFields(fields as any, 'view').map(f => f.key)).toEqual(['name', 'number'])
49
+ })
50
+ })
51
+
52
+ describe('ReadonlyEditField — edit-mode rendering of a readonly field', () => {
53
+ it('renders a disabled, muted input with the value visible', () => {
54
+ render(
55
+ <ReadonlyEditField
56
+ field={{ key: 'number', label: 'Número', type: 'text', readonly: true }}
57
+ value="GH-42"
58
+ />,
59
+ )
60
+ const input = screen.getByDisplayValue('GH-42') as HTMLInputElement
61
+ expect(input).toBeTruthy()
62
+ expect(input.disabled).toBe(true)
63
+ expect(input.className).toContain('text-muted-foreground')
64
+ })
65
+
66
+ it('renders a boolean readonly field as a disabled switch', () => {
67
+ const { container } = render(
68
+ <ReadonlyEditField
69
+ field={{ key: 'active', label: 'Activo', type: 'boolean', readonly: true }}
70
+ value={true}
71
+ />,
72
+ )
73
+ const sw = container.querySelector('[role="switch"]') as HTMLElement
74
+ expect(sw).toBeTruthy()
75
+ expect(sw.getAttribute('data-disabled') != null || sw.hasAttribute('disabled')).toBe(true)
76
+ expect(screen.getByText('Sí')).toBeTruthy()
77
+ })
78
+ })
79
+
80
+ describe('action fields — options_source materialized to a select', () => {
81
+ it('resolves a select-typed field (server-populated options) to the select widget', () => {
82
+ // ops materializes an `options_source` action field into `type:'select'`
83
+ // with the options populated server-side; the modal's renderField keys off
84
+ // resolveWidget to render a <Select> for it.
85
+ expect(
86
+ resolveWidget({
87
+ key: 'status',
88
+ label: 'Estado',
89
+ type: 'select',
90
+ options: [
91
+ { value: 'open', label: 'Abierto' },
92
+ { value: 'closed', label: 'Cerrado' },
93
+ ],
94
+ } as any),
95
+ ).toBe('select')
96
+ })
97
+ })
@@ -0,0 +1,113 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // Detail-dialog display mapping: the read-only `ViewValue` must render values
4
+ // with the SAME "pro" display logic as the table (shared `display-value.tsx`
5
+ // primitives), keyed off the stamped display type (`cellStyle ?? type`):
6
+ // - served option → colored badge with the localized label,
7
+ // - `cellStyle:'url'` on a text column → clickable external link (new tab),
8
+ // - `cellStyle:'status'` bare token (kanban stage) → colored, translated pill,
9
+ // - a `labels`/`tags` array → a row of badges (never a raw mini-table / JSON).
10
+ import { afterEach, describe, expect, it, vi } from 'vitest'
11
+ import { cleanup, render, screen } from '@testing-library/react'
12
+
13
+ // Identity-ish translator with a tiny stage dictionary, matching how the host
14
+ // resolves an addon's i18n keys (t(token, { defaultValue })).
15
+ vi.mock('react-i18next', () => ({
16
+ useTranslation: () => ({
17
+ t: (k: string, opts?: any) =>
18
+ k === 'backlog' ? 'Pendiente' : opts?.defaultValue ?? k,
19
+ i18n: { language: 'es' },
20
+ }),
21
+ }))
22
+
23
+ import { ViewValue } from '../dialogs/dynamic-record'
24
+
25
+ afterEach(cleanup)
26
+
27
+ describe('ViewValue — detail dialog display mapping', () => {
28
+ it('renders a served option as a colored badge with the localized label', () => {
29
+ render(
30
+ <ViewValue
31
+ field={{
32
+ key: 'product_type',
33
+ label: 'Tipo',
34
+ type: 'select',
35
+ options: [
36
+ { value: 'storable', label: 'Almacenable', color: '#22c55e' },
37
+ ],
38
+ }}
39
+ value="storable"
40
+ record={{}}
41
+ />
42
+ )
43
+ const badge = screen.getByText('Almacenable')
44
+ expect(badge).toBeTruthy()
45
+ // Never the raw enum token.
46
+ expect(screen.queryByText('storable')).toBeNull()
47
+ })
48
+
49
+ it('renders a cellStyle:url text column as a clickable external link', () => {
50
+ const { container } = render(
51
+ <ViewValue
52
+ field={{ key: 'github_url', label: 'GitHub', type: 'text', cellStyle: 'url' }}
53
+ value="https://github.com/asteby/x"
54
+ record={{}}
55
+ />
56
+ )
57
+ const link = container.querySelector('a') as HTMLAnchorElement
58
+ expect(link).toBeTruthy()
59
+ expect(link.getAttribute('href')).toBe('https://github.com/asteby/x')
60
+ expect(link.getAttribute('target')).toBe('_blank')
61
+ })
62
+
63
+ it('renders a bare stage token (cellStyle:status) as a translated colored pill', () => {
64
+ const { container } = render(
65
+ <ViewValue
66
+ field={{ key: 'stage', label: 'Etapa', type: 'text', cellStyle: 'status' }}
67
+ value="backlog"
68
+ record={{}}
69
+ />
70
+ )
71
+ // Localized via the manifest i18n key, not the raw "backlog".
72
+ expect(screen.getByText('Pendiente')).toBeTruthy()
73
+ expect(container.textContent).not.toContain('backlog')
74
+ // Colored: the badge carries an inline background style.
75
+ const styled = container.querySelector('[style*="background"]')
76
+ expect(styled).toBeTruthy()
77
+ })
78
+
79
+ it('renders a labels array (cellStyle:tags) as a row of badges', () => {
80
+ render(
81
+ <ViewValue
82
+ field={{ key: 'labels', label: 'Labels', type: 'json', cellStyle: 'tags' }}
83
+ value={['bug', 'urgent']}
84
+ record={{}}
85
+ />
86
+ )
87
+ expect(screen.getByText('bug')).toBeTruthy()
88
+ expect(screen.getByText('urgent')).toBeTruthy()
89
+ })
90
+
91
+ it('renders label objects with color as colored badges', () => {
92
+ render(
93
+ <ViewValue
94
+ field={{ key: 'labels', label: 'Labels', type: 'json', cellStyle: 'relation-badge-list' }}
95
+ value={[{ label: 'Bug', color: '#ef4444' }, { name: 'P1' }]}
96
+ record={{}}
97
+ />
98
+ )
99
+ expect(screen.getByText('Bug')).toBeTruthy()
100
+ expect(screen.getByText('P1')).toBeTruthy()
101
+ })
102
+
103
+ it('keeps the "—" empty marker for an empty labels array', () => {
104
+ const { container } = render(
105
+ <ViewValue
106
+ field={{ key: 'labels', label: 'Labels', type: 'json', cellStyle: 'tags' }}
107
+ value={[]}
108
+ record={{}}
109
+ />
110
+ )
111
+ expect(container.textContent).toContain('—')
112
+ })
113
+ })
@@ -225,11 +225,11 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
225
225
  const url = buildActionUrl(endpoint, model, record.id, action.key)
226
226
  const res = await api.post(url, {})
227
227
  if (res.data.success) {
228
- toast.success(res.data.message || t('common.success'))
228
+ toast.success(res.data.message ? t(res.data.message, { defaultValue: res.data.message }) : t('common.success'))
229
229
  onOpenChange(false)
230
230
  onSuccess()
231
231
  } else {
232
- toast.error(res.data.message || t('common.error'))
232
+ toast.error(res.data.message ? t(res.data.message, { defaultValue: res.data.message }) : t('common.error'))
233
233
  }
234
234
  } catch (err: any) {
235
235
  toast.error(err?.response?.data?.message || t('common.error'))
@@ -342,11 +342,11 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
342
342
  const url = buildActionUrl(endpoint, model, record.id, action.key)
343
343
  const res = await api.post(url, formData)
344
344
  if (res.data.success) {
345
- toast.success(res.data.message || t('common.success'))
345
+ toast.success(res.data.message ? t(res.data.message, { defaultValue: res.data.message }) : t('common.success'))
346
346
  onOpenChange(false)
347
347
  onSuccess()
348
348
  } else {
349
- toast.error(res.data.message || t('common.error'))
349
+ toast.error(res.data.message ? t(res.data.message, { defaultValue: res.data.message }) : t('common.error'))
350
350
  }
351
351
  } catch (err: any) {
352
352
  toast.error(err?.response?.data?.message || t('common.error'))