From 961fd9dc3c179009b15d51fa63703c01fb151086 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 01:34:39 +0000 Subject: [PATCH] fix(app-shell): keep the Tab visibility name input across keystrokes Each row was keyed by the tab name its input edits, so every keystroke remounted the row and the input lost focus after the first character. Rows are now keyed by position, and the name input buffers what is typed, writing the rename only while the name is no other row's (a name passing through another row's merged the two rows and dropped one). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01JQwxbXbEWQehrHjkZUViYN --- ...ssionAdvancedFacets.tabName-26748.test.tsx | 113 ++++++++++++++++++ .../PermissionAdvancedFacets.tsx | 50 ++++++-- 2 files changed, 156 insertions(+), 7 deletions(-) create mode 100644 ui/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.tabName-26748.test.tsx diff --git a/ui/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.tabName-26748.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.tabName-26748.test.tsx new file mode 100644 index 00000000000..c8e5bd1795f --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.tabName-26748.test.tsx @@ -0,0 +1,113 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A Tab visibility row keeps its name input across keystrokes (objectui#26748). + * + * Each row was keyed by the tab name its input edits, so every keystroke + * renamed the key, remounted the row, and the input lost focus after the first + * character: typing `showcase_app` stored `{"s":"hidden"}`. + * + * What is pinned: + * - a name typed one key at a time (not pasted) into a new row keeps focus + * and lands whole in `tabPermissions`, with the row's visibility; + * - renaming an existing row keeps the other rows and their order; + * - a name that passes through another row's name merges nothing, and the + * row writes again once the name is free; + * - the remove button drops only its own row. + * + * DIRECTION: with `key={tab}` restored the typed-name pin is red (focus leaves + * after the first key). + */ + +import "@testing-library/jest-dom/vitest"; +import * as React from "react"; +import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { PermissionAdvancedFacets } from "./PermissionAdvancedFacets"; + +afterEach(cleanup); + +const t = (k: string) => k; + +type Draft = Record; + +/** A stateful host, so each write re-renders the facets like the editor does. */ +function Host({ + initial, + onDraft, +}: { + initial: Draft; + onDraft: (d: Draft) => void; +}) { + const [draft, setDraft] = React.useState(initial); + React.useEffect(() => { + onDraft(draft); + }, [draft, onDraft]); + return ( + + ); +} + +function mount(tabPermissions: Record) { + let latest: Draft = {}; + render( + { + latest = d; + }} + />, + ); + fireEvent.click(screen.getByText("perm.tabs.title")); + return { tabs: () => latest.tabPermissions }; +} + +describe("a Tab visibility row keeps its name input across keystrokes (objectui#26748)", () => { + it("stores a name typed key by key into a new row, focus kept", async () => { + const user = userEvent.setup(); + const { tabs } = mount({ "": "hidden" }); + const input = screen.getByDisplayValue(""); + await user.click(input); + await user.type(input, "showcase_app"); + expect(document.activeElement).toBe(input); + expect(input).toHaveValue("showcase_app"); + expect(tabs()).toEqual({ showcase_app: "hidden" }); + }); + + it("renames an existing row, keeping the others in order", async () => { + const user = userEvent.setup(); + const { tabs } = mount({ a_tab: "hidden", b_tab: "visible" }); + const input = screen.getByDisplayValue("a_tab"); + await user.type(input, "_x"); + expect(document.activeElement).toBe(input); + expect(JSON.stringify(tabs())).toBe( + '{"a_tab_x":"hidden","b_tab":"visible"}', + ); + }); + + it("merges nothing while the typed name is another row’s", async () => { + const user = userEvent.setup(); + const { tabs } = mount({ app: "visible", "": "hidden" }); + const input = screen.getByDisplayValue(""); + await user.type(input, "app"); + expect(input).toHaveValue("app"); + expect(JSON.stringify(tabs())).toBe('{"app":"visible","ap":"hidden"}'); + await user.type(input, "_x"); + expect(JSON.stringify(tabs())).toBe('{"app":"visible","app_x":"hidden"}'); + expect(screen.getByDisplayValue("app")).not.toBe(input); + }); + + it("removes only its own row", () => { + const { tabs } = mount({ a_tab: "hidden", b_tab: "visible" }); + fireEvent.click(screen.getAllByLabelText("perm.tabs.remove")[0]!); + expect(tabs()).toEqual({ b_tab: "visible" }); + expect(screen.getByDisplayValue("b_tab")).toBeInTheDocument(); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.tsx b/ui/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.tsx index 1b2cc05943e..3bdfbf92438 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/PermissionAdvancedFacets.tsx @@ -745,18 +745,20 @@ export function PermissionAdvancedFacets({ >

{t('perm.tabs.help')}

- {tabEntries.map(([tab, vis]) => ( -
- ( + // objectui#26748 — keyed by position, not by the name the input + // edits: a rename keeps insertion order, so the row (and the + // input's focus) survives each keystroke. +
+ { - const nextKey = e.target.value; + taken={(name) => tabEntries.some(([k], j) => j !== row && k === name)} + onRename={(nextKey) => { const next: TabPerms = {}; for (const [k, v] of tabEntries) next[k === tab ? nextKey : k] = v; setTabs(next); }} - className="h-8 w-64" /> boolean; + onRename: (name: string) => void; +}) { + const [text, setText] = React.useState(name); + React.useEffect(() => setText(name), [name]); + return ( + { + const next = e.target.value; + setText(next); + if (next !== name && !taken(next)) onRename(next); + }} + className="h-8 w-64" + /> + ); +}