@object-ui/app-shell 3.3.2 → 4.0.1
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 +89 -0
- package/dist/layout/AppHeader.js +28 -9
- package/dist/layout/UnifiedSidebar.js +4 -4
- package/dist/providers/MetadataProvider.js +42 -2
- package/dist/utils/getIcon.js +2 -37
- package/dist/utils/index.d.ts +7 -4
- package/dist/utils/index.js +44 -12
- package/dist/views/CreateViewDialog.d.ts +44 -0
- package/dist/views/CreateViewDialog.js +149 -0
- package/dist/views/DashboardView.js +31 -9
- package/dist/views/ObjectView.js +435 -64
- package/dist/views/RecordDetailView.js +8 -11
- 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 +28 -26
- package/src/styles.css +16 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,94 @@
|
|
|
1
1
|
# @object-ui/app-shell — Changelog
|
|
2
2
|
|
|
3
|
+
## 4.0.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- @object-ui/types@4.0.1
|
|
8
|
+
- @object-ui/core@4.0.1
|
|
9
|
+
- @object-ui/i18n@4.0.1
|
|
10
|
+
- @object-ui/react@4.0.1
|
|
11
|
+
- @object-ui/components@4.0.1
|
|
12
|
+
- @object-ui/fields@4.0.1
|
|
13
|
+
- @object-ui/layout@4.0.1
|
|
14
|
+
- @object-ui/data-objectstack@4.0.1
|
|
15
|
+
- @object-ui/auth@4.0.1
|
|
16
|
+
- @object-ui/permissions@4.0.1
|
|
17
|
+
- @object-ui/plugin-calendar@4.0.1
|
|
18
|
+
- @object-ui/plugin-charts@4.0.1
|
|
19
|
+
- @object-ui/plugin-chatbot@4.0.1
|
|
20
|
+
- @object-ui/plugin-dashboard@4.0.1
|
|
21
|
+
- @object-ui/plugin-designer@4.0.1
|
|
22
|
+
- @object-ui/plugin-detail@4.0.1
|
|
23
|
+
- @object-ui/plugin-form@4.0.1
|
|
24
|
+
- @object-ui/plugin-grid@4.0.1
|
|
25
|
+
- @object-ui/plugin-kanban@4.0.1
|
|
26
|
+
- @object-ui/plugin-list@4.0.1
|
|
27
|
+
- @object-ui/plugin-report@4.0.1
|
|
28
|
+
- @object-ui/plugin-view@4.0.1
|
|
29
|
+
- @object-ui/collaboration@4.0.1
|
|
30
|
+
|
|
31
|
+
## 4.0.0
|
|
32
|
+
|
|
33
|
+
### Patch Changes
|
|
34
|
+
|
|
35
|
+
- Updated dependencies
|
|
36
|
+
- @object-ui/types@4.0.0
|
|
37
|
+
- @object-ui/auth@4.0.0
|
|
38
|
+
- @object-ui/collaboration@4.0.0
|
|
39
|
+
- @object-ui/components@4.0.0
|
|
40
|
+
- @object-ui/core@4.0.0
|
|
41
|
+
- @object-ui/data-objectstack@4.0.0
|
|
42
|
+
- @object-ui/fields@4.0.0
|
|
43
|
+
- @object-ui/layout@4.0.0
|
|
44
|
+
- @object-ui/permissions@4.0.0
|
|
45
|
+
- @object-ui/plugin-calendar@4.0.0
|
|
46
|
+
- @object-ui/plugin-charts@4.0.0
|
|
47
|
+
- @object-ui/plugin-chatbot@4.0.0
|
|
48
|
+
- @object-ui/plugin-dashboard@4.0.0
|
|
49
|
+
- @object-ui/plugin-designer@4.0.0
|
|
50
|
+
- @object-ui/plugin-detail@4.0.0
|
|
51
|
+
- @object-ui/plugin-form@4.0.0
|
|
52
|
+
- @object-ui/plugin-grid@4.0.0
|
|
53
|
+
- @object-ui/plugin-kanban@4.0.0
|
|
54
|
+
- @object-ui/plugin-list@4.0.0
|
|
55
|
+
- @object-ui/plugin-report@4.0.0
|
|
56
|
+
- @object-ui/plugin-view@4.0.0
|
|
57
|
+
- @object-ui/react@4.0.0
|
|
58
|
+
- @object-ui/i18n@4.0.0
|
|
59
|
+
|
|
60
|
+
## 4.0.0
|
|
61
|
+
|
|
62
|
+
### Patch Changes
|
|
63
|
+
|
|
64
|
+
- Updated dependencies [a2d7023]
|
|
65
|
+
- Updated dependencies [f1ca238]
|
|
66
|
+
- Updated dependencies [de881ef]
|
|
67
|
+
- Updated dependencies [b2be122]
|
|
68
|
+
- @object-ui/components@3.4.0
|
|
69
|
+
- @object-ui/fields@3.4.0
|
|
70
|
+
- @object-ui/plugin-designer@3.4.0
|
|
71
|
+
- @object-ui/plugin-grid@3.4.0
|
|
72
|
+
- @object-ui/plugin-kanban@3.4.0
|
|
73
|
+
- @object-ui/types@3.4.0
|
|
74
|
+
- @object-ui/plugin-form@3.4.0
|
|
75
|
+
- @object-ui/plugin-calendar@3.4.0
|
|
76
|
+
- @object-ui/layout@3.4.0
|
|
77
|
+
- @object-ui/plugin-charts@3.4.0
|
|
78
|
+
- @object-ui/plugin-chatbot@3.4.0
|
|
79
|
+
- @object-ui/plugin-dashboard@3.4.0
|
|
80
|
+
- @object-ui/plugin-detail@3.4.0
|
|
81
|
+
- @object-ui/plugin-list@3.4.0
|
|
82
|
+
- @object-ui/plugin-report@3.4.0
|
|
83
|
+
- @object-ui/plugin-view@3.4.0
|
|
84
|
+
- @object-ui/auth@3.4.0
|
|
85
|
+
- @object-ui/collaboration@3.4.0
|
|
86
|
+
- @object-ui/core@3.4.0
|
|
87
|
+
- @object-ui/data-objectstack@3.4.0
|
|
88
|
+
- @object-ui/permissions@3.4.0
|
|
89
|
+
- @object-ui/react@3.4.0
|
|
90
|
+
- @object-ui/i18n@3.4.0
|
|
91
|
+
|
|
3
92
|
## 3.3.2
|
|
4
93
|
|
|
5
94
|
### Patch Changes
|
package/dist/layout/AppHeader.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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') });
|
|
@@ -169,7 +181,14 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
|
|
|
169
181
|
extraSegments.push({ label: displayTitle || `#${shortId}` });
|
|
170
182
|
}
|
|
171
183
|
else if (pathParts[3] === 'view' && pathParts[4]) {
|
|
172
|
-
|
|
184
|
+
// Prefer the view's metadata label (e.g. "Lead Pipeline") over a
|
|
185
|
+
// humanized slug ("Kanban By Status") so the breadcrumb matches the
|
|
186
|
+
// tab label users clicked.
|
|
187
|
+
const viewName = pathParts[4];
|
|
188
|
+
const definedViews = currentObject.listViews || currentObject.list_views || {};
|
|
189
|
+
const viewDef = definedViews[viewName];
|
|
190
|
+
const viewLabel = (viewDef && (viewDef.label || viewDef.title)) || humanizeSlug(viewName);
|
|
191
|
+
extraSegments.push({ label: viewLabel });
|
|
173
192
|
}
|
|
174
193
|
}
|
|
175
194
|
}
|
|
@@ -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" }),
|
|
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:
|
|
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" }),
|
|
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
|
-
|
|
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);
|
package/dist/utils/getIcon.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import React from 'react';
|
|
14
14
|
import { Database } from 'lucide-react';
|
|
15
|
-
// @ts-
|
|
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 =
|
|
43
|
+
const kebab = toKebab(name);
|
|
79
44
|
const Wrapped = (props) => React.createElement(DynamicIcon, {
|
|
80
45
|
name: kebab,
|
|
81
46
|
fallback: Database,
|
package/dist/utils/index.d.ts
CHANGED
|
@@ -18,10 +18,13 @@ export declare function resolveI18nLabel(label: string | {
|
|
|
18
18
|
*/
|
|
19
19
|
export declare function capitalizeFirst(str: string): string;
|
|
20
20
|
/**
|
|
21
|
-
* Format a record title using the titleFormat pattern
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
21
|
+
* Format a record title using the titleFormat pattern.
|
|
22
|
+
*
|
|
23
|
+
* Empty placeholders (missing or null/empty fields) are stripped along with
|
|
24
|
+
* any orphan separator they leave behind, so a template like
|
|
25
|
+
* "{full_name} - {company}"
|
|
26
|
+
* evaluated against `{ company: "Acme" }` resolves to `"Acme"` rather than
|
|
27
|
+
* `" - Acme"`. Returns an empty string when no placeholder resolved.
|
|
25
28
|
*/
|
|
26
29
|
export declare function formatRecordTitle(titleFormat: string | undefined, record: any): string;
|
|
27
30
|
/**
|
package/dist/utils/index.js
CHANGED
|
@@ -28,24 +28,47 @@ export function capitalizeFirst(str) {
|
|
|
28
28
|
return str;
|
|
29
29
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
30
30
|
}
|
|
31
|
+
// Sentinel used to mark empty-placeholder positions inside formatRecordTitle
|
|
32
|
+
// so adjacent separators can be stripped in a second pass.
|
|
33
|
+
const EMPTY_TOKEN = '\u0000';
|
|
34
|
+
// Separator characters commonly placed between {fields} in titleFormat patterns
|
|
35
|
+
// (hyphen, em/en dashes, pipes, slashes, middle dot, comma, colon).
|
|
36
|
+
const SEPARATOR_CLASS = '[-\\u2013\\u2014|/·,:]';
|
|
31
37
|
/**
|
|
32
|
-
* Format a record title using the titleFormat pattern
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
38
|
+
* Format a record title using the titleFormat pattern.
|
|
39
|
+
*
|
|
40
|
+
* Empty placeholders (missing or null/empty fields) are stripped along with
|
|
41
|
+
* any orphan separator they leave behind, so a template like
|
|
42
|
+
* "{full_name} - {company}"
|
|
43
|
+
* evaluated against `{ company: "Acme" }` resolves to `"Acme"` rather than
|
|
44
|
+
* `" - Acme"`. Returns an empty string when no placeholder resolved.
|
|
36
45
|
*/
|
|
37
46
|
export function formatRecordTitle(titleFormat, record) {
|
|
38
47
|
if (!titleFormat || !record) {
|
|
39
48
|
return record?.id || record?._id || 'Record';
|
|
40
49
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const value = record[fieldName];
|
|
44
|
-
if (value === null || value === undefined) {
|
|
45
|
-
return
|
|
50
|
+
let anyResolved = false;
|
|
51
|
+
let out = titleFormat.replace(/\{([^{}]+)\}/g, (_match, fieldName) => {
|
|
52
|
+
const value = record[fieldName.trim()];
|
|
53
|
+
if (value === null || value === undefined || value === '') {
|
|
54
|
+
return EMPTY_TOKEN;
|
|
46
55
|
}
|
|
56
|
+
anyResolved = true;
|
|
47
57
|
return String(value);
|
|
48
58
|
});
|
|
59
|
+
if (!anyResolved)
|
|
60
|
+
return '';
|
|
61
|
+
// Drop separators on either side of an empty token, then any leftover
|
|
62
|
+
// tokens, then collapse runs of whitespace.
|
|
63
|
+
const sepBefore = new RegExp(`\\s*${SEPARATOR_CLASS}\\s*${EMPTY_TOKEN}`, 'g');
|
|
64
|
+
const sepAfter = new RegExp(`${EMPTY_TOKEN}\\s*${SEPARATOR_CLASS}\\s*`, 'g');
|
|
65
|
+
out = out
|
|
66
|
+
.replace(sepBefore, '')
|
|
67
|
+
.replace(sepAfter, '')
|
|
68
|
+
.replace(new RegExp(EMPTY_TOKEN, 'g'), '')
|
|
69
|
+
.replace(/\s+/g, ' ')
|
|
70
|
+
.trim();
|
|
71
|
+
return out;
|
|
49
72
|
}
|
|
50
73
|
/**
|
|
51
74
|
* Get display name for a record using titleFormat or fallback
|
|
@@ -55,8 +78,17 @@ export function formatRecordTitle(titleFormat, record) {
|
|
|
55
78
|
*/
|
|
56
79
|
export function getRecordDisplayName(objectDef, record) {
|
|
57
80
|
if (objectDef?.titleFormat) {
|
|
58
|
-
|
|
81
|
+
const formatted = formatRecordTitle(objectDef.titleFormat, record);
|
|
82
|
+
if (formatted)
|
|
83
|
+
return formatted;
|
|
59
84
|
}
|
|
60
|
-
|
|
61
|
-
|
|
85
|
+
return (record?.name ||
|
|
86
|
+
record?.full_name ||
|
|
87
|
+
record?.fullName ||
|
|
88
|
+
record?.title ||
|
|
89
|
+
record?.label ||
|
|
90
|
+
record?.subject ||
|
|
91
|
+
record?.id ||
|
|
92
|
+
record?._id ||
|
|
93
|
+
'Untitled');
|
|
62
94
|
}
|
|
@@ -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,149 @@
|
|
|
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
|
+
// Stabilise the existing-labels list across renders so we don't churn the
|
|
62
|
+
// `existingSet` memo (and the dependent useEffects) on every parent render.
|
|
63
|
+
// Callers commonly pass `views.map(v => v.label)` inline, which is a fresh
|
|
64
|
+
// array each render — without this normalisation, the name-suggest effect
|
|
65
|
+
// below would re-fire indefinitely and could trigger "Maximum update depth"
|
|
66
|
+
// when the array contents are stable but the reference is not.
|
|
67
|
+
const existingKey = (existingLabels ?? []).join('\u0000');
|
|
68
|
+
const existingSet = useMemo(() => new Set(existingLabels ?? []),
|
|
69
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
70
|
+
[existingKey]);
|
|
71
|
+
const fieldOptions = useMemo(() => (objectDef ? deriveFieldOptions(objectDef) : []), [objectDef]);
|
|
72
|
+
const [selectedType, setSelectedType] = useState(types[0]?.type ?? 'grid');
|
|
73
|
+
const [label, setLabel] = useState('');
|
|
74
|
+
const [touched, setTouched] = useState(false);
|
|
75
|
+
/** Map of `${type}.${fieldKey}` → selected field name. Per-type so switching
|
|
76
|
+
* view types preserves the user's earlier choices in case they switch back. */
|
|
77
|
+
const [requiredFieldValues, setRequiredFieldValues] = useState({});
|
|
78
|
+
// Reset when the dialog opens, and re-suggest name whenever type changes
|
|
79
|
+
// (only while the user hasn't manually edited it yet).
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (open) {
|
|
82
|
+
setSelectedType(types[0]?.type ?? 'grid');
|
|
83
|
+
setTouched(false);
|
|
84
|
+
setRequiredFieldValues({});
|
|
85
|
+
}
|
|
86
|
+
}, [open, types]);
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (!touched) {
|
|
89
|
+
const meta = types.find(v => v.type === selectedType);
|
|
90
|
+
setLabel(suggestName(meta?.label ?? 'View', existingSet));
|
|
91
|
+
}
|
|
92
|
+
}, [selectedType, touched, types, existingSet]);
|
|
93
|
+
// Required fields for the currently selected type
|
|
94
|
+
const requiredFields = REQUIRED_FIELDS_BY_TYPE[selectedType] ?? [];
|
|
95
|
+
const getRequiredValue = (key) => requiredFieldValues[`${selectedType}.${key}`] ?? '';
|
|
96
|
+
const setRequiredValue = (key, value) => setRequiredFieldValues(prev => ({ ...prev, [`${selectedType}.${key}`]: value }));
|
|
97
|
+
const trimmed = label.trim();
|
|
98
|
+
const isDuplicate = trimmed.length > 0 && existingSet.has(trimmed);
|
|
99
|
+
const allRequiredFilled = requiredFields.every(f => getRequiredValue(f.key).length > 0);
|
|
100
|
+
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.
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (requiredFields.length === 0 || fieldOptions.length === 0)
|
|
106
|
+
return;
|
|
107
|
+
requiredFields.forEach((rf) => {
|
|
108
|
+
if (getRequiredValue(rf.key).length > 0)
|
|
109
|
+
return;
|
|
110
|
+
const eligible = rf.filter ? fieldOptions.filter(rf.filter) : fieldOptions;
|
|
111
|
+
if (eligible.length === 1)
|
|
112
|
+
setRequiredValue(rf.key, eligible[0].value);
|
|
113
|
+
});
|
|
114
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
115
|
+
}, [selectedType, fieldOptions]);
|
|
116
|
+
const handleSubmit = () => {
|
|
117
|
+
if (!canSubmit)
|
|
118
|
+
return;
|
|
119
|
+
// Bundle required fields under their type-specific sub-key, matching the
|
|
120
|
+
// NamedListView spec (e.g. { type: "kanban", kanban: { groupByField: ... } })
|
|
121
|
+
const subConfig = {};
|
|
122
|
+
requiredFields.forEach((rf) => {
|
|
123
|
+
const v = getRequiredValue(rf.key);
|
|
124
|
+
if (v)
|
|
125
|
+
subConfig[rf.key] = v;
|
|
126
|
+
});
|
|
127
|
+
const payload = {
|
|
128
|
+
type: selectedType,
|
|
129
|
+
label: trimmed,
|
|
130
|
+
};
|
|
131
|
+
if (Object.keys(subConfig).length > 0) {
|
|
132
|
+
payload[selectedType] = subConfig;
|
|
133
|
+
}
|
|
134
|
+
onCreate(payload);
|
|
135
|
+
onOpenChange(false);
|
|
136
|
+
};
|
|
137
|
+
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
|
+
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
|
|
140
|
+
? '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) => {
|
|
143
|
+
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));
|
|
147
|
+
}) })), _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
|
+
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
|
+
}
|