@object-ui/app-shell 6.1.0 → 6.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.
- package/CHANGELOG.md +129 -0
- package/README.md +10 -1
- package/dist/console/AppContent.js +53 -2
- package/dist/console/ai/AiChatPage.d.ts +8 -0
- package/dist/console/ai/AiChatPage.js +188 -0
- package/dist/console/ai/ConversationsSidebar.d.ts +7 -0
- package/dist/console/ai/ConversationsSidebar.js +111 -0
- package/dist/console/auth/LoginPage.js +19 -2
- package/dist/console/auth/RegisterPage.js +30 -1
- package/dist/console/marketplace/MarketplaceAccessDenied.js +3 -1
- package/dist/console/marketplace/MarketplaceInstalledPage.js +11 -3
- package/dist/console/marketplace/MarketplacePackagePage.js +57 -19
- package/dist/console/marketplace/MarketplacePage.js +55 -18
- package/dist/console/marketplace/marketplaceApi.d.ts +20 -0
- package/dist/console/marketplace/usePackageL10n.d.ts +38 -0
- package/dist/console/marketplace/usePackageL10n.js +110 -0
- package/dist/console/organizations/CreateWorkspaceDialog.js +29 -1
- package/dist/console/organizations/OrganizationsPage.js +24 -3
- package/dist/context/FavoritesProvider.d.ts +40 -2
- package/dist/context/FavoritesProvider.js +201 -20
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/useChatConversation.d.ts +7 -0
- package/dist/hooks/useChatConversation.js +37 -5
- package/dist/hooks/useConversationList.d.ts +25 -0
- package/dist/hooks/useConversationList.js +131 -0
- package/dist/hooks/useNavPins.d.ts +11 -4
- package/dist/hooks/useNavPins.js +104 -53
- package/dist/index.d.ts +7 -0
- package/dist/index.js +14 -0
- package/dist/layout/AppHeader.js +2 -2
- package/dist/layout/AppSidebar.js +20 -1
- package/dist/layout/UnifiedSidebar.js +1 -1
- package/dist/providers/ExpressionProvider.d.ts +11 -1
- package/dist/providers/ExpressionProvider.js +11 -6
- package/dist/services/builtinComponents.d.ts +1 -0
- package/dist/services/builtinComponents.js +169 -0
- package/dist/services/componentRegistry.d.ts +63 -0
- package/dist/services/componentRegistry.js +36 -0
- package/dist/views/ComponentNavView.d.ts +6 -0
- package/dist/views/ComponentNavView.js +26 -0
- package/dist/views/RecordDetailView.js +66 -6
- package/dist/views/RecordFormPage.js +15 -3
- package/dist/views/SearchResultsPage.js +4 -0
- package/dist/views/metadata-admin/DesignerEditorWrapper.d.ts +68 -0
- package/dist/views/metadata-admin/DesignerEditorWrapper.js +158 -0
- package/dist/views/metadata-admin/DirectoryPage.d.ts +1 -0
- package/dist/views/metadata-admin/DirectoryPage.js +135 -0
- package/dist/views/metadata-admin/LayeredDiff.d.ts +6 -0
- package/dist/views/metadata-admin/LayeredDiff.js +26 -0
- package/dist/views/metadata-admin/MetadataDetailDrawer.d.ts +13 -0
- package/dist/views/metadata-admin/MetadataDetailDrawer.js +52 -0
- package/dist/views/metadata-admin/PageShell.d.ts +34 -0
- package/dist/views/metadata-admin/PageShell.js +40 -0
- package/dist/views/metadata-admin/PermissionMatrixEditor.d.ts +5 -0
- package/dist/views/metadata-admin/PermissionMatrixEditor.js +288 -0
- package/dist/views/metadata-admin/QuickFind.d.ts +5 -0
- package/dist/views/metadata-admin/QuickFind.js +152 -0
- package/dist/views/metadata-admin/RelatedPanel.d.ts +33 -0
- package/dist/views/metadata-admin/RelatedPanel.js +171 -0
- package/dist/views/metadata-admin/ResourceEditPage.d.ts +13 -0
- package/dist/views/metadata-admin/ResourceEditPage.js +302 -0
- package/dist/views/metadata-admin/ResourceHistoryPage.d.ts +5 -0
- package/dist/views/metadata-admin/ResourceHistoryPage.js +100 -0
- package/dist/views/metadata-admin/ResourceListPage.d.ts +4 -0
- package/dist/views/metadata-admin/ResourceListPage.js +146 -0
- package/dist/views/metadata-admin/ResourceRouter.d.ts +10 -0
- package/dist/views/metadata-admin/ResourceRouter.js +47 -0
- package/dist/views/metadata-admin/SchemaForm.d.ts +99 -0
- package/dist/views/metadata-admin/SchemaForm.js +565 -0
- package/dist/views/metadata-admin/anchors.d.ts +1 -0
- package/dist/views/metadata-admin/anchors.js +229 -0
- package/dist/views/metadata-admin/default-schemas.d.ts +6 -0
- package/dist/views/metadata-admin/default-schemas.js +207 -0
- package/dist/views/metadata-admin/i18n.d.ts +33 -0
- package/dist/views/metadata-admin/i18n.js +303 -0
- package/dist/views/metadata-admin/index.d.ts +33 -0
- package/dist/views/metadata-admin/index.js +39 -0
- package/dist/views/metadata-admin/predicate.d.ts +31 -0
- package/dist/views/metadata-admin/predicate.js +150 -0
- package/dist/views/metadata-admin/registry.d.ts +232 -0
- package/dist/views/metadata-admin/registry.js +106 -0
- package/dist/views/metadata-admin/useMetadata.d.ts +37 -0
- package/dist/views/metadata-admin/useMetadata.js +96 -0
- package/dist/views/metadata-admin/widgets.d.ts +68 -0
- package/dist/views/metadata-admin/widgets.js +287 -0
- package/package.json +27 -26
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usePackageL10n
|
|
3
|
+
*
|
|
4
|
+
* Resolves a marketplace package's translatable fields against the
|
|
5
|
+
* current i18n locale. Mirrors the server-side resolver
|
|
6
|
+
* `resolvePackageL10n` in @objectstack/spec/cloud but stays inline here
|
|
7
|
+
* so the app-shell doesn't pull the full spec package into its bundle.
|
|
8
|
+
*
|
|
9
|
+
* Resolution chain (first hit wins):
|
|
10
|
+
* 1. translations[<exact requested locale>] (e.g. `zh-CN`)
|
|
11
|
+
* 2. translations[<language-only locale>] (e.g. `zh`)
|
|
12
|
+
* 3. translations[<fallback locale>] (default `en`)
|
|
13
|
+
* 4. base column on the package row (snake_case from REST)
|
|
14
|
+
*/
|
|
15
|
+
import { useMemo } from 'react';
|
|
16
|
+
import { useObjectTranslation } from '@object-ui/i18n';
|
|
17
|
+
const FALLBACK_LOCALE = 'en';
|
|
18
|
+
function languageOf(locale) {
|
|
19
|
+
const dash = locale.indexOf('-');
|
|
20
|
+
return dash === -1 ? locale : locale.slice(0, dash);
|
|
21
|
+
}
|
|
22
|
+
function uniqueLocales(codes) {
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const c of codes) {
|
|
26
|
+
if (!c || seen.has(c))
|
|
27
|
+
continue;
|
|
28
|
+
seen.add(c);
|
|
29
|
+
out.push(c);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
function pickFromTranslations(translations, locale, field) {
|
|
34
|
+
if (!translations)
|
|
35
|
+
return undefined;
|
|
36
|
+
const lang = languageOf(locale);
|
|
37
|
+
// Build chain: exact → language-only → any regional variant matching the
|
|
38
|
+
// language (e.g. 'zh' → 'zh-CN' / 'zh-Hans') → fallback. The regional
|
|
39
|
+
// expansion covers the common case where the i18n provider returns a
|
|
40
|
+
// bare language code ('zh') but package manifests ship region-tagged
|
|
41
|
+
// translations ('zh-CN').
|
|
42
|
+
const variants = Object.keys(translations).filter((code) => code === lang || code.startsWith(`${lang}-`));
|
|
43
|
+
const chain = uniqueLocales([locale, lang, ...variants, FALLBACK_LOCALE]);
|
|
44
|
+
for (const code of chain) {
|
|
45
|
+
const entry = translations[code];
|
|
46
|
+
if (!entry)
|
|
47
|
+
continue;
|
|
48
|
+
const value = entry[field];
|
|
49
|
+
if (typeof value === 'string' && value.length > 0)
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
function baseColumn(pkg, field) {
|
|
55
|
+
// Map camelCase L10n field → REST snake_case column.
|
|
56
|
+
const SNAKE = {
|
|
57
|
+
displayName: 'display_name',
|
|
58
|
+
description: 'description',
|
|
59
|
+
readme: 'readme',
|
|
60
|
+
tagline: 'tagline',
|
|
61
|
+
// Captions live only inside translations on the wire — no base column.
|
|
62
|
+
screenshotCaptions: 'screenshotCaptions',
|
|
63
|
+
};
|
|
64
|
+
const v = pkg[SNAKE[field]] ?? pkg[field];
|
|
65
|
+
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Resolve `displayName`, `description`, `readme`, and `tagline` for a
|
|
69
|
+
* package against the active locale. `displayName` is guaranteed to be
|
|
70
|
+
* a non-empty string (falls back to `manifest_id` as a last resort) so
|
|
71
|
+
* callers can render it without further guards.
|
|
72
|
+
*/
|
|
73
|
+
export function usePackageL10n(pkg) {
|
|
74
|
+
const { language } = useObjectTranslation();
|
|
75
|
+
return useMemo(() => {
|
|
76
|
+
if (!pkg) {
|
|
77
|
+
return { displayName: '', description: undefined, readme: undefined, tagline: undefined };
|
|
78
|
+
}
|
|
79
|
+
const locale = language || FALLBACK_LOCALE;
|
|
80
|
+
const displayName = pickFromTranslations(pkg.translations ?? undefined, locale, 'displayName')
|
|
81
|
+
?? baseColumn(pkg, 'displayName')
|
|
82
|
+
?? pkg.manifest_id;
|
|
83
|
+
const description = pickFromTranslations(pkg.translations ?? undefined, locale, 'description')
|
|
84
|
+
?? baseColumn(pkg, 'description');
|
|
85
|
+
const readme = pickFromTranslations(pkg.translations ?? undefined, locale, 'readme')
|
|
86
|
+
?? baseColumn(pkg, 'readme');
|
|
87
|
+
const tagline = pickFromTranslations(pkg.translations ?? undefined, locale, 'tagline')
|
|
88
|
+
?? baseColumn(pkg, 'tagline');
|
|
89
|
+
return { displayName, description, readme, tagline };
|
|
90
|
+
}, [pkg, language]);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Stateless variant for use in `.map()` over a list of packages where
|
|
94
|
+
* calling `usePackageL10n` per row would violate the rules of hooks.
|
|
95
|
+
* The caller passes the language they obtained once from
|
|
96
|
+
* `useObjectTranslation()` at the top of the component.
|
|
97
|
+
*/
|
|
98
|
+
export function localizePackage(pkg, language) {
|
|
99
|
+
const locale = language || FALLBACK_LOCALE;
|
|
100
|
+
const displayName = pickFromTranslations(pkg.translations ?? undefined, locale, 'displayName')
|
|
101
|
+
?? baseColumn(pkg, 'displayName')
|
|
102
|
+
?? pkg.manifest_id;
|
|
103
|
+
const description = pickFromTranslations(pkg.translations ?? undefined, locale, 'description')
|
|
104
|
+
?? baseColumn(pkg, 'description');
|
|
105
|
+
const readme = pickFromTranslations(pkg.translations ?? undefined, locale, 'readme')
|
|
106
|
+
?? baseColumn(pkg, 'readme');
|
|
107
|
+
const tagline = pickFromTranslations(pkg.translations ?? undefined, locale, 'tagline')
|
|
108
|
+
?? baseColumn(pkg, 'tagline');
|
|
109
|
+
return { displayName, description, readme, tagline };
|
|
110
|
+
}
|
|
@@ -24,12 +24,34 @@ function nameToSlug(name) {
|
|
|
24
24
|
}
|
|
25
25
|
export function CreateWorkspaceDialog({ open, onOpenChange, onCreated, }) {
|
|
26
26
|
const { t } = useObjectTranslation();
|
|
27
|
-
const { createOrganization } = useAuth();
|
|
27
|
+
const { createOrganization, getAuthConfig } = useAuth();
|
|
28
28
|
const [name, setName] = useState('');
|
|
29
29
|
const [slug, setSlug] = useState('');
|
|
30
30
|
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false);
|
|
31
31
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
32
32
|
const [error, setError] = useState(null);
|
|
33
|
+
// Defense-in-depth: the toolbar button that opens this dialog is already
|
|
34
|
+
// hidden when `multiOrgEnabled === false`, but if a future caller opens the
|
|
35
|
+
// dialog by another path we still want to fail fast with a friendly message
|
|
36
|
+
// instead of bouncing off the server's FORBIDDEN.
|
|
37
|
+
const [multiOrgDisabled, setMultiOrgDisabled] = useState(false);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!open)
|
|
40
|
+
return;
|
|
41
|
+
let cancelled = false;
|
|
42
|
+
getAuthConfig()
|
|
43
|
+
.then((cfg) => {
|
|
44
|
+
if (cancelled)
|
|
45
|
+
return;
|
|
46
|
+
setMultiOrgDisabled(cfg?.features?.multiOrgEnabled === false);
|
|
47
|
+
})
|
|
48
|
+
.catch(() => {
|
|
49
|
+
/* leave default — server still enforces */
|
|
50
|
+
});
|
|
51
|
+
return () => {
|
|
52
|
+
cancelled = true;
|
|
53
|
+
};
|
|
54
|
+
}, [open, getAuthConfig]);
|
|
33
55
|
// Auto-generate slug from name (unless manually edited)
|
|
34
56
|
useEffect(() => {
|
|
35
57
|
if (!slugManuallyEdited) {
|
|
@@ -49,6 +71,12 @@ export function CreateWorkspaceDialog({ open, onOpenChange, onCreated, }) {
|
|
|
49
71
|
e.preventDefault();
|
|
50
72
|
if (!name.trim() || !slug.trim())
|
|
51
73
|
return;
|
|
74
|
+
if (multiOrgDisabled) {
|
|
75
|
+
setError(t('workspace.multiOrgDisabled', {
|
|
76
|
+
defaultValue: 'Creating new organizations is disabled on this instance.',
|
|
77
|
+
}));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
52
80
|
setIsSubmitting(true);
|
|
53
81
|
setError(null);
|
|
54
82
|
try {
|
|
@@ -26,10 +26,31 @@ function getOrgInitials(name) {
|
|
|
26
26
|
export function OrganizationsPage() {
|
|
27
27
|
const { t } = useObjectTranslation();
|
|
28
28
|
const navigate = useNavigate();
|
|
29
|
-
const { organizations, activeOrganization, isOrganizationsLoading, switchOrganization, } = useAuth();
|
|
29
|
+
const { organizations, activeOrganization, isOrganizationsLoading, switchOrganization, getAuthConfig, } = useAuth();
|
|
30
30
|
const [query, setQuery] = useState('');
|
|
31
31
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
|
32
32
|
const [switchingId, setSwitchingId] = useState(null);
|
|
33
|
+
// `multiOrgEnabled === false` ⇒ server-side `beforeCreateOrganization` hook
|
|
34
|
+
// blocks creation. Mirror that in the UI so users don't see a button that
|
|
35
|
+
// only ever fails. Default to allowing creation until we've heard back so
|
|
36
|
+
// we don't briefly hide the button on slow networks.
|
|
37
|
+
const [canCreateOrg, setCanCreateOrg] = useState(true);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
let cancelled = false;
|
|
40
|
+
getAuthConfig()
|
|
41
|
+
.then((cfg) => {
|
|
42
|
+
if (cancelled)
|
|
43
|
+
return;
|
|
44
|
+
if (cfg?.features?.multiOrgEnabled === false)
|
|
45
|
+
setCanCreateOrg(false);
|
|
46
|
+
})
|
|
47
|
+
.catch(() => {
|
|
48
|
+
/* leave default — server will still enforce */
|
|
49
|
+
});
|
|
50
|
+
return () => {
|
|
51
|
+
cancelled = true;
|
|
52
|
+
};
|
|
53
|
+
}, [getAuthConfig]);
|
|
33
54
|
const orgList = organizations ?? [];
|
|
34
55
|
const filtered = useMemo(() => {
|
|
35
56
|
const q = query.trim().toLowerCase();
|
|
@@ -87,9 +108,9 @@ export function OrganizationsPage() {
|
|
|
87
108
|
defaultValue: 'Select an organization to continue, or create a new one.',
|
|
88
109
|
}) })] }), _jsxs("div", { className: "mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between", children: [_jsxs("div", { className: "relative w-full sm:max-w-sm", children: [_jsx(Search, { className: "pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { value: query, onChange: (e) => setQuery(e.target.value), placeholder: t('organizations.searchPlaceholder', {
|
|
89
110
|
defaultValue: 'Search for an organization',
|
|
90
|
-
}), className: "pl-9", "data-testid": "organizations-search" })] }), _jsxs(Button, { onClick: () => setIsCreateOpen(true), "data-testid": "organizations-new", children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), t('organizations.new', { defaultValue: 'New organization' })] })] }), orgList.length === 0 ? (_jsxs(Empty, { children: [_jsx(EmptyTitle, { children: t('organizations.emptyTitle', { defaultValue: 'No organizations yet' }) }), _jsx(EmptyDescription, { children: t('organizations.emptyDescription', {
|
|
111
|
+
}), className: "pl-9", "data-testid": "organizations-search" })] }), canCreateOrg && (_jsxs(Button, { onClick: () => setIsCreateOpen(true), "data-testid": "organizations-new", children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), t('organizations.new', { defaultValue: 'New organization' })] }))] }), orgList.length === 0 ? (_jsxs(Empty, { children: [_jsx(EmptyTitle, { children: t('organizations.emptyTitle', { defaultValue: 'No organizations yet' }) }), _jsx(EmptyDescription, { children: t('organizations.emptyDescription', {
|
|
91
112
|
defaultValue: 'Create your first organization to get started.',
|
|
92
|
-
}) }), _jsxs(Button, { className: "mt-6", onClick: () => setIsCreateOpen(true), children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), t('organizations.new', { defaultValue: 'New organization' })] })] })) : filtered.length === 0 ? (_jsx("div", { className: "rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground", children: t('organizations.noMatches', {
|
|
113
|
+
}) }), canCreateOrg && (_jsxs(Button, { className: "mt-6", onClick: () => setIsCreateOpen(true), children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), t('organizations.new', { defaultValue: 'New organization' })] }))] })) : filtered.length === 0 ? (_jsx("div", { className: "rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground", children: t('organizations.noMatches', {
|
|
93
114
|
defaultValue: 'No organizations match your search.',
|
|
94
115
|
}) })) : (_jsx("div", { className: "grid grid-cols-1 gap-3 sm:grid-cols-2", children: filtered.map((org) => {
|
|
95
116
|
const isActive = org.id === activeOrganization?.id;
|
|
@@ -18,13 +18,35 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { type ReactNode } from 'react';
|
|
20
20
|
export interface FavoriteItem {
|
|
21
|
-
/** Unique key, e.g. "object:contact" or "dashboard:sales_overview" */
|
|
21
|
+
/** Unique key, e.g. "object:contact" or "dashboard:sales_overview" or "nav:<navId>" */
|
|
22
22
|
id: string;
|
|
23
23
|
label: string;
|
|
24
24
|
href: string;
|
|
25
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Item kind.
|
|
27
|
+
*
|
|
28
|
+
* - `object` / `dashboard` / `page` / `report` / `record` — content favorites
|
|
29
|
+
* surfaced on Home (Starred Apps) and the sidebar Favorites section.
|
|
30
|
+
* - `nav` — a sidebar navigation entry promoted to the "Pinned" position via
|
|
31
|
+
* the in-tree pin toggle (see `useNavPins`). Excluded from Home/Starred and
|
|
32
|
+
* from the generic sidebar Favorites list so it doesn't render twice.
|
|
33
|
+
*/
|
|
34
|
+
type: 'object' | 'dashboard' | 'page' | 'report' | 'record' | 'nav';
|
|
26
35
|
/** ISO timestamp of when the item was favorited */
|
|
27
36
|
favoritedAt: string;
|
|
37
|
+
/**
|
|
38
|
+
* When true, the item is pinned to the top of the sidebar (Pinned section).
|
|
39
|
+
* `nav`-type items are always pinned by construction; other types may also
|
|
40
|
+
* carry this flag if the user explicitly promotes a content favorite — that
|
|
41
|
+
* behaviour is reserved for a future iteration.
|
|
42
|
+
*/
|
|
43
|
+
pinned?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* For `type === 'nav'` items: the originating `NavigationItem.id` in the
|
|
46
|
+
* application's nav tree. Used by `useNavPins.applyPins` to flag the live
|
|
47
|
+
* tree node without leaking the synthesized favorite into other UIs.
|
|
48
|
+
*/
|
|
49
|
+
navId?: string;
|
|
28
50
|
}
|
|
29
51
|
interface FavoritesContextValue {
|
|
30
52
|
favorites: FavoriteItem[];
|
|
@@ -40,6 +62,22 @@ interface FavoritesContextValue {
|
|
|
40
62
|
* loaded) get rewritten transparently on the next visit.
|
|
41
63
|
*/
|
|
42
64
|
refreshLabel: (id: string, label: string) => void;
|
|
65
|
+
/**
|
|
66
|
+
* Flip the `pinned` flag on an existing favorite. No-op if the id is not
|
|
67
|
+
* present. Used by `useNavPins` for nav-tree pin toggles backed by the
|
|
68
|
+
* unified favorites store (same backend sync channel).
|
|
69
|
+
*/
|
|
70
|
+
setPinned: (id: string, pinned: boolean) => void;
|
|
71
|
+
/**
|
|
72
|
+
* Returns true when a favorite with the given id is currently `pinned`.
|
|
73
|
+
* Use `isFavorite` for membership-only queries.
|
|
74
|
+
*/
|
|
75
|
+
isPinned: (id: string) => boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Set of NavigationItem ids that are currently pinned via a `type === 'nav'`
|
|
78
|
+
* favorite. Memoised; safe to use as a dependency.
|
|
79
|
+
*/
|
|
80
|
+
pinnedNavIds: Set<string>;
|
|
43
81
|
}
|
|
44
82
|
interface FavoritesProviderProps {
|
|
45
83
|
children: ReactNode;
|
|
@@ -24,7 +24,55 @@ import { createDebouncedFlush, scopedKey, useStorageSync, useUserStateAdapter, }
|
|
|
24
24
|
// Storage helpers
|
|
25
25
|
// ---------------------------------------------------------------------------
|
|
26
26
|
const STORAGE_BASE_KEY = 'objectui-favorites';
|
|
27
|
+
/** Legacy key used by the standalone `useNavPins` hook before it was merged
|
|
28
|
+
* into Favorites. Migrated once at provider mount, then deleted. */
|
|
29
|
+
const LEGACY_NAV_PINS_KEY = 'objectui-nav-pins';
|
|
30
|
+
/** Cap for user-visible content favorites (object/dashboard/page/report/record). */
|
|
27
31
|
const MAX_FAVORITES = 20;
|
|
32
|
+
/** Independent cap for nav-pin favorites — they live in a separate bucket. */
|
|
33
|
+
const MAX_NAV_PINS = 20;
|
|
34
|
+
/**
|
|
35
|
+
* Enforce per-bucket caps while preserving order within each bucket. Content
|
|
36
|
+
* favorites (`type !== 'nav'`) cap at `MAX_FAVORITES`; nav-pin favorites
|
|
37
|
+
* (`type === 'nav'`) cap at `MAX_NAV_PINS`. Returns the original array when
|
|
38
|
+
* neither bucket overflows so memoized consumers aren't invalidated.
|
|
39
|
+
*/
|
|
40
|
+
function capByBucket(items) {
|
|
41
|
+
let contentCount = 0;
|
|
42
|
+
let navCount = 0;
|
|
43
|
+
let overflowed = false;
|
|
44
|
+
for (const it of items) {
|
|
45
|
+
if (it.type === 'nav') {
|
|
46
|
+
navCount++;
|
|
47
|
+
if (navCount > MAX_NAV_PINS)
|
|
48
|
+
overflowed = true;
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
contentCount++;
|
|
52
|
+
if (contentCount > MAX_FAVORITES)
|
|
53
|
+
overflowed = true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (!overflowed)
|
|
57
|
+
return items;
|
|
58
|
+
const result = [];
|
|
59
|
+
let c = 0, n = 0;
|
|
60
|
+
for (const it of items) {
|
|
61
|
+
if (it.type === 'nav') {
|
|
62
|
+
if (n < MAX_NAV_PINS) {
|
|
63
|
+
result.push(it);
|
|
64
|
+
n++;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
if (c < MAX_FAVORITES) {
|
|
69
|
+
result.push(it);
|
|
70
|
+
c++;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
28
76
|
function loadFavorites(userId) {
|
|
29
77
|
try {
|
|
30
78
|
const raw = localStorage.getItem(scopedKey(STORAGE_BASE_KEY, userId));
|
|
@@ -42,6 +90,69 @@ function saveFavorites(items, userId) {
|
|
|
42
90
|
// Storage full — silently ignore
|
|
43
91
|
}
|
|
44
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* One-shot migration of legacy `objectui-nav-pins` (a `string[]` of
|
|
95
|
+
* NavigationItem ids) into the unified favorites list as `type: 'nav'`
|
|
96
|
+
* entries. The label/href fields are placeholders because at migration time
|
|
97
|
+
* we only have the raw nav id — the live nav tree resolves the real label
|
|
98
|
+
* when it renders the Pinned section (see `useNavPins.applyPins`).
|
|
99
|
+
*
|
|
100
|
+
* Idempotent: runs only when the legacy key exists. Once consumed, the
|
|
101
|
+
* legacy key is removed so the next launch is a no-op. Existing nav-pinned
|
|
102
|
+
* favorites (e.g. on a subsequent device after backend hydration) are not
|
|
103
|
+
* duplicated.
|
|
104
|
+
*/
|
|
105
|
+
function migrateLegacyNavPins(current) {
|
|
106
|
+
let raw;
|
|
107
|
+
try {
|
|
108
|
+
raw = localStorage.getItem(LEGACY_NAV_PINS_KEY);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return { migrated: current, didMigrate: false };
|
|
112
|
+
}
|
|
113
|
+
if (!raw)
|
|
114
|
+
return { migrated: current, didMigrate: false };
|
|
115
|
+
let legacyIds = [];
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(raw);
|
|
118
|
+
if (Array.isArray(parsed)) {
|
|
119
|
+
legacyIds = parsed.filter((id) => typeof id === 'string');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
/* fall through — treat as empty */
|
|
124
|
+
}
|
|
125
|
+
// Always clear the legacy key, even on empty/corrupt payload, so we never
|
|
126
|
+
// re-enter this branch on the next mount.
|
|
127
|
+
try {
|
|
128
|
+
localStorage.removeItem(LEGACY_NAV_PINS_KEY);
|
|
129
|
+
}
|
|
130
|
+
catch { /* noop */ }
|
|
131
|
+
if (legacyIds.length === 0)
|
|
132
|
+
return { migrated: current, didMigrate: false };
|
|
133
|
+
const existingNavIds = new Set(current.filter(f => f.type === 'nav' && f.navId).map(f => f.navId));
|
|
134
|
+
const now = new Date().toISOString();
|
|
135
|
+
const additions = [];
|
|
136
|
+
for (const navId of legacyIds) {
|
|
137
|
+
if (existingNavIds.has(navId))
|
|
138
|
+
continue;
|
|
139
|
+
additions.push({
|
|
140
|
+
id: `nav:${navId}`,
|
|
141
|
+
label: navId, // placeholder — sidebar uses live nav tree label
|
|
142
|
+
href: '', // placeholder — sidebar uses live nav tree href
|
|
143
|
+
type: 'nav',
|
|
144
|
+
navId,
|
|
145
|
+
pinned: true,
|
|
146
|
+
favoritedAt: now,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
if (additions.length === 0)
|
|
150
|
+
return { migrated: current, didMigrate: true };
|
|
151
|
+
// Nav pins do not count against the user-visible favorites cap — they are
|
|
152
|
+
// a separate logical bucket. Append after content favorites so a user with
|
|
153
|
+
// 20 stars keeps them all visible on Home/Starred.
|
|
154
|
+
return { migrated: [...current, ...additions], didMigrate: true };
|
|
155
|
+
}
|
|
45
156
|
// ---------------------------------------------------------------------------
|
|
46
157
|
// Context
|
|
47
158
|
// ---------------------------------------------------------------------------
|
|
@@ -61,12 +172,15 @@ export function FavoritesProvider({ children }) {
|
|
|
61
172
|
// so this never echoes our own mutations.
|
|
62
173
|
useStorageSync(scopedKey(STORAGE_BASE_KEY, userId), value => {
|
|
63
174
|
setFavorites(Array.isArray(value)
|
|
64
|
-
? value.filter(it => it && typeof it.id === 'string')
|
|
175
|
+
? capByBucket(value.filter(it => it && typeof it.id === 'string'))
|
|
65
176
|
: []);
|
|
66
177
|
});
|
|
67
178
|
// Hydrate from backend whenever an adapter is attached (or user changes).
|
|
68
|
-
// Backend
|
|
69
|
-
//
|
|
179
|
+
// Backend is the baseline, but we *merge in* any locally-known nav-pin
|
|
180
|
+
// favorites the backend doesn't have yet — this preserves pins added
|
|
181
|
+
// offline / pre-auth, and pins recovered from the legacy `objectui-nav-pins`
|
|
182
|
+
// key by the offline migration effect below. Legacy migration also runs
|
|
183
|
+
// here as a safety net if the offline effect hasn't yet fired in time.
|
|
70
184
|
const hydrationToken = useRef(0);
|
|
71
185
|
useEffect(() => {
|
|
72
186
|
if (!adapter)
|
|
@@ -79,11 +193,28 @@ export function FavoritesProvider({ children }) {
|
|
|
79
193
|
if (cancelled || token !== hydrationToken.current)
|
|
80
194
|
return;
|
|
81
195
|
// Defensive sanitize: drop unparseable shapes, enforce cap.
|
|
82
|
-
const sane = (Array.isArray(remote) ? remote : [])
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
196
|
+
const sane = capByBucket((Array.isArray(remote) ? remote : []).filter(it => it && typeof it.id === 'string'));
|
|
197
|
+
// Re-read the local snapshot — it may contain nav-pin favorites that
|
|
198
|
+
// were added (or migrated) before the adapter arrived. Without this
|
|
199
|
+
// merge, signing in would silently wipe a user's offline pins.
|
|
200
|
+
const local = loadFavorites(userId);
|
|
201
|
+
const remoteNavIds = new Set(sane.filter(f => f.type === 'nav' && f.navId).map(f => f.navId));
|
|
202
|
+
const localNavExtras = local.filter(f => f.type === 'nav' && f.navId && !remoteNavIds.has(f.navId));
|
|
203
|
+
let merged = sane;
|
|
204
|
+
let pushBack = false;
|
|
205
|
+
if (localNavExtras.length > 0) {
|
|
206
|
+
merged = capByBucket([...sane, ...localNavExtras]);
|
|
207
|
+
pushBack = true;
|
|
208
|
+
}
|
|
209
|
+
const { migrated, didMigrate } = migrateLegacyNavPins(merged);
|
|
210
|
+
if (didMigrate) {
|
|
211
|
+
merged = capByBucket(migrated);
|
|
212
|
+
pushBack = true;
|
|
213
|
+
}
|
|
214
|
+
setFavorites(merged);
|
|
215
|
+
saveFavorites(merged, userId);
|
|
216
|
+
if (pushBack)
|
|
217
|
+
flusher.schedule(merged);
|
|
87
218
|
}
|
|
88
219
|
catch {
|
|
89
220
|
// Keep localStorage state — adapter degrade-to-noop is acceptable.
|
|
@@ -93,6 +224,22 @@ export function FavoritesProvider({ children }) {
|
|
|
93
224
|
cancelled = true;
|
|
94
225
|
};
|
|
95
226
|
}, [adapter, userId]);
|
|
227
|
+
// Offline / pre-auth migration: when there is no adapter to push to, fold
|
|
228
|
+
// legacy nav-pins into the localStorage baseline immediately so the very
|
|
229
|
+
// first render after upgrade already includes them. If an adapter later
|
|
230
|
+
// attaches, the hydrate effect above merges these into the backend.
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
if (adapter)
|
|
233
|
+
return; // adapter path handles migration above
|
|
234
|
+
setFavorites(prev => {
|
|
235
|
+
const { migrated, didMigrate } = migrateLegacyNavPins(prev);
|
|
236
|
+
if (!didMigrate)
|
|
237
|
+
return prev;
|
|
238
|
+
const next = capByBucket(migrated);
|
|
239
|
+
saveFavorites(next, userId);
|
|
240
|
+
return next;
|
|
241
|
+
});
|
|
242
|
+
}, [adapter, userId]);
|
|
96
243
|
// Debounced write-through to backend.
|
|
97
244
|
const flusher = useMemo(() => createDebouncedFlush(async (items) => {
|
|
98
245
|
if (adapter)
|
|
@@ -109,10 +256,10 @@ export function FavoritesProvider({ children }) {
|
|
|
109
256
|
setFavorites(prev => {
|
|
110
257
|
if (prev.some(f => f.id === item.id))
|
|
111
258
|
return prev;
|
|
112
|
-
const updated = [
|
|
259
|
+
const updated = capByBucket([
|
|
113
260
|
{ ...item, favoritedAt: new Date().toISOString() },
|
|
114
261
|
...prev,
|
|
115
|
-
]
|
|
262
|
+
]);
|
|
116
263
|
commit(updated);
|
|
117
264
|
return updated;
|
|
118
265
|
});
|
|
@@ -129,7 +276,10 @@ export function FavoritesProvider({ children }) {
|
|
|
129
276
|
const exists = prev.some(f => f.id === item.id);
|
|
130
277
|
const updated = exists
|
|
131
278
|
? prev.filter(f => f.id !== item.id)
|
|
132
|
-
: [
|
|
279
|
+
: capByBucket([
|
|
280
|
+
{ ...item, favoritedAt: new Date().toISOString() },
|
|
281
|
+
...prev,
|
|
282
|
+
]);
|
|
133
283
|
commit(updated);
|
|
134
284
|
return updated;
|
|
135
285
|
});
|
|
@@ -156,15 +306,43 @@ export function FavoritesProvider({ children }) {
|
|
|
156
306
|
return updated;
|
|
157
307
|
});
|
|
158
308
|
}, [commit]);
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
309
|
+
const setPinned = useCallback((id, pinned) => {
|
|
310
|
+
setFavorites(prev => {
|
|
311
|
+
let changed = false;
|
|
312
|
+
const updated = prev.map(f => {
|
|
313
|
+
if (f.id !== id)
|
|
314
|
+
return f;
|
|
315
|
+
const cur = !!f.pinned;
|
|
316
|
+
if (cur === pinned)
|
|
317
|
+
return f;
|
|
318
|
+
changed = true;
|
|
319
|
+
return { ...f, pinned };
|
|
320
|
+
});
|
|
321
|
+
if (!changed)
|
|
322
|
+
return prev;
|
|
323
|
+
commit(updated);
|
|
324
|
+
return updated;
|
|
325
|
+
});
|
|
326
|
+
}, [commit]);
|
|
327
|
+
const value = useMemo(() => {
|
|
328
|
+
const pinnedNavIds = new Set();
|
|
329
|
+
for (const f of favorites) {
|
|
330
|
+
if (f.pinned && f.type === 'nav' && f.navId)
|
|
331
|
+
pinnedNavIds.add(f.navId);
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
favorites,
|
|
335
|
+
addFavorite,
|
|
336
|
+
removeFavorite,
|
|
337
|
+
toggleFavorite,
|
|
338
|
+
isFavorite: (id) => favorites.some(f => f.id === id),
|
|
339
|
+
clearFavorites,
|
|
340
|
+
refreshLabel,
|
|
341
|
+
setPinned,
|
|
342
|
+
isPinned: (id) => favorites.some(f => f.id === id && !!f.pinned),
|
|
343
|
+
pinnedNavIds,
|
|
344
|
+
};
|
|
345
|
+
}, [favorites, addFavorite, removeFavorite, toggleFavorite, clearFavorites, refreshLabel, setPinned]);
|
|
168
346
|
return (_jsx(FavoritesContext.Provider, { value: value, children: children }));
|
|
169
347
|
}
|
|
170
348
|
// ---------------------------------------------------------------------------
|
|
@@ -189,6 +367,9 @@ export function useFavorites() {
|
|
|
189
367
|
isFavorite: () => false,
|
|
190
368
|
clearFavorites: () => { },
|
|
191
369
|
refreshLabel: () => { },
|
|
370
|
+
setPinned: () => { },
|
|
371
|
+
isPinned: () => false,
|
|
372
|
+
pinnedNavIds: new Set(),
|
|
192
373
|
};
|
|
193
374
|
}
|
|
194
375
|
return ctx;
|
package/dist/hooks/index.d.ts
CHANGED
|
@@ -8,3 +8,4 @@ export { useRecordApprovals, type ApprovalProcessLite, type ApprovalRequestLite
|
|
|
8
8
|
export { useResponsiveSidebar } from './useResponsiveSidebar';
|
|
9
9
|
export { useTrackRouteAsRecent, type UseTrackRouteAsRecentOptions } from './useTrackRouteAsRecent';
|
|
10
10
|
export { useChatConversation, type HydratedUIMessage, type UseChatConversationOptions, type UseChatConversationReturn, } from './useChatConversation';
|
|
11
|
+
export { useConversationList, type ConversationSummary, type UseConversationListOptions, type UseConversationListReturn, } from './useConversationList';
|
package/dist/hooks/index.js
CHANGED
|
@@ -8,3 +8,4 @@ export { useRecordApprovals } from './useRecordApprovals';
|
|
|
8
8
|
export { useResponsiveSidebar } from './useResponsiveSidebar';
|
|
9
9
|
export { useTrackRouteAsRecent } from './useTrackRouteAsRecent';
|
|
10
10
|
export { useChatConversation, } from './useChatConversation';
|
|
11
|
+
export { useConversationList, } from './useConversationList';
|
|
@@ -20,6 +20,13 @@ export interface UseChatConversationOptions {
|
|
|
20
20
|
* `${apiBase}/conversations[/...]`. Required.
|
|
21
21
|
*/
|
|
22
22
|
apiBase: string;
|
|
23
|
+
/**
|
|
24
|
+
* Explicit conversation id to hydrate. When provided the cache is bypassed
|
|
25
|
+
* and the hook fetches this conversation directly; falls back to creating a
|
|
26
|
+
* fresh one if the server returns 404/403. When omitted the hook keeps its
|
|
27
|
+
* original cache-first / create-on-miss behaviour.
|
|
28
|
+
*/
|
|
29
|
+
activeId?: string;
|
|
23
30
|
}
|
|
24
31
|
export interface UseChatConversationReturn {
|
|
25
32
|
conversationId: string | undefined;
|
|
@@ -100,11 +100,15 @@ async function deleteConversation(apiBase, id) {
|
|
|
100
100
|
});
|
|
101
101
|
}
|
|
102
102
|
export function useChatConversation(options) {
|
|
103
|
-
const { userId, scope, apiBase } = options;
|
|
103
|
+
const { userId, scope, apiBase, activeId } = options;
|
|
104
104
|
const [conversationId, setConversationId] = useState(undefined);
|
|
105
105
|
const [initialMessages, setInitialMessages] = useState([]);
|
|
106
106
|
const [isLoading, setIsLoading] = useState(Boolean(userId));
|
|
107
107
|
const mountedRef = useRef(true);
|
|
108
|
+
// Tracks "we have already resolved a no-activeId conversation for this user".
|
|
109
|
+
// Prevents creating duplicate conversations when sibling state (e.g. the
|
|
110
|
+
// page's selected agent / `scope`) transitions during the same /ai visit.
|
|
111
|
+
const resolvedForUserRef = useRef(undefined);
|
|
108
112
|
useEffect(() => {
|
|
109
113
|
mountedRef.current = true;
|
|
110
114
|
return () => {
|
|
@@ -116,6 +120,12 @@ export function useChatConversation(options) {
|
|
|
116
120
|
setConversationId(undefined);
|
|
117
121
|
setInitialMessages([]);
|
|
118
122
|
setIsLoading(false);
|
|
123
|
+
resolvedForUserRef.current = undefined;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
// Already resolved a conversation for this user during the no-activeId
|
|
127
|
+
// window — don't re-create just because `scope` or another dep changed.
|
|
128
|
+
if (!activeId && resolvedForUserRef.current === userId && conversationId) {
|
|
119
129
|
return;
|
|
120
130
|
}
|
|
121
131
|
let cancelled = false;
|
|
@@ -123,24 +133,42 @@ export function useChatConversation(options) {
|
|
|
123
133
|
setIsLoading(true);
|
|
124
134
|
(async () => {
|
|
125
135
|
try {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const existing = await fetchConversation(apiBase, cached);
|
|
136
|
+
if (activeId) {
|
|
137
|
+
const existing = await fetchConversation(apiBase, activeId);
|
|
129
138
|
if (cancelled)
|
|
130
139
|
return;
|
|
131
140
|
if (existing) {
|
|
141
|
+
writeCache(key, existing.id);
|
|
132
142
|
setConversationId(existing.id);
|
|
133
143
|
setInitialMessages(toUIMessages(existing.messages));
|
|
144
|
+
resolvedForUserRef.current = userId;
|
|
134
145
|
return;
|
|
135
146
|
}
|
|
147
|
+
// Requested id is gone — fall through to create a fresh one.
|
|
136
148
|
writeCache(key, undefined);
|
|
137
149
|
}
|
|
150
|
+
else {
|
|
151
|
+
const cached = readCache(key);
|
|
152
|
+
if (cached) {
|
|
153
|
+
const existing = await fetchConversation(apiBase, cached);
|
|
154
|
+
if (cancelled)
|
|
155
|
+
return;
|
|
156
|
+
if (existing) {
|
|
157
|
+
setConversationId(existing.id);
|
|
158
|
+
setInitialMessages(toUIMessages(existing.messages));
|
|
159
|
+
resolvedForUserRef.current = userId;
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
writeCache(key, undefined);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
138
165
|
const fresh = await createConversation(apiBase);
|
|
139
166
|
if (cancelled)
|
|
140
167
|
return;
|
|
141
168
|
writeCache(key, fresh.id);
|
|
142
169
|
setConversationId(fresh.id);
|
|
143
170
|
setInitialMessages(toUIMessages(fresh.messages));
|
|
171
|
+
resolvedForUserRef.current = userId;
|
|
144
172
|
}
|
|
145
173
|
catch {
|
|
146
174
|
if (!cancelled) {
|
|
@@ -156,7 +184,11 @@ export function useChatConversation(options) {
|
|
|
156
184
|
return () => {
|
|
157
185
|
cancelled = true;
|
|
158
186
|
};
|
|
159
|
-
|
|
187
|
+
// `conversationId` intentionally omitted: it's only read inside the
|
|
188
|
+
// short-circuit guard, which is governed by the ref. Including it would
|
|
189
|
+
// re-run the effect after we successfully resolved an id.
|
|
190
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
191
|
+
}, [userId, scope, apiBase, activeId]);
|
|
160
192
|
const reset = useCallback(async () => {
|
|
161
193
|
if (!userId)
|
|
162
194
|
return;
|