Skip to content

Commit 3230bdb

Browse files
fix(app-shell): keep the Tab visibility name input across keystrokes (#27025)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8HLVrp8SYSogdEQyNHRBNsf)_ Fixes #26748 ## 维护者速读 - 改了什么:Studio Access › 标签页可见性的每一行改按位置作 React key,名称输入框改为行内缓冲,只在新名称不与其它行重名时写回草稿。 - 为什么改:行 key 原是输入框正在编辑的标签名,每按一键整行重挂载,焦点丢失,只留下首字符。 - 风险与回滚:只动 Tab Visibility 区块与一个新测试文件;回滚即 revert 本 PR。 - 先看:`PermissionAdvancedFacets.tsx` 的 `tabEntries.map` 与文件末尾的 `TabNameInput`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.tabName-26748.test.tsx`。 - 需要你做的:无,等待 seat 复核。 ## What changes - Each Tab Visibility row is keyed by its position in `tabEntries`, not by the tab name its input edits. A rename keeps insertion order, so the row and the input's focus survive each keystroke. - The name input (`TabNameInput`) buffers what is typed and writes the rename only while the name is no other row's. Before, a name that passed through or ended at another row's name merged the two rows in `tabPermissions` and dropped one. While the typed name collides, the input carries `aria-invalid`. - `FacetPicker` `testId` is unchanged (`tab-visibility-${tab}`). ## Why Typing `showcase_app` into a new row stored `{"s":"hidden"}`: the row remounted after the first key. ## Verification - New `PermissionAdvancedFacets.tabName-26748.test.tsx`: `userEvent.type` of `showcase_app` key by key keeps focus and stores `{ showcase_app: 'hidden' }`; renaming an existing row keeps the other row and order; typing through another row's name merges nothing; remove drops only its own row. - Ablation: restoring `key={tab}` turns the typed-name test red. - `PermissionAdvancedFacets*` + `PermissionMatrixEditor*`: 35 files, 256 tests pass. The 24 derived gates, app-shell type-check, ui `check:spec-symbols` and eslint on both files exit 0. - Known limit, unchanged: a tab name JS orders as an integer (e.g. `1`) moves to the front of `tabPermissions`, so its row moves too. Clause-②: no Metadata-protocol: none ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [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. (`@object-ui/app-shell` is private: `skip-changeset`.) - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [ ] 8. Title, body and comments are in English. (The dispatch order asks for the Chinese 维护者速读 block.) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JQwxbXbEWQehrHjkZUViYN Co-authored-by: Claude <noreply@anthropic.com>
1 parent cb8988e commit 3230bdb

2 files changed

Lines changed: 156 additions & 7 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A Tab visibility row keeps its name input across keystrokes (objectui#26748).
5+
*
6+
* Each row was keyed by the tab name its input edits, so every keystroke
7+
* renamed the key, remounted the row, and the input lost focus after the first
8+
* character: typing `showcase_app` stored `{"s":"hidden"}`.
9+
*
10+
* What is pinned:
11+
* - a name typed one key at a time (not pasted) into a new row keeps focus
12+
* and lands whole in `tabPermissions`, with the row's visibility;
13+
* - renaming an existing row keeps the other rows and their order;
14+
* - a name that passes through another row's name merges nothing, and the
15+
* row writes again once the name is free;
16+
* - the remove button drops only its own row.
17+
*
18+
* DIRECTION: with `key={tab}` restored the typed-name pin is red (focus leaves
19+
* after the first key).
20+
*/
21+
22+
import "@testing-library/jest-dom/vitest";
23+
import * as React from "react";
24+
import { afterEach, describe, expect, it } from "vitest";
25+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
26+
import userEvent from "@testing-library/user-event";
27+
import { PermissionAdvancedFacets } from "./PermissionAdvancedFacets";
28+
29+
afterEach(cleanup);
30+
31+
const t = (k: string) => k;
32+
33+
type Draft = Record<string, unknown>;
34+
35+
/** A stateful host, so each write re-renders the facets like the editor does. */
36+
function Host({
37+
initial,
38+
onDraft,
39+
}: {
40+
initial: Draft;
41+
onDraft: (d: Draft) => void;
42+
}) {
43+
const [draft, setDraft] = React.useState<Draft>(initial);
44+
React.useEffect(() => {
45+
onDraft(draft);
46+
}, [draft, onDraft]);
47+
return (
48+
<PermissionAdvancedFacets
49+
draft={draft}
50+
setDraft={setDraft}
51+
writable
52+
allSetNames={[]}
53+
t={t}
54+
/>
55+
);
56+
}
57+
58+
function mount(tabPermissions: Record<string, string>) {
59+
let latest: Draft = {};
60+
render(
61+
<Host
62+
initial={{ tabPermissions }}
63+
onDraft={(d) => {
64+
latest = d;
65+
}}
66+
/>,
67+
);
68+
fireEvent.click(screen.getByText("perm.tabs.title"));
69+
return { tabs: () => latest.tabPermissions };
70+
}
71+
72+
describe("a Tab visibility row keeps its name input across keystrokes (objectui#26748)", () => {
73+
it("stores a name typed key by key into a new row, focus kept", async () => {
74+
const user = userEvent.setup();
75+
const { tabs } = mount({ "": "hidden" });
76+
const input = screen.getByDisplayValue("");
77+
await user.click(input);
78+
await user.type(input, "showcase_app");
79+
expect(document.activeElement).toBe(input);
80+
expect(input).toHaveValue("showcase_app");
81+
expect(tabs()).toEqual({ showcase_app: "hidden" });
82+
});
83+
84+
it("renames an existing row, keeping the others in order", async () => {
85+
const user = userEvent.setup();
86+
const { tabs } = mount({ a_tab: "hidden", b_tab: "visible" });
87+
const input = screen.getByDisplayValue("a_tab");
88+
await user.type(input, "_x");
89+
expect(document.activeElement).toBe(input);
90+
expect(JSON.stringify(tabs())).toBe(
91+
'{"a_tab_x":"hidden","b_tab":"visible"}',
92+
);
93+
});
94+
95+
it("merges nothing while the typed name is another row’s", async () => {
96+
const user = userEvent.setup();
97+
const { tabs } = mount({ app: "visible", "": "hidden" });
98+
const input = screen.getByDisplayValue("");
99+
await user.type(input, "app");
100+
expect(input).toHaveValue("app");
101+
expect(JSON.stringify(tabs())).toBe('{"app":"visible","ap":"hidden"}');
102+
await user.type(input, "_x");
103+
expect(JSON.stringify(tabs())).toBe('{"app":"visible","app_x":"hidden"}');
104+
expect(screen.getByDisplayValue("app")).not.toBe(input);
105+
});
106+
107+
it("removes only its own row", () => {
108+
const { tabs } = mount({ a_tab: "hidden", b_tab: "visible" });
109+
fireEvent.click(screen.getAllByLabelText("perm.tabs.remove")[0]!);
110+
expect(tabs()).toEqual({ b_tab: "visible" });
111+
expect(screen.getByDisplayValue("b_tab")).toBeInTheDocument();
112+
});
113+
});

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

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -777,18 +777,20 @@ export function PermissionAdvancedFacets({
777777
>
778778
<p className="text-xs text-muted-foreground mb-3">{t('perm.tabs.help')}</p>
779779
<div className="space-y-2">
780-
{tabEntries.map(([tab, vis]) => (
781-
<div key={tab} className="flex items-center gap-2">
782-
<Input
783-
value={tab}
780+
{tabEntries.map(([tab, vis], row) => (
781+
// objectui#26748 — keyed by position, not by the name the input
782+
// edits: a rename keeps insertion order, so the row (and the
783+
// input's focus) survives each keystroke.
784+
<div key={row} className="flex items-center gap-2">
785+
<TabNameInput
786+
name={tab}
784787
disabled={!writable}
785-
onChange={(e) => {
786-
const nextKey = e.target.value;
788+
taken={(name) => tabEntries.some(([k], j) => j !== row && k === name)}
789+
onRename={(nextKey) => {
787790
const next: TabPerms = {};
788791
for (const [k, v] of tabEntries) next[k === tab ? nextKey : k] = v;
789792
setTabs(next);
790793
}}
791-
className="h-8 w-64"
792794
/>
793795
<FacetPicker
794796
testId={`tab-visibility-${tab}`}
@@ -967,3 +969,37 @@ export function PermissionAdvancedFacets({
967969
}
968970

969971
export default PermissionAdvancedFacets;
972+
973+
/**
974+
* objectui#26748 — a Tab visibility row's name input. It holds what is typed
975+
* and writes the rename only while the name is no other row's: a name that
976+
* passes through (or ends at) another row's would otherwise merge the two
977+
* rows in `tabPermissions` and drop one.
978+
*/
979+
function TabNameInput({
980+
name,
981+
disabled,
982+
taken,
983+
onRename,
984+
}: {
985+
name: string;
986+
disabled: boolean;
987+
taken: (name: string) => boolean;
988+
onRename: (name: string) => void;
989+
}) {
990+
const [text, setText] = React.useState(name);
991+
React.useEffect(() => setText(name), [name]);
992+
return (
993+
<Input
994+
value={text}
995+
disabled={disabled}
996+
aria-invalid={text !== name || undefined}
997+
onChange={(e) => {
998+
const next = e.target.value;
999+
setText(next);
1000+
if (next !== name && !taken(next)) onRename(next);
1001+
}}
1002+
className="h-8 w-64"
1003+
/>
1004+
);
1005+
}

0 commit comments

Comments
 (0)