@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.
- package/CHANGELOG.md +322 -0
- package/dist/chrome/CommandPalette.d.ts +6 -1
- package/dist/chrome/CommandPalette.js +40 -4
- package/dist/chrome/ConsoleToaster.js +8 -1
- package/dist/chrome/ErrorBoundary.js +3 -0
- package/dist/chrome/RouteFader.d.ts +32 -0
- package/dist/chrome/RouteFader.js +52 -0
- package/dist/chrome/index.d.ts +2 -0
- package/dist/chrome/index.js +2 -0
- package/dist/chrome/toast-helpers.d.ts +48 -0
- package/dist/chrome/toast-helpers.js +59 -0
- package/dist/console/AppContent.js +108 -2
- package/dist/console/home/HomePage.js +5 -4
- package/dist/console/marketplace/MarkdownText.d.ts +19 -0
- package/dist/console/marketplace/MarkdownText.js +141 -0
- package/dist/console/marketplace/MarketplaceInstalledPage.d.ts +17 -0
- package/dist/console/marketplace/MarketplaceInstalledPage.js +64 -0
- package/dist/console/marketplace/MarketplacePackagePage.d.ts +7 -0
- package/dist/console/marketplace/MarketplacePackagePage.js +200 -0
- package/dist/console/marketplace/MarketplacePage.d.ts +8 -0
- package/dist/console/marketplace/MarketplacePage.js +94 -0
- package/dist/console/marketplace/PackageIcon.d.ts +19 -0
- package/dist/console/marketplace/PackageIcon.js +17 -0
- package/dist/console/marketplace/marketplaceApi.d.ts +122 -0
- package/dist/console/marketplace/marketplaceApi.js +207 -0
- package/dist/index.d.ts +6 -6
- package/dist/index.js +6 -5
- package/dist/layout/AppHeader.js +16 -2
- package/dist/layout/AppSidebar.js +15 -11
- package/dist/layout/InboxPopover.js +43 -3
- package/dist/observability/index.d.ts +9 -0
- package/dist/observability/index.js +9 -0
- package/dist/observability/sentry.d.ts +46 -0
- package/dist/observability/sentry.js +120 -0
- package/dist/types.d.ts +0 -46
- package/dist/views/RecordDetailView.js +79 -15
- package/package.json +26 -25
- package/src/styles.css +49 -0
- package/dist/components/DashboardRenderer.d.ts +0 -8
- package/dist/components/DashboardRenderer.js +0 -16
- package/dist/components/FormRenderer.d.ts +0 -8
- package/dist/components/FormRenderer.js +0 -31
- package/dist/components/ObjectRenderer.d.ts +0 -8
- package/dist/components/ObjectRenderer.js +0 -74
- package/dist/components/PageRenderer.d.ts +0 -7
- 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 (
|
|
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 (
|
|
184
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
603
|
-
|
|
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: [
|
|
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: [
|
|
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.
|
|
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.
|
|
31
|
-
"@object-ui/collaboration": "5.
|
|
32
|
-
"@object-ui/components": "5.
|
|
33
|
-
"@object-ui/core": "5.
|
|
34
|
-
"@object-ui/data-objectstack": "5.
|
|
35
|
-
"@object-ui/fields": "5.
|
|
36
|
-
"@object-ui/i18n": "5.
|
|
37
|
-
"@object-ui/layout": "5.
|
|
38
|
-
"@object-ui/permissions": "5.
|
|
39
|
-
"@object-ui/providers": "5.
|
|
40
|
-
"@object-ui/react": "5.
|
|
41
|
-
"@object-ui/types": "5.
|
|
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.
|
|
48
|
-
"@object-ui/plugin-charts": "^5.
|
|
49
|
-
"@object-ui/plugin-chatbot": "^5.
|
|
50
|
-
"@object-ui/plugin-dashboard": "^5.
|
|
51
|
-
"@object-ui/plugin-designer": "^5.
|
|
52
|
-
"@object-ui/plugin-detail": "^5.
|
|
53
|
-
"@object-ui/plugin-form": "^5.
|
|
54
|
-
"@object-ui/plugin-grid": "^5.
|
|
55
|
-
"@object-ui/plugin-kanban": "^5.
|
|
56
|
-
"@object-ui/plugin-list": "^5.
|
|
57
|
-
"@object-ui/plugin-report": "^5.
|
|
58
|
-
"@object-ui/plugin-view": "^5.
|
|
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;
|