@object-ui/app-shell 3.3.2 → 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 CHANGED
@@ -1,5 +1,37 @@
1
1
  # @object-ui/app-shell — Changelog
2
2
 
3
+ ## 4.0.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [a2d7023]
8
+ - Updated dependencies [f1ca238]
9
+ - Updated dependencies [de881ef]
10
+ - Updated dependencies [b2be122]
11
+ - @object-ui/components@3.4.0
12
+ - @object-ui/fields@3.4.0
13
+ - @object-ui/plugin-designer@3.4.0
14
+ - @object-ui/plugin-grid@3.4.0
15
+ - @object-ui/plugin-kanban@3.4.0
16
+ - @object-ui/types@3.4.0
17
+ - @object-ui/plugin-form@3.4.0
18
+ - @object-ui/plugin-calendar@3.4.0
19
+ - @object-ui/layout@3.4.0
20
+ - @object-ui/plugin-charts@3.4.0
21
+ - @object-ui/plugin-chatbot@3.4.0
22
+ - @object-ui/plugin-dashboard@3.4.0
23
+ - @object-ui/plugin-detail@3.4.0
24
+ - @object-ui/plugin-list@3.4.0
25
+ - @object-ui/plugin-report@3.4.0
26
+ - @object-ui/plugin-view@3.4.0
27
+ - @object-ui/auth@3.4.0
28
+ - @object-ui/collaboration@3.4.0
29
+ - @object-ui/core@3.4.0
30
+ - @object-ui/data-objectstack@3.4.0
31
+ - @object-ui/permissions@3.4.0
32
+ - @object-ui/react@3.4.0
33
+ - @object-ui/i18n@3.4.0
34
+
3
35
  ## 3.3.2
4
36
 
5
37
  ### Patch Changes
@@ -53,8 +53,8 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
53
53
  const { user, signOut, organizations, activeOrganization, isOrganizationsLoading, } = useAuth();
54
54
  const dataSource = useAdapter();
55
55
  const { t } = useObjectTranslation();
56
- const { objectLabel } = useObjectLabel();
57
- const { apps: metadataApps } = useMetadata();
56
+ const { objectLabel, dashboardLabel, pageLabel, reportLabel } = useObjectLabel();
57
+ const { apps: metadataApps, dashboards: metadataDashboards, pages: metadataPages, reports: metadataReports } = useMetadata();
58
58
  const { currentAppName, recordTitle } = useNavigationContext();
59
59
  const [apiPresenceUsers, setApiPresenceUsers] = useState(null);
60
60
  const [apiActivities, setApiActivities] = useState(null);
@@ -134,18 +134,30 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
134
134
  if (isApp) {
135
135
  if (routeType === 'dashboard') {
136
136
  extraSegments.push({ label: t('console.breadcrumb.dashboards'), href: baseHref });
137
- if (pathParts[3])
138
- extraSegments.push({ label: humanizeSlug(pathParts[3]) });
137
+ if (pathParts[3]) {
138
+ const dashboardName = pathParts[3];
139
+ const dashboardDef = (metadataDashboards || []).find((d) => d.name === dashboardName);
140
+ const fallback = dashboardDef?.label || humanizeSlug(dashboardName);
141
+ extraSegments.push({ label: dashboardLabel({ name: dashboardName, label: fallback }) });
142
+ }
139
143
  }
140
144
  else if (routeType === 'page') {
141
145
  extraSegments.push({ label: t('console.breadcrumb.pages'), href: baseHref });
142
- if (pathParts[3])
143
- extraSegments.push({ label: humanizeSlug(pathParts[3]) });
146
+ if (pathParts[3]) {
147
+ const pageName = pathParts[3];
148
+ const pageDef = (metadataPages || []).find((p) => p.name === pageName);
149
+ const fallback = pageDef?.label || humanizeSlug(pageName);
150
+ extraSegments.push({ label: pageLabel({ name: pageName, label: fallback }) });
151
+ }
144
152
  }
145
153
  else if (routeType === 'report') {
146
154
  extraSegments.push({ label: t('console.breadcrumb.reports'), href: baseHref });
147
- if (pathParts[3])
148
- extraSegments.push({ label: humanizeSlug(pathParts[3]) });
155
+ if (pathParts[3]) {
156
+ const reportName = pathParts[3];
157
+ const reportDef = (metadataReports || []).find((r) => r.name === reportName);
158
+ const fallback = reportDef?.label || humanizeSlug(reportName);
159
+ extraSegments.push({ label: reportLabel({ name: reportName, label: fallback }) });
160
+ }
149
161
  }
150
162
  else if (routeType === 'system') {
151
163
  extraSegments.push({ label: t('console.breadcrumb.system') });
@@ -89,7 +89,7 @@ export function UnifiedSidebar({ activeAppName }) {
89
89
  const { isMobile } = useSidebar();
90
90
  const location = useLocation();
91
91
  const { t } = useObjectTranslation();
92
- const { objectLabel: resolveNavObjectLabel } = useObjectLabel();
92
+ const { objectLabel: resolveNavObjectLabel, dashboardLabel: resolveNavDashboardLabel, navGroupLabel: resolveNavGroupLabel } = useObjectLabel();
93
93
  const { context, currentAppName } = useNavigationContext();
94
94
  // Swipe-from-left-edge gesture to open sidebar on mobile
95
95
  React.useEffect(() => {
@@ -161,15 +161,15 @@ export function UnifiedSidebar({ activeAppName }) {
161
161
  return can(object, action);
162
162
  }), [can]);
163
163
  const basePath = context === 'app' && activeApp ? `/apps/${activeApp.name}` : '';
164
- return (_jsxs(_Fragment, { children: [_jsxs(Sidebar, { collapsible: "icon", className: "!top-14 !h-[calc(100svh-3.5rem)]", children: [_jsx(SidebarContent, { className: "pt-2", children: _jsx("div", { className: "transition-opacity duration-200 ease-in-out", children: context === 'app' && activeApp ? (_jsxs(_Fragment, { children: [areas.length > 1 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(Layers, { className: "h-3.5 w-3.5" }), "Area"] }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: areas.map((area) => {
164
+ return (_jsxs(_Fragment, { children: [_jsxs(Sidebar, { collapsible: "icon", className: "!top-14 !h-[calc(100svh-3.5rem)]", children: [_jsx(SidebarContent, { className: "pt-2", children: _jsx("div", { className: "transition-opacity duration-200 ease-in-out", children: context === 'app' && activeApp ? (_jsxs(_Fragment, { children: [areas.length > 1 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(Layers, { className: "h-3.5 w-3.5" }), t('sidebar.area', { defaultValue: 'Area' })] }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: areas.map((area) => {
165
165
  const AreaIcon = getIcon(area.icon);
166
166
  const isActiveArea = area.id === activeAreaId;
167
167
  return (_jsx(SidebarMenuItem, { children: _jsxs(SidebarMenuButton, { isActive: isActiveArea, tooltip: area.label, onClick: () => setActiveAreaId(area.id), children: [_jsx(AreaIcon, { className: "h-4 w-4" }), _jsx("span", { children: area.label })] }) }, area.id));
168
- }) }) })] })), _jsx(NavigationRenderer, { items: processedNavigation, basePath: basePath, evaluateVisibility: evalVis, checkPermission: checkPerm, enablePinning: true, onPinToggle: togglePin, enableReorder: true, onReorder: handleReorder, resolveObjectLabel: (objectName, fallback) => resolveNavObjectLabel({ name: objectName, label: fallback }), t: t }), recentItems.length > 0 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5 cursor-pointer select-none", onClick: () => setRecentExpanded(prev => !prev), children: [_jsx(ChevronRight, { className: `h-3 w-3 transition-transform duration-150 ${recentExpanded ? 'rotate-90' : ''}` }), _jsx(Clock, { className: "h-3.5 w-3.5" }), "Recent"] }), recentExpanded && (_jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: recentItems.slice(0, 5).map(item => (_jsx(SidebarMenuItem, { children: _jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, children: _jsxs(Link, { to: item.href, children: [_jsx("span", { className: "text-muted-foreground", children: item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : '📄' }), _jsx("span", { className: "truncate", children: item.label })] }) }) }, item.id))) }) }))] })), favorites.length > 0 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(Star, { className: "h-3.5 w-3.5" }), "Favorites"] }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: favorites.slice(0, 8).map(item => (_jsxs(SidebarMenuItem, { children: [_jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, children: _jsxs(Link, { to: item.href, children: [_jsx("span", { className: "text-muted-foreground", children: item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : item.type === 'page' ? '📄' : '📋' }), _jsx("span", { className: "truncate", children: item.label })] }) }), _jsx(SidebarMenuAction, { showOnHover: true, onClick: (e) => { e.stopPropagation(); removeFavorite(item.id); }, "aria-label": `Remove ${item.label} from favorites`, children: _jsx(StarOff, { className: "h-3 w-3" }) })] }, item.id))) }) })] }))] })) : (_jsxs(_Fragment, { children: [_jsx(SidebarGroup, { children: _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: homeNavigation.map((item) => {
168
+ }) }) })] })), _jsx(NavigationRenderer, { items: processedNavigation, basePath: basePath, evaluateVisibility: evalVis, checkPermission: checkPerm, enablePinning: !isMobile, onPinToggle: togglePin, enableReorder: !isMobile, onReorder: handleReorder, resolveObjectLabel: (objectName, fallback) => resolveNavObjectLabel({ name: objectName, label: fallback }), resolveDashboardLabel: (dashboardName, fallback) => resolveNavDashboardLabel({ name: dashboardName, label: fallback }), resolveGroupLabel: activeApp ? (groupId, fallback) => resolveNavGroupLabel(activeApp.name, groupId, fallback) : undefined, t: t }), recentItems.length > 0 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5 cursor-pointer select-none", onClick: () => setRecentExpanded(prev => !prev), children: [_jsx(ChevronRight, { className: `h-3 w-3 transition-transform duration-150 ${recentExpanded ? 'rotate-90' : ''}` }), _jsx(Clock, { className: "h-3.5 w-3.5" }), t('sidebar.recent', { defaultValue: 'Recent' })] }), recentExpanded && (_jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: recentItems.slice(0, 5).map(item => (_jsx(SidebarMenuItem, { children: _jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, children: _jsxs(Link, { to: item.href, children: [_jsx("span", { className: "text-muted-foreground", children: item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : '📄' }), _jsx("span", { className: "truncate", children: item.label })] }) }) }, item.id))) }) }))] })), favorites.length > 0 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(Star, { className: "h-3.5 w-3.5" }), t('sidebar.favorites', { defaultValue: 'Favorites' })] }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: favorites.slice(0, 8).map(item => (_jsxs(SidebarMenuItem, { children: [_jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, children: _jsxs(Link, { to: item.href, children: [_jsx("span", { className: "text-muted-foreground", children: item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : item.type === 'page' ? '📄' : '📋' }), _jsx("span", { className: "truncate", children: item.label })] }) }), _jsx(SidebarMenuAction, { showOnHover: true, onClick: (e) => { e.stopPropagation(); removeFavorite(item.id); }, "aria-label": t('sidebar.removeFromFavorites', { defaultValue: 'Remove {{name}} from favorites', name: item.label }), children: _jsx(StarOff, { className: "h-3 w-3" }) })] }, item.id))) }) })] }))] })) : (_jsxs(_Fragment, { children: [_jsx(SidebarGroup, { children: _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: homeNavigation.map((item) => {
169
169
  const NavIcon = getIcon(item.icon);
170
170
  const isActive = location.pathname === item.url;
171
171
  return (_jsx(SidebarMenuItem, { children: _jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, isActive: isActive, children: _jsxs(Link, { to: item.url || '/home', children: [_jsx(NavIcon, { className: "h-4 w-4" }), _jsx("span", { children: item.label })] }) }) }, item.id));
172
- }) }) }) }), favorites.filter(f => f.type === 'object' || f.type === 'dashboard' || f.type === 'page').length > 0 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(Star, { className: "h-3.5 w-3.5" }), "Starred"] }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: favorites.filter(f => f.type === 'object' || f.type === 'dashboard' || f.type === 'page').slice(0, 8).map(item => (_jsxs(SidebarMenuItem, { children: [_jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, children: _jsxs(Link, { to: item.href, children: [_jsx("span", { className: "text-muted-foreground", children: item.type === 'dashboard' ? '📊' : item.type === 'page' ? '📄' : '📋' }), _jsx("span", { className: "truncate", children: item.label })] }) }), _jsx(SidebarMenuAction, { showOnHover: true, onClick: (e) => { e.stopPropagation(); removeFavorite(item.id); }, "aria-label": `Remove ${item.label} from favorites`, children: _jsx(StarOff, { className: "h-3 w-3" }) })] }, item.id))) }) })] }))] })) }) }), _jsx(SidebarFooter, { className: "border-t p-1", children: _jsx(SidebarTrigger, { className: "w-full justify-start pl-2 group-data-[state=collapsed]:justify-center group-data-[state=collapsed]:pl-0" }) })] }), isMobile && context === 'app' && (_jsx("div", { className: "fixed bottom-0 left-0 right-0 z-50 flex items-center justify-around border-t bg-background/95 backdrop-blur-sm px-2 py-1 sm:hidden safe-area-bottom", children: processedNavigation.filter((n) => n.type !== 'group').slice(0, 5).map((item) => {
172
+ }) }) }) }), favorites.filter(f => f.type === 'object' || f.type === 'dashboard' || f.type === 'page').length > 0 && (_jsxs(SidebarGroup, { children: [_jsxs(SidebarGroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(Star, { className: "h-3.5 w-3.5" }), t('sidebar.starred', { defaultValue: 'Starred' })] }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: favorites.filter(f => f.type === 'object' || f.type === 'dashboard' || f.type === 'page').slice(0, 8).map(item => (_jsxs(SidebarMenuItem, { children: [_jsx(SidebarMenuButton, { asChild: true, tooltip: item.label, children: _jsxs(Link, { to: item.href, children: [_jsx("span", { className: "text-muted-foreground", children: item.type === 'dashboard' ? '📊' : item.type === 'page' ? '📄' : '📋' }), _jsx("span", { className: "truncate", children: item.label })] }) }), _jsx(SidebarMenuAction, { showOnHover: true, onClick: (e) => { e.stopPropagation(); removeFavorite(item.id); }, "aria-label": t('sidebar.removeFromFavorites', { defaultValue: 'Remove {{name}} from favorites', name: item.label }), children: _jsx(StarOff, { className: "h-3 w-3" }) })] }, item.id))) }) })] }))] })) }) }), _jsx(SidebarFooter, { className: "border-t p-1", children: _jsx(SidebarTrigger, { className: "w-full justify-start pl-2 group-data-[state=collapsed]:justify-center group-data-[state=collapsed]:pl-0" }) })] }), isMobile && context === 'app' && (_jsx("div", { className: "fixed bottom-0 left-0 right-0 z-50 flex items-center justify-around border-t bg-background/95 backdrop-blur-sm px-2 py-1 sm:hidden safe-area-bottom", children: processedNavigation.filter((n) => n.type !== 'group').slice(0, 5).map((item) => {
173
173
  const NavIcon = getIcon(item.icon);
174
174
  let href = item.url || '#';
175
175
  if (item.type === 'object') {
@@ -7,7 +7,7 @@ export { useMetadata };
7
7
  // Constants
8
8
  // ---------------------------------------------------------------------------
9
9
  const DEFAULT_TTL_MS = 5 * 60 * 1000;
10
- const EAGER_TYPES = ['app'];
10
+ const EAGER_TYPES = ['app', 'view'];
11
11
  const TYPE_BY_STATE_KEY = {
12
12
  apps: 'app',
13
13
  objects: 'object',
@@ -61,6 +61,44 @@ function isNamedItem(item) {
61
61
  'name' in item &&
62
62
  typeof item.name === 'string');
63
63
  }
64
+ /**
65
+ * Merge `view` metadata (the @objectstack/spec View container, keyed by
66
+ * target object name) into object definitions so that `objectDef.listViews`
67
+ * is populated for the renderer (`@object-ui/plugin-view`) which expects it.
68
+ *
69
+ * Each View has shape `{ list?, form?, listViews?, formViews? }`. We collapse:
70
+ * - `view.list` → `listViews[view.list.name || 'default']`
71
+ * - `view.listViews` → spread into `listViews`
72
+ *
73
+ * Existing `obj.listViews` / `obj.list_views` win to preserve overrides.
74
+ */
75
+ function mergeViewsIntoObjects(objects, views) {
76
+ if (!objects.length || !views.length)
77
+ return objects;
78
+ const byObject = {};
79
+ for (const view of views) {
80
+ const objName = view?.name || view?.list?.data?.object || view?.form?.data?.object;
81
+ if (!objName)
82
+ continue;
83
+ const bucket = (byObject[objName] || (byObject[objName] = {}));
84
+ if (view.list) {
85
+ const k = view.list.name || 'default';
86
+ bucket[k] = view.list;
87
+ }
88
+ if (view.listViews && typeof view.listViews === 'object') {
89
+ for (const [k, v] of Object.entries(view.listViews)) {
90
+ bucket[k] = v;
91
+ }
92
+ }
93
+ }
94
+ return objects.map(obj => {
95
+ const extra = byObject[obj.name];
96
+ if (!extra)
97
+ return obj;
98
+ const existing = obj.listViews || obj.list_views || {};
99
+ return { ...obj, listViews: { ...extra, ...existing } };
100
+ });
101
+ }
64
102
  function emptyEntry() {
65
103
  return {
66
104
  status: 'idle',
@@ -294,7 +332,9 @@ export function MetadataProvider({ children, adapter, ttlMs = DEFAULT_TTL_MS })
294
332
  const base = {
295
333
  apps: getEntry('app').items,
296
334
  get objects() {
297
- return readType(TYPE_BY_STATE_KEY.objects);
335
+ const objs = readType(TYPE_BY_STATE_KEY.objects);
336
+ const views = readType('view');
337
+ return views.length ? mergeViewsIntoObjects(objs, views) : objs;
298
338
  },
299
339
  get dashboards() {
300
340
  return readType(TYPE_BY_STATE_KEY.dashboards);
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import React from 'react';
14
14
  import { Database } from 'lucide-react';
15
- // @ts-expect-error - lucide-react has no `exports` field; subpath types live alongside dynamic.mjs
15
+ // @ts-ignore - lucide-react has no `exports` field; subpath types live alongside dynamic.mjs
16
16
  import { DynamicIcon } from 'lucide-react/dynamic.mjs';
17
17
  /** Convert PascalCase / camelCase / mixed names to kebab-case for DynamicIcon. */
18
18
  function toKebab(name) {
@@ -23,41 +23,6 @@ function toKebab(name) {
23
23
  .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
24
24
  .toLowerCase();
25
25
  }
26
- /**
27
- * Aliases for icon names that exist in other icon sets (FontAwesome, Material,
28
- * Bootstrap…) but use a different name in Lucide. Mapped to the closest
29
- * Lucide equivalent so existing metadata authored against FA stays portable.
30
- */
31
- const ICON_ALIASES = {
32
- bullseye: 'target',
33
- bullhorn: 'megaphone',
34
- tachometer: 'gauge',
35
- 'tachometer-alt': 'gauge',
36
- dashboard: 'layout-dashboard',
37
- 'life-ring': 'life-buoy',
38
- tasks: 'list-checks',
39
- 'file-invoice': 'receipt',
40
- 'file-signature': 'file-pen-line',
41
- 'file-pdf': 'file-text',
42
- 'check-square': 'square-check',
43
- 'arrow-up-right-from-square': 'external-link',
44
- 'sign-out-alt': 'log-out',
45
- 'sign-in-alt': 'log-in',
46
- 'cog': 'settings',
47
- 'gears': 'settings',
48
- 'envelope': 'mail',
49
- 'phone-alt': 'phone',
50
- 'map-marker-alt': 'map-pin',
51
- 'edit': 'pencil',
52
- 'trash-alt': 'trash-2',
53
- 'plus-circle': 'circle-plus',
54
- 'minus-circle': 'circle-minus',
55
- 'times-circle': 'circle-x',
56
- 'check-circle': 'circle-check',
57
- 'info-circle': 'info',
58
- 'question-circle': 'circle-help',
59
- 'exclamation-triangle': 'triangle-alert',
60
- };
61
26
  const cache = new Map();
62
27
  /**
63
28
  * Resolve a Lucide icon by name (kebab-case or PascalCase).
@@ -75,7 +40,7 @@ export function getIcon(name) {
75
40
  const cached = cache.get(name);
76
41
  if (cached)
77
42
  return cached;
78
- const kebab = ICON_ALIASES[toKebab(name)] ?? toKebab(name);
43
+ const kebab = toKebab(name);
79
44
  const Wrapped = (props) => React.createElement(DynamicIcon, {
80
45
  name: kebab,
81
46
  fallback: Database,
@@ -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') to a
124
- // ModalForm-compatible descriptor so header `modal` actions like
125
- // { actionType: 'modal', actionUrl: 'opportunity' } open the create
126
- // form for that object.
127
- const normalized = typeof schema === 'string'
128
- ? { objectName: schema, mode: 'create' }
129
- : schema;
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: resolveI18nLabel(dashboard.label, t) || dashboard.name }), dashboard.description && (_jsx("p", { className: "text-sm text-muted-foreground mt-1 line-clamp-2", children: resolveI18nLabel(dashboard.description, t) }))] }), _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" }), "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: {
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,