@object-ui/app-shell 5.1.1 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +322 -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/ErrorBoundary.js +3 -0
  6. package/dist/chrome/RouteFader.d.ts +32 -0
  7. package/dist/chrome/RouteFader.js +52 -0
  8. package/dist/chrome/index.d.ts +2 -0
  9. package/dist/chrome/index.js +2 -0
  10. package/dist/chrome/toast-helpers.d.ts +48 -0
  11. package/dist/chrome/toast-helpers.js +59 -0
  12. package/dist/console/AppContent.js +108 -2
  13. package/dist/console/home/HomePage.js +5 -4
  14. package/dist/console/marketplace/MarkdownText.d.ts +19 -0
  15. package/dist/console/marketplace/MarkdownText.js +141 -0
  16. package/dist/console/marketplace/MarketplaceInstalledPage.d.ts +17 -0
  17. package/dist/console/marketplace/MarketplaceInstalledPage.js +64 -0
  18. package/dist/console/marketplace/MarketplacePackagePage.d.ts +7 -0
  19. package/dist/console/marketplace/MarketplacePackagePage.js +200 -0
  20. package/dist/console/marketplace/MarketplacePage.d.ts +8 -0
  21. package/dist/console/marketplace/MarketplacePage.js +94 -0
  22. package/dist/console/marketplace/PackageIcon.d.ts +19 -0
  23. package/dist/console/marketplace/PackageIcon.js +17 -0
  24. package/dist/console/marketplace/marketplaceApi.d.ts +122 -0
  25. package/dist/console/marketplace/marketplaceApi.js +207 -0
  26. package/dist/index.d.ts +6 -6
  27. package/dist/index.js +6 -5
  28. package/dist/layout/AppHeader.js +16 -2
  29. package/dist/layout/AppSidebar.js +15 -11
  30. package/dist/layout/InboxPopover.js +43 -3
  31. package/dist/observability/index.d.ts +9 -0
  32. package/dist/observability/index.js +9 -0
  33. package/dist/observability/sentry.d.ts +46 -0
  34. package/dist/observability/sentry.js +120 -0
  35. package/dist/types.d.ts +0 -46
  36. package/dist/views/RecordDetailView.js +79 -15
  37. package/package.json +26 -25
  38. package/src/styles.css +49 -0
  39. package/dist/components/DashboardRenderer.d.ts +0 -8
  40. package/dist/components/DashboardRenderer.js +0 -16
  41. package/dist/components/FormRenderer.d.ts +0 -8
  42. package/dist/components/FormRenderer.js +0 -31
  43. package/dist/components/ObjectRenderer.d.ts +0 -8
  44. package/dist/components/ObjectRenderer.js +0 -74
  45. package/dist/components/PageRenderer.d.ts +0 -7
  46. package/dist/components/PageRenderer.js +0 -14
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Sentry integration — opt-in via `VITE_SENTRY_DSN`.
3
+ *
4
+ * Design goals:
5
+ * - **Zero cost when disabled.** `@sentry/react` is dynamically imported only
6
+ * when a DSN is configured, so apps without Sentry pay zero bundle bytes.
7
+ * - **Graceful degradation.** If init fails (network, CSP, etc.) we log a
8
+ * warning and continue — the host app must still render.
9
+ * - **Sensible defaults.** 10% transaction sampling, no session replay,
10
+ * `release` + `environment` pulled from Vite envvars.
11
+ *
12
+ * Env vars consumed (all optional):
13
+ * - `VITE_SENTRY_DSN` — DSN; absent disables the integration entirely
14
+ * - `VITE_SENTRY_ENVIRONMENT` — defaults to `MODE` (production/development)
15
+ * - `VITE_SENTRY_RELEASE` — defaults to `VITE_APP_VERSION` or `unknown`
16
+ * - `VITE_SENTRY_TRACES_SAMPLE_RATE` — defaults to `0.1`
17
+ *
18
+ * @module
19
+ */
20
+ let sentryModule = null;
21
+ let initPromise = null;
22
+ /**
23
+ * Returns the loaded Sentry module, or `null` if Sentry was never initialized
24
+ * (e.g. DSN missing). Callers must handle the null case.
25
+ */
26
+ export function getSentry() {
27
+ return sentryModule;
28
+ }
29
+ /**
30
+ * Initializes Sentry if `VITE_SENTRY_DSN` is configured. Safe to call multiple
31
+ * times — only the first invocation runs.
32
+ *
33
+ * @returns `true` if Sentry was initialized, `false` if disabled or failed.
34
+ */
35
+ export function initSentry() {
36
+ if (initPromise)
37
+ return initPromise;
38
+ initPromise = (async () => {
39
+ const env = import.meta.env ?? {};
40
+ const dsn = env.VITE_SENTRY_DSN;
41
+ if (!dsn)
42
+ return false;
43
+ try {
44
+ const Sentry = (await import('@sentry/react'));
45
+ const tracesSampleRate = Number(env.VITE_SENTRY_TRACES_SAMPLE_RATE ?? '0.1');
46
+ Sentry.init({
47
+ dsn,
48
+ environment: env.VITE_SENTRY_ENVIRONMENT || env.MODE || 'production',
49
+ release: env.VITE_SENTRY_RELEASE || env.VITE_APP_VERSION || 'unknown',
50
+ tracesSampleRate: Number.isFinite(tracesSampleRate) ? tracesSampleRate : 0.1,
51
+ // Send IP address + user agent on events. Sentry's recommended default
52
+ // for production. Disable by setting VITE_SENTRY_SEND_DEFAULT_PII=false.
53
+ sendDefaultPii: env.VITE_SENTRY_SEND_DEFAULT_PII !== 'false',
54
+ // Replay is opt-in via VITE_SENTRY_REPLAY=true to keep payload small.
55
+ // When enabled, only 10% of error sessions are recorded.
56
+ replaysSessionSampleRate: 0,
57
+ replaysOnErrorSampleRate: env.VITE_SENTRY_REPLAY === 'true' ? 0.1 : 0,
58
+ // Browser tracing — captures pageloads + navigation transactions.
59
+ integrations: [Sentry.browserTracingIntegration()],
60
+ // Strip query strings + Authorization from breadcrumbs before send.
61
+ beforeBreadcrumb(breadcrumb) {
62
+ if (breadcrumb.category === 'fetch' || breadcrumb.category === 'xhr') {
63
+ if (breadcrumb.data?.url && typeof breadcrumb.data.url === 'string') {
64
+ breadcrumb.data.url = stripSensitive(breadcrumb.data.url);
65
+ }
66
+ }
67
+ return breadcrumb;
68
+ },
69
+ });
70
+ sentryModule = Sentry;
71
+ return true;
72
+ }
73
+ catch (err) {
74
+ console.warn('[sentry] init failed; continuing without observability:', err);
75
+ return false;
76
+ }
77
+ })();
78
+ return initPromise;
79
+ }
80
+ /**
81
+ * Reports an error to Sentry if initialized; otherwise no-op. Use this from
82
+ * ErrorBoundary or any catch block where you want best-effort reporting.
83
+ */
84
+ export function captureError(error, context) {
85
+ if (!sentryModule)
86
+ return;
87
+ try {
88
+ sentryModule.captureException(error, context ? { extra: context } : undefined);
89
+ }
90
+ catch {
91
+ // never let observability break the host app
92
+ }
93
+ }
94
+ /**
95
+ * Sets the active user context for subsequent events. Pass `null` on logout.
96
+ */
97
+ export function setSentryUser(user) {
98
+ if (!sentryModule)
99
+ return;
100
+ try {
101
+ sentryModule.setUser(user);
102
+ }
103
+ catch {
104
+ /* swallow */
105
+ }
106
+ }
107
+ function stripSensitive(url) {
108
+ try {
109
+ const u = new URL(url, 'http://localhost');
110
+ // Drop common token-shaped query params before sending to Sentry.
111
+ for (const key of ['token', 'access_token', 'id_token', 'apiKey', 'api_key', 'password']) {
112
+ if (u.searchParams.has(key))
113
+ u.searchParams.set(key, '[redacted]');
114
+ }
115
+ return u.pathname + (u.searchParams.toString() ? '?' + u.searchParams.toString() : '');
116
+ }
117
+ catch {
118
+ return url;
119
+ }
120
+ }
package/dist/types.d.ts CHANGED
@@ -24,49 +24,3 @@ export interface AppShellProps {
24
24
  /** Custom className */
25
25
  className?: string;
26
26
  }
27
- export interface ObjectRendererProps {
28
- /** Object API name */
29
- objectName: string;
30
- /** View ID (optional) */
31
- viewId?: string;
32
- /** Data source for CRUD operations */
33
- dataSource: DataSource;
34
- /** Callback when a record is clicked */
35
- onRecordClick?: (record: any) => void;
36
- /** Callback when edit is triggered */
37
- onEdit?: (record: any) => void;
38
- /** Object metadata (optional, will fetch if not provided) */
39
- objectDef?: any;
40
- /** Refresh key to force re-render */
41
- refreshKey?: number;
42
- }
43
- export interface DashboardRendererProps {
44
- /** Dashboard schema */
45
- schema: any;
46
- /** Data source for widgets */
47
- dataSource: DataSource;
48
- /** Dashboard name */
49
- dashboardName?: string;
50
- }
51
- export interface PageRendererProps {
52
- /** Page schema */
53
- schema: any;
54
- /** Page name */
55
- pageName?: string;
56
- }
57
- export interface FormRendererProps {
58
- /** Form schema */
59
- schema: any;
60
- /** Data source for form submission */
61
- dataSource: DataSource;
62
- /** Form mode */
63
- mode?: 'create' | 'edit';
64
- /** Record ID (for edit mode) */
65
- recordId?: string;
66
- /** Success callback */
67
- onSuccess?: (result: any) => void;
68
- /** Cancel callback */
69
- onCancel?: () => void;
70
- /** Object definition */
71
- objectDef?: any;
72
- }
@@ -8,12 +8,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  */
9
9
  import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
10
10
  import { useParams, useNavigate, useLocation, Link } from 'react-router-dom';
11
- import { DetailView, RecordChatterPanel, buildDefaultPageSchema } from '@object-ui/plugin-detail';
11
+ import { DetailView, RecordChatterPanel, buildDefaultPageSchema, extractMentions } from '@object-ui/plugin-detail';
12
12
  import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
13
13
  import { useAuth, createAuthenticatedFetch } from '@object-ui/auth';
14
14
  import { ActionProvider, useObjectTranslation, useObjectLabel, usePageAssignment, RecordContextProvider, SchemaRenderer, DiscussionContextProvider, HighlightFieldsProvider } from '@object-ui/react';
15
15
  import { buildExpandFields } from '@object-ui/core';
16
16
  import { toast } from 'sonner';
17
+ import { useRecordPresence, PresenceAvatars } from '@object-ui/collaboration';
17
18
  import { Database, ChevronLeft } from 'lucide-react';
18
19
  import { MetadataPanel, useMetadataInspector } from './MetadataInspector';
19
20
  import { SkeletonDetail } from '../skeletons';
@@ -98,6 +99,13 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
98
99
  // Navigation code passes `record.id || record._id` directly into the URL
99
100
  // without adding any prefix, so no stripping is needed.
100
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);
101
109
  const favoriteRecord = useMemo(() => {
102
110
  if (!objectName || !pureRecordId)
103
111
  return null;
@@ -159,10 +167,16 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
159
167
  }, [renderViaSchemaFlag, assignedPage, assignedSlots, objectDef]);
160
168
  const effectivePage = assignedPage || synthesizedPage;
161
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');
162
175
  useEffect(() => {
163
176
  let cancelled = false;
164
177
  if (!effectivePage || !pureRecordId || !objectName || !dataSource?.findOne) {
165
178
  setPageRecord(null);
179
+ setPageRecordStatus('idle');
166
180
  return;
167
181
  }
168
182
  // Expand lookup/master_detail fields so the page receives display
@@ -171,17 +185,28 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
171
185
  const expandFields = buildExpandFields(objectDef?.fields);
172
186
  const params = expandFields.length > 0 ? { $expand: expandFields } : undefined;
173
187
  const loadRecord = () => {
188
+ setPageRecordStatus('loading');
174
189
  const findOnePromise = params
175
190
  ? dataSource.findOne(objectName, pureRecordId, params)
176
191
  : dataSource.findOne(objectName, pureRecordId);
177
192
  findOnePromise
178
193
  .then((rec) => {
179
- if (!cancelled)
194
+ if (cancelled)
195
+ return;
196
+ if (rec && typeof rec === 'object') {
180
197
  setPageRecord(rec);
198
+ setPageRecordStatus('loaded');
199
+ }
200
+ else {
201
+ setPageRecord(null);
202
+ setPageRecordStatus('missing');
203
+ }
181
204
  })
182
205
  .catch(() => {
183
- if (!cancelled)
184
- setPageRecord(null);
206
+ if (cancelled)
207
+ return;
208
+ setPageRecord(null);
209
+ setPageRecordStatus('missing');
185
210
  });
186
211
  };
187
212
  loadRecord();
@@ -580,6 +605,16 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
580
605
  if (!dataSource)
581
606
  return;
582
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
+ : [];
583
618
  (async () => {
584
619
  try {
585
620
  const res = await dataSource.find('sys_user', {
@@ -589,22 +624,30 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
589
624
  if (cancelled)
590
625
  return;
591
626
  const rows = Array.isArray(res) ? res : res?.data || [];
592
- const suggestions = rows
627
+ const fetched = rows
593
628
  .map((u) => ({
594
629
  id: String(u.id),
595
630
  label: u.name || u.email || String(u.id),
596
631
  avatarUrl: u.image || undefined,
597
632
  }))
598
633
  .filter((s) => s.label);
599
- setMentionSuggestions(suggestions);
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);
600
641
  }
601
642
  catch {
602
- // Silently fall back to free-text @mention when the user dir is
603
- // unavailable (e.g. the backend has no sys_user collection).
643
+ if (cancelled)
644
+ return;
645
+ // Fall back to just the current user so mentions still work.
646
+ setMentionSuggestions(selfSuggestion);
604
647
  }
605
648
  })();
606
649
  return () => { cancelled = true; };
607
- }, [dataSource]);
650
+ }, [dataSource, user?.id, user?.name, user?.email]);
608
651
  // Memoize so the object identity is stable across renders — otherwise
609
652
  // any effect that depends on it (e.g. the feed loader below) would
610
653
  // re-fire every render and create an infinite request loop.
@@ -760,6 +803,15 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
760
803
  })
761
804
  .catch(() => { });
762
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
+ */
763
815
  const handleAddComment = useCallback(async (text) => {
764
816
  const newItem = {
765
817
  id: crypto.randomUUID(),
@@ -773,6 +825,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
773
825
  // Persist to backend (M10.10: snake_case fields per sys_comment schema)
774
826
  if (dataSource) {
775
827
  const threadId = `${objectName}:${pureRecordId}`;
828
+ const mentionIds = extractMentions(text, mentionSuggestions);
776
829
  dataSource.create('sys_comment', {
777
830
  id: newItem.id,
778
831
  thread_id: threadId,
@@ -780,11 +833,11 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
780
833
  author_name: currentUser.name,
781
834
  author_avatar_url: 'avatar' in currentUser ? currentUser.avatar : undefined,
782
835
  body: text,
783
- mentions: '[]',
836
+ mentions: JSON.stringify(mentionIds),
784
837
  created_at: newItem.createdAt,
785
838
  }).catch(() => { });
786
839
  }
787
- }, [currentUser, dataSource, objectName, pureRecordId]);
840
+ }, [currentUser, dataSource, objectName, pureRecordId, mentionSuggestions]);
788
841
  const handleAddReply = useCallback(async (parentId, text) => {
789
842
  const newItem = {
790
843
  id: crypto.randomUUID(),
@@ -804,6 +857,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
804
857
  });
805
858
  if (dataSource) {
806
859
  const threadId = `${objectName}:${pureRecordId}`;
860
+ const mentionIds = extractMentions(text, mentionSuggestions);
807
861
  dataSource.create('sys_comment', {
808
862
  id: newItem.id,
809
863
  thread_id: threadId,
@@ -811,12 +865,12 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
811
865
  author_name: currentUser.name,
812
866
  author_avatar_url: 'avatar' in currentUser ? currentUser.avatar : undefined,
813
867
  body: text,
814
- mentions: '[]',
868
+ mentions: JSON.stringify(mentionIds),
815
869
  created_at: newItem.createdAt,
816
870
  parent_id: parentId,
817
871
  }).catch(() => { });
818
872
  }
819
- }, [currentUser, dataSource, objectName, pureRecordId]);
873
+ }, [currentUser, dataSource, objectName, pureRecordId, mentionSuggestions]);
820
874
  const handleToggleReaction = useCallback((itemId, emoji) => {
821
875
  setFeedItems(prev => prev.map(item => {
822
876
  if (item.id !== itemId)
@@ -1187,6 +1241,16 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1187
1241
  if (!objectDef) {
1188
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 }) })] }) }));
1189
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
+ }
1190
1254
  if (effectivePage) {
1191
1255
  const disableDiscussion = effectivePage?.disableDiscussion === true;
1192
1256
  // When the page schema embeds an explicit `record:discussion` /
@@ -1356,7 +1420,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1356
1420
  hideRelatedTab: objectDef?.detail?.hideRelatedTab === true || undefined,
1357
1421
  ...(assignedSlots ? { slots: assignedSlots } : {}),
1358
1422
  });
1359
- 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: _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: {
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: {
1360
1424
  position: 'bottom',
1361
1425
  collapsible: false,
1362
1426
  feed: {
@@ -1372,7 +1436,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
1372
1436
  setParamState(s => ({ ...s, open: false }));
1373
1437
  } })] }));
1374
1438
  }
1375
- 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: _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) => {
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) => {
1376
1440
  if (!record || typeof record !== 'object')
1377
1441
  return;
1378
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.1.1",
3
+ "version": "5.3.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Minimal application shell for ObjectUI - framework-agnostic rendering engine",
@@ -25,37 +25,38 @@
25
25
  "./styles.css": "./src/styles.css"
26
26
  },
27
27
  "dependencies": {
28
+ "@sentry/react": "^8.55.2",
28
29
  "lucide-react": "^1.16.0",
29
30
  "sonner": "^2.0.7",
30
- "@object-ui/auth": "5.1.1",
31
- "@object-ui/collaboration": "5.1.1",
32
- "@object-ui/components": "5.1.1",
33
- "@object-ui/core": "5.1.1",
34
- "@object-ui/data-objectstack": "5.1.1",
35
- "@object-ui/fields": "5.1.1",
36
- "@object-ui/i18n": "5.1.1",
37
- "@object-ui/layout": "5.1.1",
38
- "@object-ui/permissions": "5.1.1",
39
- "@object-ui/providers": "5.1.1",
40
- "@object-ui/react": "5.1.1",
41
- "@object-ui/types": "5.1.1"
31
+ "@object-ui/auth": "5.3.0",
32
+ "@object-ui/collaboration": "5.3.0",
33
+ "@object-ui/components": "5.3.0",
34
+ "@object-ui/core": "5.3.0",
35
+ "@object-ui/data-objectstack": "5.3.0",
36
+ "@object-ui/fields": "5.3.0",
37
+ "@object-ui/i18n": "5.3.0",
38
+ "@object-ui/layout": "5.3.0",
39
+ "@object-ui/permissions": "5.3.0",
40
+ "@object-ui/providers": "5.3.0",
41
+ "@object-ui/react": "5.3.0",
42
+ "@object-ui/types": "5.3.0"
42
43
  },
43
44
  "peerDependencies": {
44
45
  "react": "^18.0.0 || ^19.0.0",
45
46
  "react-dom": "^18.0.0 || ^19.0.0",
46
47
  "react-router-dom": "^6.0.0 || ^7.0.0",
47
- "@object-ui/plugin-calendar": "^5.1.1",
48
- "@object-ui/plugin-charts": "^5.1.1",
49
- "@object-ui/plugin-chatbot": "^5.1.1",
50
- "@object-ui/plugin-dashboard": "^5.1.1",
51
- "@object-ui/plugin-designer": "^5.1.1",
52
- "@object-ui/plugin-detail": "^5.1.1",
53
- "@object-ui/plugin-form": "^5.1.1",
54
- "@object-ui/plugin-grid": "^5.1.1",
55
- "@object-ui/plugin-kanban": "^5.1.1",
56
- "@object-ui/plugin-list": "^5.1.1",
57
- "@object-ui/plugin-report": "^5.1.1",
58
- "@object-ui/plugin-view": "^5.1.1"
48
+ "@object-ui/plugin-calendar": "^5.3.0",
49
+ "@object-ui/plugin-charts": "^5.3.0",
50
+ "@object-ui/plugin-chatbot": "^5.3.0",
51
+ "@object-ui/plugin-dashboard": "^5.3.0",
52
+ "@object-ui/plugin-designer": "^5.3.0",
53
+ "@object-ui/plugin-detail": "^5.3.0",
54
+ "@object-ui/plugin-form": "^5.3.0",
55
+ "@object-ui/plugin-grid": "^5.3.0",
56
+ "@object-ui/plugin-kanban": "^5.3.0",
57
+ "@object-ui/plugin-list": "^5.3.0",
58
+ "@object-ui/plugin-report": "^5.3.0",
59
+ "@object-ui/plugin-view": "^5.3.0"
59
60
  },
60
61
  "devDependencies": {
61
62
  "@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;