@asteby/metacore-runtime-react 43.0.0 → 45.0.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/action-modal-dispatcher.d.ts +2 -0
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +74 -8
  5. package/dist/dynamic-columns.d.ts +11 -0
  6. package/dist/dynamic-columns.d.ts.map +1 -1
  7. package/dist/dynamic-columns.js +13 -1
  8. package/dist/dynamic-kanban.d.ts.map +1 -1
  9. package/dist/dynamic-kanban.js +4 -5
  10. package/dist/dynamic-row-actions.d.ts.map +1 -1
  11. package/dist/dynamic-row-actions.js +23 -16
  12. package/dist/federated-action-loader.d.ts +18 -0
  13. package/dist/federated-action-loader.d.ts.map +1 -0
  14. package/dist/federated-action-loader.js +34 -0
  15. package/dist/index.d.ts +3 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +3 -1
  18. package/dist/model-action-toolbar.d.ts.map +1 -1
  19. package/dist/model-action-toolbar.js +19 -15
  20. package/dist/requires-addon.d.ts +37 -0
  21. package/dist/requires-addon.d.ts.map +1 -0
  22. package/dist/requires-addon.js +104 -0
  23. package/dist/types.d.ts +27 -0
  24. package/dist/types.d.ts.map +1 -1
  25. package/package.json +3 -3
  26. package/src/__tests__/action-modal-missing-custom.test.tsx +12 -4
  27. package/src/__tests__/federated-action-modal.test.tsx +315 -0
  28. package/src/__tests__/requires-addon.test.tsx +201 -0
  29. package/src/action-modal-dispatcher.tsx +102 -10
  30. package/src/dynamic-columns.tsx +36 -6
  31. package/src/dynamic-kanban.tsx +6 -7
  32. package/src/dynamic-row-actions.tsx +26 -16
  33. package/src/federated-action-loader.ts +40 -0
  34. package/src/index.ts +13 -0
  35. package/src/model-action-toolbar.tsx +31 -15
  36. package/src/requires-addon.tsx +160 -0
  37. package/src/types.ts +28 -0
@@ -57,6 +57,7 @@ import { DynamicIcon, isLucideIconName } from './dynamic-icon'
57
57
  import { CollectionCell } from './collection-cell'
58
58
  import { isNilUuid, normalizeNilUuid } from './nil-uuid'
59
59
  import { useOptionsResolver } from './use-options-resolver'
60
+ import { RequiresAddonLock, resolveRequiresAddon, useRequiresAddonLabel } from './requires-addon'
60
61
  import type { TableMetadata, ColumnDefinition } from './types'
61
62
  import { isColumnVisibleInTable } from './column-visibility'
62
63
  import type {
@@ -207,6 +208,36 @@ const CodeCell: React.FC<{ text: string; maxLength?: number }> = ({ text, maxLen
207
208
  )
208
209
  }
209
210
 
211
+ /**
212
+ * One entry of the row "…" menu. An action gated by a missing optional addon
213
+ * (`requiresAddon`) stays listed with a lock + tooltip; the click still goes to
214
+ * `onAction`, whose shared handler (useDynamicRowActions) opens the
215
+ * requires-addon dialog instead of executing.
216
+ */
217
+ export function RowActionMenuItem({
218
+ action,
219
+ label,
220
+ onSelect,
221
+ }: {
222
+ action: any
223
+ label: React.ReactNode
224
+ onSelect: (e: React.MouseEvent) => void
225
+ }) {
226
+ const requirement = resolveRequiresAddon(action)
227
+ const requiresAddonLabel = useRequiresAddonLabel()
228
+ return (
229
+ <DropdownMenuItem
230
+ onClick={onSelect}
231
+ title={requirement ? requiresAddonLabel(requirement) : undefined}
232
+ data-requires-addon={requirement?.key}
233
+ >
234
+ <DynamicIcon name={action.icon || 'Zap'} className="mr-2 h-4 w-4" />
235
+ {label}
236
+ {requirement && <RequiresAddonLock requirement={requirement} />}
237
+ </DropdownMenuItem>
238
+ )
239
+ }
240
+
210
241
  /**
211
242
  * Lifecycle column used by `requiresState`: prefer `status` (workshop, vehicles,
212
243
  * …) and fall back to `state` (purchases, inventory transfers, …). Empty string
@@ -1528,13 +1559,12 @@ export function makeDefaultGetDynamicColumns(
1528
1559
  {resolvedActions
1529
1560
  .filter((action) => isRowActionVisible(action, row.original))
1530
1561
  .map((action) => (
1531
- <DropdownMenuItem
1562
+ <RowActionMenuItem
1532
1563
  key={action.key}
1533
- onClick={() => onAction && onAction(action.key, row.original)}
1534
- >
1535
- <DynamicIcon name={action.icon} className="mr-2 h-4 w-4" />
1536
- {translateMetadataLabel(action.label, t)}
1537
- </DropdownMenuItem>
1564
+ action={action}
1565
+ label={translateMetadataLabel(action.label, t)}
1566
+ onSelect={() => onAction && onAction(action.key, row.original)}
1567
+ />
1538
1568
  ))}
1539
1569
  </DropdownMenuContent>
1540
1570
  </DropdownMenu>
@@ -96,7 +96,6 @@ import {
96
96
  CardContent,
97
97
  DropdownMenu,
98
98
  DropdownMenuContent,
99
- DropdownMenuItem,
100
99
  DropdownMenuTrigger,
101
100
  Input,
102
101
  Popover,
@@ -161,6 +160,7 @@ import {
161
160
  formatAggregateTotal,
162
161
  isRowActionVisible,
163
162
  relationKeyFor,
163
+ RowActionMenuItem,
164
164
  } from './dynamic-columns'
165
165
  import { useCan, usePermissionsActive, resolveRowActions } from './permissions-context'
166
166
  import { useDynamicRowActions } from './dynamic-row-actions'
@@ -3113,16 +3113,15 @@ function KanbanCard({
3113
3113
  </DropdownMenuTrigger>
3114
3114
  <DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
3115
3115
  {visibleActions.map((a) => (
3116
- <DropdownMenuItem
3116
+ <RowActionMenuItem
3117
3117
  key={a.key}
3118
- onClick={(e) => {
3118
+ action={a}
3119
+ label={a.label}
3120
+ onSelect={(e) => {
3119
3121
  e.stopPropagation()
3120
3122
  onAction(a.key, card)
3121
3123
  }}
3122
- >
3123
- <DynamicIcon name={a.icon || 'Zap'} className="mr-2 h-4 w-4" />
3124
- {a.label}
3125
- </DropdownMenuItem>
3124
+ />
3126
3125
  ))}
3127
3126
  </DropdownMenuContent>
3128
3127
  </DropdownMenu>
@@ -11,8 +11,10 @@
11
11
  // view / edit → host `onAction(action, row)` when provided (string
12
12
  // contract), else the built-in record dialog.
13
13
  // link action → navigate to the action's templated `linkUrl`.
14
- // custom (fields/confirm/
15
- // executable) action → opens the ActionModal via the dispatcher.
14
+ // requiresAddon → opens the "requires addon" dialog; the action is
15
+ // never dispatched (no modal, no backend call).
16
+ // custom (modal/fields/
17
+ // confirm/executable) → opens the ActionModal via the dispatcher.
16
18
  // anything else → host `onAction` + refresh (or just refresh).
17
19
  //
18
20
  // The host `onAction` contract is a STRING action key on purpose — the existing
@@ -34,7 +36,8 @@ import {
34
36
  import { useApi } from './api-context'
35
37
  import { ActionModalDispatcher } from './action-modal-dispatcher'
36
38
  import { DynamicRecordDialog } from './dialogs/dynamic-record'
37
- import type { TableMetadata, ActionMetadata } from './types'
39
+ import { RequiresAddonDialog, resolveRequiresAddon } from './requires-addon'
40
+ import type { TableMetadata, ActionMetadata, RequiresAddon } from './types'
38
41
 
39
42
  export interface UseDynamicRowActionsParams {
40
43
  /** Model key as registered on the backend (e.g. "issue"). */
@@ -106,7 +109,13 @@ export function useDynamicRowActions({
106
109
  record: any | null
107
110
  }>({ open: false, action: null, record: null })
108
111
 
112
+ const [missingAddon, setMissingAddon] = useState<RequiresAddon | null>(null)
113
+
109
114
  const handleInternalAction = useCallback(async (action: string, row: any) => {
115
+ // A missing optional addon gates the action BEFORE any other routing:
116
+ // it must not reach the dispatcher, the host, or the backend.
117
+ const requirement = resolveRequiresAddon(metadata?.actions?.find((a) => a.key === action))
118
+ if (requirement) { setMissingAddon(requirement); return }
110
119
  if (action === 'delete') { setRowToDelete(row); return }
111
120
  if (action === 'view' || action === 'edit') {
112
121
  if (onAction) await Promise.resolve(onAction(action, row))
@@ -120,24 +129,20 @@ export function useDynamicRowActions({
120
129
  return
121
130
  }
122
131
  const actionDef = metadata?.actions?.find((a) => a.key === action)
123
- // Open the dispatcher when there is ANY UI surface (fields, wizard
124
- // steps, confirm bool, confirmMessage, or host `executable`). A
125
- // confirm_message without confirm:true used to fall through / open and
126
- // then render null — derive confirm so the ConfirmActionDialog path wins.
127
- if (actionDef && (actionDef.fields?.length || actionDef.steps?.length || actionDef.confirm || actionDef.confirmMessage || actionDef.executable)) {
132
+ // Open the dispatcher when there is ANY UI surface (federated `modal`,
133
+ // fields, wizard steps, confirm bool, confirmMessage, or host
134
+ // `executable`). A confirm_message without confirm:true used to fall
135
+ // through / open and then render null — derive confirm so the
136
+ // ConfirmActionDialog path wins. Pass the WHOLE definition: a
137
+ // cherry-picked copy dropped `modal` and the dispatcher fell back to
138
+ // the generic confirm while the remote was still loading.
139
+ if (actionDef && (actionDef.modal || actionDef.fields?.length || actionDef.steps?.length || actionDef.confirm || actionDef.confirmMessage || actionDef.executable)) {
128
140
  setActionModal({
129
141
  open: true,
130
142
  action: {
131
- key: actionDef.key,
132
- label: actionDef.label,
143
+ ...actionDef,
133
144
  icon: actionDef.icon || 'Zap',
134
- color: actionDef.color,
135
145
  confirm: !!(actionDef.confirm || actionDef.confirmMessage),
136
- confirmMessage: actionDef.confirmMessage,
137
- fields: actionDef.fields,
138
- steps: actionDef.steps,
139
- requiresState: actionDef.requiresState,
140
- executable: actionDef.executable,
141
146
  },
142
147
  record: row,
143
148
  })
@@ -204,6 +209,11 @@ export function useDynamicRowActions({
204
209
  onSuccess={onRefresh}
205
210
  />
206
211
  )}
212
+
213
+ <RequiresAddonDialog
214
+ requirement={missingAddon}
215
+ onOpenChange={(open: boolean) => !open && setMissingAddon(null)}
216
+ />
207
217
  </>
208
218
  )
209
219
 
@@ -0,0 +1,40 @@
1
+ // Host hook that loads the federated remote behind an action's `modal` slug.
2
+ //
3
+ // The SDK does not know how the host resolves addon remotes (Module Federation
4
+ // manifests, fibers, lazy bundles). When ActionModalDispatcher opens an action
5
+ // that declares `modal` and no component is registered yet, it asks the host to
6
+ // load it through this hook and waits for the registry to report the component.
7
+ // The dispatcher re-invokes the loader while it waits, so a loader installed
8
+ // after the first click still gets called.
9
+
10
+ export type FederatedActionLoader = (req: {
11
+ model: string
12
+ actionKey: string
13
+ modal?: string
14
+ }) => void | Promise<void>
15
+
16
+ let loader: FederatedActionLoader | null = null
17
+
18
+ /** Install (or clear with `null`) the host's federated action loader. */
19
+ export function setFederatedActionLoader(fn: FederatedActionLoader | null): void {
20
+ loader = fn
21
+ }
22
+
23
+ /**
24
+ * Ask the host to load the remote for (model, actionKey). No-op without a
25
+ * loader; loader errors are logged, never thrown — the dispatcher keeps waiting
26
+ * until its timeout and then shows the explicit error.
27
+ */
28
+ export function requestFederatedAction(req: { model: string; actionKey: string; modal?: string }): void {
29
+ if (!loader) return
30
+ try {
31
+ const pending = loader(req)
32
+ if (pending && typeof (pending as Promise<void>).catch === 'function') {
33
+ ;(pending as Promise<void>).catch((err) => {
34
+ console.warn('[metacore] federated action loader failed', req, err)
35
+ })
36
+ }
37
+ } catch (err) {
38
+ console.warn('[metacore] federated action loader failed', req, err)
39
+ }
40
+ }
package/src/index.ts CHANGED
@@ -174,8 +174,10 @@ export * from './form-layout-ui'
174
174
  export { FieldGrid, FieldCell, FieldLabel } from './field-grid'
175
175
  export {
176
176
  ActionModalDispatcher,
177
+ FEDERATED_ACTION_MODAL_TIMEOUT_MS,
177
178
  type ActionModalProps,
178
179
  } from './action-modal-dispatcher'
180
+ export { setFederatedActionLoader, type FederatedActionLoader } from './federated-action-loader'
179
181
  export {
180
182
  ModelActionToolbar,
181
183
  useModelActions,
@@ -221,6 +223,17 @@ export {
221
223
  type UseDynamicRowActionsParams,
222
224
  type DynamicRowActions,
223
225
  } from './dynamic-row-actions'
226
+ export {
227
+ resolveRequiresAddon,
228
+ setAddonInstallHandler,
229
+ getAddonInstallHandler,
230
+ requiresAddonName,
231
+ useRequiresAddonLabel,
232
+ RequiresAddonLock,
233
+ RequiresAddonDialog,
234
+ type RequiresAddon,
235
+ type RequiresAddonDialogProps,
236
+ } from './requires-addon'
224
237
  export { ColorPickerField, DEFAULT_ROLE_COLOR, normalizeHex } from './color-picker-field'
225
238
  export {
226
239
  PermissionsManager,
@@ -25,7 +25,13 @@ import { useMetadataCache } from './metadata-cache'
25
25
  import { DynamicIcon } from './dynamic-icon'
26
26
  import { ActionModalDispatcher } from './action-modal-dispatcher'
27
27
  import { useCan, modelCapability } from './permissions-context'
28
- import type { ActionDefinition, ActionMetadata, TableMetadata } from './types'
28
+ import {
29
+ RequiresAddonDialog,
30
+ RequiresAddonLock,
31
+ resolveRequiresAddon,
32
+ useRequiresAddonLabel,
33
+ } from './requires-addon'
34
+ import type { ActionDefinition, ActionMetadata, RequiresAddon, TableMetadata } from './types'
29
35
 
30
36
  export type ActionPlacement = 'row' | 'table' | 'create'
31
37
 
@@ -83,18 +89,9 @@ export function humanizeActionLabel(label: string): string {
83
89
  }
84
90
 
85
91
  function toActionMetadata(a: ActionDefinition): ActionMetadata {
86
- return {
87
- key: a.key,
88
- label: a.label,
89
- icon: a.icon || 'Zap',
90
- color: a.color,
91
- confirm: a.confirm,
92
- confirmMessage: a.confirmMessage,
93
- fields: a.fields,
94
- requiresState: a.requiresState,
95
- executable: a.executable,
96
- placement: a.placement,
97
- }
92
+ // Spread, don't cherry-pick: a hand-picked copy dropped `modal`/`steps`
93
+ // and the dispatcher fell back to generics for federated/wizard actions.
94
+ return { ...a, icon: a.icon || 'Zap' }
98
95
  }
99
96
 
100
97
  /**
@@ -153,6 +150,8 @@ export function ModelActionToolbar({
153
150
  [all, can, model],
154
151
  )
155
152
  const [active, setActive] = useState<ActionMetadata | null>(null)
153
+ const [missingAddon, setMissingAddon] = useState<RequiresAddon | null>(null)
154
+ const requiresAddonLabel = useRequiresAddonLabel()
156
155
  const dataEndpoint = endpoint ?? `/data/${model}/me`
157
156
 
158
157
  if (surfaced.length === 0) return null
@@ -162,6 +161,9 @@ export function ModelActionToolbar({
162
161
  <div className={className ?? 'flex items-center gap-2'}>
163
162
  {surfaced.map((a) => {
164
163
  const isCreate = (a.placement ?? 'row') === 'create'
164
+ // Locked while its optional addon is missing: still shown,
165
+ // but a click explains + offers install instead of running.
166
+ const requirement = resolveRequiresAddon(a)
165
167
  return (
166
168
  <Button
167
169
  key={a.key}
@@ -171,12 +173,18 @@ export function ModelActionToolbar({
171
173
  ? `model.${model}.create`
172
174
  : `model.${model}.action.${a.key}`
173
175
  }
176
+ data-requires-addon={requirement?.key}
177
+ title={requirement ? requiresAddonLabel(requirement) : undefined}
174
178
  onClick={() => {
179
+ if (requirement) {
180
+ setMissingAddon(requirement)
181
+ return
182
+ }
175
183
  onActionIntent?.(a)
176
184
  setActive(toActionMetadata(a))
177
185
  }}
178
- onMouseEnter={() => onActionIntent?.(a)}
179
- onFocus={() => onActionIntent?.(a)}
186
+ onMouseEnter={() => !requirement && onActionIntent?.(a)}
187
+ onFocus={() => !requirement && onActionIntent?.(a)}
180
188
  style={a.color && !isCreate ? { borderColor: a.color, color: a.color } : undefined}
181
189
  >
182
190
  <DynamicIcon name={a.icon || (isCreate ? 'Plus' : 'Zap')} className="mr-2 h-4 w-4" />
@@ -188,6 +196,7 @@ export function ModelActionToolbar({
188
196
  already-localized label untouched; humanizeLastSegment avoids
189
197
  flashing the full dotted key when the bundle is still in flight. */}
190
198
  {t(a.label, { defaultValue: humanizeActionLabel(a.label) })}
199
+ {requirement && <RequiresAddonLock requirement={requirement} />}
191
200
  </Button>
192
201
  )
193
202
  })}
@@ -209,6 +218,13 @@ export function ModelActionToolbar({
209
218
  }}
210
219
  />
211
220
  )}
221
+
222
+ <RequiresAddonDialog
223
+ requirement={missingAddon}
224
+ onOpenChange={(open) => {
225
+ if (!open) setMissingAddon(null)
226
+ }}
227
+ />
212
228
  </>
213
229
  )
214
230
  }
@@ -0,0 +1,160 @@
1
+ // requiresAddon — an action that depends on an OPTIONAL addon the org has not
2
+ // installed yet (e.g. link_inbox "Conectar WhatsApp" needs connector_whatsapp).
3
+ //
4
+ // The manifest declares `compatibility.requires[].enables: ["action:<Model>.<key>"]`
5
+ // and the host stamps each affected action's metadata, only while the required
6
+ // addon is missing:
7
+ //
8
+ // "requires_addon": { "key": "connector_whatsapp", "name": "Conector WhatsApp", "reason": "…" }
9
+ //
10
+ // The action is NOT hidden (the user should discover the feature) and NOT
11
+ // executed: row menus / kanban cards / the model toolbar render it with a lock
12
+ // and a tooltip, and a click opens <RequiresAddonDialog> instead of the action
13
+ // dispatcher. "Instalar" delegates to the host (setAddonInstallHandler) or, when
14
+ // none is registered, navigates to `/marketplace/<key>` through the SDK router so
15
+ // the host basepath (ops lives under /app) is respected.
16
+ import { useNavigate } from '@tanstack/react-router'
17
+ import { useTranslation } from 'react-i18next'
18
+ import { Lock } from 'lucide-react'
19
+ import {
20
+ AlertDialog,
21
+ AlertDialogAction,
22
+ AlertDialogCancel,
23
+ AlertDialogContent,
24
+ AlertDialogDescription,
25
+ AlertDialogFooter,
26
+ AlertDialogHeader,
27
+ AlertDialogTitle,
28
+ } from '@asteby/metacore-ui/primitives'
29
+ import type { RequiresAddon } from './types'
30
+
31
+ export type { RequiresAddon }
32
+
33
+ /**
34
+ * Reads the addon requirement off an action, tolerating both the camelCase
35
+ * `requiresAddon` and the snake_case `requires_addon` the host serves. Returns
36
+ * null when the action is freely executable (no requirement, or a malformed one
37
+ * without a `key` — degrade to "runs", never to "locked forever").
38
+ */
39
+ export function resolveRequiresAddon(action: any): RequiresAddon | null {
40
+ const raw = action?.requiresAddon ?? action?.requires_addon
41
+ if (!raw || typeof raw !== 'object') return null
42
+ const key = typeof raw.key === 'string' ? raw.key.trim() : ''
43
+ if (!key) return null
44
+ const out: RequiresAddon = { key }
45
+ if (typeof raw.name === 'string' && raw.name) out.name = raw.name
46
+ if (typeof raw.reason === 'string' && raw.reason) out.reason = raw.reason
47
+ return out
48
+ }
49
+
50
+ type AddonInstallHandler = (key: string) => void
51
+
52
+ let installHandler: AddonInstallHandler | null = null
53
+
54
+ /**
55
+ * Registers the host's "install this addon" flow (e.g. open the marketplace
56
+ * install drawer). Pass `null` to unregister. Without a handler the SDK
57
+ * navigates to `/marketplace/<key>` via the router.
58
+ */
59
+ export function setAddonInstallHandler(fn: AddonInstallHandler | null): void {
60
+ installHandler = fn
61
+ }
62
+
63
+ /** Current host install handler, or null when none is registered. */
64
+ export function getAddonInstallHandler(): AddonInstallHandler | null {
65
+ return installHandler
66
+ }
67
+
68
+ /** Display name for a requirement: the host-provided name, else the key. */
69
+ export function requiresAddonName(req: RequiresAddon): string {
70
+ return req.name || req.key
71
+ }
72
+
73
+ /** Tooltip text for a gated action: "Requiere «<name>»". */
74
+ export function useRequiresAddonLabel(): (req: RequiresAddon) => string {
75
+ const { t } = useTranslation()
76
+ return (req) =>
77
+ t('dynamic.requires_addon_badge', {
78
+ defaultValue: 'Requiere «{{name}}»',
79
+ name: requiresAddonName(req),
80
+ })
81
+ }
82
+
83
+ /** Lock marker rendered next to a gated action's label (hover = addon name). */
84
+ export function RequiresAddonLock({ requirement }: { requirement: RequiresAddon }) {
85
+ const label = useRequiresAddonLabel()(requirement)
86
+ return (
87
+ <span
88
+ className="ml-auto inline-flex shrink-0 items-center pl-2 text-muted-foreground"
89
+ title={label}
90
+ aria-label={label}
91
+ data-requires-addon={requirement.key}
92
+ >
93
+ <Lock className="h-3.5 w-3.5" />
94
+ </span>
95
+ )
96
+ }
97
+
98
+ export interface RequiresAddonDialogProps {
99
+ /** The requirement to explain; the dialog renders nothing while null. */
100
+ requirement: RequiresAddon | null
101
+ onOpenChange: (open: boolean) => void
102
+ }
103
+
104
+ /**
105
+ * Compact "this action requires <addon>" dialog. Never touches the backend:
106
+ * "Instalar" hands the key to the host handler or routes to the marketplace.
107
+ */
108
+ export function RequiresAddonDialog({ requirement, onOpenChange }: RequiresAddonDialogProps) {
109
+ const { t } = useTranslation()
110
+ const navigate = useNavigate()
111
+ if (!requirement) return null
112
+ const name = requiresAddonName(requirement)
113
+
114
+ const install = () => {
115
+ onOpenChange(false)
116
+ const handler = installHandler
117
+ if (handler) handler(requirement.key)
118
+ else navigate({ to: `/marketplace/${encodeURIComponent(requirement.key)}` })
119
+ }
120
+
121
+ return (
122
+ <AlertDialog open onOpenChange={onOpenChange}>
123
+ <AlertDialogContent className="sm:max-w-md">
124
+ <AlertDialogHeader>
125
+ <AlertDialogTitle className="flex items-center gap-2">
126
+ <Lock className="h-4 w-4 shrink-0 text-muted-foreground" />
127
+ {t('dynamic.requires_addon_title', {
128
+ defaultValue: 'Esta acción requiere «{{name}}»',
129
+ name,
130
+ })}
131
+ </AlertDialogTitle>
132
+ <AlertDialogDescription>
133
+ {requirement.reason
134
+ ? t(requirement.reason, { defaultValue: requirement.reason })
135
+ : t('dynamic.requires_addon_description', {
136
+ defaultValue: 'Instala «{{name}}» para habilitar esta acción.',
137
+ name,
138
+ })}
139
+ </AlertDialogDescription>
140
+ </AlertDialogHeader>
141
+ <AlertDialogFooter>
142
+ <AlertDialogCancel>
143
+ {t('dynamic.requires_addon_cancel', { defaultValue: 'Cancelar' })}
144
+ </AlertDialogCancel>
145
+ <AlertDialogAction
146
+ onClick={(e: React.MouseEvent) => {
147
+ e.preventDefault()
148
+ install()
149
+ }}
150
+ >
151
+ {t('dynamic.requires_addon_install', {
152
+ defaultValue: 'Instalar «{{name}}»',
153
+ name,
154
+ })}
155
+ </AlertDialogAction>
156
+ </AlertDialogFooter>
157
+ </AlertDialogContent>
158
+ </AlertDialog>
159
+ )
160
+ }
package/src/types.ts CHANGED
@@ -654,6 +654,16 @@ export interface FieldOptionsConfig {
654
654
  image?: string
655
655
  }
656
656
 
657
+ /** An optional addon an action needs before it can run. */
658
+ export interface RequiresAddon {
659
+ /** Addon key, e.g. "connector_whatsapp". */
660
+ key: string
661
+ /** Localized display name, e.g. "Conector WhatsApp". Falls back to `key`. */
662
+ name?: string
663
+ /** Why the action needs it (literal or i18n key). */
664
+ reason?: string
665
+ }
666
+
657
667
  export interface ActionDefinition {
658
668
  key: string
659
669
  name: string
@@ -671,6 +681,8 @@ export interface ActionDefinition {
671
681
  steps?: ActionStep[]
672
682
  requiresState?: string[]
673
683
  executable?: boolean
684
+ /** Federated modal slug "<addon_key>.<action_key>" (manifest `modal`). */
685
+ modal?: string
674
686
  /**
675
687
  * Where the host surfaces the trigger. Mirrors manifest/v3 Action.placement.
676
688
  * "row" (default) — per-row table action.
@@ -678,6 +690,13 @@ export interface ActionDefinition {
678
690
  * "create" — toolbar button that replaces the generic create button.
679
691
  */
680
692
  placement?: 'row' | 'table' | 'create'
693
+ /**
694
+ * Optional addon this action depends on, stamped by the host only while it
695
+ * is NOT installed (served as `requires_addon`; read through
696
+ * resolveRequiresAddon). The action stays visible but locked: a click opens
697
+ * the "requires addon" dialog instead of executing.
698
+ */
699
+ requiresAddon?: RequiresAddon
681
700
  }
682
701
 
683
702
  export interface ApiResponse<T> {
@@ -719,5 +738,14 @@ export interface ActionMetadata {
719
738
  steps?: ActionStep[]
720
739
  requiresState?: string[]
721
740
  executable?: boolean
741
+ /** Federated modal slug "<addon_key>.<action_key>"; see ActionModalDispatcher. */
742
+ modal?: string
722
743
  placement?: 'row' | 'table' | 'create'
744
+ /**
745
+ * Optional addon this action depends on, stamped by the host only while it
746
+ * is NOT installed (served as `requires_addon`; read through
747
+ * resolveRequiresAddon). The action stays visible but locked: a click opens
748
+ * the "requires addon" dialog instead of executing.
749
+ */
750
+ requiresAddon?: RequiresAddon
723
751
  }