@object-ui/app-shell 5.0.2 → 5.2.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +401 -0
  2. package/dist/chrome/CommandPalette.d.ts +6 -1
  3. package/dist/chrome/CommandPalette.js +40 -4
  4. package/dist/chrome/ConsoleToaster.js +8 -1
  5. package/dist/chrome/RouteFader.d.ts +32 -0
  6. package/dist/chrome/RouteFader.js +52 -0
  7. package/dist/chrome/index.d.ts +2 -0
  8. package/dist/chrome/index.js +2 -0
  9. package/dist/chrome/toast-helpers.d.ts +48 -0
  10. package/dist/chrome/toast-helpers.js +59 -0
  11. package/dist/console/AppContent.js +108 -2
  12. package/dist/console/home/HomePage.js +5 -4
  13. package/dist/console/marketplace/MarketplaceInstalledPage.d.ts +17 -0
  14. package/dist/console/marketplace/MarketplaceInstalledPage.js +64 -0
  15. package/dist/console/marketplace/MarketplacePackagePage.d.ts +7 -0
  16. package/dist/console/marketplace/MarketplacePackagePage.js +199 -0
  17. package/dist/console/marketplace/MarketplacePage.d.ts +8 -0
  18. package/dist/console/marketplace/MarketplacePage.js +94 -0
  19. package/dist/console/marketplace/PackageIcon.d.ts +19 -0
  20. package/dist/console/marketplace/PackageIcon.js +17 -0
  21. package/dist/console/marketplace/marketplaceApi.d.ts +122 -0
  22. package/dist/console/marketplace/marketplaceApi.js +207 -0
  23. package/dist/index.d.ts +5 -6
  24. package/dist/index.js +4 -5
  25. package/dist/layout/AppHeader.js +25 -21
  26. package/dist/layout/AppSidebar.js +15 -11
  27. package/dist/layout/InboxPopover.js +43 -3
  28. package/dist/types.d.ts +0 -46
  29. package/dist/views/ObjectView.js +20 -7
  30. package/dist/views/RecordDetailView.js +213 -45
  31. package/package.json +25 -25
  32. package/src/styles.css +49 -0
  33. package/dist/components/DashboardRenderer.d.ts +0 -8
  34. package/dist/components/DashboardRenderer.js +0 -16
  35. package/dist/components/FormRenderer.d.ts +0 -8
  36. package/dist/components/FormRenderer.js +0 -31
  37. package/dist/components/ObjectRenderer.d.ts +0 -8
  38. package/dist/components/ObjectRenderer.js +0 -74
  39. package/dist/components/PageRenderer.d.ts +0 -7
  40. package/dist/components/PageRenderer.js +0 -14
@@ -7,15 +7,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
7
7
  * the object field definitions.
8
8
  */
9
9
  import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
10
- import { useParams, useNavigate } from 'react-router-dom';
11
- import { DetailView, RecordChatterPanel, buildDefaultPageSchema } from '@object-ui/plugin-detail';
10
+ import { useParams, useNavigate, useLocation, Link } from 'react-router-dom';
11
+ import { DetailView, RecordChatterPanel, buildDefaultPageSchema, extractMentions } from '@object-ui/plugin-detail';
12
12
  import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
13
- import { PresenceAvatars } from '@object-ui/collaboration';
14
13
  import { useAuth, createAuthenticatedFetch } from '@object-ui/auth';
15
14
  import { ActionProvider, useObjectTranslation, useObjectLabel, usePageAssignment, RecordContextProvider, SchemaRenderer, DiscussionContextProvider, HighlightFieldsProvider } from '@object-ui/react';
16
15
  import { buildExpandFields } from '@object-ui/core';
17
16
  import { toast } from 'sonner';
18
- import { Database, Users } from 'lucide-react';
17
+ import { useRecordPresence, PresenceAvatars } from '@object-ui/collaboration';
18
+ import { Database, ChevronLeft } from 'lucide-react';
19
19
  import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
20
20
  import { SkeletonDetail } from '../skeletons';
21
21
  import { ManagedByBadge } from '../components/ManagedByBadge';
@@ -50,6 +50,24 @@ const AUDIT_FIELD_NAMES = new Set(['created_at', 'created_by', 'updated_at', 'up
50
50
  const HIDDEN_SYSTEM_FIELD_NAMES = new Set([
51
51
  'organization_id', 'tenant_id', 'is_deleted', 'deleted_at',
52
52
  ]);
53
+ /**
54
+ * Field-type signals that suggest a "secondary / system / metadata"
55
+ * placement when auto-grouping fields. These move out of the main
56
+ * section and into a collapsible "More details" section by default,
57
+ * keeping the primary section dense with business-critical fields.
58
+ *
59
+ * The heuristic is conservative: when no objectDef metadata is available
60
+ * we surface most fields in the main section; long-form text and
61
+ * audit-by-name fields drop down.
62
+ */
63
+ const SECONDARY_FIELD_NAME_HINTS = ['description', 'notes', 'note', 'remark', 'remarks', 'comments'];
64
+ const SECONDARY_FIELD_TYPES = new Set(['textarea', 'markdown', 'html', 'rich-text', 'json', 'code']);
65
+ function isSecondaryField(fieldName, fieldDef) {
66
+ if (SECONDARY_FIELD_TYPES.has(fieldDef?.type))
67
+ return true;
68
+ const lc = fieldName.toLowerCase();
69
+ return SECONDARY_FIELD_NAME_HINTS.some((hint) => lc === hint || lc.endsWith(`_${hint}`));
70
+ }
53
71
  export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverride, recordIdOverride, embedded }) {
54
72
  const params = useParams();
55
73
  const appName = params.appName;
@@ -58,13 +76,15 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
58
76
  const { showDebug } = useMetadataInspector();
59
77
  const { user } = useAuth();
60
78
  const navigate = useNavigate();
79
+ const location = useLocation();
80
+ const originFrom = location.state?.from;
61
81
  const { t } = useObjectTranslation();
62
82
  const { objectLabel, viewLabel: _vLabel, sectionLabel, actionLabel, actionConfirm, actionSuccess, fieldLabel, fieldOptionLabel } = useObjectLabel();
63
83
  const { isFavorite, toggleFavorite, refreshLabel: refreshFavoriteLabel } = useFavorites();
64
84
  const { addRecentItem } = useRecentItems();
65
85
  const [isLoading, setIsLoading] = useState(true);
66
86
  const [feedItems, setFeedItems] = useState([]);
67
- const [recordViewers, setRecordViewers] = useState([]);
87
+ const [mentionSuggestions, setMentionSuggestions] = useState([]);
68
88
  const [actionRefreshKey, setActionRefreshKey] = useState(0);
69
89
  const [childRelatedData, setChildRelatedData] = useState({});
70
90
  const [historyEntries, setHistoryEntries] = useState(null);
@@ -79,6 +99,13 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
79
99
  // Navigation code passes `record.id || record._id` directly into the URL
80
100
  // without adding any prefix, so no stripping is needed.
81
101
  const pureRecordId = recordId;
102
+ // Record-scoped presence ("who else is viewing this record"). The default
103
+ // PresenceProvider source is a no-op, so this resolves to `[]` until a
104
+ // realtime transport (WebSocket-backed source) is wired in by the host
105
+ // app — see `@object-ui/collaboration`'s `<PresenceProvider>`. The
106
+ // PresenceAvatars row is hidden when the array is empty, so the
107
+ // affordance is invisible until the transport lights up.
108
+ const recordPresence = useRecordPresence(objectName, pureRecordId);
82
109
  const favoriteRecord = useMemo(() => {
83
110
  if (!objectName || !pureRecordId)
84
111
  return null;
@@ -140,10 +167,16 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
140
167
  }, [renderViaSchemaFlag, assignedPage, assignedSlots, objectDef]);
141
168
  const effectivePage = assignedPage || synthesizedPage;
142
169
  const [pageRecord, setPageRecord] = useState(null);
170
+ // 'idle' | 'loading' | 'loaded' | 'missing' — distinguishes "haven't
171
+ // tried yet" from "tried and the record really doesn't exist". The
172
+ // not-found short-circuit below uses `missing` to render a clean empty
173
+ // state instead of a half-broken page chrome (rail + discussion).
174
+ const [pageRecordStatus, setPageRecordStatus] = useState('idle');
143
175
  useEffect(() => {
144
176
  let cancelled = false;
145
177
  if (!effectivePage || !pureRecordId || !objectName || !dataSource?.findOne) {
146
178
  setPageRecord(null);
179
+ setPageRecordStatus('idle');
147
180
  return;
148
181
  }
149
182
  // Expand lookup/master_detail fields so the page receives display
@@ -152,17 +185,28 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
152
185
  const expandFields = buildExpandFields(objectDef?.fields);
153
186
  const params = expandFields.length > 0 ? { $expand: expandFields } : undefined;
154
187
  const loadRecord = () => {
188
+ setPageRecordStatus('loading');
155
189
  const findOnePromise = params
156
190
  ? dataSource.findOne(objectName, pureRecordId, params)
157
191
  : dataSource.findOne(objectName, pureRecordId);
158
192
  findOnePromise
159
193
  .then((rec) => {
160
- if (!cancelled)
194
+ if (cancelled)
195
+ return;
196
+ if (rec && typeof rec === 'object') {
161
197
  setPageRecord(rec);
198
+ setPageRecordStatus('loaded');
199
+ }
200
+ else {
201
+ setPageRecord(null);
202
+ setPageRecordStatus('missing');
203
+ }
162
204
  })
163
205
  .catch(() => {
164
- if (!cancelled)
165
- setPageRecord(null);
206
+ if (cancelled)
207
+ return;
208
+ setPageRecord(null);
209
+ setPageRecordStatus('missing');
166
210
  });
167
211
  };
168
212
  loadRecord();
@@ -553,20 +597,74 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
553
597
  });
554
598
  return () => { cancelled = true; };
555
599
  }, [dataSource, pureRecordId, objectDef, historyEnabled]);
600
+ // Fetch a directory of active users once per dataSource mount and expose
601
+ // them as @-mention suggestions to the DiscussionContext. Capped at 50 to
602
+ // keep the dropdown tight; hosts can swap in a paginated/server-search
603
+ // implementation later by mounting their own DiscussionContextProvider.
604
+ useEffect(() => {
605
+ if (!dataSource)
606
+ return;
607
+ let cancelled = false;
608
+ // Always seed with the current user so the @-mention dropdown shows at
609
+ // least one entry even when the backend has no sys_user directory.
610
+ // Hosts wanting a richer roster should provide it via dataSource.
611
+ const selfSuggestion = user
612
+ ? [{
613
+ id: String(user.id),
614
+ label: user.name || user.email || String(user.id),
615
+ avatarUrl: user.image || undefined,
616
+ }]
617
+ : [];
618
+ (async () => {
619
+ try {
620
+ const res = await dataSource.find('sys_user', {
621
+ $top: 50,
622
+ $select: ['id', 'name', 'email', 'image'],
623
+ });
624
+ if (cancelled)
625
+ return;
626
+ const rows = Array.isArray(res) ? res : res?.data || [];
627
+ const fetched = rows
628
+ .map((u) => ({
629
+ id: String(u.id),
630
+ label: u.name || u.email || String(u.id),
631
+ avatarUrl: u.image || undefined,
632
+ }))
633
+ .filter((s) => s.label);
634
+ // Merge: current user first, then directory (de-duped by id).
635
+ const seen = new Set(selfSuggestion.map((s) => s.id));
636
+ const merged = [
637
+ ...selfSuggestion,
638
+ ...fetched.filter((s) => !seen.has(s.id)),
639
+ ];
640
+ setMentionSuggestions(merged.length > 0 ? merged : selfSuggestion);
641
+ }
642
+ catch {
643
+ if (cancelled)
644
+ return;
645
+ // Fall back to just the current user so mentions still work.
646
+ setMentionSuggestions(selfSuggestion);
647
+ }
648
+ })();
649
+ return () => { cancelled = true; };
650
+ }, [dataSource, user?.id, user?.name, user?.email]);
556
651
  // Memoize so the object identity is stable across renders — otherwise
557
652
  // any effect that depends on it (e.g. the feed loader below) would
558
653
  // re-fire every render and create an infinite request loop.
559
654
  const currentUser = useMemo(() => (user ? { id: user.id, name: user.name, avatar: user.image } : FALLBACK_USER), [user?.id, user?.name, user?.image]);
560
- // Fetch presence and comments from API
655
+ // Fetch comments from API.
656
+ //
657
+ // NOTE: Record-level presence ("who else is viewing this record") used to
658
+ // be probed here by `dataSource.find('sys_presence', …)`, but that was an
659
+ // architectural mistake: presence is real-time ephemeral state and does
660
+ // not belong in a regular REST collection. The probe has been removed
661
+ // pending a proper transport-level design (WebSocket-backed
662
+ // `<PresenceProvider>` in @object-ui/collaboration). See ROADMAP for the
663
+ // realtime / OCC plan.
561
664
  useEffect(() => {
562
665
  if (!dataSource || !objectName || !pureRecordId)
563
666
  return;
564
667
  const threadId = `${objectName}:${pureRecordId}`;
565
- // Fetch record viewers
566
- dataSource.find('sys_presence', { $filter: { recordId: pureRecordId } })
567
- .then((res) => { if (res.data?.length)
568
- setRecordViewers(res.data); })
569
- .catch(() => { });
570
668
  // M10.10: Fetch persisted comments from sys_comment. Field names
571
669
  // are snake_case to match the platform-objects schema
572
670
  // (`packages/platform-objects/src/audit/sys-comment.object.ts`):
@@ -705,6 +803,15 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
705
803
  })
706
804
  .catch(() => { });
707
805
  }, [dataSource, objectName, pureRecordId, currentUser]);
806
+ /**
807
+ * Note: comment-mention → notification fan-out lives on the server
808
+ * (`@objectstack/plugin-audit` registers a `sys_comment` afterInsert
809
+ * hook that parses the `mentions` JSON and writes one `sys_notification`
810
+ * row per recipient). The client's only job is to ensure
811
+ * `sys_comment.mentions` carries the real id list (see handleAddComment
812
+ * /handleAddReply below). Deployments without plugin-audit will not
813
+ * deliver bell notifications, which is the expected degradation.
814
+ */
708
815
  const handleAddComment = useCallback(async (text) => {
709
816
  const newItem = {
710
817
  id: crypto.randomUUID(),
@@ -718,6 +825,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
718
825
  // Persist to backend (M10.10: snake_case fields per sys_comment schema)
719
826
  if (dataSource) {
720
827
  const threadId = `${objectName}:${pureRecordId}`;
828
+ const mentionIds = extractMentions(text, mentionSuggestions);
721
829
  dataSource.create('sys_comment', {
722
830
  id: newItem.id,
723
831
  thread_id: threadId,
@@ -725,11 +833,11 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
725
833
  author_name: currentUser.name,
726
834
  author_avatar_url: 'avatar' in currentUser ? currentUser.avatar : undefined,
727
835
  body: text,
728
- mentions: '[]',
836
+ mentions: JSON.stringify(mentionIds),
729
837
  created_at: newItem.createdAt,
730
838
  }).catch(() => { });
731
839
  }
732
- }, [currentUser, dataSource, objectName, pureRecordId]);
840
+ }, [currentUser, dataSource, objectName, pureRecordId, mentionSuggestions]);
733
841
  const handleAddReply = useCallback(async (parentId, text) => {
734
842
  const newItem = {
735
843
  id: crypto.randomUUID(),
@@ -749,6 +857,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
749
857
  });
750
858
  if (dataSource) {
751
859
  const threadId = `${objectName}:${pureRecordId}`;
860
+ const mentionIds = extractMentions(text, mentionSuggestions);
752
861
  dataSource.create('sys_comment', {
753
862
  id: newItem.id,
754
863
  thread_id: threadId,
@@ -756,12 +865,12 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
756
865
  author_name: currentUser.name,
757
866
  author_avatar_url: 'avatar' in currentUser ? currentUser.avatar : undefined,
758
867
  body: text,
759
- mentions: '[]',
868
+ mentions: JSON.stringify(mentionIds),
760
869
  created_at: newItem.createdAt,
761
870
  parent_id: parentId,
762
871
  }).catch(() => { });
763
872
  }
764
- }, [currentUser, dataSource, objectName, pureRecordId]);
873
+ }, [currentUser, dataSource, objectName, pureRecordId, mentionSuggestions]);
765
874
  const handleToggleReaction = useCallback((itemId, emoji) => {
766
875
  setFeedItems(prev => prev.map(item => {
767
876
  if (item.id !== itemId)
@@ -867,29 +976,55 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
867
976
  };
868
977
  }),
869
978
  }))
870
- : [
871
- {
872
- // Intentionally untitled: when there's only one auto-generated
873
- // section, DetailSection flattens it (no Card chrome, no
874
- // redundant "Details" heading).
875
- showBorder: false,
876
- fields: Object.keys(objectDef.fields || {})
877
- .filter(key => !AUDIT_FIELD_NAMES.has(key) && !HIDDEN_SYSTEM_FIELD_NAMES.has(key) && !objectDef.fields[key]?.hidden)
878
- .map(key => {
879
- const fieldDef = objectDef.fields[key];
880
- const refTarget = fieldDef.reference_to || fieldDef.reference;
881
- return {
882
- name: key,
883
- label: fieldDef.label || key,
884
- type: fieldDef.type || 'text',
885
- ...(fieldDef.options && { options: fieldDef.options }),
886
- ...(refTarget && { reference_to: refTarget }),
887
- ...(fieldDef.reference_field && { reference_field: fieldDef.reference_field }),
888
- ...(fieldDef.currency && { currency: fieldDef.currency }),
889
- };
890
- }),
891
- },
892
- ];
979
+ : (() => {
980
+ // Auto-grouping (platform B): when no form sections are authored,
981
+ // split fields into a primary section and a collapsible
982
+ // "More details" section so long-form/secondary fields don't
983
+ // dilute the main grid. The primary section stays untitled so
984
+ // DetailSection still flattens its chrome when alone.
985
+ const allFields = Object.keys(objectDef.fields || {})
986
+ .filter((key) => !AUDIT_FIELD_NAMES.has(key) && !HIDDEN_SYSTEM_FIELD_NAMES.has(key) && !objectDef.fields[key]?.hidden);
987
+ const toField = (key) => {
988
+ const fieldDef = objectDef.fields[key];
989
+ const refTarget = fieldDef.reference_to || fieldDef.reference;
990
+ return {
991
+ name: key,
992
+ label: fieldDef.label || key,
993
+ type: fieldDef.type || 'text',
994
+ ...(fieldDef.options && { options: fieldDef.options }),
995
+ ...(refTarget && { reference_to: refTarget }),
996
+ ...(fieldDef.reference_field && { reference_field: fieldDef.reference_field }),
997
+ ...(fieldDef.currency && { currency: fieldDef.currency }),
998
+ };
999
+ };
1000
+ const primaryKeys = allFields.filter((k) => !isSecondaryField(k, objectDef.fields[k]));
1001
+ const secondaryKeys = allFields.filter((k) => isSecondaryField(k, objectDef.fields[k]));
1002
+ // Below ~6 primary fields the second section often looks awkward
1003
+ // — keep the legacy single-untitled-section behaviour. Also
1004
+ // honour the "no secondary fields" case the same way.
1005
+ if (secondaryKeys.length === 0 || primaryKeys.length === 0) {
1006
+ return [
1007
+ {
1008
+ showBorder: false,
1009
+ fields: allFields.map(toField),
1010
+ },
1011
+ ];
1012
+ }
1013
+ return [
1014
+ {
1015
+ showBorder: false,
1016
+ fields: primaryKeys.map(toField),
1017
+ },
1018
+ {
1019
+ name: 'details',
1020
+ title: sectionLabel(objectDef.name, 'details', t('detail.sectionMoreDetails', 'More details')),
1021
+ collapsible: true,
1022
+ defaultCollapsed: false,
1023
+ showBorder: true,
1024
+ fields: secondaryKeys.map(toField),
1025
+ },
1026
+ ];
1027
+ })();
893
1028
  // Audit fields (created_at/created_by/updated_at/updated_by) are NOT
894
1029
  // appended as a section here — they are surfaced by `<RecordMetaFooter>`
895
1030
  // (rendered by DetailView) as a single subtle line below the content,
@@ -1106,6 +1241,16 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1106
1241
  if (!objectDef) {
1107
1242
  return (_jsx("div", { className: "flex h-full items-center justify-center p-4", 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(Database, { className: "h-6 w-6 text-muted-foreground" }) }), _jsx(EmptyTitle, { children: t('empty.objectNotFound') }), _jsx(EmptyDescription, { children: t('empty.objectNotFoundDescription', { name: objectName }) })] }) }));
1108
1243
  }
1244
+ // Record-not-found short-circuit. Previously we rendered the page chrome
1245
+ // (rail, discussion, breadcrumb with the truncated raw id) even when the
1246
+ // record didn't exist, which made invalid links look like a partially-
1247
+ // broken page instead of a clean 404. Only triggers on the synth/page
1248
+ // path; the legacy DetailView path handles missing records itself.
1249
+ if (effectivePage && pageRecordStatus === 'missing') {
1250
+ return (_jsx("div", { className: "flex h-full items-center justify-center p-4", 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(Database, { className: "h-6 w-6 text-muted-foreground" }) }), _jsx(EmptyTitle, { children: t('empty.recordNotFound', { defaultValue: 'Record not found' }) }), _jsx(EmptyDescription, { children: t('empty.recordNotFoundDescription', {
1251
+ defaultValue: 'The record you are looking for does not exist or may have been deleted.',
1252
+ }) })] }) }));
1253
+ }
1109
1254
  if (effectivePage) {
1110
1255
  const disableDiscussion = effectivePage?.disableDiscussion === true;
1111
1256
  // When the page schema embeds an explicit `record:discussion` /
@@ -1143,6 +1288,24 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1143
1288
  const affordances = resolveCrudAffordances(objectDef);
1144
1289
  const items = [];
1145
1290
  if (affordances.edit) {
1291
+ // Inline-edit toggle. Surfaced ABOVE `sys_edit` so the
1292
+ // overflow menu lists field-level editing first — Lightning /
1293
+ // HubSpot put inline edit ("Edit details") above the modal /
1294
+ // form-page edit because in-page editing is the higher-frequency
1295
+ // interaction. Communicates with DetailView via a window event
1296
+ // so we don't need to lift inline-edit state out of the plugin.
1297
+ items.push({
1298
+ name: 'sys_inline_edit',
1299
+ label: t('detail.editFieldsInline', { defaultValue: 'Edit fields' }),
1300
+ type: 'script',
1301
+ locations: ['record_header'],
1302
+ variant: 'outline',
1303
+ onClick: () => {
1304
+ window.dispatchEvent(new CustomEvent('objectui:record:inline-edit-toggle', {
1305
+ detail: { recordId: pureRecordId, objectName },
1306
+ }));
1307
+ },
1308
+ });
1146
1309
  items.push({
1147
1310
  name: 'sys_edit',
1148
1311
  label: t('detail.edit', { defaultValue: 'Edit' }),
@@ -1191,7 +1354,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1191
1354
  label: t('detail.delete', { defaultValue: 'Delete' }),
1192
1355
  type: 'script',
1193
1356
  locations: ['record_header'],
1194
- variant: 'outline',
1357
+ variant: 'destructive',
1195
1358
  onClick: async () => {
1196
1359
  const msg = t('detail.deleteConfirmation', {
1197
1360
  defaultValue: 'Are you sure you want to delete this record?',
@@ -1250,9 +1413,14 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1250
1413
  headerActions: synthHeaderActions,
1251
1414
  related: synthRelated,
1252
1415
  history: synthHistory,
1416
+ // Per-object opt-outs read from `objectDef.detail.*`. Lets
1417
+ // catalog/atomic objects (product, task, ...) keep a focused
1418
+ // single-column layout instead of inheriting the rail.
1419
+ hideReferenceRail: objectDef?.detail?.hideReferenceRail === true || undefined,
1420
+ hideRelatedTab: objectDef?.detail?.hideRelatedTab === true || undefined,
1253
1421
  ...(assignedSlots ? { slots: assignedSlots } : {}),
1254
1422
  });
1255
- return (_jsxs("div", { className: "h-full bg-background overflow-hidden flex flex-col relative", children: [_jsxs("div", { className: "absolute top-2 sm:top-4 right-2 sm:right-4 z-50 flex items-center gap-2", children: [_jsx(ManagedByBadge, { managedBy: objectDef?.managedBy }), recordViewers.length > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", title: t('recordDetail.viewersTooltip'), children: [_jsx(Users, { className: "h-3.5 w-3.5 text-muted-foreground" }), _jsx(PresenceAvatars, { users: recordViewers, size: "sm", maxVisible: 4, showStatus: true })] }))] }), _jsx(RecordContextProvider, { objectName: objectName, recordId: pureRecordId, data: pageRecord, objectSchema: objectDef, dataSource: dataSource, embedded: embedded, headerSystemActions: synthSystemActions, isFavorite: isRecordFavorite, onToggleFavorite: favoriteRecord ? handleToggleRecordFavorite : undefined, children: _jsx(HighlightFieldsProvider, { children: _jsx(DiscussionContextProvider, { items: feedItems, onAddComment: handleAddComment, onAddReply: handleAddReply, onToggleReaction: handleToggleReaction, children: _jsx(ActionProvider, { context: { record: pageRecord || {}, objectName, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler, approval: approvalHandler }, children: _jsxs("div", { className: "flex-1 overflow-hidden flex flex-row", children: [_jsxs("div", { className: "flex-1 overflow-auto p-3 sm:p-4 lg:p-6 scroll-pb-48", children: [_jsx(SchemaRenderer, { schema: renderedPage }), showAutoDiscussion && (_jsx("div", { className: "mt-6", children: _jsx(RecordChatterPanel, { config: {
1423
+ return (_jsxs("div", { className: "h-full bg-background overflow-hidden flex flex-col relative", children: [_jsxs("div", { className: "absolute top-2 sm:top-4 right-2 sm:right-4 z-50 flex items-center gap-2", children: [recordPresence.length > 0 && (_jsx(PresenceAvatars, { users: recordPresence, size: "sm", maxVisible: 3, showStatus: true })), _jsx(ManagedByBadge, { managedBy: objectDef?.managedBy })] }), _jsx(RecordContextProvider, { objectName: objectName, recordId: pureRecordId, data: pageRecord, objectSchema: objectDef, dataSource: dataSource, embedded: embedded, headerSystemActions: synthSystemActions, isFavorite: isRecordFavorite, onToggleFavorite: favoriteRecord ? handleToggleRecordFavorite : undefined, children: _jsx(HighlightFieldsProvider, { children: _jsx(DiscussionContextProvider, { items: feedItems, onAddComment: handleAddComment, onAddReply: handleAddReply, onToggleReaction: handleToggleReaction, mentionSuggestions: mentionSuggestions, children: _jsx(ActionProvider, { context: { record: pageRecord || {}, objectName, user: currentUser }, onConfirm: confirmHandler, onToast: toastHandler, onNavigate: navigateHandler, onParamCollection: paramCollectionHandler, handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: serverActionHandler, approval: approvalHandler }, children: _jsxs("div", { className: "flex-1 overflow-hidden flex flex-row", children: [_jsxs("div", { className: "flex-1 overflow-auto p-3 sm:p-4 lg:p-6 scroll-pb-48", children: [originFrom?.pathname && originFrom?.label && (_jsxs(Link, { to: originFrom.pathname, className: "inline-flex items-center gap-1 mb-3 text-sm text-muted-foreground hover:text-foreground transition-colors", children: [_jsx(ChevronLeft, { className: "h-4 w-4" }), _jsx("span", { children: originFrom.label })] })), _jsx(SchemaRenderer, { schema: renderedPage }), showAutoDiscussion && (_jsx("div", { className: "mt-6", children: _jsx(RecordChatterPanel, { config: {
1256
1424
  position: 'bottom',
1257
1425
  collapsible: false,
1258
1426
  feed: {
@@ -1260,7 +1428,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1260
1428
  enableThreading: true,
1261
1429
  showCommentInput: true,
1262
1430
  },
1263
- }, items: feedItems, onAddComment: handleAddComment, onAddReply: handleAddReply, onToggleReaction: handleToggleReaction }) }))] }), _jsx(MetadataPanel, { open: showDebug, sections: [{ title: 'Page Schema', data: renderedPage }] })] }) }) }) }) }), _jsx(ActionConfirmDialog, { state: confirmState, onOpenChange: (open) => {
1431
+ }, items: feedItems, onAddComment: handleAddComment, onAddReply: handleAddReply, onToggleReaction: handleToggleReaction, mentionSuggestions: mentionSuggestions }) }))] }), _jsx(MetadataPanel, { open: showDebug, sections: [{ title: 'Page Schema', data: renderedPage }] })] }) }) }) }) }), _jsx(ActionConfirmDialog, { state: confirmState, onOpenChange: (open) => {
1264
1432
  if (!open)
1265
1433
  setConfirmState(s => ({ ...s, open: false }));
1266
1434
  } }), _jsx(ActionParamDialog, { state: paramState, onOpenChange: (open) => {
@@ -1268,7 +1436,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1268
1436
  setParamState(s => ({ ...s, open: false }));
1269
1437
  } })] }));
1270
1438
  }
1271
- return (_jsxs("div", { className: "h-full bg-background overflow-hidden flex flex-col relative", children: [_jsxs("div", { className: "absolute top-2 sm:top-4 right-2 sm:right-4 z-50 flex items-center gap-2", children: [_jsx(ManagedByBadge, { managedBy: objectDef?.managedBy }), recordViewers.length > 0 && (_jsxs("div", { className: "flex items-center gap-1.5", title: t('recordDetail.viewersTooltip'), 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, approval: approvalHandler }, children: _jsx(DetailView, { schema: detailSchema, dataSource: dataSource, objectLabel: objectLabel({ name: objectDef.name, label: objectDef.label }), isFavorite: isRecordFavorite, onToggleFavorite: favoriteRecord ? handleToggleRecordFavorite : undefined, onDataLoaded: (record) => {
1439
+ return (_jsxs("div", { className: "h-full bg-background overflow-hidden flex flex-col relative", children: [_jsxs("div", { className: "absolute top-2 sm:top-4 right-2 sm:right-4 z-50 flex items-center gap-2", children: [recordPresence.length > 0 && (_jsx(PresenceAvatars, { users: recordPresence, size: "sm", maxVisible: 3, showStatus: true })), _jsx(ManagedByBadge, { managedBy: objectDef?.managedBy })] }), _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, approval: approvalHandler }, children: _jsx(DetailView, { schema: detailSchema, dataSource: dataSource, objectLabel: objectLabel({ name: objectDef.name, label: objectDef.label }), isFavorite: isRecordFavorite, onToggleFavorite: favoriteRecord ? handleToggleRecordFavorite : undefined, onDataLoaded: (record) => {
1272
1440
  if (!record || typeof record !== 'object')
1273
1441
  return;
1274
1442
  // Resolve the same way DetailView's header does, so the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/app-shell",
3
- "version": "5.0.2",
3
+ "version": "5.2.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Minimal application shell for ObjectUI - framework-agnostic rendering engine",
@@ -27,35 +27,35 @@
27
27
  "dependencies": {
28
28
  "lucide-react": "^1.16.0",
29
29
  "sonner": "^2.0.7",
30
- "@object-ui/auth": "5.0.2",
31
- "@object-ui/collaboration": "5.0.2",
32
- "@object-ui/components": "5.0.2",
33
- "@object-ui/core": "5.0.2",
34
- "@object-ui/data-objectstack": "5.0.2",
35
- "@object-ui/fields": "5.0.2",
36
- "@object-ui/i18n": "5.0.2",
37
- "@object-ui/layout": "5.0.2",
38
- "@object-ui/permissions": "5.0.2",
39
- "@object-ui/providers": "5.0.2",
40
- "@object-ui/react": "5.0.2",
41
- "@object-ui/types": "5.0.2"
30
+ "@object-ui/auth": "5.2.1",
31
+ "@object-ui/collaboration": "5.2.1",
32
+ "@object-ui/components": "5.2.1",
33
+ "@object-ui/core": "5.2.1",
34
+ "@object-ui/data-objectstack": "5.2.1",
35
+ "@object-ui/fields": "5.2.1",
36
+ "@object-ui/i18n": "5.2.1",
37
+ "@object-ui/layout": "5.2.1",
38
+ "@object-ui/permissions": "5.2.1",
39
+ "@object-ui/providers": "5.2.1",
40
+ "@object-ui/react": "5.2.1",
41
+ "@object-ui/types": "5.2.1"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "react": "^18.0.0 || ^19.0.0",
45
45
  "react-dom": "^18.0.0 || ^19.0.0",
46
46
  "react-router-dom": "^6.0.0 || ^7.0.0",
47
- "@object-ui/plugin-calendar": "^5.0.2",
48
- "@object-ui/plugin-charts": "^5.0.2",
49
- "@object-ui/plugin-chatbot": "^5.0.2",
50
- "@object-ui/plugin-dashboard": "^5.0.2",
51
- "@object-ui/plugin-designer": "^5.0.2",
52
- "@object-ui/plugin-detail": "^5.0.2",
53
- "@object-ui/plugin-form": "^5.0.2",
54
- "@object-ui/plugin-grid": "^5.0.2",
55
- "@object-ui/plugin-kanban": "^5.0.2",
56
- "@object-ui/plugin-list": "^5.0.2",
57
- "@object-ui/plugin-report": "^5.0.2",
58
- "@object-ui/plugin-view": "^5.0.2"
47
+ "@object-ui/plugin-calendar": "^5.2.1",
48
+ "@object-ui/plugin-charts": "^5.2.1",
49
+ "@object-ui/plugin-chatbot": "^5.2.1",
50
+ "@object-ui/plugin-dashboard": "^5.2.1",
51
+ "@object-ui/plugin-designer": "^5.2.1",
52
+ "@object-ui/plugin-detail": "^5.2.1",
53
+ "@object-ui/plugin-form": "^5.2.1",
54
+ "@object-ui/plugin-grid": "^5.2.1",
55
+ "@object-ui/plugin-kanban": "^5.2.1",
56
+ "@object-ui/plugin-list": "^5.2.1",
57
+ "@object-ui/plugin-report": "^5.2.1",
58
+ "@object-ui/plugin-view": "^5.2.1"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/node": "^25.9.0",
package/src/styles.css CHANGED
@@ -14,3 +14,52 @@
14
14
  * ancestor) instead of the OS `prefers-color-scheme`. Required so the
15
15
  * in-app theme toggle fully controls light/dark UI under Tailwind v4. */
16
16
  @custom-variant dark (&:where(.dark, .dark *));
17
+
18
+ /* Shimmer keyframe + utility for skeleton loading placeholders.
19
+ *
20
+ * Usage: <div class="animate-shimmer ..."> or via ShimmerSkeleton
21
+ * from @object-ui/components.
22
+ *
23
+ * The keyframe slides a 200%-wide gradient across an element's
24
+ * background. Pair with `bg-[length:200%_100%]` and a gradient that
25
+ * uses the design-system muted tokens, e.g.
26
+ * bg-[linear-gradient(90deg,var(--muted),var(--muted-foreground)/10,var(--muted))]
27
+ *
28
+ * Respects `prefers-reduced-motion` automatically via the
29
+ * `motion-safe:` Tailwind variant on the consuming site.
30
+ */
31
+ @keyframes shimmer {
32
+ 0% { background-position: 200% 0; }
33
+ 100% { background-position: -200% 0; }
34
+ }
35
+
36
+ @utility animate-shimmer {
37
+ animation: shimmer 1.6s linear infinite;
38
+ }
39
+
40
+ /* Press feedback for shadcn-style Button components.
41
+ *
42
+ * The cva base in `packages/components/src/ui/button.tsx` always emits
43
+ * this 6-class combination on the outermost element:
44
+ * inline-flex + whitespace-nowrap + rounded-md + text-sm +
45
+ * font-medium + ring-offset-background
46
+ *
47
+ * That intersection is specific enough to scope the rule to Button
48
+ * (and `asChild` <a> Buttons) while leaving cmdk rows, SidebarNav
49
+ * links, native input[type=submit], and bare <button> in third-party
50
+ * libraries untouched.
51
+ *
52
+ * Gated on `prefers-reduced-motion: no-preference` so reduced-motion
53
+ * users see a hard click with no transform.
54
+ *
55
+ * Failure mode: if shadcn upstream changes the cva base class
56
+ * combination, the selector silently no-longer matches and we lose
57
+ * the animation — no UI breakage. Search the cva base string in
58
+ * `ui/button.tsx` to locate and update this rule when that happens.
59
+ */
60
+ @media (prefers-reduced-motion: no-preference) {
61
+ :is(button, a).inline-flex.whitespace-nowrap.rounded-md.text-sm.font-medium.ring-offset-background:not(:disabled):active {
62
+ transform: scale(0.97);
63
+ transition: transform 100ms cubic-bezier(0.4, 0, 0.2, 1);
64
+ }
65
+ }
@@ -1,8 +0,0 @@
1
- import type { DashboardRendererProps } from '../types';
2
- /**
3
- * DashboardRenderer - Renders dashboard layouts from schema
4
- *
5
- * Framework-agnostic component that renders a dashboard based on JSON schema.
6
- * Delegates to registered dashboard plugins.
7
- */
8
- export declare function DashboardRenderer({ schema, dataSource, dashboardName, }: DashboardRendererProps): import("react/jsx-runtime").JSX.Element;
@@ -1,16 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { SchemaRendererProvider } from '@object-ui/react';
3
- import { useObjectTranslation } from '@object-ui/i18n';
4
- /**
5
- * DashboardRenderer - Renders dashboard layouts from schema
6
- *
7
- * Framework-agnostic component that renders a dashboard based on JSON schema.
8
- * Delegates to registered dashboard plugins.
9
- */
10
- export function DashboardRenderer({ schema, dataSource, dashboardName, }) {
11
- const { t } = useObjectTranslation();
12
- if (!schema) {
13
- return (_jsx("div", { className: "flex h-full items-center justify-center", children: _jsx("div", { className: "text-muted-foreground", children: t('renderer.noDashboardSchema') }) }));
14
- }
15
- return (_jsx(SchemaRendererProvider, { dataSource: dataSource, children: _jsxs("div", { className: "dashboard-renderer h-full p-4", children: [_jsx("h1", { className: "mb-4 text-2xl font-bold", children: schema.title || dashboardName || t('renderer.dashboard') }), _jsx("div", { className: "text-muted-foreground", children: t('renderer.dashboardRendering', { name: schema.title || dashboardName }) })] }) }));
16
- }
@@ -1,8 +0,0 @@
1
- import type { FormRendererProps } from '../types';
2
- /**
3
- * FormRenderer - Renders forms (modal or inline)
4
- *
5
- * Framework-agnostic component that renders a form based on schema.
6
- * Handles both create and edit modes.
7
- */
8
- export declare function FormRenderer({ schema, dataSource, mode, recordId, onSuccess, onCancel, objectDef, }: FormRendererProps): import("react/jsx-runtime").JSX.Element;
@@ -1,31 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { SchemaRendererProvider } from '@object-ui/react';
3
- import { useObjectTranslation } from '@object-ui/i18n';
4
- /**
5
- * FormRenderer - Renders forms (modal or inline)
6
- *
7
- * Framework-agnostic component that renders a form based on schema.
8
- * Handles both create and edit modes.
9
- */
10
- export function FormRenderer({ schema, dataSource, mode = 'create', recordId, onSuccess, onCancel, objectDef, }) {
11
- const { t } = useObjectTranslation();
12
- if (!schema) {
13
- return (_jsx("div", { className: "flex items-center justify-center p-4", children: _jsx("div", { className: "text-muted-foreground", children: t('renderer.noFormSchema') }) }));
14
- }
15
- const handleSubmit = async (data) => {
16
- try {
17
- if (mode === 'create' && objectDef) {
18
- const result = await dataSource.create(objectDef.name, data);
19
- onSuccess?.(result);
20
- }
21
- else if (mode === 'edit' && recordId && objectDef) {
22
- const result = await dataSource.update(objectDef.name, recordId, data);
23
- onSuccess?.(result);
24
- }
25
- }
26
- catch (error) {
27
- console.error('Form submission error:', error);
28
- }
29
- };
30
- return (_jsx(SchemaRendererProvider, { dataSource: dataSource, children: _jsxs("div", { className: "form-renderer p-4", children: [_jsx("h2", { className: "mb-4 text-xl font-semibold", children: schema.title || (mode === 'create' ? t('renderer.createRecord') : t('renderer.editRecord')) }), _jsxs("div", { className: "text-muted-foreground", children: [t('renderer.formRenderingMode', { mode }), recordId && ` ${t('renderer.formRenderingFor', { id: recordId })}`] }), _jsxs("div", { className: "mt-4 flex gap-2", children: [_jsx("button", { onClick: () => handleSubmit({}), className: "rounded bg-primary px-4 py-2 text-primary-foreground", children: t('renderer.save') }), onCancel && (_jsx("button", { onClick: onCancel, className: "rounded border px-4 py-2", children: t('renderer.cancel') }))] })] }) }));
31
- }
@@ -1,8 +0,0 @@
1
- import type { ObjectRendererProps } from '../types';
2
- /**
3
- * ObjectRenderer - Renders object views (Grid, Kanban, List, etc.)
4
- *
5
- * Framework-agnostic component that renders an object view based on schema.
6
- * Supports all view types registered in the ComponentRegistry.
7
- */
8
- export declare function ObjectRenderer({ objectName, viewId, dataSource, onRecordClick: _onRecordClick, onEdit: _onEdit, objectDef: externalObjectDef, refreshKey, }: ObjectRendererProps): import("react/jsx-runtime").JSX.Element;