@object-ui/app-shell 4.0.0 → 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 CHANGED
@@ -1,5 +1,62 @@
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
+
3
60
  ## 4.0.0
4
61
 
5
62
  ### Patch Changes
@@ -181,7 +181,14 @@ export function AppHeader({ variant, appName, objects, connectionState, presence
181
181
  extraSegments.push({ label: displayTitle || `#${shortId}` });
182
182
  }
183
183
  else if (pathParts[3] === 'view' && pathParts[4]) {
184
- extraSegments.push({ label: humanizeSlug(pathParts[4]) });
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 });
185
192
  }
186
193
  }
187
194
  }
@@ -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
- * @param titleFormat Pattern like "{name} - {email}" or "{firstName} {lastName}"
23
- * @param record The record data object
24
- * @returns Formatted title string
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
  /**
@@ -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
- * @param titleFormat Pattern like "{name} - {email}" or "{firstName} {lastName}"
34
- * @param record The record data object
35
- * @returns Formatted title string
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
- // Replace {fieldName} patterns with actual values
42
- return titleFormat.replace(/\{(\w+)\}/g, (_match, fieldName) => {
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
- return formatRecordTitle(objectDef.titleFormat, record);
81
+ const formatted = formatRecordTitle(objectDef.titleFormat, record);
82
+ if (formatted)
83
+ return formatted;
59
84
  }
60
- // Fallback: Try common name fields
61
- return record?.name || record?.title || record?.label || record?.id || record?._id || 'Untitled';
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
  }
@@ -58,7 +58,16 @@ export function CreateViewDialog({ open, onOpenChange, onCreate, existingLabels,
58
58
  const types = useMemo(() => (availableTypes && availableTypes.length > 0
59
59
  ? allTypes.filter(v => availableTypes.includes(v.type))
60
60
  : allTypes), [allTypes, availableTypes]);
61
- const existingSet = useMemo(() => new Set(existingLabels ?? []), [existingLabels]);
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]);
62
71
  const fieldOptions = useMemo(() => (objectDef ? deriveFieldOptions(objectDef) : []), [objectDef]);
63
72
  const [selectedType, setSelectedType] = useState(types[0]?.type ?? 'grid');
64
73
  const [label, setLabel] = useState('');
@@ -634,14 +634,17 @@ export function ObjectView({ dataSource, objects, onEdit }) {
634
634
  const handleSetDefaultView = useCallback(async (vid) => {
635
635
  if (!dataSource?.update)
636
636
  return;
637
+ if (!isSavedView(vid)) {
638
+ toast.error(t('console.objectView.cannotEditMetaView')
639
+ || 'System view — duplicate it to mark a default.');
640
+ return;
641
+ }
637
642
  try {
638
643
  // Clear `isDefault` on all other saved views, then set this one.
639
644
  const updates = savedViews
640
645
  .filter((sv) => (sv.id || sv._id) !== vid && sv.isDefault)
641
646
  .map((sv) => dataSource.update('sys_view', sv.id || sv._id, { isDefault: false }));
642
- if (isSavedView(vid)) {
643
- updates.push(dataSource.update('sys_view', vid, { isDefault: true }));
644
- }
647
+ updates.push(dataSource.update('sys_view', vid, { isDefault: true }));
645
648
  await Promise.all(updates);
646
649
  setRefreshKey(k => k + 1);
647
650
  }
@@ -649,7 +652,7 @@ export function ObjectView({ dataSource, objects, onEdit }) {
649
652
  console.error('[ViewTabBar] Failed to set default view:', err);
650
653
  toast.error('Failed to set default view');
651
654
  }
652
- }, [dataSource, savedViews, isSavedView]);
655
+ }, [dataSource, savedViews, isSavedView, t]);
653
656
  const handleReorderViews = useCallback(async (orderedIds) => {
654
657
  // Persist order for ALL views (incl. metadata) in localStorage so the
655
658
  // UI immediately reflects the new ordering, including reorderings
@@ -677,11 +680,19 @@ export function ObjectView({ dataSource, objects, onEdit }) {
677
680
  setRefreshKey(k => k + 1);
678
681
  }, [dataSource, savedViews, objectName]);
679
682
  const handleConfigView = useCallback((vid) => {
683
+ // System (metadata-defined) views are read-only — opening the
684
+ // ViewConfigPanel against one would let the user save changes that
685
+ // never persist. Steer them to "Duplicate view" instead.
686
+ if (!isSavedView(vid)) {
687
+ toast.error(t('console.objectView.cannotEditMetaView')
688
+ || 'System view — duplicate it to make changes.');
689
+ return;
690
+ }
680
691
  if (vid !== activeViewId)
681
692
  handleViewChange(vid);
682
693
  setViewConfigPanelMode('edit');
683
694
  setShowViewConfigPanel(true);
684
- }, [activeViewId, handleViewChange]);
695
+ }, [activeViewId, handleViewChange, isSavedView, t]);
685
696
  const handleAddView = useCallback(() => {
686
697
  setShowCreateViewDialog(true);
687
698
  }, []);
@@ -1001,7 +1012,13 @@ export function ObjectView({ dataSource, objects, onEdit }) {
1001
1012
  center: viewDef.map?.center,
1002
1013
  },
1003
1014
  gallery: {
1004
- imageField: viewDef.gallery?.imageField || 'image',
1015
+ // Spread the full view-defined gallery first so spec
1016
+ // fields (cardSize, visibleFields, coverField, coverFit)
1017
+ // make it through; then layer legacy field aliases that
1018
+ // ObjectGallery still consults.
1019
+ ...(viewDef.gallery || {}),
1020
+ imageField: viewDef.gallery?.imageField || viewDef.gallery?.coverField || 'image',
1021
+ coverField: viewDef.gallery?.coverField || viewDef.gallery?.imageField,
1005
1022
  titleField: viewDef.gallery?.titleField || objectDef.titleField || 'name',
1006
1023
  subtitleField: viewDef.gallery?.subtitleField,
1007
1024
  },
@@ -1072,6 +1089,10 @@ export function ObjectView({ dataSource, objects, onEdit }) {
1072
1089
  } }))] })] }), views.length >= 1 && (() => {
1073
1090
  const viewTabItems = views.map((view) => {
1074
1091
  const saved = savedViews.find((sv) => (sv.id || sv._id) === view.id);
1092
+ // System views (loaded from objectDef.listViews / metadata) are
1093
+ // *read-only*. Only sys_view-backed records can be mutated by
1094
+ // the user; admins must duplicate a system view to customize it.
1095
+ const isSystem = !saved;
1075
1096
  return {
1076
1097
  id: view.id,
1077
1098
  label: view.label,
@@ -1081,6 +1102,11 @@ export function ObjectView({ dataSource, objects, onEdit }) {
1081
1102
  isDefault: !!(saved?.isDefault ?? view.isDefault),
1082
1103
  isPinned: !!(saved?.isPinned ?? view.isPinned),
1083
1104
  visibility: saved?.visibility ?? view.visibility,
1105
+ readonly: isSystem,
1106
+ readonlyReason: isSystem
1107
+ ? (t('console.objectView.systemViewReadonly')
1108
+ || 'System view defined in code — duplicate to customize.')
1109
+ : undefined,
1084
1110
  };
1085
1111
  });
1086
1112
  return (_jsxs("div", { className: "border-b px-3 sm:px-4 bg-background overflow-x-auto shrink-0", children: [_jsx(ViewTabBar, { views: viewTabItems, activeViewId: activeViewId, onViewChange: handleViewChange, viewTypeIcons: VIEW_TYPE_ICONS, config: {
@@ -20,6 +20,7 @@ import { SkeletonDetail } from '../skeletons';
20
20
  import { ActionConfirmDialog } from './ActionConfirmDialog';
21
21
  import { ActionParamDialog } from './ActionParamDialog';
22
22
  import { useRecordBreadcrumbTitle } from '../context/NavigationContext';
23
+ import { getRecordDisplayName } from '../utils';
23
24
  const FALLBACK_USER = { id: 'current-user', name: 'Demo User' };
24
25
  export function RecordDetailView({ dataSource, objects, onEdit }) {
25
26
  const { objectName, recordId } = useParams();
@@ -477,17 +478,13 @@ export function RecordDetailView({ dataSource, objects, onEdit }) {
477
478
  return (_jsxs("div", { className: "h-full bg-background overflow-hidden flex flex-col relative", children: [_jsx("div", { className: "absolute top-2 sm:top-4 right-2 sm:right-4 z-50 flex items-center gap-2", children: recordViewers.length > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", title: "Users viewing this record", children: [_jsx(Users, { className: "h-3.5 w-3.5 text-muted-foreground" }), _jsx(PresenceAvatars, { users: recordViewers, size: "sm", maxVisible: 4, showStatus: true })] })) }), _jsxs("div", { className: "flex-1 overflow-hidden flex flex-row", children: [_jsx("div", { className: "flex-1 overflow-auto p-3 sm:p-4 lg:p-6 scroll-pb-48", children: _jsx(ActionProvider, { context: { record: {}, objectName, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler }, children: _jsx(DetailView, { schema: detailSchema, dataSource: dataSource, objectLabel: objectDef.label, onDataLoaded: (record) => {
478
479
  if (!record || typeof record !== 'object')
479
480
  return;
480
- const primary = detailSchema.primaryField;
481
- const candidates = [
482
- primary && record[primary],
483
- record.name,
484
- record.title,
485
- record.label,
486
- record.subject,
487
- ];
488
- const next = candidates.find((v) => typeof v === 'string' && v.trim().length > 0);
489
- if (next && next !== recordTitle)
490
- setRecordTitle(next);
481
+ // Resolve the same way DetailView's header does, so the
482
+ // breadcrumb matches the on-page title (e.g. "David Kim"
483
+ // instead of "#lead-1778…").
484
+ const resolved = getRecordDisplayName(objectDef, record);
485
+ if (resolved && resolved !== recordTitle && resolved !== 'Untitled') {
486
+ setRecordTitle(resolved);
487
+ }
491
488
  }, onEdit: () => {
492
489
  onEdit({ id: pureRecordId });
493
490
  }, discussionSlot: _jsx(RecordChatterPanel, { config: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/app-shell",
3
- "version": "4.0.0",
3
+ "version": "4.0.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Minimal application shell for ObjectUI - framework-agnostic rendering engine",
@@ -27,34 +27,34 @@
27
27
  "dependencies": {
28
28
  "lucide-react": "^1.14.0",
29
29
  "sonner": "^2.0.7",
30
- "@object-ui/auth": "3.4.0",
31
- "@object-ui/collaboration": "3.4.0",
32
- "@object-ui/components": "3.4.0",
33
- "@object-ui/core": "3.4.0",
34
- "@object-ui/data-objectstack": "3.4.0",
35
- "@object-ui/fields": "3.4.0",
36
- "@object-ui/i18n": "3.4.0",
37
- "@object-ui/layout": "3.4.0",
38
- "@object-ui/permissions": "3.4.0",
39
- "@object-ui/react": "3.4.0",
40
- "@object-ui/types": "3.4.0"
30
+ "@object-ui/auth": "4.0.1",
31
+ "@object-ui/collaboration": "4.0.1",
32
+ "@object-ui/components": "4.0.1",
33
+ "@object-ui/core": "4.0.1",
34
+ "@object-ui/data-objectstack": "4.0.1",
35
+ "@object-ui/fields": "4.0.1",
36
+ "@object-ui/i18n": "4.0.1",
37
+ "@object-ui/layout": "4.0.1",
38
+ "@object-ui/permissions": "4.0.1",
39
+ "@object-ui/react": "4.0.1",
40
+ "@object-ui/types": "4.0.1"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "react": "^18.0.0 || ^19.0.0",
44
44
  "react-dom": "^18.0.0 || ^19.0.0",
45
45
  "react-router-dom": "^6.0.0 || ^7.0.0",
46
- "@object-ui/plugin-calendar": "3.4.0",
47
- "@object-ui/plugin-charts": "3.4.0",
48
- "@object-ui/plugin-chatbot": "3.4.0",
49
- "@object-ui/plugin-dashboard": "3.4.0",
50
- "@object-ui/plugin-designer": "3.4.0",
51
- "@object-ui/plugin-detail": "3.4.0",
52
- "@object-ui/plugin-form": "3.4.0",
53
- "@object-ui/plugin-grid": "3.4.0",
54
- "@object-ui/plugin-kanban": "3.4.0",
55
- "@object-ui/plugin-list": "3.4.0",
56
- "@object-ui/plugin-report": "3.4.0",
57
- "@object-ui/plugin-view": "3.4.0"
46
+ "@object-ui/plugin-calendar": "4.0.1",
47
+ "@object-ui/plugin-charts": "4.0.1",
48
+ "@object-ui/plugin-chatbot": "4.0.1",
49
+ "@object-ui/plugin-dashboard": "4.0.1",
50
+ "@object-ui/plugin-designer": "4.0.1",
51
+ "@object-ui/plugin-detail": "4.0.1",
52
+ "@object-ui/plugin-form": "4.0.1",
53
+ "@object-ui/plugin-grid": "4.0.1",
54
+ "@object-ui/plugin-kanban": "4.0.1",
55
+ "@object-ui/plugin-list": "4.0.1",
56
+ "@object-ui/plugin-report": "4.0.1",
57
+ "@object-ui/plugin-view": "4.0.1"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^25.6.0",