@asteby/metacore-runtime-react 37.1.0 → 37.1.2
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 +12 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +27 -4
- package/dist/dynamic-form-schema.d.ts.map +1 -1
- package/dist/dynamic-form-schema.js +8 -2
- package/dist/dynamic-line-items.d.ts.map +1 -1
- package/dist/dynamic-line-items.js +7 -1
- package/package.json +1 -1
- package/src/__tests__/action-modal-missing-custom.test.tsx +76 -0
- package/src/__tests__/line-item-formulas.test.ts +12 -0
- package/src/action-modal-dispatcher.tsx +61 -4
- package/src/dynamic-form-schema.ts +9 -2
- package/src/dynamic-line-items.tsx +8 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 37.1.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 2d4e1d8: Fail closed when an action declares `modal` but no federated component is registered — never fall back to the generic confirm dialog.
|
|
8
|
+
|
|
9
|
+
## 37.1.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- ae2004d: Coerce blank line-item discount to 0 so create payloads never POST discount:"" (avoids server "discount is required").
|
|
14
|
+
|
|
3
15
|
## 37.1.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"action-modal-dispatcher.d.ts","sourceRoot":"","sources":["../src/action-modal-dispatcher.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"action-modal-dispatcher.d.ts","sourceRoot":"","sources":["../src/action-modal-dispatcher.tsx"],"names":[],"mappings":"AAsEA,OAAO,KAAK,EAAE,cAAc,EAAmC,MAAM,SAAS,CAAA;AAE9E,OAAO,EACH,KAAK,cAAc,EACnB,KAAK,gBAAgB,EAExB,MAAM,sBAAsB,CAAA;AAE7B,YAAY,EAAE,cAAc,EAAE,gBAAgB,EAAE,CAAA;AAiBhD,MAAM,WAAW,WAAW;IACxB,kBAAkB,EAAE,MAAM,CAAA;IAC1B,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC5B,SAAS,CAAC,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IAC1D;;;;;OAKG;IACH,IAAI,CAAC,EAAE,MAAM,EAAE,CAAA;CAClB;AAED,wBAAgB,aAAa,CAAC,CAAC,EAAE,OAAO,GAAG,CAAC,IAAI,WAAW,CAM1D;AAsBD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,cAAc,GAAG,cAAc,CAStE;AAGD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,WAAW,EAAE,MAAM,EAAE,GAAG,GAAG,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,CAkB3F;AASD,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAY1D;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CASjE;AAUD,oEAAoE;AACpE,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,cAAc,EAAE,MAAM,EAAE,GAAG,GAAG,OAAO,CAgBnF;AAED,wBAAgB,qBAAqB,CAAC,EAClC,IAAI,EACJ,YAAY,EACZ,MAAM,EACN,KAAK,EACL,MAAM,EACN,QAAQ,EACR,SAAS,GACZ,EAAE,gBAAgB,sCAkFlB"}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
// ActionModalDispatcher — renders the right modal for a custom action:
|
|
3
3
|
// 1) Custom component from the SDK registry → use it
|
|
4
|
-
// 2) action.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// 2) action.modal set but no registered component → MissingCustomActionModal
|
|
5
|
+
// (NEVER fall back to confirm/fields — that hides a broken federated UI)
|
|
6
|
+
// 3) action.fields[] / action.steps[] → GenericActionModal / WizardActionModal
|
|
7
|
+
// 4) action.confirm OR action.confirmMessage → ConfirmActionDialog
|
|
8
|
+
// 5) action.executable (host opened the modal; no modal/fields/confirm) →
|
|
7
9
|
// ConfirmActionDialog so a click never silently no-ops
|
|
8
|
-
//
|
|
10
|
+
// 6) otherwise → null (caller should execute immediately without opening us)
|
|
9
11
|
//
|
|
10
12
|
// The host injects its axios-like client via <ApiProvider>; we no longer
|
|
11
13
|
// depend on a bundler alias to `@/lib/api`.
|
|
@@ -158,6 +160,11 @@ export function ActionModalDispatcher({ open, onOpenChange, action, model, recor
|
|
|
158
160
|
if (CustomComponent) {
|
|
159
161
|
return (_jsx(CustomComponent, { open: open, onOpenChange: onOpenChange, action: action, model: model, record: record, endpoint: endpoint, onSuccess: onSuccess }));
|
|
160
162
|
}
|
|
163
|
+
// Declarative custom slot (`modal: "addon.action"`). Prefer a hard error
|
|
164
|
+
// over confirm/fields generics — those look "fine" and hide a missing remote.
|
|
165
|
+
if (action.modal) {
|
|
166
|
+
return (_jsx(MissingCustomActionModal, { open: open, onOpenChange: onOpenChange, action: action, model: model }));
|
|
167
|
+
}
|
|
161
168
|
if (action.steps && action.steps.length > 0) {
|
|
162
169
|
return (_jsx(WizardActionModal, { open: open, onOpenChange: onOpenChange, action: action, model: model, record: record, endpoint: endpoint, onSuccess: onSuccess }));
|
|
163
170
|
}
|
|
@@ -168,12 +175,28 @@ export function ActionModalDispatcher({ open, onOpenChange, action, model, recor
|
|
|
168
175
|
// boolean). Hosts also mark every wasm action `executable` and open THIS
|
|
169
176
|
// dispatcher; without a confirm/fields/custom UI we used to return null and
|
|
170
177
|
// the row click did nothing. Treat message-only + executable-open as confirm.
|
|
178
|
+
// Never reached when action.modal is set (handled above).
|
|
171
179
|
const wantsConfirm = !!(action.confirm || action.confirmMessage);
|
|
172
180
|
if (wantsConfirm || (open && action.executable)) {
|
|
173
181
|
return (_jsx(ConfirmActionDialog, { open: open, onOpenChange: onOpenChange, action: action, model: model, record: record, endpoint: endpoint, onSuccess: onSuccess }));
|
|
174
182
|
}
|
|
175
183
|
return null;
|
|
176
184
|
}
|
|
185
|
+
/** Shown when the action declares `modal` but no federated component registered. */
|
|
186
|
+
function MissingCustomActionModal({ open, onOpenChange, action, model, }) {
|
|
187
|
+
const { t } = useTranslation();
|
|
188
|
+
const slug = action.modal || '';
|
|
189
|
+
const title = t('dynamic.action_modal_missing_title', {
|
|
190
|
+
defaultValue: 'No se pudo cargar el formulario',
|
|
191
|
+
});
|
|
192
|
+
const description = t('dynamic.action_modal_missing_description', {
|
|
193
|
+
defaultValue: 'Esta acción requiere una interfaz personalizada ({{slug}}) que no está registrada. Recarga la página o reinstala el módulo; no se abre un confirmatorio genérico para no confundir el flujo.',
|
|
194
|
+
slug: slug || `${model}.${action.key}`,
|
|
195
|
+
action: action.key,
|
|
196
|
+
model,
|
|
197
|
+
});
|
|
198
|
+
return (_jsx(AlertDialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: title }), _jsx(AlertDialogDescription, { children: description })] }), _jsx(AlertDialogFooter, { children: _jsx(AlertDialogAction, { onClick: () => onOpenChange(false), children: t('common.close', { defaultValue: 'Cerrar' }) }) })] }) }));
|
|
199
|
+
}
|
|
177
200
|
function buildActionUrl(endpoint, model, recordId, actionKey) {
|
|
178
201
|
// A create-placement (collection) action has no record yet, so `recordId`
|
|
179
202
|
// is undefined. Omit the `/{id}` segment so the request hits the collection
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-form-schema.d.ts","sourceRoot":"","sources":["../src/dynamic-form-schema.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAmB,MAAM,KAAK,CAAA;AACxC,OAAO,KAAK,EAAE,cAAc,EAAE,kBAAkB,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,SAAS,CAAA;AAkBzF;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,SAAS,KAAK,CAAC,CAAC,SAAS,GAAG,IAAI,CAEzF;AAcD,wBAAgB,cAAc,CAAC,MAAM,EAAE,cAAc,EAAE;;kBAMtD;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,cAAc,GAAG,cAAc,EAAE,CAGrE;AAED,8EAA8E;AAC9E,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAE/D;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAC1B,KAAK,EAAE,cAAc,GACtB;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,cAAc,EAAE,OAAO,CAAA;CAAE,GAAG,SAAS,CAatG;AAED,6EAA6E;AAC7E,wBAAgB,QAAQ,CAAC,CAAC,EAAE,OAAO,GAAG,MAAM,CAO3C;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CACjC,KAAK,EAAE,cAAc,EACrB,IAAI,EAAE,GAAG,EAAE,GAAG,SAAS,GACxB,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAWxB;AAwBD;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CACpC,UAAU,EAAE,cAAc,EAAE,EAC5B,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GACzB,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,
|
|
1
|
+
{"version":3,"file":"dynamic-form-schema.d.ts","sourceRoot":"","sources":["../src/dynamic-form-schema.ts"],"names":[],"mappings":"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"}
|
|
@@ -152,9 +152,15 @@ export function applyLineItemRowFormulas(itemFields, row) {
|
|
|
152
152
|
const price = toNumber(row[priceKey]);
|
|
153
153
|
const discount = discountKey ? toNumber(row[discountKey]) : 0;
|
|
154
154
|
const next = Math.round((qty * price - discount) * 100) / 100;
|
|
155
|
-
|
|
155
|
+
// Coerce blank optional discount to 0 so submit never POSTs `discount: ""`
|
|
156
|
+
// (servers that see a present key then reject it as "discount is required").
|
|
157
|
+
const discountBlank = !!discountKey && (row[discountKey] === '' || row[discountKey] == null);
|
|
158
|
+
if (toNumber(row[amountKey]) === next && !discountBlank)
|
|
156
159
|
return row;
|
|
157
|
-
|
|
160
|
+
const out = { ...row, [amountKey]: next };
|
|
161
|
+
if (discountBlank)
|
|
162
|
+
out[discountKey] = 0;
|
|
163
|
+
return out;
|
|
158
164
|
}
|
|
159
165
|
/**
|
|
160
166
|
* Evaluates a line-items field's balance rule against its rows. Returns
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-line-items.d.ts","sourceRoot":"","sources":["../src/dynamic-line-items.tsx"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAiB7C,MAAM,WAAW,qBAAqB;IAClC,KAAK,EAAE,cAAc,CAAA;IACrB,KAAK,EAAE,GAAG,EAAE,GAAG,SAAS,CAAA;IACxB,QAAQ,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IAC/B,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAChC;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAClC;
|
|
1
|
+
{"version":3,"file":"dynamic-line-items.d.ts","sourceRoot":"","sources":["../src/dynamic-line-items.tsx"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAiB7C,MAAM,WAAW,qBAAqB;IAClC,KAAK,EAAE,cAAc,CAAA;IACrB,KAAK,EAAE,GAAG,EAAE,GAAG,SAAS,CAAA;IACxB,QAAQ,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,CAAA;IAC/B,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAChC;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAClC;AAyBD,wBAAgB,gBAAgB,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAgB,EAAE,UAAU,EAAE,MAAM,EAAE,EAAE,qBAAqB,+BAwQvH"}
|
|
@@ -22,7 +22,13 @@ function isNumericCol(col) {
|
|
|
22
22
|
function emptyRow(itemFields) {
|
|
23
23
|
const row = {};
|
|
24
24
|
for (const f of itemFields) {
|
|
25
|
-
|
|
25
|
+
// Kernel serves `default` on action fields; the SDK type uses
|
|
26
|
+
// `defaultValue` (host carryActionFieldDefaults). Accept both so a
|
|
27
|
+
// manifest `"default": 0` on optional money cells (discount) seeds 0
|
|
28
|
+
// instead of "" — blank strings trip server parsers that treat a
|
|
29
|
+
// present key as required.
|
|
30
|
+
const seeded = f.defaultValue ?? f.default;
|
|
31
|
+
row[f.key] = seeded ?? (f.type === 'boolean' ? false : '');
|
|
26
32
|
}
|
|
27
33
|
return row;
|
|
28
34
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// When an action declares `modal: "<slug>"` and no federated component is
|
|
4
|
+
// registered, ActionModalDispatcher must surface an error — never the generic
|
|
5
|
+
// ConfirmActionDialog (even if confirm/confirmMessage are also set).
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
7
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
8
|
+
|
|
9
|
+
vi.mock('react-i18next', () => ({
|
|
10
|
+
useTranslation: () => ({
|
|
11
|
+
t: (_k: string, opts?: { defaultValue?: string }) => opts?.defaultValue ?? _k,
|
|
12
|
+
}),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
import { ActionModalDispatcher } from '../action-modal-dispatcher'
|
|
16
|
+
import { ApiProvider, type ApiClient } from '../api-context'
|
|
17
|
+
import {
|
|
18
|
+
registerActionComponent,
|
|
19
|
+
unregisterActionComponent,
|
|
20
|
+
type ActionMetadata,
|
|
21
|
+
} from '@asteby/metacore-sdk'
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
cleanup()
|
|
25
|
+
unregisterActionComponent('SalesOrder', 'authorize_credit')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const noopApi: ApiClient = {
|
|
29
|
+
get: vi.fn().mockResolvedValue({ data: {} }),
|
|
30
|
+
post: vi.fn().mockResolvedValue({ data: { success: true } }),
|
|
31
|
+
} as unknown as ApiClient
|
|
32
|
+
|
|
33
|
+
const creditAction: ActionMetadata = {
|
|
34
|
+
key: 'authorize_credit',
|
|
35
|
+
label: 'Autorizar a crédito',
|
|
36
|
+
icon: 'Landmark',
|
|
37
|
+
modal: 'credit_approval.authorize_credit',
|
|
38
|
+
confirm: true,
|
|
39
|
+
confirmMessage: 'customers.action.authorize_credit_confirm_message',
|
|
40
|
+
executable: true,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function renderDispatcher(action: ActionMetadata = creditAction) {
|
|
44
|
+
return render(
|
|
45
|
+
<ApiProvider client={noopApi}>
|
|
46
|
+
<ActionModalDispatcher
|
|
47
|
+
open
|
|
48
|
+
onOpenChange={() => {}}
|
|
49
|
+
action={action}
|
|
50
|
+
model="SalesOrder"
|
|
51
|
+
record={{ id: '1', folio: 'SO-1' } as any}
|
|
52
|
+
endpoint="/data/SalesOrder"
|
|
53
|
+
onSuccess={() => {}}
|
|
54
|
+
/>
|
|
55
|
+
</ApiProvider>,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('ActionModalDispatcher custom modal contract', () => {
|
|
60
|
+
it('errors when modal is declared but no component is registered (no generic confirm)', () => {
|
|
61
|
+
renderDispatcher()
|
|
62
|
+
expect(screen.getByText('No se pudo cargar el formulario')).toBeTruthy()
|
|
63
|
+
expect(screen.queryByText('customers.action.authorize_credit_confirm_message')).toBeNull()
|
|
64
|
+
expect(screen.queryByRole('button', { name: /autorizar a crédito/i })).toBeNull()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('renders the registered custom component when present', () => {
|
|
68
|
+
function CustomModal() {
|
|
69
|
+
return <div data-testid="credit-dossier">dossier</div>
|
|
70
|
+
}
|
|
71
|
+
registerActionComponent('SalesOrder', 'authorize_credit', CustomModal as never)
|
|
72
|
+
renderDispatcher()
|
|
73
|
+
expect(screen.getByTestId('credit-dossier')).toBeTruthy()
|
|
74
|
+
expect(screen.queryByText('No se pudo cargar el formulario')).toBeNull()
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -30,6 +30,18 @@ describe('applyLineItemRowFormulas', () => {
|
|
|
30
30
|
subtotal: '',
|
|
31
31
|
})
|
|
32
32
|
expect(row.subtotal).toBe(25)
|
|
33
|
+
expect(row.discount).toBe(0)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('coerces blank discount to 0 even when subtotal already matches', () => {
|
|
37
|
+
const row = applyLineItemRowFormulas(fields, {
|
|
38
|
+
qty: 1,
|
|
39
|
+
unit_price: 10,
|
|
40
|
+
discount: '',
|
|
41
|
+
subtotal: 10,
|
|
42
|
+
})
|
|
43
|
+
expect(row.subtotal).toBe(10)
|
|
44
|
+
expect(row.discount).toBe(0)
|
|
33
45
|
})
|
|
34
46
|
|
|
35
47
|
it('is a no-op without an amount column', () => {
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// ActionModalDispatcher — renders the right modal for a custom action:
|
|
2
2
|
// 1) Custom component from the SDK registry → use it
|
|
3
|
-
// 2) action.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// 2) action.modal set but no registered component → MissingCustomActionModal
|
|
4
|
+
// (NEVER fall back to confirm/fields — that hides a broken federated UI)
|
|
5
|
+
// 3) action.fields[] / action.steps[] → GenericActionModal / WizardActionModal
|
|
6
|
+
// 4) action.confirm OR action.confirmMessage → ConfirmActionDialog
|
|
7
|
+
// 5) action.executable (host opened the modal; no modal/fields/confirm) →
|
|
6
8
|
// ConfirmActionDialog so a click never silently no-ops
|
|
7
|
-
//
|
|
9
|
+
// 6) otherwise → null (caller should execute immediately without opening us)
|
|
8
10
|
//
|
|
9
11
|
// The host injects its axios-like client via <ApiProvider>; we no longer
|
|
10
12
|
// depend on a bundler alias to `@/lib/api`.
|
|
@@ -251,6 +253,19 @@ export function ActionModalDispatcher({
|
|
|
251
253
|
)
|
|
252
254
|
}
|
|
253
255
|
|
|
256
|
+
// Declarative custom slot (`modal: "addon.action"`). Prefer a hard error
|
|
257
|
+
// over confirm/fields generics — those look "fine" and hide a missing remote.
|
|
258
|
+
if (action.modal) {
|
|
259
|
+
return (
|
|
260
|
+
<MissingCustomActionModal
|
|
261
|
+
open={open}
|
|
262
|
+
onOpenChange={onOpenChange}
|
|
263
|
+
action={action}
|
|
264
|
+
model={model}
|
|
265
|
+
/>
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
254
269
|
if (action.steps && action.steps.length > 0) {
|
|
255
270
|
return (
|
|
256
271
|
<WizardActionModal
|
|
@@ -283,6 +298,7 @@ export function ActionModalDispatcher({
|
|
|
283
298
|
// boolean). Hosts also mark every wasm action `executable` and open THIS
|
|
284
299
|
// dispatcher; without a confirm/fields/custom UI we used to return null and
|
|
285
300
|
// the row click did nothing. Treat message-only + executable-open as confirm.
|
|
301
|
+
// Never reached when action.modal is set (handled above).
|
|
286
302
|
const wantsConfirm = !!(action.confirm || action.confirmMessage)
|
|
287
303
|
if (wantsConfirm || (open && action.executable)) {
|
|
288
304
|
return (
|
|
@@ -301,6 +317,47 @@ export function ActionModalDispatcher({
|
|
|
301
317
|
return null
|
|
302
318
|
}
|
|
303
319
|
|
|
320
|
+
/** Shown when the action declares `modal` but no federated component registered. */
|
|
321
|
+
function MissingCustomActionModal({
|
|
322
|
+
open,
|
|
323
|
+
onOpenChange,
|
|
324
|
+
action,
|
|
325
|
+
model,
|
|
326
|
+
}: {
|
|
327
|
+
open: boolean
|
|
328
|
+
onOpenChange: (open: boolean) => void
|
|
329
|
+
action: ActionMetadata
|
|
330
|
+
model: string
|
|
331
|
+
}) {
|
|
332
|
+
const { t } = useTranslation()
|
|
333
|
+
const slug = action.modal || ''
|
|
334
|
+
const title = t('dynamic.action_modal_missing_title', {
|
|
335
|
+
defaultValue: 'No se pudo cargar el formulario',
|
|
336
|
+
})
|
|
337
|
+
const description = t('dynamic.action_modal_missing_description', {
|
|
338
|
+
defaultValue:
|
|
339
|
+
'Esta acción requiere una interfaz personalizada ({{slug}}) que no está registrada. Recarga la página o reinstala el módulo; no se abre un confirmatorio genérico para no confundir el flujo.',
|
|
340
|
+
slug: slug || `${model}.${action.key}`,
|
|
341
|
+
action: action.key,
|
|
342
|
+
model,
|
|
343
|
+
})
|
|
344
|
+
return (
|
|
345
|
+
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
|
346
|
+
<AlertDialogContent>
|
|
347
|
+
<AlertDialogHeader>
|
|
348
|
+
<AlertDialogTitle>{title}</AlertDialogTitle>
|
|
349
|
+
<AlertDialogDescription>{description}</AlertDialogDescription>
|
|
350
|
+
</AlertDialogHeader>
|
|
351
|
+
<AlertDialogFooter>
|
|
352
|
+
<AlertDialogAction onClick={() => onOpenChange(false)}>
|
|
353
|
+
{t('common.close', { defaultValue: 'Cerrar' })}
|
|
354
|
+
</AlertDialogAction>
|
|
355
|
+
</AlertDialogFooter>
|
|
356
|
+
</AlertDialogContent>
|
|
357
|
+
</AlertDialog>
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
|
|
304
361
|
function buildActionUrl(endpoint: string | undefined, model: string, recordId: string | undefined, actionKey: string) {
|
|
305
362
|
// A create-placement (collection) action has no record yet, so `recordId`
|
|
306
363
|
// is undefined. Omit the `/{id}` segment so the request hits the collection
|
|
@@ -166,8 +166,15 @@ export function applyLineItemRowFormulas(
|
|
|
166
166
|
const price = toNumber(row[priceKey])
|
|
167
167
|
const discount = discountKey ? toNumber(row[discountKey]) : 0
|
|
168
168
|
const next = Math.round((qty * price - discount) * 100) / 100
|
|
169
|
-
|
|
170
|
-
|
|
169
|
+
|
|
170
|
+
// Coerce blank optional discount to 0 so submit never POSTs `discount: ""`
|
|
171
|
+
// (servers that see a present key then reject it as "discount is required").
|
|
172
|
+
const discountBlank =
|
|
173
|
+
!!discountKey && (row[discountKey] === '' || row[discountKey] == null)
|
|
174
|
+
if (toNumber(row[amountKey]) === next && !discountBlank) return row
|
|
175
|
+
const out: Record<string, any> = { ...row, [amountKey]: next }
|
|
176
|
+
if (discountBlank) out[discountKey!] = 0
|
|
177
|
+
return out
|
|
171
178
|
}
|
|
172
179
|
|
|
173
180
|
export interface BalanceState {
|
|
@@ -66,7 +66,14 @@ function isNumericCol(col: ActionFieldDef): boolean {
|
|
|
66
66
|
function emptyRow(itemFields: ActionFieldDef[]): Record<string, any> {
|
|
67
67
|
const row: Record<string, any> = {}
|
|
68
68
|
for (const f of itemFields) {
|
|
69
|
-
|
|
69
|
+
// Kernel serves `default` on action fields; the SDK type uses
|
|
70
|
+
// `defaultValue` (host carryActionFieldDefaults). Accept both so a
|
|
71
|
+
// manifest `"default": 0` on optional money cells (discount) seeds 0
|
|
72
|
+
// instead of "" — blank strings trip server parsers that treat a
|
|
73
|
+
// present key as required.
|
|
74
|
+
const seeded =
|
|
75
|
+
f.defaultValue ?? (f as { default?: unknown }).default
|
|
76
|
+
row[f.key] = seeded ?? (f.type === 'boolean' ? false : '')
|
|
70
77
|
}
|
|
71
78
|
return row
|
|
72
79
|
}
|