@object-ui/app-shell 3.3.1 → 4.0.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 +60 -0
- package/dist/chrome/CommandPalette.js +2 -16
- package/dist/layout/AppHeader.js +21 -9
- package/dist/layout/AppSidebar.js +7 -22
- package/dist/layout/AppSwitcher.js +1 -16
- package/dist/layout/ConsoleFloatingChatbot.d.ts +10 -0
- package/dist/layout/ConsoleFloatingChatbot.js +27 -0
- package/dist/layout/ConsoleLayout.d.ts +1 -0
- package/dist/layout/ConsoleLayout.js +6 -28
- package/dist/layout/UnifiedSidebar.d.ts +5 -0
- package/dist/layout/UnifiedSidebar.js +5 -29
- package/dist/providers/MetadataProvider.js +42 -2
- package/dist/utils/getIcon.d.ts +16 -2
- package/dist/utils/getIcon.js +40 -12
- package/dist/views/CreateViewDialog.d.ts +44 -0
- package/dist/views/CreateViewDialog.js +140 -0
- package/dist/views/DashboardView.js +31 -9
- package/dist/views/ObjectView.d.ts +0 -3
- package/dist/views/ObjectView.js +424 -80
- package/dist/views/ReportView.js +4 -3
- package/dist/views/ViewConfigPanel.d.ts +7 -5
- package/dist/views/ViewConfigPanel.js +9 -6
- package/dist/views/index.d.ts +1 -0
- package/dist/views/index.js +1 -0
- package/package.json +29 -27
- package/src/styles.css +16 -0
package/dist/utils/getIcon.js
CHANGED
|
@@ -2,23 +2,51 @@
|
|
|
2
2
|
* Icon utilities
|
|
3
3
|
*
|
|
4
4
|
* Helpers for resolving Lucide icons by name.
|
|
5
|
+
*
|
|
6
|
+
* Implementation: instead of statically importing every icon (~1500
|
|
7
|
+
* components, ~568 KB raw / 140 KB gz), we wrap lucide-react's built-in
|
|
8
|
+
* `DynamicIcon` so each icon is fetched as its own tiny chunk on first use.
|
|
9
|
+
*
|
|
10
|
+
* The exported `getIcon(name)` API stays synchronous and returns a React
|
|
11
|
+
* component, preserving call sites that do `const Icon = getIcon(name); <Icon />`.
|
|
5
12
|
*/
|
|
6
|
-
import
|
|
13
|
+
import React from 'react';
|
|
7
14
|
import { Database } from 'lucide-react';
|
|
15
|
+
// @ts-ignore - lucide-react has no `exports` field; subpath types live alongside dynamic.mjs
|
|
16
|
+
import { DynamicIcon } from 'lucide-react/dynamic.mjs';
|
|
17
|
+
/** Convert PascalCase / camelCase / mixed names to kebab-case for DynamicIcon. */
|
|
18
|
+
function toKebab(name) {
|
|
19
|
+
if (name.includes('-'))
|
|
20
|
+
return name.toLowerCase();
|
|
21
|
+
return name
|
|
22
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
23
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
|
|
24
|
+
.toLowerCase();
|
|
25
|
+
}
|
|
26
|
+
const cache = new Map();
|
|
8
27
|
/**
|
|
9
|
-
* Resolve a Lucide icon by name (kebab-case or PascalCase)
|
|
10
|
-
*
|
|
28
|
+
* Resolve a Lucide icon by name (kebab-case or PascalCase).
|
|
29
|
+
*
|
|
30
|
+
* Returns a React component that lazy-loads the underlying SVG icon on
|
|
31
|
+
* mount. Falls back to the `Database` icon (statically imported) when no
|
|
32
|
+
* `name` is given.
|
|
33
|
+
*
|
|
34
|
+
* The returned component is memoised per `name` so repeated calls with the
|
|
35
|
+
* same name yield the same component reference (stable for React.memo).
|
|
11
36
|
*/
|
|
12
37
|
export function getIcon(name) {
|
|
13
38
|
if (!name)
|
|
14
39
|
return Database;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
40
|
+
const cached = cache.get(name);
|
|
41
|
+
if (cached)
|
|
42
|
+
return cached;
|
|
43
|
+
const kebab = toKebab(name);
|
|
44
|
+
const Wrapped = (props) => React.createElement(DynamicIcon, {
|
|
45
|
+
name: kebab,
|
|
46
|
+
fallback: Database,
|
|
47
|
+
...props,
|
|
48
|
+
});
|
|
49
|
+
Wrapped.displayName = `LucideIcon(${name})`;
|
|
50
|
+
cache.set(name, Wrapped);
|
|
51
|
+
return Wrapped;
|
|
24
52
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CreateViewDialog — Airtable-style "Create new view" modal.
|
|
3
|
+
*
|
|
4
|
+
* Step 1: User picks a view type from a visual grid of cards (icon + label
|
|
5
|
+
* + short description). Selection is highlighted.
|
|
6
|
+
* Step 2: For view types that need them, the user picks the required
|
|
7
|
+
* configuration fields (e.g. the group-by field for kanban, the start-date
|
|
8
|
+
* field for calendar/timeline/gantt, lat/lng for map, image for gallery).
|
|
9
|
+
* The Create button stays disabled until every required field is set.
|
|
10
|
+
* Step 3: The user enters a name (required, defaults to "Grid 1" etc.).
|
|
11
|
+
*
|
|
12
|
+
* On submit, calls `onCreate({ type, label, [type]: {...required fields} })`.
|
|
13
|
+
* The parent is responsible for actually persisting the view (we keep this
|
|
14
|
+
* component pure — no dataSource coupling).
|
|
15
|
+
*/
|
|
16
|
+
export interface CreateViewDialogProps {
|
|
17
|
+
open: boolean;
|
|
18
|
+
onOpenChange: (open: boolean) => void;
|
|
19
|
+
/**
|
|
20
|
+
* Called with a fully-formed view config payload. Required type-specific
|
|
21
|
+
* fields are nested under their type key (e.g. `kanban.groupByField`),
|
|
22
|
+
* matching the @objectstack/spec NamedListView shape.
|
|
23
|
+
*/
|
|
24
|
+
onCreate: (config: Record<string, any> & {
|
|
25
|
+
type: string;
|
|
26
|
+
label: string;
|
|
27
|
+
}) => void;
|
|
28
|
+
/** Used to suggest unique default names like "Grid 2" if "Grid 1" exists. */
|
|
29
|
+
existingLabels?: string[];
|
|
30
|
+
/** Restrict the available view types. Defaults to all built-in types. */
|
|
31
|
+
availableTypes?: string[];
|
|
32
|
+
/**
|
|
33
|
+
* Object definition. Provides the available fields used to populate the
|
|
34
|
+
* required field selectors (group-by, start-date, etc.). When omitted,
|
|
35
|
+
* required-field validation is skipped.
|
|
36
|
+
*/
|
|
37
|
+
objectDef?: {
|
|
38
|
+
name: string;
|
|
39
|
+
label?: string;
|
|
40
|
+
fields?: Record<string, any>;
|
|
41
|
+
[key: string]: any;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export declare function CreateViewDialog({ open, onOpenChange, onCreate, existingLabels, availableTypes, objectDef, }: CreateViewDialogProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* CreateViewDialog — Airtable-style "Create new view" modal.
|
|
4
|
+
*
|
|
5
|
+
* Step 1: User picks a view type from a visual grid of cards (icon + label
|
|
6
|
+
* + short description). Selection is highlighted.
|
|
7
|
+
* Step 2: For view types that need them, the user picks the required
|
|
8
|
+
* configuration fields (e.g. the group-by field for kanban, the start-date
|
|
9
|
+
* field for calendar/timeline/gantt, lat/lng for map, image for gallery).
|
|
10
|
+
* The Create button stays disabled until every required field is set.
|
|
11
|
+
* Step 3: The user enters a name (required, defaults to "Grid 1" etc.).
|
|
12
|
+
*
|
|
13
|
+
* On submit, calls `onCreate({ type, label, [type]: {...required fields} })`.
|
|
14
|
+
* The parent is responsible for actually persisting the view (we keep this
|
|
15
|
+
* component pure — no dataSource coupling).
|
|
16
|
+
*/
|
|
17
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
18
|
+
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Input, Button, cn, } from '@object-ui/components';
|
|
19
|
+
import { useObjectTranslation } from '@object-ui/i18n';
|
|
20
|
+
import { deriveFieldOptions } from '@object-ui/plugin-view';
|
|
21
|
+
import { LayoutGrid, KanbanSquare, Calendar as CalendarIcon, Image as ImageIcon, GanttChartSquare, Clock, Map as MapIcon, BarChart3, AlertCircle, } from 'lucide-react';
|
|
22
|
+
function buildViewTypeMeta(t) {
|
|
23
|
+
return [
|
|
24
|
+
{ type: 'grid', icon: LayoutGrid, label: t('console.objectView.viewTypeGrid'), description: t('console.objectView.viewTypeGridDesc') },
|
|
25
|
+
{ type: 'kanban', icon: KanbanSquare, label: t('console.objectView.viewTypeKanban'), description: t('console.objectView.viewTypeKanbanDesc') },
|
|
26
|
+
{ type: 'calendar', icon: CalendarIcon, label: t('console.objectView.viewTypeCalendar'), description: t('console.objectView.viewTypeCalendarDesc') },
|
|
27
|
+
{ type: 'gallery', icon: ImageIcon, label: t('console.objectView.viewTypeGallery'), description: t('console.objectView.viewTypeGalleryDesc') },
|
|
28
|
+
{ type: 'timeline', icon: Clock, label: t('console.objectView.viewTypeTimeline'), description: t('console.objectView.viewTypeTimelineDesc') },
|
|
29
|
+
{ type: 'gantt', icon: GanttChartSquare, label: t('console.objectView.viewTypeGantt'), description: t('console.objectView.viewTypeGanttDesc') },
|
|
30
|
+
{ type: 'map', icon: MapIcon, label: t('console.objectView.viewTypeMap'), description: t('console.objectView.viewTypeMapDesc') },
|
|
31
|
+
{ type: 'chart', icon: BarChart3, label: t('console.objectView.viewTypeChart'), description: t('console.objectView.viewTypeChartDesc') },
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
/** Suggest a non-colliding default name like "Grid 1", "Grid 2", … */
|
|
35
|
+
function suggestName(typeLabel, existing) {
|
|
36
|
+
for (let i = 1; i < 1000; i++) {
|
|
37
|
+
const candidate = `${typeLabel} ${i}`;
|
|
38
|
+
if (!existing.has(candidate))
|
|
39
|
+
return candidate;
|
|
40
|
+
}
|
|
41
|
+
return typeLabel;
|
|
42
|
+
}
|
|
43
|
+
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 */ }],
|
|
49
|
+
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' },
|
|
52
|
+
],
|
|
53
|
+
// grid + chart have no strictly required fields at create time
|
|
54
|
+
};
|
|
55
|
+
export function CreateViewDialog({ open, onOpenChange, onCreate, existingLabels, availableTypes, objectDef, }) {
|
|
56
|
+
const { t } = useObjectTranslation();
|
|
57
|
+
const allTypes = useMemo(() => buildViewTypeMeta(t), [t]);
|
|
58
|
+
const types = useMemo(() => (availableTypes && availableTypes.length > 0
|
|
59
|
+
? allTypes.filter(v => availableTypes.includes(v.type))
|
|
60
|
+
: allTypes), [allTypes, availableTypes]);
|
|
61
|
+
const existingSet = useMemo(() => new Set(existingLabels ?? []), [existingLabels]);
|
|
62
|
+
const fieldOptions = useMemo(() => (objectDef ? deriveFieldOptions(objectDef) : []), [objectDef]);
|
|
63
|
+
const [selectedType, setSelectedType] = useState(types[0]?.type ?? 'grid');
|
|
64
|
+
const [label, setLabel] = useState('');
|
|
65
|
+
const [touched, setTouched] = useState(false);
|
|
66
|
+
/** Map of `${type}.${fieldKey}` → selected field name. Per-type so switching
|
|
67
|
+
* view types preserves the user's earlier choices in case they switch back. */
|
|
68
|
+
const [requiredFieldValues, setRequiredFieldValues] = useState({});
|
|
69
|
+
// Reset when the dialog opens, and re-suggest name whenever type changes
|
|
70
|
+
// (only while the user hasn't manually edited it yet).
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (open) {
|
|
73
|
+
setSelectedType(types[0]?.type ?? 'grid');
|
|
74
|
+
setTouched(false);
|
|
75
|
+
setRequiredFieldValues({});
|
|
76
|
+
}
|
|
77
|
+
}, [open, types]);
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!touched) {
|
|
80
|
+
const meta = types.find(v => v.type === selectedType);
|
|
81
|
+
setLabel(suggestName(meta?.label ?? 'View', existingSet));
|
|
82
|
+
}
|
|
83
|
+
}, [selectedType, touched, types, existingSet]);
|
|
84
|
+
// Required fields for the currently selected type
|
|
85
|
+
const requiredFields = REQUIRED_FIELDS_BY_TYPE[selectedType] ?? [];
|
|
86
|
+
const getRequiredValue = (key) => requiredFieldValues[`${selectedType}.${key}`] ?? '';
|
|
87
|
+
const setRequiredValue = (key, value) => setRequiredFieldValues(prev => ({ ...prev, [`${selectedType}.${key}`]: value }));
|
|
88
|
+
const trimmed = label.trim();
|
|
89
|
+
const isDuplicate = trimmed.length > 0 && existingSet.has(trimmed);
|
|
90
|
+
const allRequiredFilled = requiredFields.every(f => getRequiredValue(f.key).length > 0);
|
|
91
|
+
const canSubmit = trimmed.length > 0 && !isDuplicate && allRequiredFilled;
|
|
92
|
+
// Auto-pick a sensible default for any required field when there's only
|
|
93
|
+
// one eligible option — saves the user a click. Runs whenever the type or
|
|
94
|
+
// available options change.
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (requiredFields.length === 0 || fieldOptions.length === 0)
|
|
97
|
+
return;
|
|
98
|
+
requiredFields.forEach((rf) => {
|
|
99
|
+
if (getRequiredValue(rf.key).length > 0)
|
|
100
|
+
return;
|
|
101
|
+
const eligible = rf.filter ? fieldOptions.filter(rf.filter) : fieldOptions;
|
|
102
|
+
if (eligible.length === 1)
|
|
103
|
+
setRequiredValue(rf.key, eligible[0].value);
|
|
104
|
+
});
|
|
105
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
106
|
+
}, [selectedType, fieldOptions]);
|
|
107
|
+
const handleSubmit = () => {
|
|
108
|
+
if (!canSubmit)
|
|
109
|
+
return;
|
|
110
|
+
// Bundle required fields under their type-specific sub-key, matching the
|
|
111
|
+
// NamedListView spec (e.g. { type: "kanban", kanban: { groupByField: ... } })
|
|
112
|
+
const subConfig = {};
|
|
113
|
+
requiredFields.forEach((rf) => {
|
|
114
|
+
const v = getRequiredValue(rf.key);
|
|
115
|
+
if (v)
|
|
116
|
+
subConfig[rf.key] = v;
|
|
117
|
+
});
|
|
118
|
+
const payload = {
|
|
119
|
+
type: selectedType,
|
|
120
|
+
label: trimmed,
|
|
121
|
+
};
|
|
122
|
+
if (Object.keys(subConfig).length > 0) {
|
|
123
|
+
payload[selectedType] = subConfig;
|
|
124
|
+
}
|
|
125
|
+
onCreate(payload);
|
|
126
|
+
onOpenChange(false);
|
|
127
|
+
};
|
|
128
|
+
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 }) => {
|
|
129
|
+
const selected = type === selectedType;
|
|
130
|
+
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
|
|
131
|
+
? 'border-primary ring-2 ring-primary/30 bg-accent/40'
|
|
132
|
+
: '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));
|
|
133
|
+
}) }), 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) => {
|
|
134
|
+
const selectedFieldValue = getRequiredValue(rf.key);
|
|
135
|
+
const eligible = rf.filter ? fieldOptions.filter(rf.filter) : fieldOptions;
|
|
136
|
+
const noEligible = fieldOptions.length > 0 && eligible.length === 0;
|
|
137
|
+
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));
|
|
138
|
+
}) })), _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)
|
|
139
|
+
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') })] })] }) }));
|
|
140
|
+
}
|
|
@@ -17,7 +17,7 @@ import { SkeletonDashboard } from '../skeletons';
|
|
|
17
17
|
import { useMetadata } from '../providers/MetadataProvider';
|
|
18
18
|
import { resolveI18nLabel } from '../utils';
|
|
19
19
|
import { useAdapter } from '../providers/AdapterProvider';
|
|
20
|
-
import { useObjectTranslation } from '@object-ui/i18n';
|
|
20
|
+
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
|
|
21
21
|
// ---------------------------------------------------------------------------
|
|
22
22
|
// Widget type palette for the add-widget toolbar
|
|
23
23
|
// ---------------------------------------------------------------------------
|
|
@@ -105,6 +105,7 @@ export function DashboardView({ dataSource }) {
|
|
|
105
105
|
const { showDebug } = useMetadataInspector();
|
|
106
106
|
const adapter = useAdapter();
|
|
107
107
|
const { t } = useObjectTranslation();
|
|
108
|
+
const { dashboardLabel, dashboardDescription } = useObjectLabel();
|
|
108
109
|
const [isLoading, setIsLoading] = useState(true);
|
|
109
110
|
const [configPanelOpen, setConfigPanelOpen] = useState(false);
|
|
110
111
|
const [selectedWidgetId, setSelectedWidgetId] = useState(null);
|
|
@@ -120,13 +121,28 @@ export function DashboardView({ dataSource }) {
|
|
|
120
121
|
});
|
|
121
122
|
}, []);
|
|
122
123
|
const modalHandler = useCallback((schema) => new Promise((resolve) => {
|
|
123
|
-
// Normalize string schema (e.g. action.target = 'opportunity'
|
|
124
|
-
// ModalForm-compatible descriptor so header
|
|
125
|
-
// { actionType: 'modal', actionUrl: '
|
|
126
|
-
// form for that object.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
124
|
+
// Normalize string schema (e.g. action.target = 'opportunity' or
|
|
125
|
+
// 'create_opportunity') to a ModalForm-compatible descriptor so header
|
|
126
|
+
// `modal` actions like { actionType: 'modal', actionUrl: 'create_opportunity' }
|
|
127
|
+
// open the create form for that object. Supports the conventional
|
|
128
|
+
// `<verb>_<object>` form (create_/new_/add_/edit_/update_) emitted by
|
|
129
|
+
// server-driven dashboard schemas.
|
|
130
|
+
let normalized;
|
|
131
|
+
if (typeof schema === 'string') {
|
|
132
|
+
const m = schema.match(/^(create|new|add|edit|update)_(.+)$/);
|
|
133
|
+
if (m) {
|
|
134
|
+
const verb = m[1];
|
|
135
|
+
const objectName = m[2];
|
|
136
|
+
const mode = verb === 'edit' || verb === 'update' ? 'edit' : 'create';
|
|
137
|
+
normalized = { objectName, mode };
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
normalized = { objectName: schema, mode: 'create' };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
normalized = schema;
|
|
145
|
+
}
|
|
130
146
|
setModalState({ schema: normalized, resolve });
|
|
131
147
|
}), []);
|
|
132
148
|
const scriptHandlers = useMemo(() => ({
|
|
@@ -336,7 +352,13 @@ export function DashboardView({ dataSource }) {
|
|
|
336
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."] })] }) }));
|
|
337
353
|
}
|
|
338
354
|
const previewSchema = editSchema || dashboard;
|
|
339
|
-
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:
|
|
355
|
+
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 }), (() => {
|
|
356
|
+
const desc = dashboardDescription({
|
|
357
|
+
name: dashboard.name,
|
|
358
|
+
description: resolveI18nLabel(dashboard.description, t),
|
|
359
|
+
});
|
|
360
|
+
return desc ? (_jsx("p", { className: "text-sm text-muted-foreground mt-1 line-clamp-2", children: desc })) : null;
|
|
361
|
+
})()] }), _jsxs("div", { className: "shrink-0 flex items-center gap-1.5", children: [configPanelOpen && (_jsx("div", { className: "flex items-center gap-1 mr-2", role: "toolbar", "aria-label": "Add widgets", "data-testid": "dashboard-widget-toolbar", children: WIDGET_TYPES.map(({ type, label, Icon }) => (_jsxs("button", { type: "button", "data-testid": `dashboard-add-${type}`, onClick: () => addWidget(type), className: "inline-flex items-center gap-1 rounded-md border border-input bg-background px-2 py-1.5 text-[11px] font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", title: `Add ${label}`, "aria-label": `Add ${label} widget`, children: [_jsx(Plus, { className: "h-3 w-3" }), _jsx(Icon, { className: "h-3 w-3" })] }, type))) })), _jsxs("button", { type: "button", onClick: handleOpenConfigPanel, className: "inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-2.5 py-1.5 text-xs font-medium text-muted-foreground shadow-sm hover:bg-accent hover:text-accent-foreground", "data-testid": "dashboard-edit-button", children: [_jsx(Pencil, { className: "h-3.5 w-3.5" }), t('common.edit', { defaultValue: 'Edit' })] })] })] }), _jsxs("div", { className: "flex-1 overflow-hidden flex flex-col sm:flex-row relative", children: [_jsx("div", { className: "flex-1 min-w-0 overflow-auto p-2 sm:p-4 md:p-6", children: _jsx(DashboardRenderer, { schema: previewSchema, dataSource: dataSource, designMode: configPanelOpen, selectedWidgetId: selectedWidgetId, onWidgetClick: setSelectedWidgetId, modalHandler: modalHandler, scriptHandlers: scriptHandlers }) }), selectedWidget ? (_jsx(WidgetConfigPanel, { open: configPanelOpen, onClose: handleCloseConfigPanel, config: widgetConfig, onSave: handleWidgetConfigSave, onFieldChange: handleWidgetFieldChange, availableObjects: availableObjects, availableFields: availableFields, headerExtra: _jsx(Button, { size: "sm", variant: "ghost", onClick: () => removeWidget(selectedWidgetId), className: "h-7 w-7 p-0 text-destructive hover:text-destructive", "data-testid": "widget-delete-button", title: "Delete widget", children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) }) }, selectedWidgetId)) : (_jsx(DashboardConfigPanel, { open: configPanelOpen, onClose: handleCloseConfigPanel, config: dashboardConfig, onSave: handleDashboardConfigSave, onFieldChange: handleDashboardFieldChange })), _jsx(MetadataPanel, { open: showDebug, sections: [{ title: 'Dashboard Configuration', data: previewSchema }] })] }), modalState && modalState.schema?.objectName ? (_jsx(ModalForm, { schema: {
|
|
340
362
|
type: 'object-form',
|
|
341
363
|
formType: 'modal',
|
|
342
364
|
objectName: modalState.schema.objectName,
|
|
@@ -8,7 +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
|
-
import '@object-ui/plugin-grid';
|
|
12
|
-
import '@object-ui/plugin-kanban';
|
|
13
|
-
import '@object-ui/plugin-calendar';
|
|
14
11
|
export declare function ObjectView({ dataSource, objects, onEdit }: any): import("react/jsx-runtime").JSX.Element;
|