Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

/** 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<Draft>(initial);
React.useEffect(() => {
onDraft(draft);
}, [draft, onDraft]);
return (
<PermissionAdvancedFacets
draft={draft}
setDraft={setDraft}
writable
allSetNames={[]}
t={t}
/>
);
}

function mount(tabPermissions: Record<string, string>) {
let latest: Draft = {};
render(
<Host
initial={{ tabPermissions }}
onDraft={(d) => {
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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -745,18 +745,20 @@ export function PermissionAdvancedFacets({
>
<p className="text-xs text-muted-foreground mb-3">{t('perm.tabs.help')}</p>
<div className="space-y-2">
{tabEntries.map(([tab, vis]) => (
<div key={tab} className="flex items-center gap-2">
<Input
value={tab}
{tabEntries.map(([tab, vis], row) => (
// 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.
<div key={row} className="flex items-center gap-2">
<TabNameInput
name={tab}
disabled={!writable}
onChange={(e) => {
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"
/>
<FacetPicker
testId={`tab-visibility-${tab}`}
Expand Down Expand Up @@ -935,3 +937,37 @@ export function PermissionAdvancedFacets({
}

export default PermissionAdvancedFacets;

/**
* objectui#26748 — a Tab visibility row's name input. It holds what is typed
* and writes the rename only while the name is no other row's: a name that
* passes through (or ends at) another row's would otherwise merge the two
* rows in `tabPermissions` and drop one.
*/
function TabNameInput({
name,
disabled,
taken,
onRename,
}: {
name: string;
disabled: boolean;
taken: (name: string) => boolean;
onRename: (name: string) => void;
}) {
const [text, setText] = React.useState(name);
React.useEffect(() => setText(name), [name]);
return (
<Input
value={text}
disabled={disabled}
aria-invalid={text !== name || undefined}
onChange={(e) => {
const next = e.target.value;
setText(next);
if (next !== name && !taken(next)) onRename(next);
}}
className="h-8 w-64"
/>
);
}
Loading