@asteby/metacore-runtime-react 13.4.0 → 13.5.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 +34 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +36 -5
- package/dist/dynamic-date-field.d.ts +9 -0
- package/dist/dynamic-date-field.d.ts.map +1 -0
- package/dist/dynamic-date-field.js +56 -0
- package/dist/dynamic-form.d.ts +1 -0
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +3 -1
- package/package.json +1 -1
- package/src/action-modal-dispatcher.tsx +63 -15
- package/src/dynamic-date-field.tsx +96 -0
- package/src/dynamic-form.tsx +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 13.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 0e427f1: feat(forms): modern date picker, roomy line-items modal, 2-column layout
|
|
8
|
+
|
|
9
|
+
Three declarative-form polish items, all driven by field shape — zero per-app code:
|
|
10
|
+
- **DynamicDateField**: `type: "date"` fields now render a shadcn Calendar inside
|
|
11
|
+
a Popover instead of the native `<input type="date">`. The Popover portals to
|
|
12
|
+
the body so the calendar is never clipped by the modal (fixes the cut-off), and
|
|
13
|
+
it looks modern. The field value stays an ISO `YYYY-MM-DD` string, so payloads
|
|
14
|
+
are unchanged. No future-date restriction (entries can be post-dated).
|
|
15
|
+
- **Roomy modal for line-items**: GenericActionModal auto-widens to ~820px when
|
|
16
|
+
the action has a line-items (`type:"array"`) field so the debit/credit grid has
|
|
17
|
+
room; plain forms stay compact. An optional `action.modalWidth` overrides.
|
|
18
|
+
Applied as an inline style so it always takes effect.
|
|
19
|
+
- **2-column field layout**: scalar fields (journal, date, reference) flow
|
|
20
|
+
side-by-side instead of one tall vertical stack; line-items grids and textareas
|
|
21
|
+
span full width. Mirrors DynamicForm so the action modal and standalone form
|
|
22
|
+
render identically.
|
|
23
|
+
|
|
24
|
+
## 13.4.1
|
|
25
|
+
|
|
26
|
+
### Patch Changes
|
|
27
|
+
|
|
28
|
+
- f03fe86: fix(action-modal): render `dynamic_select` action fields as the searchable async picker instead of a plain text input
|
|
29
|
+
|
|
30
|
+
ActionModalDispatcher's GenericActionModal had its own field renderer that keyed
|
|
31
|
+
off `field.type` and had no `dynamic_select` case, so a declarative action field
|
|
32
|
+
with `type: "dynamic_select"` (e.g. the Diario/Cuenta pickers of a journal entry)
|
|
33
|
+
fell through to a plain text `<Input>`. It now resolves the widget the same way
|
|
34
|
+
DynamicForm does (`resolveWidget`) and routes `dynamic_select` to
|
|
35
|
+
`DynamicSelectField`, keeping action modals and the standalone form in lockstep.
|
|
36
|
+
|
|
3
37
|
## 13.4.0
|
|
4
38
|
|
|
5
39
|
### 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":"AA8CA,OAAO,EACH,KAAK,cAAc,EACnB,KAAK,gBAAgB,EAExB,MAAM,sBAAsB,CAAA;AAE7B,YAAY,EAAE,cAAc,EAAE,gBAAgB,EAAE,CAAA;AAEhD,wBAAgB,qBAAqB,CAAC,EAClC,IAAI,EACJ,YAAY,EACZ,MAAM,EACN,KAAK,EACL,MAAM,EACN,QAAQ,EACR,SAAS,GACZ,EAAE,gBAAgB,kDAiDlB"}
|
|
@@ -15,7 +15,9 @@ import { toast } from 'sonner';
|
|
|
15
15
|
import { useApi } from './api-context';
|
|
16
16
|
import { DynamicIcon } from './dynamic-icon';
|
|
17
17
|
import { DynamicLineItems } from './dynamic-line-items';
|
|
18
|
-
import {
|
|
18
|
+
import { DynamicSelectField } from './dynamic-select-field';
|
|
19
|
+
import { DynamicDateField } from './dynamic-date-field';
|
|
20
|
+
import { isLineItemsField, resolveWidget } from './dynamic-form-schema';
|
|
19
21
|
// Canonical registry lives in @asteby/metacore-sdk
|
|
20
22
|
import { getActionComponent, } from '@asteby/metacore-sdk';
|
|
21
23
|
export function ActionModalDispatcher({ open, onOpenChange, action, model, record, endpoint, onSuccess, }) {
|
|
@@ -119,24 +121,53 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
119
121
|
setExecuting(false);
|
|
120
122
|
}
|
|
121
123
|
};
|
|
122
|
-
|
|
124
|
+
// Size the modal to the form. A line-items form (the debit/credit grid of a
|
|
125
|
+
// journal entry, a "receive goods" modal) needs room for its columns, so it
|
|
126
|
+
// gets a roomy width; a plain field form stays compact. An explicit
|
|
127
|
+
// `action.modalWidth` (number px or CSS length) overrides — the declarative
|
|
128
|
+
// escape hatch. Width is applied as an inline style (guaranteed to apply,
|
|
129
|
+
// unlike an arbitrary Tailwind class that the host's scan may drop), capped
|
|
130
|
+
// to the viewport so it stays responsive on phones.
|
|
131
|
+
const hasLineItems = useMemo(() => (action.fields ?? []).some(isLineItemsField), [action.fields]);
|
|
132
|
+
const explicitWidth = action.modalWidth;
|
|
133
|
+
const widthPx = explicitWidth != null
|
|
134
|
+
? (typeof explicitWidth === 'number' ? `${explicitWidth}px` : explicitWidth)
|
|
135
|
+
: hasLineItems
|
|
136
|
+
? '820px'
|
|
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' } : undefined, children: [_jsxs(DialogHeader, { 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: "grid gap-4 py-4 sm:grid-cols-2", children: action.fields?.map((field) => {
|
|
139
|
+
const fullWidth = isLineItemsField(field) ||
|
|
140
|
+
resolveWidget(field) === 'textarea' ||
|
|
141
|
+
resolveWidget(field) === 'richtext';
|
|
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] })] })] }) }));
|
|
123
144
|
}
|
|
124
145
|
function renderField(field, value, onChange) {
|
|
125
146
|
// Repeatable line-items group → row grid (value is an array of row objects).
|
|
126
147
|
if (isLineItemsField(field)) {
|
|
127
148
|
return _jsx(DynamicLineItems, { field: field, value: value, onChange: onChange });
|
|
128
149
|
}
|
|
129
|
-
|
|
150
|
+
// Resolve the widget the same way DynamicForm does (explicit widget wins,
|
|
151
|
+
// else inferred from type) so action modals and the standalone form stay in
|
|
152
|
+
// lockstep — previously this switch keyed off `field.type` and silently
|
|
153
|
+
// dropped `dynamic_select` to a plain text input.
|
|
154
|
+
const widget = resolveWidget(field);
|
|
155
|
+
if (widget === 'dynamic_select') {
|
|
156
|
+
return _jsx(DynamicSelectField, { field: field, value: value, onChange: onChange });
|
|
157
|
+
}
|
|
158
|
+
switch (widget) {
|
|
130
159
|
case 'textarea':
|
|
131
160
|
return _jsx(Textarea, { id: field.key, value: value || '', onChange: (e) => onChange(e.target.value), placeholder: field.placeholder });
|
|
132
161
|
case 'select':
|
|
133
162
|
return (_jsxs(Select, { value: value || '', onValueChange: onChange, children: [_jsx(SelectTrigger, { children: _jsx(SelectValue, { placeholder: field.placeholder || 'Seleccionar...' }) }), _jsx(SelectContent, { children: field.options?.map((opt) => _jsx(SelectItem, { value: opt.value, children: opt.label }, opt.value)) })] }));
|
|
134
|
-
case '
|
|
163
|
+
case 'switch':
|
|
135
164
|
return _jsx(Switch, { id: field.key, checked: !!value, onCheckedChange: onChange });
|
|
136
165
|
case 'number':
|
|
137
166
|
return _jsx(Input, { id: field.key, type: "number", value: value ?? '', onChange: (e) => onChange(e.target.valueAsNumber || ''), placeholder: field.placeholder });
|
|
138
167
|
case 'date':
|
|
139
|
-
|
|
168
|
+
// Modern shadcn Calendar in a Popover (portaled, never clipped by the
|
|
169
|
+
// modal) instead of the native, dated, easily-cut <input type=date>.
|
|
170
|
+
return _jsx(DynamicDateField, { field: field, value: value, onChange: onChange });
|
|
140
171
|
default:
|
|
141
172
|
return _jsx(Input, { id: field.key, type: field.type === 'email' ? 'email' : field.type === 'url' ? 'url' : 'text', value: value || '', onChange: (e) => onChange(e.target.value), placeholder: field.placeholder });
|
|
142
173
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ActionFieldDef } from './types';
|
|
2
|
+
export interface DynamicDateFieldProps {
|
|
3
|
+
field: ActionFieldDef;
|
|
4
|
+
value: any;
|
|
5
|
+
onChange: (v: any) => void;
|
|
6
|
+
}
|
|
7
|
+
export declare function DynamicDateField({ field, value, onChange }: DynamicDateFieldProps): import("react/jsx-runtime").JSX.Element;
|
|
8
|
+
export default DynamicDateField;
|
|
9
|
+
//# sourceMappingURL=dynamic-date-field.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dynamic-date-field.d.ts","sourceRoot":"","sources":["../src/dynamic-date-field.tsx"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAE7C,MAAM,WAAW,qBAAqB;IAClC,KAAK,EAAE,cAAc,CAAA;IACrB,KAAK,EAAE,GAAG,CAAA;IACV,QAAQ,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK,IAAI,CAAA;CAC7B;AA+BD,wBAAgB,gBAAgB,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,qBAAqB,2CAkCjF;AAED,eAAe,gBAAgB,CAAA"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// DynamicDateField — modern date picker for declarative forms: a shadcn
|
|
3
|
+
// Calendar inside a Popover, instead of the native `<input type="date">` whose
|
|
4
|
+
// browser calendar looks dated and gets clipped inside a modal.
|
|
5
|
+
//
|
|
6
|
+
// Contract: the field VALUE stays an ISO date string ("YYYY-MM-DD") so the form
|
|
7
|
+
// payload is unchanged (the kernel/handlers already expect that). We parse the
|
|
8
|
+
// string to a Date for the Calendar and format back to ISO on select. The
|
|
9
|
+
// Popover portals to the document body, so the calendar is never clipped by the
|
|
10
|
+
// modal's overflow — fixing the "se corta" cut-off.
|
|
11
|
+
//
|
|
12
|
+
// Deliberately NOT reusing metacore-ui's `DatePicker` shared helper: that one
|
|
13
|
+
// hard-disables future dates and pins a fixed 240px width, neither of which is
|
|
14
|
+
// right for a generic declarative field (a journal entry can be post-dated).
|
|
15
|
+
import { useState } from 'react';
|
|
16
|
+
import { Button, Calendar, Popover, PopoverContent, PopoverTrigger, } from '@asteby/metacore-ui/primitives';
|
|
17
|
+
import { CalendarIcon } from 'lucide-react';
|
|
18
|
+
// Parse "YYYY-MM-DD" (or any Date-parseable string) into a local Date, or
|
|
19
|
+
// undefined when empty/invalid. Uses noon to dodge timezone day-shift.
|
|
20
|
+
function toDate(v) {
|
|
21
|
+
if (!v)
|
|
22
|
+
return undefined;
|
|
23
|
+
const s = String(v);
|
|
24
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s);
|
|
25
|
+
if (m)
|
|
26
|
+
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 12);
|
|
27
|
+
const d = new Date(s);
|
|
28
|
+
return isNaN(d.getTime()) ? undefined : d;
|
|
29
|
+
}
|
|
30
|
+
// Format a Date back to "YYYY-MM-DD" (local, no timezone shift).
|
|
31
|
+
function toISO(d) {
|
|
32
|
+
const y = d.getFullYear();
|
|
33
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
34
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
35
|
+
return `${y}-${m}-${day}`;
|
|
36
|
+
}
|
|
37
|
+
// Human label for the trigger. Locale-aware, falls back to the raw ISO.
|
|
38
|
+
function label(d) {
|
|
39
|
+
if (!d)
|
|
40
|
+
return '';
|
|
41
|
+
try {
|
|
42
|
+
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return toISO(d);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function DynamicDateField({ field, value, onChange }) {
|
|
49
|
+
const [open, setOpen] = useState(false);
|
|
50
|
+
const selected = toDate(value);
|
|
51
|
+
return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { type: "button", variant: "outline", id: field.key, "data-empty": !selected, className: "w-full justify-start text-start font-normal data-[empty=true]:text-muted-foreground", children: [_jsx(CalendarIcon, { className: "mr-2 size-4 shrink-0 opacity-50" }), _jsx("span", { className: "truncate", children: selected ? label(selected) : (field.placeholder || 'Elegí una fecha') })] }) }), _jsx(PopoverContent, { className: "w-auto p-0", align: "start", children: _jsx(Calendar, { mode: "single", captionLayout: "dropdown", selected: selected, defaultMonth: selected, onSelect: (d) => {
|
|
52
|
+
onChange(d ? toISO(d) : '');
|
|
53
|
+
setOpen(false);
|
|
54
|
+
} }) })] }));
|
|
55
|
+
}
|
|
56
|
+
export default DynamicDateField;
|
package/dist/dynamic-form.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { buildZodSchema, resolveWidget } from './dynamic-form-schema';
|
|
|
3
3
|
export { buildZodSchema, resolveWidget };
|
|
4
4
|
export { DynamicLineItems } from './dynamic-line-items';
|
|
5
5
|
export { DynamicSelectField } from './dynamic-select-field';
|
|
6
|
+
export { DynamicDateField } from './dynamic-date-field';
|
|
6
7
|
export interface DynamicFormProps {
|
|
7
8
|
fields: ActionFieldDef[];
|
|
8
9
|
initialValues?: Record<string, any>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-form.d.ts","sourceRoot":"","sources":["../src/dynamic-form.tsx"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAC7C,OAAO,EACH,cAAc,EACd,aAAa,EAGhB,MAAM,uBAAuB,CAAA;
|
|
1
|
+
{"version":3,"file":"dynamic-form.d.ts","sourceRoot":"","sources":["../src/dynamic-form.tsx"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAC7C,OAAO,EACH,cAAc,EACd,aAAa,EAGhB,MAAM,uBAAuB,CAAA;AAM9B,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,CAAA;AACxC,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AACvD,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAA;AAC3D,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AAEvD,MAAM,WAAW,gBAAgB;IAC7B,MAAM,EAAE,cAAc,EAAE,CAAA;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACnC,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC/D,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;IACrB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACrB;AAED,wBAAgB,WAAW,CAAC,EACxB,MAAM,EACN,aAAa,EACb,QAAQ,EACR,QAAQ,EACR,WAAuB,EACvB,WAAwB,EACxB,QAAgB,GACnB,EAAE,gBAAgB,2CAkGlB"}
|
package/dist/dynamic-form.js
CHANGED
|
@@ -8,9 +8,11 @@ import { buildZodSchema, resolveWidget, isLineItemsField, evaluateBalance, } fro
|
|
|
8
8
|
import { useOptionsResolver } from './use-options-resolver';
|
|
9
9
|
import { DynamicLineItems } from './dynamic-line-items';
|
|
10
10
|
import { DynamicSelectField } from './dynamic-select-field';
|
|
11
|
+
import { DynamicDateField } from './dynamic-date-field';
|
|
11
12
|
export { buildZodSchema, resolveWidget };
|
|
12
13
|
export { DynamicLineItems } from './dynamic-line-items';
|
|
13
14
|
export { DynamicSelectField } from './dynamic-select-field';
|
|
15
|
+
export { DynamicDateField } from './dynamic-date-field';
|
|
14
16
|
export function DynamicForm({ fields, initialValues, onSubmit, onCancel, submitLabel = 'Guardar', cancelLabel = 'Cancelar', disabled = false, }) {
|
|
15
17
|
const [values, setValues] = useState({});
|
|
16
18
|
const [errors, setErrors] = useState({});
|
|
@@ -113,7 +115,7 @@ function FieldRenderer({ field, value, onChange }) {
|
|
|
113
115
|
case 'number':
|
|
114
116
|
return _jsx(Input, { id: field.key, type: "number", value: value ?? '', onChange: (e) => onChange(e.target.valueAsNumber || ''), placeholder: field.placeholder });
|
|
115
117
|
case 'date':
|
|
116
|
-
return _jsx(
|
|
118
|
+
return _jsx(DynamicDateField, { field: field, value: value, onChange: onChange });
|
|
117
119
|
default:
|
|
118
120
|
return _jsx(Input, { id: field.key, type: field.type === 'email' ? 'email' : field.type === 'url' ? 'url' : 'text', value: value || '', onChange: (e) => onChange(e.target.value), placeholder: field.placeholder });
|
|
119
121
|
}
|
package/package.json
CHANGED
|
@@ -39,7 +39,9 @@ import { toast } from 'sonner'
|
|
|
39
39
|
import { useApi } from './api-context'
|
|
40
40
|
import { DynamicIcon } from './dynamic-icon'
|
|
41
41
|
import { DynamicLineItems } from './dynamic-line-items'
|
|
42
|
-
import {
|
|
42
|
+
import { DynamicSelectField } from './dynamic-select-field'
|
|
43
|
+
import { DynamicDateField } from './dynamic-date-field'
|
|
44
|
+
import { isLineItemsField, resolveWidget } from './dynamic-form-schema'
|
|
43
45
|
import type { ActionFieldDef } from './types'
|
|
44
46
|
// Canonical registry lives in @asteby/metacore-sdk
|
|
45
47
|
import {
|
|
@@ -223,9 +225,31 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
223
225
|
}
|
|
224
226
|
}
|
|
225
227
|
|
|
228
|
+
// Size the modal to the form. A line-items form (the debit/credit grid of a
|
|
229
|
+
// journal entry, a "receive goods" modal) needs room for its columns, so it
|
|
230
|
+
// gets a roomy width; a plain field form stays compact. An explicit
|
|
231
|
+
// `action.modalWidth` (number px or CSS length) overrides — the declarative
|
|
232
|
+
// escape hatch. Width is applied as an inline style (guaranteed to apply,
|
|
233
|
+
// unlike an arbitrary Tailwind class that the host's scan may drop), capped
|
|
234
|
+
// to the viewport so it stays responsive on phones.
|
|
235
|
+
const hasLineItems = useMemo(
|
|
236
|
+
() => (action.fields ?? []).some(isLineItemsField),
|
|
237
|
+
[action.fields],
|
|
238
|
+
)
|
|
239
|
+
const explicitWidth = (action as unknown as { modalWidth?: number | string }).modalWidth
|
|
240
|
+
const widthPx =
|
|
241
|
+
explicitWidth != null
|
|
242
|
+
? (typeof explicitWidth === 'number' ? `${explicitWidth}px` : explicitWidth)
|
|
243
|
+
: hasLineItems
|
|
244
|
+
? '820px'
|
|
245
|
+
: undefined
|
|
246
|
+
|
|
226
247
|
return (
|
|
227
248
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
228
|
-
<DialogContent
|
|
249
|
+
<DialogContent
|
|
250
|
+
className={widthPx ? '' : 'sm:max-w-lg'}
|
|
251
|
+
style={widthPx ? { maxWidth: widthPx, width: '95vw' } : undefined}
|
|
252
|
+
>
|
|
229
253
|
<DialogHeader>
|
|
230
254
|
<DialogTitle className="flex items-center gap-2">
|
|
231
255
|
<DynamicIcon name={action.icon} className="h-5 w-5" />
|
|
@@ -233,16 +257,30 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
233
257
|
</DialogTitle>
|
|
234
258
|
{action.confirmMessage && <DialogDescription>{action.confirmMessage}</DialogDescription>}
|
|
235
259
|
</DialogHeader>
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
260
|
+
{/* Responsive 2-column grid: scalar fields (journal, date,
|
|
261
|
+
reference) flow side-by-side instead of one tall vertical
|
|
262
|
+
stack; line-items grids and textareas span the full width so
|
|
263
|
+
they get room. Mirrors DynamicForm's pro layout — driven only
|
|
264
|
+
by field shape, fully declarative. */}
|
|
265
|
+
<div className="grid gap-4 py-4 sm:grid-cols-2">
|
|
266
|
+
{action.fields?.map((field) => {
|
|
267
|
+
const fullWidth =
|
|
268
|
+
isLineItemsField(field) ||
|
|
269
|
+
resolveWidget(field) === 'textarea' ||
|
|
270
|
+
resolveWidget(field) === 'richtext'
|
|
271
|
+
return (
|
|
272
|
+
<div
|
|
273
|
+
key={field.key}
|
|
274
|
+
className={'grid gap-2 ' + (fullWidth ? 'sm:col-span-2' : '')}
|
|
275
|
+
>
|
|
276
|
+
<Label htmlFor={field.key}>
|
|
277
|
+
{field.label}
|
|
278
|
+
{field.required && <span className="text-red-500 ml-1">*</span>}
|
|
279
|
+
</Label>
|
|
280
|
+
{renderField(field, formData[field.key], (v: any) => updateField(field.key, v))}
|
|
281
|
+
</div>
|
|
282
|
+
)
|
|
283
|
+
})}
|
|
246
284
|
</div>
|
|
247
285
|
<DialogFooter>
|
|
248
286
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={executing}>
|
|
@@ -271,7 +309,15 @@ function renderField(
|
|
|
271
309
|
if (isLineItemsField(field)) {
|
|
272
310
|
return <DynamicLineItems field={field} value={value} onChange={onChange} />
|
|
273
311
|
}
|
|
274
|
-
|
|
312
|
+
// Resolve the widget the same way DynamicForm does (explicit widget wins,
|
|
313
|
+
// else inferred from type) so action modals and the standalone form stay in
|
|
314
|
+
// lockstep — previously this switch keyed off `field.type` and silently
|
|
315
|
+
// dropped `dynamic_select` to a plain text input.
|
|
316
|
+
const widget = resolveWidget(field)
|
|
317
|
+
if (widget === 'dynamic_select') {
|
|
318
|
+
return <DynamicSelectField field={field} value={value} onChange={onChange} />
|
|
319
|
+
}
|
|
320
|
+
switch (widget) {
|
|
275
321
|
case 'textarea':
|
|
276
322
|
return <Textarea id={field.key} value={value || ''} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)} placeholder={field.placeholder} />
|
|
277
323
|
case 'select':
|
|
@@ -283,12 +329,14 @@ function renderField(
|
|
|
283
329
|
</SelectContent>
|
|
284
330
|
</Select>
|
|
285
331
|
)
|
|
286
|
-
case '
|
|
332
|
+
case 'switch':
|
|
287
333
|
return <Switch id={field.key} checked={!!value} onCheckedChange={onChange} />
|
|
288
334
|
case 'number':
|
|
289
335
|
return <Input id={field.key} type="number" value={value ?? ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.valueAsNumber || '')} placeholder={field.placeholder} />
|
|
290
336
|
case 'date':
|
|
291
|
-
|
|
337
|
+
// Modern shadcn Calendar in a Popover (portaled, never clipped by the
|
|
338
|
+
// modal) instead of the native, dated, easily-cut <input type=date>.
|
|
339
|
+
return <DynamicDateField field={field} value={value} onChange={onChange} />
|
|
292
340
|
default:
|
|
293
341
|
return <Input id={field.key} type={field.type === 'email' ? 'email' : field.type === 'url' ? 'url' : 'text'} value={value || ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)} placeholder={field.placeholder} />
|
|
294
342
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// DynamicDateField — modern date picker for declarative forms: a shadcn
|
|
2
|
+
// Calendar inside a Popover, instead of the native `<input type="date">` whose
|
|
3
|
+
// browser calendar looks dated and gets clipped inside a modal.
|
|
4
|
+
//
|
|
5
|
+
// Contract: the field VALUE stays an ISO date string ("YYYY-MM-DD") so the form
|
|
6
|
+
// payload is unchanged (the kernel/handlers already expect that). We parse the
|
|
7
|
+
// string to a Date for the Calendar and format back to ISO on select. The
|
|
8
|
+
// Popover portals to the document body, so the calendar is never clipped by the
|
|
9
|
+
// modal's overflow — fixing the "se corta" cut-off.
|
|
10
|
+
//
|
|
11
|
+
// Deliberately NOT reusing metacore-ui's `DatePicker` shared helper: that one
|
|
12
|
+
// hard-disables future dates and pins a fixed 240px width, neither of which is
|
|
13
|
+
// right for a generic declarative field (a journal entry can be post-dated).
|
|
14
|
+
import { useState } from 'react'
|
|
15
|
+
import {
|
|
16
|
+
Button,
|
|
17
|
+
Calendar,
|
|
18
|
+
Popover,
|
|
19
|
+
PopoverContent,
|
|
20
|
+
PopoverTrigger,
|
|
21
|
+
} from '@asteby/metacore-ui/primitives'
|
|
22
|
+
import { CalendarIcon } from 'lucide-react'
|
|
23
|
+
import type { ActionFieldDef } from './types'
|
|
24
|
+
|
|
25
|
+
export interface DynamicDateFieldProps {
|
|
26
|
+
field: ActionFieldDef
|
|
27
|
+
value: any
|
|
28
|
+
onChange: (v: any) => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Parse "YYYY-MM-DD" (or any Date-parseable string) into a local Date, or
|
|
32
|
+
// undefined when empty/invalid. Uses noon to dodge timezone day-shift.
|
|
33
|
+
function toDate(v: any): Date | undefined {
|
|
34
|
+
if (!v) return undefined
|
|
35
|
+
const s = String(v)
|
|
36
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
|
|
37
|
+
if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 12)
|
|
38
|
+
const d = new Date(s)
|
|
39
|
+
return isNaN(d.getTime()) ? undefined : d
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Format a Date back to "YYYY-MM-DD" (local, no timezone shift).
|
|
43
|
+
function toISO(d: Date): string {
|
|
44
|
+
const y = d.getFullYear()
|
|
45
|
+
const m = String(d.getMonth() + 1).padStart(2, '0')
|
|
46
|
+
const day = String(d.getDate()).padStart(2, '0')
|
|
47
|
+
return `${y}-${m}-${day}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Human label for the trigger. Locale-aware, falls back to the raw ISO.
|
|
51
|
+
function label(d: Date | undefined): string {
|
|
52
|
+
if (!d) return ''
|
|
53
|
+
try {
|
|
54
|
+
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
|
55
|
+
} catch {
|
|
56
|
+
return toISO(d)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function DynamicDateField({ field, value, onChange }: DynamicDateFieldProps) {
|
|
61
|
+
const [open, setOpen] = useState(false)
|
|
62
|
+
const selected = toDate(value)
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
66
|
+
<PopoverTrigger asChild>
|
|
67
|
+
<Button
|
|
68
|
+
type="button"
|
|
69
|
+
variant="outline"
|
|
70
|
+
id={field.key}
|
|
71
|
+
data-empty={!selected}
|
|
72
|
+
className="w-full justify-start text-start font-normal data-[empty=true]:text-muted-foreground"
|
|
73
|
+
>
|
|
74
|
+
<CalendarIcon className="mr-2 size-4 shrink-0 opacity-50" />
|
|
75
|
+
<span className="truncate">
|
|
76
|
+
{selected ? label(selected) : (field.placeholder || 'Elegí una fecha')}
|
|
77
|
+
</span>
|
|
78
|
+
</Button>
|
|
79
|
+
</PopoverTrigger>
|
|
80
|
+
<PopoverContent className="w-auto p-0" align="start">
|
|
81
|
+
<Calendar
|
|
82
|
+
mode="single"
|
|
83
|
+
captionLayout="dropdown"
|
|
84
|
+
selected={selected}
|
|
85
|
+
defaultMonth={selected}
|
|
86
|
+
onSelect={(d: Date | undefined) => {
|
|
87
|
+
onChange(d ? toISO(d) : '')
|
|
88
|
+
setOpen(false)
|
|
89
|
+
}}
|
|
90
|
+
/>
|
|
91
|
+
</PopoverContent>
|
|
92
|
+
</Popover>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export default DynamicDateField
|
package/src/dynamic-form.tsx
CHANGED
|
@@ -24,10 +24,12 @@ import {
|
|
|
24
24
|
import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
|
|
25
25
|
import { DynamicLineItems } from './dynamic-line-items'
|
|
26
26
|
import { DynamicSelectField } from './dynamic-select-field'
|
|
27
|
+
import { DynamicDateField } from './dynamic-date-field'
|
|
27
28
|
|
|
28
29
|
export { buildZodSchema, resolveWidget }
|
|
29
30
|
export { DynamicLineItems } from './dynamic-line-items'
|
|
30
31
|
export { DynamicSelectField } from './dynamic-select-field'
|
|
32
|
+
export { DynamicDateField } from './dynamic-date-field'
|
|
31
33
|
|
|
32
34
|
export interface DynamicFormProps {
|
|
33
35
|
fields: ActionFieldDef[]
|
|
@@ -197,7 +199,7 @@ function FieldRenderer({ field, value, onChange }: FieldRendererProps) {
|
|
|
197
199
|
case 'number':
|
|
198
200
|
return <Input id={field.key} type="number" value={value ?? ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.valueAsNumber || '')} placeholder={field.placeholder} />
|
|
199
201
|
case 'date':
|
|
200
|
-
return <
|
|
202
|
+
return <DynamicDateField field={field} value={value} onChange={onChange} />
|
|
201
203
|
default:
|
|
202
204
|
return <Input id={field.key} type={field.type === 'email' ? 'email' : field.type === 'url' ? 'url' : 'text'} value={value || ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)} placeholder={field.placeholder} />
|
|
203
205
|
}
|