@object-ui/app-shell 4.0.7 → 4.0.9
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 +76 -0
- package/dist/console/AppContent.js +4 -2
- package/dist/hooks/useObjectActions.js +26 -2
- package/dist/layout/AppHeader.js +14 -8
- package/dist/layout/AppSidebar.js +2 -2
- package/dist/layout/AppSwitcher.js +1 -1
- package/dist/layout/PageHeader.d.ts +42 -0
- package/dist/layout/PageHeader.js +13 -0
- package/dist/layout/index.d.ts +2 -0
- package/dist/layout/index.js +1 -0
- package/dist/utils/getIcon.d.ts +4 -1
- package/dist/utils/getIcon.js +11 -2
- package/dist/views/CreateViewDialog.js +162 -23
- package/dist/views/DashboardView.js +69 -22
- package/dist/views/ObjectView.d.ts +1 -1
- package/dist/views/ObjectView.js +255 -30
- package/dist/views/RecordDetailView.js +90 -8
- package/dist/views/RecordFormPage.js +20 -1
- package/dist/views/ReportView.js +68 -16
- package/dist/views/ViewConfigPanel.js +24 -6
- package/package.json +29 -29
|
@@ -17,7 +17,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
17
17
|
import { useEffect, useMemo, useState } from 'react';
|
|
18
18
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Input, Button, cn, } from '@object-ui/components';
|
|
19
19
|
import { useObjectTranslation } from '@object-ui/i18n';
|
|
20
|
-
import { deriveFieldOptions } from '@object-ui/plugin-view';
|
|
20
|
+
import { deriveFieldOptions, isImageLikeField, isGeoLikeField, pickPreferredField, KANBAN_GROUP_PREFERRED, PRIMARY_DATE_PREFERRED, END_DATE_PREFERRED, } from '@object-ui/plugin-view';
|
|
21
21
|
import { LayoutGrid, KanbanSquare, Calendar as CalendarIcon, Image as ImageIcon, GanttChartSquare, Clock, Map as MapIcon, BarChart3, AlertCircle, } from 'lucide-react';
|
|
22
22
|
function buildViewTypeMeta(t) {
|
|
23
23
|
return [
|
|
@@ -40,17 +40,103 @@ function suggestName(typeLabel, existing) {
|
|
|
40
40
|
}
|
|
41
41
|
return typeLabel;
|
|
42
42
|
}
|
|
43
|
+
const CHART_TYPE_OPTIONS = [
|
|
44
|
+
{ value: 'bar', i18nKey: 'console.objectView.chartTypeBar' },
|
|
45
|
+
{ value: 'line', i18nKey: 'console.objectView.chartTypeLine' },
|
|
46
|
+
{ value: 'pie', i18nKey: 'console.objectView.chartTypePie' },
|
|
47
|
+
{ value: 'area', i18nKey: 'console.objectView.chartTypeArea' },
|
|
48
|
+
{ value: 'scatter', i18nKey: 'console.objectView.chartTypeScatter' },
|
|
49
|
+
];
|
|
43
50
|
const REQUIRED_FIELDS_BY_TYPE = {
|
|
44
|
-
kanban: [
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
kanban: [
|
|
52
|
+
{
|
|
53
|
+
key: 'groupByField',
|
|
54
|
+
i18nKey: 'console.objectView.groupByField',
|
|
55
|
+
helpI18nKey: 'console.objectView.groupByFieldHelp',
|
|
56
|
+
filter: (f) => f.type === 'select' || f.type === 'boolean',
|
|
57
|
+
preferred: KANBAN_GROUP_PREFERRED,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
calendar: [
|
|
61
|
+
{
|
|
62
|
+
key: 'startDateField',
|
|
63
|
+
i18nKey: 'console.objectView.startDateField',
|
|
64
|
+
helpI18nKey: 'console.objectView.startDateFieldHelp',
|
|
65
|
+
filter: (f) => f.type === 'date',
|
|
66
|
+
preferred: PRIMARY_DATE_PREFERRED,
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
timeline: [
|
|
70
|
+
{
|
|
71
|
+
key: 'dateField',
|
|
72
|
+
i18nKey: 'console.objectView.dateField',
|
|
73
|
+
helpI18nKey: 'console.objectView.timelineDateFieldHelp',
|
|
74
|
+
filter: (f) => f.type === 'date',
|
|
75
|
+
preferred: PRIMARY_DATE_PREFERRED,
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
gantt: [
|
|
79
|
+
{
|
|
80
|
+
key: 'startDateField',
|
|
81
|
+
i18nKey: 'console.objectView.startDateField',
|
|
82
|
+
helpI18nKey: 'console.objectView.ganttStartDateFieldHelp',
|
|
83
|
+
filter: (f) => f.type === 'date',
|
|
84
|
+
preferred: PRIMARY_DATE_PREFERRED,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
key: 'endDateField',
|
|
88
|
+
i18nKey: 'console.objectView.endDateField',
|
|
89
|
+
helpI18nKey: 'console.objectView.ganttEndDateFieldHelp',
|
|
90
|
+
filter: (f) => f.type === 'date',
|
|
91
|
+
preferred: END_DATE_PREFERRED,
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
gallery: [
|
|
95
|
+
{
|
|
96
|
+
key: 'imageField',
|
|
97
|
+
i18nKey: 'console.objectView.imageField',
|
|
98
|
+
helpI18nKey: 'console.objectView.imageFieldHelp',
|
|
99
|
+
filter: (f) => isImageLikeField(f),
|
|
100
|
+
},
|
|
101
|
+
],
|
|
49
102
|
map: [
|
|
50
|
-
{
|
|
51
|
-
|
|
103
|
+
{
|
|
104
|
+
key: 'latitudeField',
|
|
105
|
+
i18nKey: 'console.objectView.latitudeField',
|
|
106
|
+
helpI18nKey: 'console.objectView.latitudeFieldHelp',
|
|
107
|
+
filter: (f) => f.type === 'number' && isGeoLikeField(f, 'latitude'),
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
key: 'longitudeField',
|
|
111
|
+
i18nKey: 'console.objectView.longitudeField',
|
|
112
|
+
helpI18nKey: 'console.objectView.longitudeFieldHelp',
|
|
113
|
+
filter: (f) => f.type === 'number' && isGeoLikeField(f, 'longitude'),
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
chart: [
|
|
117
|
+
{
|
|
118
|
+
key: 'chartType',
|
|
119
|
+
i18nKey: 'console.objectView.chartType',
|
|
120
|
+
helpI18nKey: 'console.objectView.chartTypeHelp',
|
|
121
|
+
kind: 'enum',
|
|
122
|
+
enumOptions: CHART_TYPE_OPTIONS,
|
|
123
|
+
defaultValue: 'bar',
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
key: 'xAxisField',
|
|
127
|
+
i18nKey: 'console.objectView.xAxisField',
|
|
128
|
+
helpI18nKey: 'console.objectView.xAxisFieldHelp',
|
|
129
|
+
filter: (f) => f.type === 'select' || f.type === 'boolean' || f.type === 'date' || f.type === 'text',
|
|
130
|
+
preferred: KANBAN_GROUP_PREFERRED,
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
key: 'yAxisFields',
|
|
134
|
+
i18nKey: 'console.objectView.yAxisField',
|
|
135
|
+
helpI18nKey: 'console.objectView.yAxisFieldHelp',
|
|
136
|
+
filter: (f) => f.type === 'number',
|
|
137
|
+
},
|
|
52
138
|
],
|
|
53
|
-
// grid
|
|
139
|
+
// grid has no strictly required fields at create time
|
|
54
140
|
};
|
|
55
141
|
export function CreateViewDialog({ open, onOpenChange, onCreate, existingLabels, availableTypes, objectDef, }) {
|
|
56
142
|
const { t } = useObjectTranslation();
|
|
@@ -92,24 +178,62 @@ export function CreateViewDialog({ open, onOpenChange, onCreate, existingLabels,
|
|
|
92
178
|
}, [selectedType, touched, types, existingSet]);
|
|
93
179
|
// Required fields for the currently selected type
|
|
94
180
|
const requiredFields = REQUIRED_FIELDS_BY_TYPE[selectedType] ?? [];
|
|
181
|
+
/** True when this required field has at least one eligible option (or is an
|
|
182
|
+
* enum/has no filter). Used by both submit-gating and type-grid disabling. */
|
|
183
|
+
const hasEligible = (rf) => {
|
|
184
|
+
if (rf.kind === 'enum')
|
|
185
|
+
return (rf.enumOptions?.length ?? 0) > 0;
|
|
186
|
+
if (!objectDef)
|
|
187
|
+
return true; // no objectDef → skip eligibility checks
|
|
188
|
+
const eligible = rf.filter ? fieldOptions.filter(rf.filter) : fieldOptions;
|
|
189
|
+
return eligible.length > 0;
|
|
190
|
+
};
|
|
191
|
+
/** Map: viewType -> reason ("missing field type") if it can't be created.
|
|
192
|
+
* Computed once per render so the type grid knows which cards to disable. */
|
|
193
|
+
const typeUnavailability = useMemo(() => {
|
|
194
|
+
const out = {};
|
|
195
|
+
types.forEach((vt) => {
|
|
196
|
+
const reqs = REQUIRED_FIELDS_BY_TYPE[vt.type] ?? [];
|
|
197
|
+
const blocker = reqs.find((rf) => !hasEligible(rf));
|
|
198
|
+
out[vt.type] = blocker ?? null;
|
|
199
|
+
});
|
|
200
|
+
return out;
|
|
201
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
202
|
+
}, [types, fieldOptions, objectDef]);
|
|
95
203
|
const getRequiredValue = (key) => requiredFieldValues[`${selectedType}.${key}`] ?? '';
|
|
96
204
|
const setRequiredValue = (key, value) => setRequiredFieldValues(prev => ({ ...prev, [`${selectedType}.${key}`]: value }));
|
|
97
205
|
const trimmed = label.trim();
|
|
98
206
|
const isDuplicate = trimmed.length > 0 && existingSet.has(trimmed);
|
|
99
207
|
const allRequiredFilled = requiredFields.every(f => getRequiredValue(f.key).length > 0);
|
|
100
208
|
const canSubmit = trimmed.length > 0 && !isDuplicate && allRequiredFilled;
|
|
101
|
-
// Auto-pick a sensible default for any required field
|
|
102
|
-
//
|
|
103
|
-
//
|
|
209
|
+
// Auto-pick a sensible default for any required field. Runs whenever the
|
|
210
|
+
// type or available options change, but only fills slots the user hasn't
|
|
211
|
+
// touched yet. Strategy:
|
|
212
|
+
// - enum: seed with `defaultValue` (e.g. chart → 'bar')
|
|
213
|
+
// - field with single eligible option: pick it (saves a click)
|
|
214
|
+
// - field with multiple eligibles + `preferred`: pick the first match
|
|
215
|
+
// in the preferred list (e.g. kanban groupBy → status > stage > …)
|
|
104
216
|
useEffect(() => {
|
|
105
|
-
if (requiredFields.length === 0
|
|
217
|
+
if (requiredFields.length === 0)
|
|
106
218
|
return;
|
|
107
219
|
requiredFields.forEach((rf) => {
|
|
108
220
|
if (getRequiredValue(rf.key).length > 0)
|
|
109
221
|
return;
|
|
222
|
+
if (rf.kind === 'enum') {
|
|
223
|
+
if (rf.defaultValue)
|
|
224
|
+
setRequiredValue(rf.key, rf.defaultValue);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
110
227
|
const eligible = rf.filter ? fieldOptions.filter(rf.filter) : fieldOptions;
|
|
111
|
-
if (eligible.length ===
|
|
228
|
+
if (eligible.length === 0)
|
|
229
|
+
return;
|
|
230
|
+
if (eligible.length === 1) {
|
|
112
231
|
setRequiredValue(rf.key, eligible[0].value);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const picked = pickPreferredField(eligible, rf.preferred ?? []);
|
|
235
|
+
if (picked)
|
|
236
|
+
setRequiredValue(rf.key, picked);
|
|
113
237
|
});
|
|
114
238
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
115
239
|
}, [selectedType, fieldOptions]);
|
|
@@ -117,12 +241,14 @@ export function CreateViewDialog({ open, onOpenChange, onCreate, existingLabels,
|
|
|
117
241
|
if (!canSubmit)
|
|
118
242
|
return;
|
|
119
243
|
// Bundle required fields under their type-specific sub-key, matching the
|
|
120
|
-
// NamedListView spec (e.g. { type: "kanban", kanban: { groupByField: ... } })
|
|
244
|
+
// NamedListView spec (e.g. { type: "kanban", kanban: { groupByField: ... } }).
|
|
245
|
+
// `yAxisFields` is wrapped in an array per spec (chart supports multi-y).
|
|
121
246
|
const subConfig = {};
|
|
122
247
|
requiredFields.forEach((rf) => {
|
|
123
248
|
const v = getRequiredValue(rf.key);
|
|
124
|
-
if (v)
|
|
125
|
-
|
|
249
|
+
if (!v)
|
|
250
|
+
return;
|
|
251
|
+
subConfig[rf.key] = rf.key === 'yAxisFields' ? [v] : v;
|
|
126
252
|
});
|
|
127
253
|
const payload = {
|
|
128
254
|
type: selectedType,
|
|
@@ -136,14 +262,27 @@ export function CreateViewDialog({ open, onOpenChange, onCreate, existingLabels,
|
|
|
136
262
|
};
|
|
137
263
|
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "sm:max-w-[560px]", "data-testid": "create-view-dialog", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: t('console.objectView.createView') }), _jsx(DialogDescription, { children: t('console.objectView.createViewDesc') })] }), _jsx("div", { className: "grid grid-cols-4 gap-2 py-2", "data-testid": "create-view-type-grid", children: types.map(({ type, label: typeLabel, description, icon: Icon }) => {
|
|
138
264
|
const selected = type === selectedType;
|
|
139
|
-
|
|
265
|
+
const blocker = typeUnavailability[type];
|
|
266
|
+
const disabled = !!blocker;
|
|
267
|
+
const disabledTitle = disabled
|
|
268
|
+
? t('console.objectView.viewTypeUnavailable', { field: t(blocker.i18nKey) })
|
|
269
|
+
: undefined;
|
|
270
|
+
return (_jsxs("button", { type: "button", "data-testid": `create-view-type-${type}`, "aria-pressed": selected, "aria-disabled": disabled, disabled: disabled, title: disabledTitle, onClick: () => { if (!disabled)
|
|
271
|
+
setSelectedType(type); }, className: cn('group flex flex-col items-start gap-1 rounded-lg border bg-background p-3 text-left transition-colors', !disabled && 'hover:border-primary/60 hover:bg-accent/40', selected && !disabled
|
|
140
272
|
? 'border-primary ring-2 ring-primary/30 bg-accent/40'
|
|
141
|
-
: 'border-border'), children: [_jsx(Icon, { className: cn('h-5 w-5', selected
|
|
142
|
-
|
|
273
|
+
: 'border-border', disabled && 'opacity-50 cursor-not-allowed'), children: [_jsx(Icon, { className: cn('h-5 w-5', selected && !disabled
|
|
274
|
+
? 'text-primary'
|
|
275
|
+
: 'text-muted-foreground group-hover:text-foreground') }), _jsx("div", { className: "text-xs font-medium leading-tight", children: typeLabel }), _jsx("div", { className: "text-[10px] leading-tight text-muted-foreground line-clamp-2", children: disabled ? t('console.objectView.viewTypeUnavailableShort') : description })] }, type));
|
|
276
|
+
}) }), requiredFields.length > 0 && (_jsx("div", { className: "space-y-3 rounded-md border border-dashed bg-muted/30 p-3", "data-testid": "create-view-required-fields", children: requiredFields.map((rf) => {
|
|
143
277
|
const selectedFieldValue = getRequiredValue(rf.key);
|
|
144
|
-
const
|
|
145
|
-
const
|
|
146
|
-
|
|
278
|
+
const isEnum = rf.kind === 'enum';
|
|
279
|
+
const eligible = isEnum
|
|
280
|
+
? []
|
|
281
|
+
: (rf.filter ? fieldOptions.filter(rf.filter) : fieldOptions);
|
|
282
|
+
const noEligible = !isEnum && fieldOptions.length > 0 && eligible.length === 0;
|
|
283
|
+
return (_jsxs("div", { className: "space-y-1", children: [_jsxs("label", { htmlFor: `create-view-required-${rf.key}`, className: "text-xs font-medium", children: [t(rf.i18nKey), _jsx("span", { className: "ml-1 text-destructive", children: "*" })] }), _jsxs("select", { id: `create-view-required-${rf.key}`, "data-testid": `create-view-required-${rf.key}`, value: selectedFieldValue, onChange: (e) => setRequiredValue(rf.key, e.target.value), disabled: noEligible, className: cn('h-9 w-full rounded-md border bg-background px-2 text-xs', 'border-input'), children: [_jsx("option", { value: "", children: isEnum ? t('console.objectView.selectOption') : t('console.objectView.selectField') }), isEnum
|
|
284
|
+
? rf.enumOptions.map(opt => (_jsx("option", { value: opt.value, children: t(opt.i18nKey) }, opt.value)))
|
|
285
|
+
: eligible.map(f => (_jsx("option", { value: f.value, children: f.label }, f.value)))] }), rf.helpI18nKey && !noEligible && (_jsx("p", { className: "text-[11px] text-muted-foreground", children: t(rf.helpI18nKey) })), noEligible && (_jsxs("p", { className: "flex items-center gap-1 text-[11px] text-destructive", "data-testid": `create-view-error-no-field-${rf.key}`, children: [_jsx(AlertCircle, { className: "h-3 w-3" }), t('console.objectView.noEligibleFieldForType')] }))] }, rf.key));
|
|
147
286
|
}) })), _jsxs("div", { className: "space-y-1", children: [_jsxs("label", { htmlFor: "create-view-name-input", className: "text-xs font-medium", children: [t('console.objectView.title'), _jsx("span", { className: "ml-1 text-destructive", children: "*" })] }), _jsx(Input, { id: "create-view-name-input", "data-testid": "create-view-name-input", autoFocus: true, value: label, onChange: (e) => { setLabel(e.target.value); setTouched(true); }, onKeyDown: (e) => { if (e.key === 'Enter' && canSubmit)
|
|
148
287
|
handleSubmit(); }, placeholder: t('console.objectView.newView'), className: "h-9" }), isDuplicate && (_jsx("p", { className: "text-[11px] text-destructive", "data-testid": "create-view-error-duplicate", children: t('console.objectView.duplicateViewName') }))] }), _jsxs(DialogFooter, { className: "mt-2", children: [_jsx(Button, { type: "button", variant: "outline", onClick: () => onOpenChange(false), "data-testid": "create-view-cancel", children: t('console.objectView.cancel') }), _jsx(Button, { type: "button", disabled: !canSubmit, onClick: handleSubmit, "data-testid": "create-view-submit", children: t('console.objectView.create') })] })] }) }));
|
|
149
288
|
}
|
|
@@ -57,22 +57,56 @@ function defaultWidgetTitle(type) {
|
|
|
57
57
|
// ---------------------------------------------------------------------------
|
|
58
58
|
// Helpers: flatten / unflatten widget config for WidgetConfigPanel
|
|
59
59
|
// ---------------------------------------------------------------------------
|
|
60
|
+
// Top-level widget schema keys that the config panel exposes as flat fields.
|
|
61
|
+
// Anything outside this set (e.g. pivot rowField/columnField, table searchable,
|
|
62
|
+
// chart xAxisField/yAxisFields, list itemTemplate, custom component) is stored
|
|
63
|
+
// under `widget.options` per the spec and round-tripped via spread.
|
|
64
|
+
const TOP_LEVEL_WIDGET_KEYS = new Set([
|
|
65
|
+
'title',
|
|
66
|
+
'description',
|
|
67
|
+
'type',
|
|
68
|
+
'object',
|
|
69
|
+
'categoryField',
|
|
70
|
+
'valueField',
|
|
71
|
+
'aggregate',
|
|
72
|
+
'colorVariant',
|
|
73
|
+
'actionUrl',
|
|
74
|
+
'layoutW',
|
|
75
|
+
'layoutH',
|
|
76
|
+
'id',
|
|
77
|
+
]);
|
|
60
78
|
function flattenWidgetConfig(widget) {
|
|
79
|
+
// Spread options first so explicit top-level widget fields take precedence
|
|
80
|
+
// on collision. This surfaces type-specific options (pivot/table/chart axes,
|
|
81
|
+
// list itemTemplate, etc.) so they appear pre-filled in the config panel.
|
|
82
|
+
const options = (widget.options ?? {});
|
|
61
83
|
return {
|
|
84
|
+
...options,
|
|
62
85
|
title: widget.title ?? '',
|
|
63
86
|
description: widget.description ?? '',
|
|
64
87
|
type: widget.type ?? 'metric',
|
|
65
88
|
object: widget.object ?? '',
|
|
66
|
-
categoryField: widget.categoryField ?? '',
|
|
67
|
-
valueField: widget.valueField ?? '',
|
|
68
|
-
aggregate: widget.aggregate ?? 'count',
|
|
89
|
+
categoryField: widget.categoryField ?? options.categoryField ?? '',
|
|
90
|
+
valueField: widget.valueField ?? options.valueField ?? '',
|
|
91
|
+
aggregate: widget.aggregate ?? options.aggregate ?? 'count',
|
|
69
92
|
layoutW: widget.layout?.w ?? 1,
|
|
70
93
|
layoutH: widget.layout?.h ?? 1,
|
|
71
|
-
colorVariant: widget.colorVariant ?? 'default',
|
|
72
|
-
actionUrl: widget.actionUrl ?? '',
|
|
94
|
+
colorVariant: widget.colorVariant ?? options.colorVariant ?? 'default',
|
|
95
|
+
actionUrl: widget.actionUrl ?? options.actionUrl ?? '',
|
|
73
96
|
};
|
|
74
97
|
}
|
|
75
98
|
function unflattenWidgetConfig(config, base) {
|
|
99
|
+
// Collect any unknown keys (pivot/table/chart-specific) into options so the
|
|
100
|
+
// serialized widget keeps the spec-compliant nested shape.
|
|
101
|
+
const baseOptions = (base.options ?? {});
|
|
102
|
+
const newOptions = { ...baseOptions };
|
|
103
|
+
for (const [key, value] of Object.entries(config)) {
|
|
104
|
+
if (TOP_LEVEL_WIDGET_KEYS.has(key))
|
|
105
|
+
continue;
|
|
106
|
+
if (value === undefined)
|
|
107
|
+
continue;
|
|
108
|
+
newOptions[key] = value;
|
|
109
|
+
}
|
|
76
110
|
return {
|
|
77
111
|
title: config.title,
|
|
78
112
|
description: config.description,
|
|
@@ -84,17 +118,19 @@ function unflattenWidgetConfig(config, base) {
|
|
|
84
118
|
layout: { ...(base.layout || {}), w: config.layoutW, h: config.layoutH },
|
|
85
119
|
colorVariant: config.colorVariant,
|
|
86
120
|
actionUrl: config.actionUrl,
|
|
121
|
+
...(Object.keys(newOptions).length > 0 ? { options: newOptions } : {}),
|
|
87
122
|
};
|
|
88
123
|
}
|
|
89
124
|
function extractDashboardConfig(schema) {
|
|
125
|
+
const s = (schema ?? {});
|
|
90
126
|
return {
|
|
91
|
-
columns:
|
|
92
|
-
gap:
|
|
93
|
-
rowHeight: String(
|
|
94
|
-
refreshInterval: String(
|
|
95
|
-
title:
|
|
96
|
-
showDescription:
|
|
97
|
-
theme:
|
|
127
|
+
columns: s.columns ?? 3,
|
|
128
|
+
gap: s.gap ?? 4,
|
|
129
|
+
rowHeight: String(s.rowHeight ?? '120'),
|
|
130
|
+
refreshInterval: String(s.refreshInterval ?? '0'),
|
|
131
|
+
title: s.title ?? '',
|
|
132
|
+
showDescription: s.showDescription ?? true,
|
|
133
|
+
theme: s.theme ?? 'auto',
|
|
98
134
|
};
|
|
99
135
|
}
|
|
100
136
|
// ---------------------------------------------------------------------------
|
|
@@ -184,11 +220,15 @@ export function DashboardView({ dataSource }) {
|
|
|
184
220
|
// ---- Save helper --------------------------------------------------------
|
|
185
221
|
const saveSchema = useCallback(async (schema) => {
|
|
186
222
|
try {
|
|
187
|
-
if (adapter) {
|
|
223
|
+
if (adapter && adapter.updateDashboard) {
|
|
224
|
+
await adapter.updateDashboard(dashboardName, schema);
|
|
225
|
+
}
|
|
226
|
+
else if (adapter) {
|
|
227
|
+
// Fallback for adapters that don't expose updateDashboard
|
|
188
228
|
await adapter.update('sys_dashboard', dashboardName, schema);
|
|
189
|
-
// Refresh metadata cache so closing the config panel shows saved data
|
|
190
|
-
refresh().catch(() => { });
|
|
191
229
|
}
|
|
230
|
+
// Refresh metadata cache so closing the config panel shows saved data
|
|
231
|
+
refresh().catch(() => { });
|
|
192
232
|
}
|
|
193
233
|
catch (err) {
|
|
194
234
|
console.warn('[DashboardView] Auto-save failed:', err);
|
|
@@ -250,12 +290,16 @@ export function DashboardView({ dataSource }) {
|
|
|
250
290
|
const handleDashboardConfigSave = useCallback((config) => {
|
|
251
291
|
if (!editSchema)
|
|
252
292
|
return;
|
|
293
|
+
const toNum = (v, fallback) => {
|
|
294
|
+
const n = Number(v);
|
|
295
|
+
return Number.isFinite(n) ? n : fallback;
|
|
296
|
+
};
|
|
253
297
|
const newSchema = {
|
|
254
298
|
...editSchema,
|
|
255
|
-
columns: config.columns,
|
|
256
|
-
gap: config.gap,
|
|
257
|
-
rowHeight: config.rowHeight,
|
|
258
|
-
refreshInterval:
|
|
299
|
+
columns: toNum(config.columns, editSchema.columns),
|
|
300
|
+
gap: toNum(config.gap, editSchema.gap),
|
|
301
|
+
rowHeight: toNum(config.rowHeight, editSchema.rowHeight),
|
|
302
|
+
refreshInterval: toNum(config.refreshInterval, 0) ?? 0,
|
|
259
303
|
title: config.title,
|
|
260
304
|
showDescription: config.showDescription,
|
|
261
305
|
theme: config.theme,
|
|
@@ -267,12 +311,15 @@ export function DashboardView({ dataSource }) {
|
|
|
267
311
|
const handleDashboardFieldChange = useCallback((field, value) => {
|
|
268
312
|
if (!editSchema)
|
|
269
313
|
return;
|
|
270
|
-
// Map config field keys to proper DashboardSchema updates for live preview
|
|
314
|
+
// Map config field keys to proper DashboardSchema updates for live preview.
|
|
315
|
+
// Coerce numeric layout fields so previews/save payloads stay typed.
|
|
271
316
|
setEditSchema((prev) => {
|
|
272
317
|
if (!prev)
|
|
273
318
|
return prev;
|
|
274
|
-
|
|
275
|
-
|
|
319
|
+
const numericFields = new Set(['columns', 'gap', 'rowHeight', 'refreshInterval']);
|
|
320
|
+
if (numericFields.has(field)) {
|
|
321
|
+
const n = Number(value);
|
|
322
|
+
return { ...prev, [field]: Number.isFinite(n) ? n : prev[field] };
|
|
276
323
|
}
|
|
277
324
|
return { ...prev, [field]: value };
|
|
278
325
|
});
|
|
@@ -8,4 +8,4 @@
|
|
|
8
8
|
* - useObjectActions for toolbar create button
|
|
9
9
|
* - ListView delegation for non-grid view types (kanban, calendar, chart, etc.)
|
|
10
10
|
*/
|
|
11
|
-
export declare function ObjectView({ dataSource, objects, onEdit }: any): import("react/jsx-runtime").JSX.Element;
|
|
11
|
+
export declare function ObjectView({ dataSource, objects, onEdit, externalRefreshKey }: any): import("react/jsx-runtime").JSX.Element;
|