@asteby/metacore-runtime-react 13.5.0 → 13.5.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 +27 -0
- package/dist/action-modal-dispatcher.js +2 -2
- package/dist/dynamic-columns.d.ts +13 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +22 -0
- package/package.json +3 -3
- package/src/__tests__/action-visibility-by-state.test.ts +51 -0
- package/src/action-modal-dispatcher.tsx +15 -10
- package/src/dynamic-columns.tsx +21 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 13.5.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- bc99aec: Gate per-row table actions by the row's `status` against the action's `requiresState`.
|
|
8
|
+
|
|
9
|
+
Row actions that declare a non-empty `requiresState` (camelCase or the snake_case
|
|
10
|
+
`requires_state` served by the backend) are now hidden in the row-action dropdown
|
|
11
|
+
unless the row's `status` value is one of the declared states. For example, an
|
|
12
|
+
"Iniciar trabajo" action with `requiresState: ['reception']` no longer appears on an
|
|
13
|
+
order already in `in_progress`.
|
|
14
|
+
|
|
15
|
+
Additive and null-safe: actions without `requiresState` (or an empty array) are always
|
|
16
|
+
shown, and rows without a `status` field surface every action, so there is no
|
|
17
|
+
regression for existing models.
|
|
18
|
+
|
|
19
|
+
## 13.5.1
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- 2778004: fix(action-modal): sticky header + footer, scrollable body
|
|
24
|
+
|
|
25
|
+
A tall declarative form (a journal entry with many line-items rows) used to push
|
|
26
|
+
the Cancel/Submit footer below the viewport. The action modal now caps at 90vh
|
|
27
|
+
and scrolls ONLY the field area — the title and the action buttons stay pinned
|
|
28
|
+
and always reachable.
|
|
29
|
+
|
|
3
30
|
## 13.5.0
|
|
4
31
|
|
|
5
32
|
### Minor Changes
|
|
@@ -135,12 +135,12 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
135
135
|
: hasLineItems
|
|
136
136
|
? '820px'
|
|
137
137
|
: undefined;
|
|
138
|
-
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: widthPx ? '' : 'sm:max-w-lg', style: widthPx ? { maxWidth: widthPx, width: '95vw' } :
|
|
138
|
+
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: 'flex max-h-[90vh] flex-col overflow-hidden ' + (widthPx ? '' : 'sm:max-w-lg'), 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 grid min-h-0 flex-1 gap-4 overflow-y-auto px-1 py-4 sm:grid-cols-2", children: action.fields?.map((field) => {
|
|
139
139
|
const fullWidth = isLineItemsField(field) ||
|
|
140
140
|
resolveWidget(field) === 'textarea' ||
|
|
141
141
|
resolveWidget(field) === 'richtext';
|
|
142
142
|
return (_jsxs("div", { className: 'grid gap-2 ' + (fullWidth ? 'sm:col-span-2' : ''), children: [_jsxs(Label, { htmlFor: field.key, children: [field.label, field.required && _jsx("span", { className: "text-red-500 ml-1", children: "*" })] }), renderField(field, formData[field.key], (v) => updateField(field.key, v))] }, field.key));
|
|
143
|
-
}) }), _jsxs(DialogFooter, { 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] })] })] }) }));
|
|
143
|
+
}) }), _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] })] })] }) }));
|
|
144
144
|
}
|
|
145
145
|
function renderField(field, value, onChange) {
|
|
146
146
|
// Repeatable line-items group → row grid (value is an array of row objects).
|
|
@@ -14,6 +14,19 @@ export interface DynamicColumnsHelpers {
|
|
|
14
14
|
*/
|
|
15
15
|
apiBaseUrl?: string;
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* State-machine gate for per-row actions.
|
|
19
|
+
*
|
|
20
|
+
* An action that declares a non-empty `requiresState` (camelCase) / `requires_state`
|
|
21
|
+
* (snake_case, as served by some backends) is only surfaced for rows whose `status`
|
|
22
|
+
* field value is contained in that array. This hides e.g. an "Iniciar trabajo"
|
|
23
|
+
* action (requiresState: ['reception']) on an order already in `in_progress`.
|
|
24
|
+
*
|
|
25
|
+
* Null-safe & non-regressive:
|
|
26
|
+
* - action without requiresState (or empty array) → always shown.
|
|
27
|
+
* - row with no `status` field → all actions shown.
|
|
28
|
+
*/
|
|
29
|
+
export declare const isActionAllowedForRowState: (action: any, row: any) => boolean;
|
|
17
30
|
/**
|
|
18
31
|
* Builds the canonical column factory used by `<DynamicTable>` when the host
|
|
19
32
|
* does not supply its own. Pass `{ getImageUrl, apiBaseUrl }` to wire avatar
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-columns.d.ts","sourceRoot":"","sources":["../src/dynamic-columns.tsx"],"names":[],"mappings":"AAsCA,OAAO,KAAK,EAER,iBAAiB,EACpB,MAAM,wBAAwB,CAAA;AAE/B,qEAAqE;AACrE,MAAM,WAAW,qBAAqB;IAClC;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACtB;
|
|
1
|
+
{"version":3,"file":"dynamic-columns.d.ts","sourceRoot":"","sources":["../src/dynamic-columns.tsx"],"names":[],"mappings":"AAsCA,OAAO,KAAK,EAER,iBAAiB,EACpB,MAAM,wBAAwB,CAAA;AAE/B,qEAAqE;AACrE,MAAM,WAAW,qBAAqB;IAClC;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACtB;AAOD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OAMlE,CAAA;AAmHD;;;;GAIG;AACH,wBAAgB,4BAA4B,CACxC,OAAO,GAAE,qBAA0B,GACpC,iBAAiB,CAsXnB;AAED;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBACL,CAAA"}
|
package/dist/dynamic-columns.js
CHANGED
|
@@ -21,6 +21,27 @@ import { DynamicIcon } from './dynamic-icon';
|
|
|
21
21
|
import { isColumnVisibleInTable } from './column-visibility';
|
|
22
22
|
const defaultGetImageUrl = (path) => path;
|
|
23
23
|
const getNestedValue = (obj, path) => path.split('.').reduce((acc, part) => acc && acc[part], obj);
|
|
24
|
+
/**
|
|
25
|
+
* State-machine gate for per-row actions.
|
|
26
|
+
*
|
|
27
|
+
* An action that declares a non-empty `requiresState` (camelCase) / `requires_state`
|
|
28
|
+
* (snake_case, as served by some backends) is only surfaced for rows whose `status`
|
|
29
|
+
* field value is contained in that array. This hides e.g. an "Iniciar trabajo"
|
|
30
|
+
* action (requiresState: ['reception']) on an order already in `in_progress`.
|
|
31
|
+
*
|
|
32
|
+
* Null-safe & non-regressive:
|
|
33
|
+
* - action without requiresState (or empty array) → always shown.
|
|
34
|
+
* - row with no `status` field → all actions shown.
|
|
35
|
+
*/
|
|
36
|
+
export const isActionAllowedForRowState = (action, row) => {
|
|
37
|
+
const requires = action?.requiresState ?? action?.requires_state;
|
|
38
|
+
if (!Array.isArray(requires) || requires.length === 0)
|
|
39
|
+
return true;
|
|
40
|
+
const status = row?.status;
|
|
41
|
+
if (status === undefined || status === null || status === '')
|
|
42
|
+
return true;
|
|
43
|
+
return requires.map(String).includes(String(status));
|
|
44
|
+
};
|
|
24
45
|
const lowerFirst = (value) => {
|
|
25
46
|
if (!value)
|
|
26
47
|
return value;
|
|
@@ -311,6 +332,7 @@ export function makeDefaultGetDynamicColumns(helpers = {}) {
|
|
|
311
332
|
maxSize: 80,
|
|
312
333
|
meta: {},
|
|
313
334
|
cell: ({ row }) => (_jsx("div", { className: "flex items-center justify-end", children: _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", className: "h-8 w-8 p-0", children: [_jsx("span", { className: "sr-only", children: "Abrir men\u00FA" }), _jsx(MoreHorizontal, { className: "h-4 w-4" })] }) }), _jsx(DropdownMenuContent, { align: "end", children: resolvedActions
|
|
335
|
+
.filter((action) => isActionAllowedForRowState(action, row.original))
|
|
314
336
|
.filter((action) => {
|
|
315
337
|
if (!action.condition)
|
|
316
338
|
return true;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asteby/metacore-runtime-react",
|
|
3
|
-
"version": "13.5.
|
|
3
|
+
"version": "13.5.2",
|
|
4
4
|
"description": "React runtime for metacore hosts — renders addon contributions dynamically",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -61,8 +61,8 @@
|
|
|
61
61
|
"typescript": "^6.0.0",
|
|
62
62
|
"vitest": "^4.0.0",
|
|
63
63
|
"zustand": "^5.0.0",
|
|
64
|
-
"@asteby/metacore-
|
|
65
|
-
"@asteby/metacore-
|
|
64
|
+
"@asteby/metacore-ui": "2.1.0",
|
|
65
|
+
"@asteby/metacore-sdk": "3.1.0"
|
|
66
66
|
},
|
|
67
67
|
"scripts": {
|
|
68
68
|
"build": "tsc -p tsconfig.json",
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { isActionAllowedForRowState } from '../dynamic-columns'
|
|
4
|
+
|
|
5
|
+
describe('isActionAllowedForRowState', () => {
|
|
6
|
+
it('hides the action when row.status is NOT in requiresState', () => {
|
|
7
|
+
const action = { key: 'start', requiresState: ['reception'] }
|
|
8
|
+
const row = { id: 1, status: 'in_progress' }
|
|
9
|
+
expect(isActionAllowedForRowState(action, row)).toBe(false)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('shows the action when row.status IS in requiresState', () => {
|
|
13
|
+
const action = { key: 'start', requiresState: ['reception'] }
|
|
14
|
+
const row = { id: 1, status: 'reception' }
|
|
15
|
+
expect(isActionAllowedForRowState(action, row)).toBe(true)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('shows the action when row.status matches one of several requiresState entries', () => {
|
|
19
|
+
const action = { key: 'finish', requiresState: ['in_progress', 'paused'] }
|
|
20
|
+
expect(isActionAllowedForRowState(action, { status: 'paused' })).toBe(true)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('always shows the action when requiresState is empty', () => {
|
|
24
|
+
const action = { key: 'view', requiresState: [] as string[] }
|
|
25
|
+
expect(isActionAllowedForRowState(action, { status: 'whatever' })).toBe(true)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('always shows the action when requiresState is absent (no regression)', () => {
|
|
29
|
+
const action = { key: 'view' }
|
|
30
|
+
expect(isActionAllowedForRowState(action, { status: 'in_progress' })).toBe(true)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('shows all actions when the row has no status field (no regression)', () => {
|
|
34
|
+
const action = { key: 'start', requiresState: ['reception'] }
|
|
35
|
+
expect(isActionAllowedForRowState(action, { id: 1 })).toBe(true)
|
|
36
|
+
expect(isActionAllowedForRowState(action, { id: 1, status: null })).toBe(true)
|
|
37
|
+
expect(isActionAllowedForRowState(action, { id: 1, status: '' })).toBe(true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('tolerates the snake_case requires_state served by the backend', () => {
|
|
41
|
+
const action = { key: 'start', requires_state: ['reception'] }
|
|
42
|
+
expect(isActionAllowedForRowState(action, { status: 'reception' })).toBe(true)
|
|
43
|
+
expect(isActionAllowedForRowState(action, { status: 'in_progress' })).toBe(false)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('coerces numeric status / state values via String() comparison', () => {
|
|
47
|
+
const action = { key: 'advance', requiresState: [1, 2] as unknown as string[] }
|
|
48
|
+
expect(isActionAllowedForRowState(action, { status: 2 })).toBe(true)
|
|
49
|
+
expect(isActionAllowedForRowState(action, { status: '3' })).toBe(false)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -246,23 +246,28 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
246
246
|
|
|
247
247
|
return (
|
|
248
248
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
249
|
+
{/* Sticky header + footer, scrollable body: the form can grow tall
|
|
250
|
+
(many line-items rows) past the viewport, so cap the dialog at
|
|
251
|
+
90vh and let ONLY the field area scroll — the title and the
|
|
252
|
+
Cancel/Submit actions stay pinned and always reachable. maxHeight
|
|
253
|
+
is inline (guaranteed) since an arbitrary max-h-[90vh] class may be
|
|
254
|
+
dropped by a consuming app's Tailwind scan. */}
|
|
249
255
|
<DialogContent
|
|
250
|
-
className={widthPx ? '' : 'sm:max-w-lg'}
|
|
251
|
-
style={widthPx ? { maxWidth: widthPx, width: '95vw' } :
|
|
256
|
+
className={'flex max-h-[90vh] flex-col overflow-hidden ' + (widthPx ? '' : 'sm:max-w-lg')}
|
|
257
|
+
style={{ maxHeight: '90vh', ...(widthPx ? { maxWidth: widthPx, width: '95vw' } : {}) }}
|
|
252
258
|
>
|
|
253
|
-
<DialogHeader>
|
|
259
|
+
<DialogHeader className="shrink-0">
|
|
254
260
|
<DialogTitle className="flex items-center gap-2">
|
|
255
261
|
<DynamicIcon name={action.icon} className="h-5 w-5" />
|
|
256
262
|
{action.label}
|
|
257
263
|
</DialogTitle>
|
|
258
264
|
{action.confirmMessage && <DialogDescription>{action.confirmMessage}</DialogDescription>}
|
|
259
265
|
</DialogHeader>
|
|
260
|
-
{/* Responsive 2-column grid: scalar fields
|
|
261
|
-
reference) flow side-by-side instead of one
|
|
262
|
-
stack; line-items grids and textareas span the
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
<div className="grid gap-4 py-4 sm:grid-cols-2">
|
|
266
|
+
{/* Scrollable body. Responsive 2-column grid: scalar fields
|
|
267
|
+
(journal, date, reference) flow side-by-side instead of one
|
|
268
|
+
tall vertical stack; line-items grids and textareas span the
|
|
269
|
+
full width. Mirrors DynamicForm — driven only by field shape. */}
|
|
270
|
+
<div className="-mx-1 grid min-h-0 flex-1 gap-4 overflow-y-auto px-1 py-4 sm:grid-cols-2">
|
|
266
271
|
{action.fields?.map((field) => {
|
|
267
272
|
const fullWidth =
|
|
268
273
|
isLineItemsField(field) ||
|
|
@@ -282,7 +287,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
282
287
|
)
|
|
283
288
|
})}
|
|
284
289
|
</div>
|
|
285
|
-
<DialogFooter>
|
|
290
|
+
<DialogFooter className="shrink-0">
|
|
286
291
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={executing}>
|
|
287
292
|
{t('common.cancel')}
|
|
288
293
|
</Button>
|
package/src/dynamic-columns.tsx
CHANGED
|
@@ -62,6 +62,26 @@ const defaultGetImageUrl = (path: string) => path
|
|
|
62
62
|
const getNestedValue = (obj: any, path: string) =>
|
|
63
63
|
path.split('.').reduce((acc, part) => acc && acc[part], obj)
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* State-machine gate for per-row actions.
|
|
67
|
+
*
|
|
68
|
+
* An action that declares a non-empty `requiresState` (camelCase) / `requires_state`
|
|
69
|
+
* (snake_case, as served by some backends) is only surfaced for rows whose `status`
|
|
70
|
+
* field value is contained in that array. This hides e.g. an "Iniciar trabajo"
|
|
71
|
+
* action (requiresState: ['reception']) on an order already in `in_progress`.
|
|
72
|
+
*
|
|
73
|
+
* Null-safe & non-regressive:
|
|
74
|
+
* - action without requiresState (or empty array) → always shown.
|
|
75
|
+
* - row with no `status` field → all actions shown.
|
|
76
|
+
*/
|
|
77
|
+
export const isActionAllowedForRowState = (action: any, row: any): boolean => {
|
|
78
|
+
const requires: unknown = action?.requiresState ?? action?.requires_state
|
|
79
|
+
if (!Array.isArray(requires) || requires.length === 0) return true
|
|
80
|
+
const status = row?.status
|
|
81
|
+
if (status === undefined || status === null || status === '') return true
|
|
82
|
+
return requires.map(String).includes(String(status))
|
|
83
|
+
}
|
|
84
|
+
|
|
65
85
|
const lowerFirst = (value?: string) => {
|
|
66
86
|
if (!value) return value
|
|
67
87
|
return value.charAt(0).toLowerCase() + value.slice(1)
|
|
@@ -519,6 +539,7 @@ export function makeDefaultGetDynamicColumns(
|
|
|
519
539
|
</DropdownMenuTrigger>
|
|
520
540
|
<DropdownMenuContent align="end">
|
|
521
541
|
{resolvedActions
|
|
542
|
+
.filter((action) => isActionAllowedForRowState(action, row.original))
|
|
522
543
|
.filter((action) => {
|
|
523
544
|
if (!action.condition) return true
|
|
524
545
|
const { field, operator, value } = action.condition
|