@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.
- package/CHANGELOG.md +27 -0
- package/dist/action-modal-dispatcher.d.ts +2 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +74 -8
- package/dist/dynamic-columns.d.ts +11 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +13 -1
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +4 -5
- package/dist/dynamic-row-actions.d.ts.map +1 -1
- package/dist/dynamic-row-actions.js +23 -16
- package/dist/federated-action-loader.d.ts +18 -0
- package/dist/federated-action-loader.d.ts.map +1 -0
- package/dist/federated-action-loader.js +34 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/model-action-toolbar.d.ts.map +1 -1
- package/dist/model-action-toolbar.js +19 -15
- package/dist/requires-addon.d.ts +37 -0
- package/dist/requires-addon.d.ts.map +1 -0
- package/dist/requires-addon.js +104 -0
- package/dist/types.d.ts +27 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/action-modal-missing-custom.test.tsx +12 -4
- package/src/__tests__/federated-action-modal.test.tsx +315 -0
- package/src/__tests__/requires-addon.test.tsx +201 -0
- package/src/action-modal-dispatcher.tsx +102 -10
- package/src/dynamic-columns.tsx +36 -6
- package/src/dynamic-kanban.tsx +6 -7
- package/src/dynamic-row-actions.tsx +26 -16
- package/src/federated-action-loader.ts +40 -0
- package/src/index.ts +13 -0
- package/src/model-action-toolbar.tsx +31 -15
- package/src/requires-addon.tsx +160 -0
- package/src/types.ts +28 -0
package/src/dynamic-columns.tsx
CHANGED
|
@@ -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
|
-
<
|
|
1562
|
+
<RowActionMenuItem
|
|
1532
1563
|
key={action.key}
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
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>
|
package/src/dynamic-kanban.tsx
CHANGED
|
@@ -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
|
-
<
|
|
3116
|
+
<RowActionMenuItem
|
|
3117
3117
|
key={a.key}
|
|
3118
|
-
|
|
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
|
-
//
|
|
15
|
-
//
|
|
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
|
|
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 (
|
|
124
|
-
// steps, confirm bool, confirmMessage, or host
|
|
125
|
-
// confirm_message without confirm:true used to fall
|
|
126
|
-
// then render null — derive confirm so the
|
|
127
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
}
|