@object-ui/app-shell 4.0.6 → 4.0.8

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.
@@ -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: [{ key: 'groupByField', i18nKey: 'console.objectView.groupByField', filter: (f) => f.type === 'select' || f.type === 'boolean' }],
45
- calendar: [{ key: 'startDateField', i18nKey: 'console.objectView.startDateField', filter: (f) => f.type === 'date' }],
46
- timeline: [{ key: 'dateField', i18nKey: 'console.objectView.dateField', filter: (f) => f.type === 'date' }],
47
- gantt: [{ key: 'dateField', i18nKey: 'console.objectView.dateField', filter: (f) => f.type === 'date' }],
48
- gallery: [{ key: 'imageField', i18nKey: 'console.objectView.imageField' /* no filter — accept any */ }],
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
- { key: 'latitudeField', i18nKey: 'console.objectView.latitudeField', filter: (f) => f.type === 'number' },
51
- { key: 'longitudeField', i18nKey: 'console.objectView.longitudeField', filter: (f) => f.type === 'number' },
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 + chart have no strictly required fields at create time
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 when there's only
102
- // one eligible option saves the user a click. Runs whenever the type or
103
- // available options change.
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 || fieldOptions.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 === 1)
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
- subConfig[rf.key] = v;
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
- return (_jsxs("button", { type: "button", "data-testid": `create-view-type-${type}`, "aria-pressed": selected, onClick: () => setSelectedType(type), className: cn('group flex flex-col items-start gap-1 rounded-lg border bg-background p-3 text-left transition-colors', 'hover:border-primary/60 hover:bg-accent/40', selected
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 ? 'text-primary' : '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: description })] }, type));
142
- }) }), requiredFields.length > 0 && (_jsx("div", { className: "space-y-2 rounded-md border border-dashed bg-muted/30 p-3", "data-testid": "create-view-required-fields", children: requiredFields.map((rf) => {
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 eligible = rf.filter ? fieldOptions.filter(rf.filter) : fieldOptions;
145
- const noEligible = fieldOptions.length > 0 && eligible.length === 0;
146
- 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', selectedFieldValue ? 'border-input' : 'border-input'), children: [_jsx("option", { value: "", children: t('console.objectView.selectField') }), eligible.map(f => (_jsx("option", { value: f.value, children: f.label }, f.value)))] }), 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));
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: schema.columns ?? 3,
92
- gap: schema.gap ?? 4,
93
- rowHeight: String(schema.rowHeight ?? '120'),
94
- refreshInterval: String(schema.refreshInterval ?? '0'),
95
- title: schema.title ?? '',
96
- showDescription: schema.showDescription ?? true,
97
- theme: schema.theme ?? 'auto',
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
  // ---------------------------------------------------------------------------
@@ -147,21 +183,21 @@ export function DashboardView({ dataSource }) {
147
183
  }), []);
148
184
  const scriptHandlers = useMemo(() => ({
149
185
  export_dashboard_pdf: async () => {
150
- toast.info('Preparing PDF export…');
186
+ toast.info(t('dashboardActions.pdfPreparing'));
151
187
  try {
152
188
  window.print();
153
189
  return { success: true };
154
190
  }
155
191
  catch (err) {
156
- toast.error(`Export failed: ${err?.message || String(err)}`);
192
+ toast.error(t('dashboardActions.exportFailed', { message: err?.message || String(err) }));
157
193
  return { success: false, error: err?.message || String(err) };
158
194
  }
159
195
  },
160
196
  forecast_dashboard: async () => {
161
- toast.info('Forecast view coming soon');
197
+ toast.info(t('dashboardActions.forecastSoon'));
162
198
  return { success: true };
163
199
  },
164
- }), []);
200
+ }), [t]);
165
201
  useEffect(() => {
166
202
  setIsLoading(true);
167
203
  setEditSchema(null);
@@ -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: Number(config.refreshInterval) || 0,
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
- if (field === 'refreshInterval') {
275
- return { ...prev, refreshInterval: Number(value) || 0 };
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
  });
@@ -349,7 +396,7 @@ export function DashboardView({ dataSource }) {
349
396
  return _jsx(SkeletonDashboard, {});
350
397
  }
351
398
  if (!dashboard) {
352
- return (_jsx("div", { className: "h-full flex items-center justify-center p-8", children: _jsxs(Empty, { children: [_jsx("div", { className: "mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted", children: _jsx(LayoutDashboard, { className: "h-6 w-6 text-muted-foreground" }) }), _jsx(EmptyTitle, { children: "Dashboard Not Found" }), _jsxs(EmptyDescription, { children: ["The dashboard \"", dashboardName, "\" could not be found. It may have been removed or renamed."] })] }) }));
399
+ return (_jsx("div", { className: "h-full flex items-center justify-center p-8", children: _jsxs(Empty, { children: [_jsx("div", { className: "mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted", children: _jsx(LayoutDashboard, { className: "h-6 w-6 text-muted-foreground" }) }), _jsx(EmptyTitle, { children: t('empty.dashboardNotFound') }), _jsx(EmptyDescription, { children: t('empty.dashboardNotFoundDescription', { name: dashboardName }) })] }) }));
353
400
  }
354
401
  const previewSchema = editSchema || dashboard;
355
402
  return (_jsxs("div", { className: "flex flex-col h-full overflow-hidden bg-background", children: [_jsxs("div", { className: "flex flex-col sm:flex-row justify-between sm:items-center gap-3 sm:gap-4 p-4 sm:p-6 border-b shrink-0", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("h1", { className: "text-lg sm:text-xl md:text-2xl font-bold tracking-tight truncate", children: dashboardLabel({ name: dashboard.name, label: resolveI18nLabel(dashboard.label, t) }) || dashboard.name }), (() => {
@@ -375,5 +422,5 @@ export function DashboardView({ dataSource }) {
375
422
  showSubmit: true,
376
423
  showCancel: true,
377
424
  }, dataSource: adapter })) : modalState ? (_jsx(Dialog, { open: true, onOpenChange: (open) => { if (!open)
378
- closeModal({ success: false }); }, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: modalState.schema?.title || 'Action' }), modalState.schema?.description && (_jsx(DialogDescription, { children: modalState.schema.description }))] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: () => closeModal({ success: false }), children: "Cancel" }), _jsx(Button, { onClick: () => closeModal({ success: true }), children: "OK" })] })] }) })) : null] }));
425
+ closeModal({ success: false }); }, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: modalState.schema?.title || t('actionDialog.defaultActionTitle') }), modalState.schema?.description && (_jsx(DialogDescription, { children: modalState.schema.description }))] }), _jsxs(DialogFooter, { children: [_jsx(Button, { variant: "outline", onClick: () => closeModal({ success: false }), children: t('actionDialog.cancel') }), _jsx(Button, { onClick: () => closeModal({ success: true }), children: t('actionDialog.ok') })] })] }) })) : null] }));
379
426
  }
@@ -9,12 +9,14 @@ import { useState, useMemo } from 'react';
9
9
  import { Button } from '@object-ui/components';
10
10
  import { Code2, Copy, Check, ChevronDown, ChevronRight } from 'lucide-react';
11
11
  import { parseDebugFlags } from '@object-ui/core';
12
+ import { useObjectTranslation } from '@object-ui/i18n';
12
13
  /**
13
14
  * Toggle button for the metadata inspector.
14
15
  * Place this in your header/toolbar area.
15
16
  */
16
17
  export function MetadataToggle({ open, onToggle, className }) {
17
- return (_jsxs(Button, { size: "sm", variant: open ? 'secondary' : 'outline', onClick: onToggle, className: `shadow-none gap-2 ${className || ''}`, title: "Toggle Metadata Inspector", children: [_jsx(Code2, { className: "h-4 w-4" }), _jsx("span", { className: "hidden lg:inline", children: "Metadata" })] }));
18
+ const { t } = useObjectTranslation();
19
+ return (_jsxs(Button, { size: "sm", variant: open ? 'secondary' : 'outline', onClick: onToggle, className: `shadow-none gap-2 ${className || ''}`, title: t('layout.metadata.toggleTitle'), children: [_jsx(Code2, { className: "h-4 w-4" }), _jsx("span", { className: "hidden lg:inline", children: t('layout.metadata.label') })] }));
18
20
  }
19
21
  /**
20
22
  * The side panel that renders JSON metadata sections.
@@ -22,6 +24,7 @@ export function MetadataToggle({ open, onToggle, className }) {
22
24
  export function MetadataPanel({ sections, open }) {
23
25
  if (!open)
24
26
  return null;
27
+ const { t } = useObjectTranslation();
25
28
  const [expandedSections, setExpandedSections] = useState(['0']);
26
29
  const [copiedIndex, setCopiedIndex] = useState(null);
27
30
  const handleCopy = async (data, index) => {
@@ -39,11 +42,11 @@ export function MetadataPanel({ sections, open }) {
39
42
  ? prev.filter(i => i !== index)
40
43
  : [...prev, index]);
41
44
  };
42
- return (_jsxs("div", { className: "w-80 border-l bg-background p-0 overflow-hidden flex flex-col shrink-0 z-20 transition-all", children: [_jsxs("div", { className: "px-4 py-3 border-b bg-muted/5 flex items-center justify-between", children: [_jsx("span", { className: "text-xs font-semibold uppercase tracking-wider text-muted-foreground", children: "Metadata Inspector" }), _jsx("span", { className: "text-[10px] text-muted-foreground/60", children: "JSON" })] }), _jsx("div", { className: "flex-1 overflow-auto", children: sections.map((section, index) => {
45
+ return (_jsxs("div", { className: "w-80 border-l bg-background p-0 overflow-hidden flex flex-col shrink-0 z-20 transition-all", children: [_jsxs("div", { className: "px-4 py-3 border-b bg-muted/5 flex items-center justify-between", children: [_jsx("span", { className: "text-xs font-semibold uppercase tracking-wider text-muted-foreground", children: t('layout.metadata.panelTitle') }), _jsx("span", { className: "text-[10px] text-muted-foreground/60", children: t('layout.metadata.jsonBadge') })] }), _jsx("div", { className: "flex-1 overflow-auto", children: sections.map((section, index) => {
43
46
  const sectionId = String(index);
44
47
  const isExpanded = expandedSections.includes(sectionId);
45
48
  const isCopied = copiedIndex === index;
46
- return (_jsxs("div", { className: "border-b last:border-b-0", children: [_jsxs("div", { className: "flex items-center justify-between px-4 py-3 hover:bg-muted/5 transition-colors", children: [_jsxs("button", { onClick: () => toggleSection(sectionId), className: "flex-1 flex items-center justify-between text-left", children: [_jsx("h4", { className: "text-xs font-semibold text-foreground", children: section.title }), isExpanded ? (_jsx(ChevronDown, { className: "h-3 w-3 text-muted-foreground ml-2" })) : (_jsx(ChevronRight, { className: "h-3 w-3 text-muted-foreground ml-2" }))] }), _jsx("button", { onClick: () => handleCopy(section.data, index), className: "p-1 hover:bg-muted rounded transition-colors ml-2", title: "Copy JSON", children: isCopied ? (_jsx(Check, { className: "h-3 w-3 text-green-600" })) : (_jsx(Copy, { className: "h-3 w-3 text-muted-foreground" })) })] }), isExpanded && (_jsx("div", { className: "px-4 pb-4", children: _jsx("div", { className: "relative rounded-md border border-border/50 bg-muted/5 overflow-hidden", children: _jsx("pre", { className: "text-[11px] leading-relaxed p-3 overflow-auto max-h-96 font-mono text-foreground/90", children: JSON.stringify(section.data, null, 2) }) }) }))] }, index));
49
+ return (_jsxs("div", { className: "border-b last:border-b-0", children: [_jsxs("div", { className: "flex items-center justify-between px-4 py-3 hover:bg-muted/5 transition-colors", children: [_jsxs("button", { onClick: () => toggleSection(sectionId), className: "flex-1 flex items-center justify-between text-left", children: [_jsx("h4", { className: "text-xs font-semibold text-foreground", children: section.title }), isExpanded ? (_jsx(ChevronDown, { className: "h-3 w-3 text-muted-foreground ml-2" })) : (_jsx(ChevronRight, { className: "h-3 w-3 text-muted-foreground ml-2" }))] }), _jsx("button", { onClick: () => handleCopy(section.data, index), className: "p-1 hover:bg-muted rounded transition-colors ml-2", title: t('layout.metadata.copyJson'), children: isCopied ? (_jsx(Check, { className: "h-3 w-3 text-green-600" })) : (_jsx(Copy, { className: "h-3 w-3 text-muted-foreground" })) })] }), isExpanded && (_jsx("div", { className: "px-4 pb-4", children: _jsx("div", { className: "relative rounded-md border border-border/50 bg-muted/5 overflow-hidden", children: _jsx("pre", { className: "text-[11px] leading-relaxed p-3 overflow-auto max-h-96 font-mono text-foreground/90", children: JSON.stringify(section.data, null, 2) }) }) }))] }, index));
47
50
  }) })] }));
48
51
  }
49
52
  /**
@@ -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;