Skip to content

Commit 780cabc

Browse files
os-zhuangclaude
andauthored
feat(studio): add Local/Custom (this env) scope to the package selector (#1946)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6c76ab7 commit 780cabc

7 files changed

Lines changed: 112 additions & 47 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@object-ui/app-shell": minor
3+
---
4+
5+
feat(studio): add a "Local / Custom (this env)" scope to the package selector
6+
7+
In a self-hosted, metadata-customizable environment (single-tenant — no org
8+
dimension), the package selector only listed code packages, so metadata authored
9+
at runtime (`package_id = null` / `sys_metadata` provenance) was filtered out of
10+
every code-package view and became un-navigable — opening such an item redirected
11+
to "new". This complements framework #2252 + objectui #1937, which stop runtime
12+
metadata from being stamped into a loaded code package and keep it editable.
13+
14+
- Surface a stable, always-present "Local / Custom (this env)" entry in the
15+
Studio package context-selector (`ContextSelectors`), mapped to the
16+
`sys_metadata` scope the metadata list/get API already understands.
17+
- Accept that scope in the metadata-admin pages (`StudioHomePage`,
18+
`DirectoryPage`, `ResourceListPage`) via a shared `buildPackageScopeOptions`
19+
helper, so it no longer redirects, and the list shows this environment's
20+
runtime-authored items (`package_id = null`).
21+
- On the Studio home grid, the Local scope shows every runtime-creatable type so
22+
the user can start authoring locally even with zero items yet.

‎packages/app-shell/src/layout/ContextSelectors.tsx‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,18 @@ import {
2727
import { getIcon } from '../utils/getIcon';
2828
import { resolveI18nLabel } from '../utils';
2929

30+
// Local/Custom scope sentinel — kept inline (not imported from metadata-admin)
31+
// so this layout module never forms an import cycle with the metadata-admin
32+
// views. Mirrors `LOCAL_PACKAGE_ID` in views/metadata-admin/package-scope.ts.
33+
const LOCAL_SCOPE_ID = 'sys_metadata';
34+
function localScopeLabel(): string {
35+
const lang =
36+
(typeof document !== 'undefined' && document.documentElement?.lang) ||
37+
(typeof navigator !== 'undefined' && navigator.language) ||
38+
'';
39+
return /^zh/i.test(lang) ? '本地 / 自定义(本环境)' : 'Local / Custom (this env)';
40+
}
41+
3042
export interface ContextSelectorFilter {
3143
key: string;
3244
op?: 'eq' | 'ne' | 'in' | 'nin';
@@ -117,6 +129,15 @@ function useSelectorOptions(def: ContextSelectorDef): { options: Option[]; refet
117129
opts.push({ value, label: typeof labelRaw === 'string' && labelRaw ? labelRaw : value });
118130
}
119131
opts.sort((a, b) => a.label.localeCompare(b.label));
132+
// The package-scope selector gets a stable "Local / Custom (this env)"
133+
// entry for this environment's runtime, DB-authored metadata — it is
134+
// never a real package row (`package_id = null` / `sys_metadata`
135+
// provenance) yet must always be selectable so org-authored items are
136+
// discoverable and editable. The metadata list/get API already treats
137+
// `?package=sys_metadata` as exactly this local scope.
138+
if (/package/i.test(endpoint) && !opts.some((o) => o.value === LOCAL_SCOPE_ID)) {
139+
opts.push({ value: LOCAL_SCOPE_ID, label: localScopeLabel() });
140+
}
120141
setOptions(opts);
121142
} catch {
122143
/* offline / unauthorized — render with no options */

‎packages/app-shell/src/views/metadata-admin/DirectoryPage.tsx‎

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
tFormat,
3838
detectLocale,
3939
} from './i18n';
40+
import { buildPackageScopeOptions } from './package-scope';
4041

4142
const DOMAIN_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
4243
data: Database,
@@ -98,21 +99,7 @@ export function MetadataDirectoryPage() {
9899
try {
99100
const list = await client.list<any>('package');
100101
if (cancelled) return;
101-
const SYSTEM_SCOPES = new Set(['system', 'cloud']);
102-
const rows = (list ?? [])
103-
.map((raw) => {
104-
const item =
105-
raw && typeof raw === 'object' && 'item' in raw ? raw.item : raw;
106-
const m = ((item as any)?.manifest ?? item ?? {}) as Record<string, unknown>;
107-
return {
108-
id: m.id as string,
109-
scope: m.scope as string,
110-
name: (m.name as string) || (m.id as string),
111-
};
112-
})
113-
.filter((p) => p.id && !SYSTEM_SCOPES.has(p.scope));
114-
rows.sort((a, b) => a.name.localeCompare(b.name));
115-
setProjectPackages(rows.map((p) => ({ id: p.id, name: p.name })));
102+
setProjectPackages(buildPackageScopeOptions(list));
116103
} catch {
117104
if (!cancelled) setProjectPackages([]);
118105
}

‎packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx‎

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
resolveResourceConfig,
4040
} from './registry';
4141
import { t, tFormat, translateMetadataType, detectLocale } from './i18n';
42+
import { buildPackageScopeOptions, LOCAL_PACKAGE_ID } from './package-scope';
4243

4344
export interface MetadataResourceListPageProps {
4445
type?: string;
@@ -134,21 +135,7 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
134135
try {
135136
const list = await client.list<any>('package');
136137
if (cancelled) return;
137-
const SYSTEM_SCOPES = new Set(['system', 'cloud']);
138-
const rows = (list ?? [])
139-
.map((raw) => {
140-
const item =
141-
raw && typeof raw === 'object' && 'item' in raw ? raw.item : raw;
142-
const m = ((item as any)?.manifest ?? item ?? {}) as Record<string, unknown>;
143-
return {
144-
id: m.id as string,
145-
scope: m.scope as string,
146-
name: (m.name as string) || (m.id as string),
147-
};
148-
})
149-
.filter((p) => p.id && !SYSTEM_SCOPES.has(p.scope));
150-
rows.sort((a, b) => a.name.localeCompare(b.name));
151-
setProjectPackages(rows.map((p) => ({ id: p.id, name: p.name })));
138+
setProjectPackages(buildPackageScopeOptions(list));
152139
} catch {
153140
if (!cancelled) setProjectPackages([]);
154141
}
@@ -287,8 +274,11 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
287274
// 'sys_metadata' sentinel and untagged rows never match.
288275
if (!activePackage) return false;
289276
const pkg = (row.item as any)?._packageId;
290-
const effectivePkg = !pkg || pkg === 'sys_metadata' ? null : pkg;
291-
return effectivePkg === activePackage;
277+
const isLocal = !pkg || pkg === LOCAL_PACKAGE_ID;
278+
// Local/Custom scope surfaces this environment's runtime-authored items
279+
// (untagged / `sys_metadata` provenance); a code package shows its own.
280+
if (activePackage === LOCAL_PACKAGE_ID) return isLocal;
281+
return !isLocal && pkg === activePackage;
292282
}),
293283
[items, activePackage, config],
294284
);

‎packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx‎

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import {
5959
tFormat,
6060
detectLocale,
6161
} from './i18n';
62+
import { buildPackageScopeOptions, LOCAL_PACKAGE_ID } from './package-scope';
6263

6364
const HIDDEN_TYPES = new Set(['field', 'package']);
6465

@@ -139,21 +140,7 @@ export function StudioHomePage() {
139140
try {
140141
const list = await client.list<any>('package');
141142
if (cancelled) return;
142-
const SYSTEM_SCOPES = new Set(['system', 'cloud']);
143-
const rows = (list ?? [])
144-
.map((raw) => {
145-
const item =
146-
raw && typeof raw === 'object' && 'item' in raw ? raw.item : raw;
147-
const m = ((item as any)?.manifest ?? item ?? {}) as Record<string, unknown>;
148-
return {
149-
id: m.id as string,
150-
scope: m.scope as string,
151-
name: (m.name as string) || (m.id as string),
152-
};
153-
})
154-
.filter((p) => p.id && !SYSTEM_SCOPES.has(p.scope));
155-
rows.sort((a, b) => a.name.localeCompare(b.name));
156-
setProjectPackages(rows.map((p) => ({ id: p.id, name: p.name })));
143+
setProjectPackages(buildPackageScopeOptions(list));
157144
} catch {
158145
if (!cancelled) setProjectPackages([]);
159146
}
@@ -194,6 +181,9 @@ export function StudioHomePage() {
194181
entries.filter((e) => {
195182
if (HIDDEN_TYPES.has(e.type)) return false;
196183
if (!activePackage) return false;
184+
// Local/Custom scope: show every runtime-creatable type so the user can
185+
// start authoring any kind of metadata here, even with zero items yet.
186+
if (activePackage === LOCAL_PACKAGE_ID) return e.allowOrgOverride || e.allowRuntimeCreate;
197187
return (packagesByType[e.type] ?? []).includes(activePackage);
198188
}),
199189
[activePackage, entries, packagesByType],

‎packages/app-shell/src/views/metadata-admin/i18n.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
212212
'engine.list.warnCount': '{count} warning(s):',
213213
'engine.list.allSources': 'All sources',
214214
'engine.list.allPackages': 'All packages',
215+
'engine.package.local': 'Local / Custom (this env)',
215216
'engine.list.packageFilter': 'Package',
216217
'engine.list.source.artifact': 'Artifact',
217218
'engine.list.source.runtime': 'Runtime',
@@ -898,6 +899,7 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
898899
'engine.list.warnCount': '{count} 个警告:',
899900
'engine.list.allSources': '全部来源',
900901
'engine.list.allPackages': '全部软件包',
902+
'engine.package.local': '本地 / 自定义(本环境)',
901903
'engine.list.packageFilter': '软件包',
902904
'engine.list.source.artifact': '代码包',
903905
'engine.list.source.runtime': '运行时',
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { detectLocale, t } from './i18n';
4+
5+
/**
6+
* Sentinel "package" id for this environment's runtime, DB-authored metadata —
7+
* items with no code-package binding (`package_id IS NULL`). The metadata
8+
* list/get API treats `?package=sys_metadata` as exactly that local scope on
9+
* READ, and a WRITE under it persists `package_id = null` (matching the
10+
* server's runtime-only provenance, see framework #2252).
11+
*
12+
* Why this exists: a self-hosted, metadata-customizable environment is
13+
* single-tenant — there is no "org" dimension here; the real axis is
14+
* code-package vs. runtime (DB-authored). Before this scope, the package
15+
* selector only listed code packages, so metadata authored at runtime
16+
* (`package_id = null`) was filtered out of every code-package view and became
17+
* un-navigable (the route redirected to "new"). Surfacing the local scope as a
18+
* first-class, always-present selector entry makes it discoverable and editable.
19+
*/
20+
export const LOCAL_PACKAGE_ID = 'sys_metadata';
21+
22+
const SYSTEM_SCOPES = new Set(['system', 'cloud']);
23+
24+
/**
25+
* Build the Studio package-scope options from the raw `package` metadata list.
26+
* Filters out system/cloud-scoped packages and appends a stable
27+
* "Local / Custom (this environment)" scope so runtime metadata authored here
28+
* is always selectable/visible — even when zero items exist yet.
29+
*/
30+
export function buildPackageScopeOptions(
31+
rawList: unknown[] | null | undefined,
32+
): { id: string; name: string }[] {
33+
const rows = (rawList ?? [])
34+
.map((raw) => {
35+
const item =
36+
raw && typeof raw === 'object' && 'item' in raw ? (raw as { item: unknown }).item : raw;
37+
const m = ((item as { manifest?: unknown } | null)?.manifest ?? item ?? {}) as Record<
38+
string,
39+
unknown
40+
>;
41+
return {
42+
id: m.id as string,
43+
scope: m.scope as string,
44+
name: (m.name as string) || (m.id as string),
45+
};
46+
})
47+
.filter((p) => p.id && !SYSTEM_SCOPES.has(p.scope));
48+
rows.sort((a, b) => a.name.localeCompare(b.name));
49+
const opts = rows.map((p) => ({ id: p.id, name: p.name }));
50+
// Append (never default) so the existing first-code-package default is
51+
// preserved; the user opts into the local scope explicitly.
52+
return [...opts, { id: LOCAL_PACKAGE_ID, name: t('engine.package.local', detectLocale()) }];
53+
}

0 commit comments

Comments
 (0)