Skip to content

Commit 7eb613c

Browse files
feat(app-shell): author a nav entry's landing, badge, order and visible condition in the Studio inspector (#26977)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ895JboDnMZJBN12E3pQATJL)_ Fixes #26740 ## 维护者速读 - 改了什么:Studio「界面」支柱的导航项检查器,现在能编辑导航项本就声明的键:对象项的落点(默认、命名视图 `viewName`、记录 `recordId` 加 `recordMode`、筛选 `filters`),以及 `badge`/`badgeVariant`、`order`、`visible`。没有新增任何键。 - 为什么改:这些键 spec 早已声明、运行时也会兑现,但 Studio 里只能看不能改(`visible` 只读,其余根本没有入口),作者只能去 Setup 或手改 JSON。 - 风险与回滚:只动 `StudioNavItemInspector` 一个组件,外加把 `FiltersEditor` 原样搬到新文件 `NavFiltersEditor.tsx`;i18n 只增 11 行 studio 键。回滚就是 revert 本 PR。 - 先看:`StudioDesignSurface.tsx` 里 `chooseLanding` 与 `onVisibleLint` 两个处理函数;前者保证 spec 互斥的落点键不会同时写入,后者保证有 CEL 错误的条件不会被写进草稿。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/StudioDesignSurface.navItemInspectorKeys-26740.test.tsx`,19 条应全绿。 - 需要你做的:评审即可;无需决定。三条顺路观察写在 Acceptance notes,均未立卡。 ## What changes The Studio Interfaces pillar's nav-item inspector (`StudioNavItemInspector`) now authors every key `NavigationItemSchema` declares on the entry's member that the ruling names, and nothing the member does not declare: - **Landing of an `object` entry**, as one choice: the object's default, a named view (`viewName`, picked from the package's views of that object through `usePackageNavTargets`), a record (`recordId` plus `recordMode`), or a filtered slice (`filters`, through the same rows editor Setup's `AppNavInspector` mounts). Choosing a landing removes the other landings' keys, because `objectNavTargetExclusivity` refuses `filters` beside `viewName`/`recordId`; choosing a record also removes `runAction`, which the spec refuses beside `recordId`. The landing is read off the keys the entry holds (`deriveObjectTargetMode`, the runtime's precedence). - **`badge` and `badgeVariant`**: a text field and a picker over the four variants the spec declares. The variant table is keyed by the spec-derived type, so a variant the spec adds or drops stops the file compiling. - **`order`**: a number field, written as a number; an emptied field removes the key. - **`visible`**: now edited in `CelPredicateField`, the control the Data pillar and the page-block inspector already use for predicates, with the roots the console binds for a nav entry (`current_user`, plus `data` only on an entry that declares `dataSource`). Text reaches the entry only while its lint finds no error, so the nav autosave can never send an unparseable condition; until then the entry keeps its previous condition and the field says "Not saved yet". An envelope keeps its envelope (only its `source` is edited). An envelope with no CEL `source` is still shown read-only, as before. - Every emptied field removes its key; nothing writes `''`. Supporting changes: - `FiltersEditor` is moved out of `AppNavInspector.tsx` into `metadata-admin/inspectors/NavFiltersEditor.tsx` as a pure move (the only change is `export`), so both inspectors mount the same editor. - 11 new `engine.inspector.appNav.*` rows in the `studio` namespace (`en`, `zh`). No row in `metadata-admin/i18n.ts` is added or changed. - Changeset: `@object-ui/i18n` patch. Clause-②: no Metadata-protocol: none ## Why The spec has declared these keys all along and the running menu honours them, but the Studio inspector offered no way to set them: `visible` was shown read-only and the rest had no field. Studio builders had to leave for Setup or hand-edit JSON. ## Tests New file `StudioDesignSurface.navItemInspectorKeys-26740.test.tsx`, 19 tests: - Each declared key is authored, written in the spec's spelling, and the resulting entry parses: named view (and the running menu's link goes to that view), record, filters, badge and variant, order (a number), visible. - A key the member does not declare is not offered: a `page` entry has no landing; a `separator` offers `order` only; an `object` entry with no object has no landing; each landing shows only its own fields. - A landing change removes the keys the spec refuses beside it (record to filters, filters to record including `runAction`, back to default). - `visible` round-trips: shown and left as authored; an envelope keeps its envelope; a condition with a lint error is not written and the hint shows, then the fix is written; emptying removes the key; a non-CEL envelope is read-only. - `order` moves an entry in the running menu (`NavigationRenderer`). Four existing tests change. Three now name the Label textbox they query, because the inspector gained a second textbox (Badge): `navItemInspectorLabel-11196`, `navItemInspectorLabelMaps-11148`, `navItemTypes-11790`. `interfacesNavTree-12455` now asserts the `visible` value in the editable field, and its control case uses a separator. Ablations, one per control, each through `scripts/ablation-replace.mjs` (anchor hit once, blob changed, then restored with blob equal to HEAD). Every one turned the suite red: | Control | Mutation | Red | |---|---|---| | landing | picker does nothing | 7 | | landing clears other keys | key removal loop dropped | 3 | | view | picker does nothing | 1 | | record id | field does nothing | 2 | | record mode | picker does nothing | 1 | | filters | editor commit does nothing | 1 | | badge | field does nothing | 1 | | badge variant | picker does nothing | 1 | | order | field does nothing | 2 | | order as a number | writes the string | 2 | | visible | lint-clean text not written | 4 | | visible lint gate | erroring text written too | 1 | | badge offered only where declared | shown on every member | 1 | ## Acceptance notes Observations from reading the code; none reproduced through a public door, none filed: - The Studio rail and canvas list entries in array order and do not sort by `order`, while the running menu does (`byNavOrder`). Once entries carry `order`, a drag reorder in the rail can be overridden at runtime. - Setup's `AppNavInspector` derives its target mode from the keys present, so choosing "record" or "view" there is not held until a value is entered. - Setup's `clearedTargetPatch` does not remove `runAction` when the target becomes a record. Design note: the dispatch asks that `visible` use Studio's predicate control and validation. The Studio's other blocking predicates block the host autosave through `onBlockingIssuesChange`; wiring that here would edit the host outside this card's file surface. Instead the inspector writes the condition only when its lint is clean, which gives the same guarantee (no erroring predicate is ever saved) and says so on the field. Not added, because the ruling does not list them: `requiredPermissions`, `requiresObject`, `requiresService`, `dataSource`. ## Verification On head `13df6681` (main merged in; the merge touched none of this PR's files): - New tests: `StudioDesignSurface.navItemInspectorKeys-26740.test.tsx` 19/19 pass. - Neighbouring Studio nav and `AppNavInspector` tests: 15 files, 124 tests pass. Before the merge: every `metadata-admin/inspectors/` test plus the deep-link and nav-surface tests, 198 files, 2650 pass, 1 skipped. - `@object-ui/app-shell` and `@object-ui/i18n` type-check exit 0; `@object-ui/i18n` tests 89 files, 1373 pass, 13 skipped. - ESLint on every touched file: 0 errors. - `dispatch-gates --commands` derived 34 gate families; all 34 ran and exit 0, and `dispatch-gates --ran` reconciles 34 derived, 34 run, 0 not measured. That includes `node scripts/check-sdui-manifest.mjs` and `pnpm check:nul-bytes`. - One gate, `pnpm --dir ui exec vitest run scripts/`, first went red (2 tests in `check-readme-exports.test.ts`) because the local `plugin-detail` dist predated the merge that added `RecordPageColumns`. After rebuilding `@object-ui/plugin-detail` it ran 178 files, 5065 pass, exit 0. This PR does not touch that package. - 13 ablations, one per control, all red, each restored with blob equal to HEAD (table under Tests). ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; ESLint on every touched file reports 0 errors. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not applicable. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the maintainer summary above is the requested Chinese digest). --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbXKcuJdVEkbLwwWgKt)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9fab076 commit 7eb613c

13 files changed

Lines changed: 804 additions & 126 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/i18n': patch
3+
---
4+
5+
feat(i18n): `studio` namespace rows for the Studio nav-item inspector's badge, order and visible-condition fields
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **Eleven new `engine.inspector.appNav.*` keys** in `@object-ui/i18n/namespaces/studio`, in `en` and `zh`: the badge and its style (with the four style names the spec's `badgeVariant` declares), the order and its hint, and two hints for the `visible` condition. They label the fields the Studio's Interfaces nav-item inspector now offers for keys a navigation entry already declares (objectstack#26740). Additive: no key changes or goes, no export changes.

‎ui/packages/app-shell/src/views/metadata-admin/inspectors/AppNavInspector.tsx‎

Lines changed: 1 addition & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,10 @@ import * as React from 'react';
4141
import { resolveHref } from '@object-ui/layout';
4242
import type { NavigationItem } from '@object-ui/types';
4343
import type { I18nLabel } from '@objectstack/spec/ui';
44-
import { Plus, X } from 'lucide-react';
4544
import { Label } from '@object-ui/components';
4645
import type { MetadataInspectorProps } from '../inspector-registry.js';
4746
import { t } from '../i18n.js';
4847
import { useMetadataClient } from '../useMetadata.js';
49-
import { useObjectFields } from '../previews/useObjectFields.js';
5048
import { useNavTargetLabel } from '../../../hooks/useNavTargetLabel.js';
5149
import {
5250
clearedLabel,
@@ -66,6 +64,7 @@ import {
6664
moveArray,
6765
} from './_shared.js';
6866
import { InspectorComboField } from './InspectorComboField.js';
67+
import { FiltersEditor } from './NavFiltersEditor.js';
6968
import { IconPickerWidget } from '../widgets.js';
7069
import {
7170
NAV_ITEM_TYPES,
@@ -221,95 +220,6 @@ const PREVIEW_CTX = {
221220
currentOrgId: '{current_org_id}',
222221
};
223222

224-
/** Key/value rows editor for the `filters` landing mode (ADR-0055). */
225-
function FiltersEditor({
226-
objectName,
227-
filters,
228-
onCommit,
229-
disabled,
230-
locale,
231-
}: {
232-
objectName: string;
233-
filters: Record<string, string>;
234-
onCommit: (next: Record<string, string>) => void;
235-
disabled?: boolean;
236-
locale: MetadataInspectorProps['locale'];
237-
}) {
238-
const { fields } = useObjectFields(objectName || undefined);
239-
const fieldOptions = React.useMemo(
240-
() => fields.map((f) => ({ value: f.name, label: f.label && f.label !== f.name ? `${f.label} (${f.name})` : f.name })),
241-
[fields],
242-
);
243-
const entries = Object.entries(filters);
244-
245-
const update = (index: number, key: string, value: string) => {
246-
const next = entries.slice();
247-
next[index] = [key, value];
248-
onCommit(Object.fromEntries(next.filter(([k]) => k !== '')));
249-
};
250-
const remove = (index: number) => {
251-
const next = entries.slice();
252-
next.splice(index, 1);
253-
onCommit(Object.fromEntries(next));
254-
};
255-
256-
return (
257-
<div className="space-y-1.5">
258-
<div className="text-[11px] font-medium text-muted-foreground">
259-
{t('engine.inspector.appNav.filters', locale)}
260-
</div>
261-
{entries.map(([field, value], i) => (
262-
<div key={`${field}-${i}`} className="flex items-center gap-1">
263-
<div className="min-w-0 flex-1">
264-
<InspectorComboField
265-
// No visible per-row label — the `field = value` shape is read
266-
// from the row itself — so the trigger carries its own name
267-
// instead of being an anonymous combobox (objectui#3997).
268-
ariaLabel={t('engine.inspector.appNav.filtersField', locale)}
269-
value={field}
270-
onCommit={(v) => update(i, v, value)}
271-
options={fieldOptions}
272-
placeholder={t('engine.inspector.appNav.filtersField', locale)}
273-
disabled={disabled}
274-
mono
275-
/>
276-
</div>
277-
<span className="text-xs text-muted-foreground">=</span>
278-
<input
279-
value={value}
280-
onChange={(e) => update(i, field, e.target.value)}
281-
placeholder={t('engine.inspector.appNav.filtersValue', locale)}
282-
disabled={disabled}
283-
className="h-7 w-0 min-w-0 flex-1 rounded border bg-background px-2 font-mono text-xs"
284-
data-testid={`nav-filter-value-${field || 'new'}`}
285-
/>
286-
<button
287-
type="button"
288-
onClick={() => remove(i)}
289-
disabled={disabled}
290-
className="rounded p-1 text-muted-foreground hover:bg-muted"
291-
aria-label={t('engine.inspector.appNav.filtersRemove', locale)}
292-
data-testid={`nav-filter-remove-${field || 'new'}`}
293-
>
294-
<X className="h-3 w-3" />
295-
</button>
296-
</div>
297-
))}
298-
<button
299-
type="button"
300-
onClick={() => onCommit({ ...filters, '': '' })}
301-
disabled={disabled || Object.prototype.hasOwnProperty.call(filters, '')}
302-
className="inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] text-muted-foreground hover:bg-muted"
303-
data-testid="nav-filter-add"
304-
>
305-
<Plus className="h-3 w-3" />
306-
{t('engine.inspector.appNav.filtersAdd', locale)}
307-
</button>
308-
<p className="text-[11px] text-muted-foreground">{t('engine.inspector.appNav.filtersHint', locale)}</p>
309-
</div>
310-
);
311-
}
312-
313223
export function AppNavInspector({ selection, draft, name, onPatch, onClearSelection, onSelectionChange, locale, readOnly }: MetadataInspectorProps) {
314224
const hops = parsePath(selection.id);
315225
const { node, parent, index } = hops ? readAt(draft, hops) : { node: null, parent: [] as NavItem[], index: -1 };
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The `filters` landing-mode editor of an `object` nav entry, shared by the two
5+
* nav-item inspectors: Setup's `AppNavInspector` and the Studio's
6+
* `StudioNavItemInspector` (objectstack#26740). Moved here unchanged out of
7+
* `AppNavInspector.tsx` so both mount the same rows editor.
8+
*/
9+
10+
import * as React from 'react';
11+
import { Plus, X } from 'lucide-react';
12+
import type { MetadataInspectorProps } from '../inspector-registry.js';
13+
import { t } from '../i18n.js';
14+
import { useObjectFields } from '../previews/useObjectFields.js';
15+
import { InspectorComboField } from './InspectorComboField.js';
16+
17+
/** Key/value rows editor for the `filters` landing mode (ADR-0055). */
18+
export function FiltersEditor({
19+
objectName,
20+
filters,
21+
onCommit,
22+
disabled,
23+
locale,
24+
}: {
25+
objectName: string;
26+
filters: Record<string, string>;
27+
onCommit: (next: Record<string, string>) => void;
28+
disabled?: boolean;
29+
locale: MetadataInspectorProps['locale'];
30+
}) {
31+
const { fields } = useObjectFields(objectName || undefined);
32+
const fieldOptions = React.useMemo(
33+
() => fields.map((f) => ({ value: f.name, label: f.label && f.label !== f.name ? `${f.label} (${f.name})` : f.name })),
34+
[fields],
35+
);
36+
const entries = Object.entries(filters);
37+
38+
const update = (index: number, key: string, value: string) => {
39+
const next = entries.slice();
40+
next[index] = [key, value];
41+
onCommit(Object.fromEntries(next.filter(([k]) => k !== '')));
42+
};
43+
const remove = (index: number) => {
44+
const next = entries.slice();
45+
next.splice(index, 1);
46+
onCommit(Object.fromEntries(next));
47+
};
48+
49+
return (
50+
<div className="space-y-1.5">
51+
<div className="text-[11px] font-medium text-muted-foreground">
52+
{t('engine.inspector.appNav.filters', locale)}
53+
</div>
54+
{entries.map(([field, value], i) => (
55+
<div key={`${field}-${i}`} className="flex items-center gap-1">
56+
<div className="min-w-0 flex-1">
57+
<InspectorComboField
58+
// No visible per-row label — the `field = value` shape is read
59+
// from the row itself — so the trigger carries its own name
60+
// instead of being an anonymous combobox (objectui#3997).
61+
ariaLabel={t('engine.inspector.appNav.filtersField', locale)}
62+
value={field}
63+
onCommit={(v) => update(i, v, value)}
64+
options={fieldOptions}
65+
placeholder={t('engine.inspector.appNav.filtersField', locale)}
66+
disabled={disabled}
67+
mono
68+
/>
69+
</div>
70+
<span className="text-xs text-muted-foreground">=</span>
71+
<input
72+
value={value}
73+
onChange={(e) => update(i, field, e.target.value)}
74+
placeholder={t('engine.inspector.appNav.filtersValue', locale)}
75+
disabled={disabled}
76+
className="h-7 w-0 min-w-0 flex-1 rounded border bg-background px-2 font-mono text-xs"
77+
data-testid={`nav-filter-value-${field || 'new'}`}
78+
/>
79+
<button
80+
type="button"
81+
onClick={() => remove(i)}
82+
disabled={disabled}
83+
className="rounded p-1 text-muted-foreground hover:bg-muted"
84+
aria-label={t('engine.inspector.appNav.filtersRemove', locale)}
85+
data-testid={`nav-filter-remove-${field || 'new'}`}
86+
>
87+
<X className="h-3 w-3" />
88+
</button>
89+
</div>
90+
))}
91+
<button
92+
type="button"
93+
onClick={() => onCommit({ ...filters, '': '' })}
94+
disabled={disabled || Object.prototype.hasOwnProperty.call(filters, '')}
95+
className="inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] text-muted-foreground hover:bg-muted"
96+
data-testid="nav-filter-add"
97+
>
98+
<Plus className="h-3 w-3" />
99+
{t('engine.inspector.appNav.filtersAdd', locale)}
100+
</button>
101+
<p className="text-[11px] text-muted-foreground">{t('engine.inspector.appNav.filtersHint', locale)}</p>
102+
</div>
103+
);
104+
}

‎ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.interfacesNavTree-12455.test.tsx‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,9 @@ describe('objectui#12455 — the item header', () => {
287287
describe('objectui#12455 — the nav-item inspector', () => {
288288
const entry = { id: 'nav_gated', type: 'page', label: 'Gated', pageName: 'home', visible: 'READS_OPPORTUNITIES' };
289289

290-
it("shows the entry's `visible` predicate as authored, read-only", () => {
290+
// objectstack#26740 — the condition is edited here now, in the CEL editor;
291+
// `StudioDesignSurface.navItemInspectorKeys-26740.test.tsx` pins the edit.
292+
it("shows the entry's `visible` predicate as authored, in its editor", () => {
291293
render(
292294
<StudioNavItemInspector
293295
navId="navigation[0]"
@@ -298,17 +300,15 @@ describe('objectui#12455 — the nav-item inspector', () => {
298300
onClear={() => {}}
299301
/>,
300302
);
301-
expect(screen.getByText('Visible when')).toBeInTheDocument();
302-
expect(screen.getByTestId('nav-visible-when')).toHaveTextContent('READS_OPPORTUNITIES');
303-
expect(screen.queryByDisplayValue('READS_OPPORTUNITIES')).toBeNull();
303+
expect(screen.getByLabelText('Visible when')).toHaveValue('READS_OPPORTUNITIES');
304+
expect(screen.queryByTestId('nav-visible-when')).toBeNull();
304305
});
305306

306-
it('CONTROL: an entry with no `visible` shows no such row', () => {
307-
const { visible: _ignored, ...plain } = entry;
307+
it('CONTROL: a separator, whose member declares no `visible`, shows no such field', () => {
308308
render(
309309
<StudioNavItemInspector
310310
navId="navigation[0]"
311-
appDraft={{ navigation: [plain] }}
311+
appDraft={{ navigation: [{ id: 'sep_1', type: 'separator' }] }}
312312
objects={[]}
313313
packageId={PKG}
314314
onNavPatch={() => {}}

0 commit comments

Comments
 (0)