@asteby/metacore-runtime-react 23.7.0 → 23.7.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
+ ## 23.7.1
4
+
5
+ ### Patch Changes
6
+
7
+ - a3538da: fix(runtime-react): translate addon action labels at render (toolbar + modal)
8
+
9
+ `ModelActionToolbar` and `ActionModalDispatcher` rendered an action's `label`
10
+ (and each field's `label`) verbatim. For an addon's custom action these are i18n
11
+ keys (e.g. `integration_github.action.create_issue.label`) whose locale bundle
12
+ loads asynchronously, so the create button, the modal title/submit, and the
13
+ field labels showed the raw key — and never re-derived once the bundle landed.
14
+ They now translate at render with `t(label, { defaultValue: label })`, so the
15
+ label resolves the moment the addon i18n arrives (via the i18next store `added`
16
+ event) and an already-localized string passes through unchanged.
17
+
3
18
  ## 23.7.0
4
19
 
5
20
  ### Minor Changes
@@ -111,6 +111,10 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
111
111
  const { t } = useTranslation();
112
112
  const api = useApi();
113
113
  const [executing, setExecuting] = useState(false);
114
+ // `action.label` is an addon-contributed i18n key; its locale bundle loads
115
+ // asynchronously, so translate at render (defaultValue keeps an already
116
+ // localized label unchanged).
117
+ const label = t(action.label, { defaultValue: action.label });
114
118
  const execute = async () => {
115
119
  setExecuting(true);
116
120
  try {
@@ -132,10 +136,14 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
132
136
  setExecuting(false);
133
137
  }
134
138
  };
135
- return (_jsx(AlertDialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsxs(AlertDialogTitle, { className: "flex items-center gap-2", children: [_jsx(DynamicIcon, { name: action.icon, className: "h-5 w-5" }), action.label] }), _jsx(AlertDialogDescription, { children: action.confirmMessage || `${action.label}?` })] }), _jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { disabled: executing, children: t('common.cancel') }), _jsxs(AlertDialogAction, { onClick: (e) => { e.preventDefault(); execute(); }, disabled: executing, style: action.color ? { backgroundColor: action.color } : undefined, children: [executing ? _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }) : _jsx(DynamicIcon, { name: action.icon, className: "mr-2 h-4 w-4" }), action.label] })] })] }) }));
139
+ return (_jsx(AlertDialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsxs(AlertDialogTitle, { className: "flex items-center gap-2", children: [_jsx(DynamicIcon, { name: action.icon, className: "h-5 w-5" }), label] }), _jsx(AlertDialogDescription, { children: action.confirmMessage || `${label}?` })] }), _jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { disabled: executing, children: t('common.cancel') }), _jsxs(AlertDialogAction, { onClick: (e) => { e.preventDefault(); execute(); }, disabled: executing, style: action.color ? { backgroundColor: action.color } : undefined, children: [executing ? _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }) : _jsx(DynamicIcon, { name: action.icon, className: "mr-2 h-4 w-4" }), label] })] })] }) }));
136
140
  }
137
141
  function GenericActionModal({ open, onOpenChange, action, model, record, endpoint, onSuccess }) {
138
142
  const { t } = useTranslation();
143
+ // Addon-contributed labels (action + fields) are i18n keys whose locale
144
+ // bundle loads asynchronously; translate at render so they don't render raw.
145
+ // defaultValue keeps an already-localized string unchanged.
146
+ const tl = (s) => t(s, { defaultValue: s });
139
147
  const api = useApi();
140
148
  const [formData, setFormData] = useState({});
141
149
  const [executing, setExecuting] = useState(false);
@@ -194,13 +202,13 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
194
202
  if (isLineItemsField(field)) {
195
203
  const rows = formData[field.key];
196
204
  if (!Array.isArray(rows) || rows.length === 0) {
197
- toast.error(`${field.label} requiere al menos un renglón`);
205
+ toast.error(`${tl(field.label)} requiere al menos un renglón`);
198
206
  return;
199
207
  }
200
208
  continue;
201
209
  }
202
210
  if (!formData[field.key] && formData[field.key] !== false) {
203
- toast.error(`${field.label} es requerido`);
211
+ toast.error(`${tl(field.label)} es requerido`);
204
212
  return;
205
213
  }
206
214
  }
@@ -239,12 +247,12 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
239
247
  : hasLineItems
240
248
  ? '820px'
241
249
  : undefined;
242
- return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: 'flex max-h-[90vh] flex-col overflow-hidden ' + (widthPx ? '' : 'sm:max-w-xl'), style: { maxHeight: '90vh', ...(widthPx ? { maxWidth: widthPx, width: '95vw' } : {}) }, children: [_jsxs(DialogHeader, { className: "shrink-0", children: [_jsxs(DialogTitle, { className: "flex items-center gap-2", children: [_jsx(DynamicIcon, { name: action.icon, className: "h-5 w-5" }), action.label] }), action.confirmMessage && _jsx(DialogDescription, { children: action.confirmMessage })] }), _jsx("div", { className: "-mx-1 min-h-0 flex-1 overflow-y-auto px-1 py-4", children: _jsxs(FieldGrid, { children: [action.fields?.map((field) => {
250
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: 'flex max-h-[90vh] flex-col overflow-hidden ' + (widthPx ? '' : 'sm:max-w-xl'), style: { maxHeight: '90vh', ...(widthPx ? { maxWidth: widthPx, width: '95vw' } : {}) }, children: [_jsxs(DialogHeader, { className: "shrink-0", children: [_jsxs(DialogTitle, { className: "flex items-center gap-2", children: [_jsx(DynamicIcon, { name: action.icon, className: "h-5 w-5" }), tl(action.label)] }), action.confirmMessage && _jsx(DialogDescription, { children: action.confirmMessage })] }), _jsx("div", { className: "-mx-1 min-h-0 flex-1 overflow-y-auto px-1 py-4", children: _jsxs(FieldGrid, { children: [action.fields?.map((field) => {
243
251
  const fullWidth = isLineItemsField(field) ||
244
252
  resolveWidget(field) === 'textarea' ||
245
253
  resolveWidget(field) === 'richtext';
246
- return (_jsxs(FieldCell, { fullWidth: fullWidth, children: [_jsx(FieldLabel, { htmlFor: field.key, required: field.required, children: field.label }), renderField(field, formData[field.key], (v) => updateField(field.key, v), formData)] }, field.key));
247
- }), relations.length > 0 && (_jsx(FieldCell, { fullWidth: true, children: _jsx(DynamicRelations, { record: record, relations: relations }) }))] }) }), _jsxs(DialogFooter, { className: "shrink-0", children: [_jsx(Button, { variant: "outline", onClick: () => onOpenChange(false), disabled: executing, children: t('common.cancel') }), _jsxs(Button, { onClick: execute, disabled: executing, style: action.color ? { backgroundColor: action.color, color: 'white' } : undefined, children: [executing ? _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }) : _jsx(DynamicIcon, { name: action.icon, className: "mr-2 h-4 w-4" }), action.label] })] })] }) }));
254
+ return (_jsxs(FieldCell, { fullWidth: fullWidth, children: [_jsx(FieldLabel, { htmlFor: field.key, required: field.required, children: tl(field.label) }), renderField(field, formData[field.key], (v) => updateField(field.key, v), formData)] }, field.key));
255
+ }), relations.length > 0 && (_jsx(FieldCell, { fullWidth: true, children: _jsx(DynamicRelations, { record: record, relations: relations }) }))] }) }), _jsxs(DialogFooter, { className: "shrink-0", children: [_jsx(Button, { variant: "outline", onClick: () => onOpenChange(false), disabled: executing, children: t('common.cancel') }), _jsxs(Button, { onClick: execute, disabled: executing, style: action.color ? { backgroundColor: action.color, color: 'white' } : undefined, children: [executing ? _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }) : _jsx(DynamicIcon, { name: action.icon, className: "mr-2 h-4 w-4" }), tl(action.label)] })] })] }) }));
248
256
  }
249
257
  function renderField(field, value, onChange,
250
258
  // Full current form values — lets a line-items grid (and any cascading
@@ -1 +1 @@
1
- {"version":3,"file":"model-action-toolbar.d.ts","sourceRoot":"","sources":["../src/model-action-toolbar.tsx"],"names":[],"mappings":"AA0BA,OAAO,KAAK,EAAE,gBAAgB,EAAiC,MAAM,SAAS,CAAA;AAE9E,MAAM,MAAM,eAAe,GAAG,KAAK,GAAG,OAAO,GAAG,QAAQ,CAAA;AAExD,MAAM,WAAW,uBAAuB;IACpC,oEAAoE;IACpE,KAAK,EAAE,MAAM,CAAA;IACb,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,OAAO,CAAC,EAAE,gBAAgB,EAAE,CAAA;IAC5B,qEAAqE;IACrE,UAAU,CAAC,EAAE,eAAe,EAAE,CAAA;IAC9B,qDAAqD;IACrD,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;IACrB,iDAAiD;IACjD,SAAS,CAAC,EAAE,MAAM,CAAA;CACrB;AAmBD;;;GAGG;AACH,wBAAgB,eAAe,CAC3B,KAAK,EAAE,MAAM,EACb,UAAU,GAAE,eAAe,EAAuB,EAClD,QAAQ,CAAC,EAAE,gBAAgB,EAAE,GAC9B,gBAAgB,EAAE,CA2BpB;AAED,wBAAgB,kBAAkB,CAAC,EAC/B,KAAK,EACL,QAAQ,EACR,OAAO,EACP,UAA+B,EAC/B,QAAQ,EACR,SAAS,GACZ,EAAE,uBAAuB,sCAmDzB"}
1
+ {"version":3,"file":"model-action-toolbar.d.ts","sourceRoot":"","sources":["../src/model-action-toolbar.tsx"],"names":[],"mappings":"AA2BA,OAAO,KAAK,EAAE,gBAAgB,EAAiC,MAAM,SAAS,CAAA;AAE9E,MAAM,MAAM,eAAe,GAAG,KAAK,GAAG,OAAO,GAAG,QAAQ,CAAA;AAExD,MAAM,WAAW,uBAAuB;IACpC,oEAAoE;IACpE,KAAK,EAAE,MAAM,CAAA;IACb,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,OAAO,CAAC,EAAE,gBAAgB,EAAE,CAAA;IAC5B,qEAAqE;IACrE,UAAU,CAAC,EAAE,eAAe,EAAE,CAAA;IAC9B,qDAAqD;IACrD,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;IACrB,iDAAiD;IACjD,SAAS,CAAC,EAAE,MAAM,CAAA;CACrB;AAmBD;;;GAGG;AACH,wBAAgB,eAAe,CAC3B,KAAK,EAAE,MAAM,EACb,UAAU,GAAE,eAAe,EAAuB,EAClD,QAAQ,CAAC,EAAE,gBAAgB,EAAE,GAC9B,gBAAgB,EAAE,CA2BpB;AAED,wBAAgB,kBAAkB,CAAC,EAC/B,KAAK,EACL,QAAQ,EACR,OAAO,EACP,UAA+B,EAC/B,QAAQ,EACR,SAAS,GACZ,EAAE,uBAAuB,sCA0DzB"}
@@ -19,6 +19,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
19
19
  // it internally, and bespoke host pages (e.g. ops `/m/$model`) mount it directly
20
20
  // next to their own toolbar. Hosts never reimplement action-button plumbing.
21
21
  import { useEffect, useMemo, useState } from 'react';
22
+ import { useTranslation } from 'react-i18next';
22
23
  import { Button } from '@asteby/metacore-ui/primitives';
23
24
  import { useApi } from './api-context';
24
25
  import { useMetadataCache } from './metadata-cache';
@@ -71,6 +72,7 @@ export function useModelActions(model, placements = DEFAULT_PLACEMENTS, provided
71
72
  return useMemo(() => all.filter((a) => placements.includes((a.placement ?? 'row'))), [all, placements]);
72
73
  }
73
74
  export function ModelActionToolbar({ model, endpoint, actions, placements = DEFAULT_PLACEMENTS, onChange, className, }) {
75
+ const { t } = useTranslation();
74
76
  const all = useModelActions(model, placements, actions);
75
77
  // Capability gating — always-true without a <PermissionsProvider>. Custom
76
78
  // table/create actions map onto `lowercase(model).<action_key>`.
@@ -82,7 +84,7 @@ export function ModelActionToolbar({ model, endpoint, actions, placements = DEFA
82
84
  return null;
83
85
  return (_jsxs(_Fragment, { children: [_jsx("div", { className: className ?? 'flex items-center gap-2', children: surfaced.map((a) => {
84
86
  const isCreate = (a.placement ?? 'row') === 'create';
85
- return (_jsxs(Button, { variant: isCreate ? 'default' : 'outline', onClick: () => setActive(toActionMetadata(a)), style: a.color && !isCreate ? { borderColor: a.color, color: a.color } : undefined, children: [_jsx(DynamicIcon, { name: a.icon || (isCreate ? 'Plus' : 'Zap'), className: "mr-2 h-4 w-4" }), a.label] }, a.key));
87
+ return (_jsxs(Button, { variant: isCreate ? 'default' : 'outline', onClick: () => setActive(toActionMetadata(a)), style: a.color && !isCreate ? { borderColor: a.color, color: a.color } : undefined, children: [_jsx(DynamicIcon, { name: a.icon || (isCreate ? 'Plus' : 'Zap'), className: "mr-2 h-4 w-4" }), t(a.label, { defaultValue: a.label })] }, a.key));
86
88
  }) }), active && (_jsx(ActionModalDispatcher, { open: !!active, onOpenChange: (open) => {
87
89
  if (!open)
88
90
  setActive(null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "23.7.0",
3
+ "version": "23.7.1",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,101 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it } from 'vitest'
3
+ import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
4
+ import i18next from 'i18next'
5
+ import { I18nextProvider } from 'react-i18next'
6
+
7
+ import { ModelActionToolbar } from '../model-action-toolbar'
8
+ import { ApiProvider } from '../api-context'
9
+ import type { ActionDefinition } from '../types'
10
+
11
+ afterEach(cleanup)
12
+
13
+ // Stub api client — the toolbar only calls it when it must fetch metadata, and
14
+ // we pass `actions` directly so it never does. Present only to satisfy useApi().
15
+ const api = {
16
+ get: async () => ({ data: { data: {} } }),
17
+ post: async () => ({ data: { success: true } }),
18
+ } as never
19
+
20
+ // A fresh i18next configured EXACTLY like the failing prod instance: keySeparator
21
+ // default and ignoreJSONStructure:false, so a flat literal key would NOT resolve
22
+ // — the fix must build/read a nested tree AND translate at render time.
23
+ function makeI18n() {
24
+ const inst = i18next.createInstance()
25
+ inst.init({
26
+ lng: 'es',
27
+ fallbackLng: 'es',
28
+ ignoreJSONStructure: false,
29
+ react: { bindI18nStore: 'added removed', useSuspense: false },
30
+ resources: { es: { translation: {} } },
31
+ })
32
+ return inst
33
+ }
34
+
35
+ // The addon's create action exactly as the metadata endpoint ships it: the
36
+ // label is an i18n KEY, not display text (backend does not localize action
37
+ // labels — see the raw-key bug).
38
+ const createAction: ActionDefinition = {
39
+ key: 'create_issue',
40
+ label: 'integration_github.action.create_issue.label',
41
+ placement: 'create',
42
+ } as ActionDefinition
43
+
44
+ function renderToolbar(inst: ReturnType<typeof makeI18n>) {
45
+ return render(
46
+ <I18nextProvider i18n={inst}>
47
+ <ApiProvider client={api}>
48
+ <ModelActionToolbar model="github_issues" actions={[createAction]} />
49
+ </ApiProvider>
50
+ </I18nextProvider>,
51
+ )
52
+ }
53
+
54
+ describe('ModelActionToolbar — translates addon action labels at render', () => {
55
+ it('shows the translation once the addon bundle lands (no raw key)', async () => {
56
+ const inst = makeI18n()
57
+ renderToolbar(inst)
58
+
59
+ // Before the addon bundle loads the toolbar has only the key; with the
60
+ // fix it renders the key text (t() falls back to defaultValue = the key).
61
+ // The point of the test is the TRANSITION below.
62
+ expect(
63
+ screen.getByText('integration_github.action.create_issue.label'),
64
+ ).toBeTruthy()
65
+
66
+ // The addon locale bundle arrives asynchronously (OpsAddonLocaleLoader
67
+ // fetch → addResourceBundle). It is NESTED, as the host loader now merges
68
+ // it. This fires the i18next store 'added' event.
69
+ act(() => {
70
+ inst.addResourceBundle(
71
+ 'es',
72
+ 'translation',
73
+ { integration_github: { action: { create_issue: { label: 'Crear issue' } } } },
74
+ true,
75
+ true,
76
+ )
77
+ })
78
+
79
+ // The toolbar must re-render and show the translated label — the exact
80
+ // behaviour that was missing (a bare {a.label} never re-derived).
81
+ await waitFor(() => expect(screen.getByText('Crear issue')).toBeTruthy())
82
+ expect(
83
+ screen.queryByText('integration_github.action.create_issue.label'),
84
+ ).toBeNull()
85
+ })
86
+
87
+ it('leaves an already-localized label untouched (defaultValue passthrough)', () => {
88
+ const inst = makeI18n()
89
+ render(
90
+ <I18nextProvider i18n={inst}>
91
+ <ApiProvider client={api}>
92
+ <ModelActionToolbar
93
+ model="github_issues"
94
+ actions={[{ key: 'x', label: 'Crear issue', placement: 'create' } as ActionDefinition]}
95
+ />
96
+ </ApiProvider>
97
+ </I18nextProvider>,
98
+ )
99
+ expect(screen.getByText('Crear issue')).toBeTruthy()
100
+ })
101
+ })
@@ -218,6 +218,10 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
218
218
  const { t } = useTranslation()
219
219
  const api = useApi()
220
220
  const [executing, setExecuting] = useState(false)
221
+ // `action.label` is an addon-contributed i18n key; its locale bundle loads
222
+ // asynchronously, so translate at render (defaultValue keeps an already
223
+ // localized label unchanged).
224
+ const label = t(action.label, { defaultValue: action.label })
221
225
 
222
226
  const execute = async () => {
223
227
  setExecuting(true)
@@ -244,10 +248,10 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
244
248
  <AlertDialogHeader>
245
249
  <AlertDialogTitle className="flex items-center gap-2">
246
250
  <DynamicIcon name={action.icon} className="h-5 w-5" />
247
- {action.label}
251
+ {label}
248
252
  </AlertDialogTitle>
249
253
  <AlertDialogDescription>
250
- {action.confirmMessage || `${action.label}?`}
254
+ {action.confirmMessage || `${label}?`}
251
255
  </AlertDialogDescription>
252
256
  </AlertDialogHeader>
253
257
  <AlertDialogFooter>
@@ -258,7 +262,7 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
258
262
  style={action.color ? { backgroundColor: action.color } : undefined}
259
263
  >
260
264
  {executing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <DynamicIcon name={action.icon} className="mr-2 h-4 w-4" />}
261
- {action.label}
265
+ {label}
262
266
  </AlertDialogAction>
263
267
  </AlertDialogFooter>
264
268
  </AlertDialogContent>
@@ -268,6 +272,10 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
268
272
 
269
273
  function GenericActionModal({ open, onOpenChange, action, model, record, endpoint, onSuccess }: ActionModalProps) {
270
274
  const { t } = useTranslation()
275
+ // Addon-contributed labels (action + fields) are i18n keys whose locale
276
+ // bundle loads asynchronously; translate at render so they don't render raw.
277
+ // defaultValue keeps an already-localized string unchanged.
278
+ const tl = (s: string) => t(s, { defaultValue: s })
271
279
  const api = useApi()
272
280
  const [formData, setFormData] = useState<Record<string, any>>({})
273
281
  const [executing, setExecuting] = useState(false)
@@ -326,13 +334,13 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
326
334
  if (isLineItemsField(field)) {
327
335
  const rows = formData[field.key]
328
336
  if (!Array.isArray(rows) || rows.length === 0) {
329
- toast.error(`${field.label} requiere al menos un renglón`)
337
+ toast.error(`${tl(field.label)} requiere al menos un renglón`)
330
338
  return
331
339
  }
332
340
  continue
333
341
  }
334
342
  if (!formData[field.key] && formData[field.key] !== false) {
335
- toast.error(`${field.label} es requerido`)
343
+ toast.error(`${tl(field.label)} es requerido`)
336
344
  return
337
345
  }
338
346
  }
@@ -389,7 +397,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
389
397
  <DialogHeader className="shrink-0">
390
398
  <DialogTitle className="flex items-center gap-2">
391
399
  <DynamicIcon name={action.icon} className="h-5 w-5" />
392
- {action.label}
400
+ {tl(action.label)}
393
401
  </DialogTitle>
394
402
  {action.confirmMessage && <DialogDescription>{action.confirmMessage}</DialogDescription>}
395
403
  </DialogHeader>
@@ -408,7 +416,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
408
416
  return (
409
417
  <FieldCell key={field.key} fullWidth={fullWidth}>
410
418
  <FieldLabel htmlFor={field.key} required={field.required}>
411
- {field.label}
419
+ {tl(field.label)}
412
420
  </FieldLabel>
413
421
  {renderField(field, formData[field.key], (v: any) => updateField(field.key, v), formData)}
414
422
  </FieldCell>
@@ -431,7 +439,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
431
439
  style={action.color ? { backgroundColor: action.color, color: 'white' } : undefined}
432
440
  >
433
441
  {executing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <DynamicIcon name={action.icon} className="mr-2 h-4 w-4" />}
434
- {action.label}
442
+ {tl(action.label)}
435
443
  </Button>
436
444
  </DialogFooter>
437
445
  </DialogContent>
@@ -18,6 +18,7 @@
18
18
  // it internally, and bespoke host pages (e.g. ops `/m/$model`) mount it directly
19
19
  // next to their own toolbar. Hosts never reimplement action-button plumbing.
20
20
  import { useEffect, useMemo, useState } from 'react'
21
+ import { useTranslation } from 'react-i18next'
21
22
  import { Button } from '@asteby/metacore-ui/primitives'
22
23
  import { useApi } from './api-context'
23
24
  import { useMetadataCache } from './metadata-cache'
@@ -109,6 +110,7 @@ export function ModelActionToolbar({
109
110
  onChange,
110
111
  className,
111
112
  }: ModelActionToolbarProps) {
113
+ const { t } = useTranslation()
112
114
  const all = useModelActions(model, placements, actions)
113
115
  // Capability gating — always-true without a <PermissionsProvider>. Custom
114
116
  // table/create actions map onto `lowercase(model).<action_key>`.
@@ -135,7 +137,13 @@ export function ModelActionToolbar({
135
137
  style={a.color && !isCreate ? { borderColor: a.color, color: a.color } : undefined}
136
138
  >
137
139
  <DynamicIcon name={a.icon || (isCreate ? 'Plus' : 'Zap')} className="mr-2 h-4 w-4" />
138
- {a.label}
140
+ {/* `a.label` is an addon-contributed i18n key (e.g.
141
+ "integration_github.action.create_issue.label"); the addon's
142
+ locale bundle loads asynchronously, so translate at render — a
143
+ bare `{a.label}` prints the raw key until (and after) the bundle
144
+ lands because nothing re-derives it. defaultValue keeps an
145
+ already-localized label untouched. */}
146
+ {t(a.label, { defaultValue: a.label })}
139
147
  </Button>
140
148
  )
141
149
  })}