@asteby/metacore-runtime-react 13.4.1 → 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 +21 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +24 -2
- 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 +51 -12
- package/src/dynamic-date-field.tsx +96 -0
- package/src/dynamic-form.tsx +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
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
|
+
|
|
3
24
|
## 13.4.1
|
|
4
25
|
|
|
5
26
|
### Patch 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"}
|
|
@@ -16,6 +16,7 @@ import { useApi } from './api-context';
|
|
|
16
16
|
import { DynamicIcon } from './dynamic-icon';
|
|
17
17
|
import { DynamicLineItems } from './dynamic-line-items';
|
|
18
18
|
import { DynamicSelectField } from './dynamic-select-field';
|
|
19
|
+
import { DynamicDateField } from './dynamic-date-field';
|
|
19
20
|
import { isLineItemsField, resolveWidget } from './dynamic-form-schema';
|
|
20
21
|
// Canonical registry lives in @asteby/metacore-sdk
|
|
21
22
|
import { getActionComponent, } from '@asteby/metacore-sdk';
|
|
@@ -120,7 +121,26 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
120
121
|
setExecuting(false);
|
|
121
122
|
}
|
|
122
123
|
};
|
|
123
|
-
|
|
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] })] })] }) }));
|
|
124
144
|
}
|
|
125
145
|
function renderField(field, value, onChange) {
|
|
126
146
|
// Repeatable line-items group → row grid (value is an array of row objects).
|
|
@@ -145,7 +165,9 @@ function renderField(field, value, onChange) {
|
|
|
145
165
|
case 'number':
|
|
146
166
|
return _jsx(Input, { id: field.key, type: "number", value: value ?? '', onChange: (e) => onChange(e.target.valueAsNumber || ''), placeholder: field.placeholder });
|
|
147
167
|
case 'date':
|
|
148
|
-
|
|
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 });
|
|
149
171
|
default:
|
|
150
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 });
|
|
151
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
|
@@ -40,6 +40,7 @@ import { useApi } from './api-context'
|
|
|
40
40
|
import { DynamicIcon } from './dynamic-icon'
|
|
41
41
|
import { DynamicLineItems } from './dynamic-line-items'
|
|
42
42
|
import { DynamicSelectField } from './dynamic-select-field'
|
|
43
|
+
import { DynamicDateField } from './dynamic-date-field'
|
|
43
44
|
import { isLineItemsField, resolveWidget } from './dynamic-form-schema'
|
|
44
45
|
import type { ActionFieldDef } from './types'
|
|
45
46
|
// Canonical registry lives in @asteby/metacore-sdk
|
|
@@ -224,9 +225,31 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
224
225
|
}
|
|
225
226
|
}
|
|
226
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
|
+
|
|
227
247
|
return (
|
|
228
248
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
229
|
-
<DialogContent
|
|
249
|
+
<DialogContent
|
|
250
|
+
className={widthPx ? '' : 'sm:max-w-lg'}
|
|
251
|
+
style={widthPx ? { maxWidth: widthPx, width: '95vw' } : undefined}
|
|
252
|
+
>
|
|
230
253
|
<DialogHeader>
|
|
231
254
|
<DialogTitle className="flex items-center gap-2">
|
|
232
255
|
<DynamicIcon name={action.icon} className="h-5 w-5" />
|
|
@@ -234,16 +257,30 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
234
257
|
</DialogTitle>
|
|
235
258
|
{action.confirmMessage && <DialogDescription>{action.confirmMessage}</DialogDescription>}
|
|
236
259
|
</DialogHeader>
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
+
})}
|
|
247
284
|
</div>
|
|
248
285
|
<DialogFooter>
|
|
249
286
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={executing}>
|
|
@@ -297,7 +334,9 @@ function renderField(
|
|
|
297
334
|
case 'number':
|
|
298
335
|
return <Input id={field.key} type="number" value={value ?? ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.valueAsNumber || '')} placeholder={field.placeholder} />
|
|
299
336
|
case 'date':
|
|
300
|
-
|
|
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} />
|
|
301
340
|
default:
|
|
302
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} />
|
|
303
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
|
}
|