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
4 changes: 2 additions & 2 deletions packages/settings-ui/src/people-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ export function PeopleTable({
<TableHead>Kind</TableHead>
<TableHead>Status</TableHead>
<TableHead>Roles</TableHead>
<TableHead>Actions</TableHead>
<TableHead className="settings-actions-cell">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
Expand Down Expand Up @@ -402,7 +402,7 @@ export function PeopleTable({
))
)}
</TableCell>
<TableCell>
<TableCell className="settings-actions-cell">
<div className="settings-row-actions">
{person.status === "suspended" ? (
<Button
Expand Down
4 changes: 2 additions & 2 deletions packages/settings-ui/src/roles-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ export function RolesTable({
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Description</TableHead>
<TableHead>Actions</TableHead>
<TableHead className="settings-actions-cell">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
Expand All @@ -243,7 +243,7 @@ export function RolesTable({
)}
</TableCell>
<TableCell>{role.description ?? ""}</TableCell>
<TableCell>
<TableCell className="settings-actions-cell">
{role.isSystem ? (
<span className="settings-field-hint">
{SETTINGS_STRINGS.rolesSystemImmutableNote}
Expand Down
10 changes: 10 additions & 0 deletions packages/settings-ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,16 @@
display: flex;
align-items: flex-end;
gap: 0.5rem;
min-width: min-content;
}

/* .page-fill td nowrap+ellipsis clips action controls. Settings tables
keep truncation on data cells; Actions cells must show their contents. */
.settings-shell .settings-actions-cell {
overflow: visible;
text-overflow: unset;
white-space: normal;
min-width: min-content;
}

.settings-inline-error {
Expand Down
58 changes: 57 additions & 1 deletion packages/settings-ui/test/people-section.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,16 @@
// which only works for existing accounts), role changes go through the
// native role-assignment routes, the last owner can't be demoted, and a
// pending invite can be cancelled.
//
// CL-7378: the People table's Actions cells must not inherit page-fill's
// nowrap+ellipsis clip, or Suspend/Remove controls get truncated.

import { afterEach, describe, expect, mock, test } from "bun:test";
import { readFileSync } from "node:fs";
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { renderToStaticMarkup } from "react-dom/server";

const reportErrorCalls: {
error: unknown;
Expand All @@ -24,7 +29,7 @@ mock.module("@corbits/error-sink", () => ({
},
}));

const { PeopleSection } = await import("../src/people-section");
const { PeopleSection, PeopleTable } = await import("../src/people-section");

const realFetch = globalThis.fetch;
afterEach(() => {
Expand Down Expand Up @@ -632,3 +637,54 @@ describe("PeopleSection", () => {
});
}
});

function actionsCellOpenTag(markup: string): string {
const heads = [...markup.matchAll(/<th\b[^>]*>[\s\S]*?<\/th>/g)];
const index = heads.findIndex((match) => /Actions/.test(match[0]));
if (index === -1) throw new Error("no Actions column");
const firstRow = /<tbody[\s\S]*?<tr[\s\S]*?<\/tr>/.exec(markup);
if (firstRow === null) throw new Error("no body row");
const cells = [...firstRow[0].matchAll(/<td\b[^>]*>/g)];
const cell = cells[index];
if (cell === undefined) throw new Error("no Actions td");
return cell[0];
}

function ruleFor(css: string, className: string): string {
const selector = new RegExp(`\\.${className}\\s*[,{]`);
const block = css.split("}").find((candidate) => selector.test(candidate));
if (block === undefined) throw new Error(`no rule for .${className}`);
return block.slice(block.indexOf("{"));
}

describe("PeopleTable Actions column", () => {
test("Actions cells opt out of page-fill nowrap-ellipsis clipping", () => {
const markup = renderToStaticMarkup(
<PeopleTable
people={[humanPrincipal()]}
roles={[OWNER_ROLE, MEMBER_ROLE]}
onSuspend={() => undefined}
onReactivate={() => undefined}
onRemove={() => undefined}
onRoleChange={() => undefined}
/>,
);

expect(actionsCellOpenTag(markup)).toContain("settings-actions-cell");
expect(markup).toContain("Remove");

const css = readFileSync(
new URL("../src/styles.css", import.meta.url),
"utf8",
);
const cell = ruleFor(css, "settings-actions-cell");
expect(cell).toContain("overflow: visible");
expect(cell).toContain("min-width");
expect(cell).not.toContain("text-overflow: ellipsis");
expect(cell).not.toContain("white-space: nowrap");
expect(cell).not.toContain("overflow: hidden");

const actions = ruleFor(css, "settings-row-actions");
expect(actions).toContain("min-width");
});
});
69 changes: 68 additions & 1 deletion packages/settings-ui/test/roles-section.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@
// principals — the same roster as the People section. Agent/workflow
// machine identities are excluded to prevent placeholder-named garbage
// accounts from polluting the picker.
//
// CL-7378: the Roles table's Actions cells must not inherit page-fill's
// nowrap+ellipsis clip, or system-role notes and Rename/Delete controls
// get truncated.

import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { renderToStaticMarkup } from "react-dom/server";

import { RoleAssignments } from "../src/roles-section";
import { RoleAssignments, RolesTable } from "../src/roles-section";

const timestamps = {
createdAt: "2026-01-01T00:00:00.000Z",
Expand All @@ -30,6 +35,25 @@ function principal(
};
}

function actionsCellOpenTag(markup: string): string {
const heads = [...markup.matchAll(/<th\b[^>]*>[\s\S]*?<\/th>/g)];
const index = heads.findIndex((match) => /Actions/.test(match[0]));
if (index === -1) throw new Error("no Actions column");
const firstRow = /<tbody[\s\S]*?<tr[\s\S]*?<\/tr>/.exec(markup);
if (firstRow === null) throw new Error("no body row");
const cells = [...firstRow[0].matchAll(/<td\b[^>]*>/g)];
const cell = cells[index];
if (cell === undefined) throw new Error("no Actions td");
return cell[0];
}

function ruleFor(css: string, className: string): string {
const selector = new RegExp(`\\.${className}\\s*[,{]`);
const block = css.split("}").find((candidate) => selector.test(candidate));
if (block === undefined) throw new Error(`no rule for .${className}`);
return block.slice(block.indexOf("{"));
}

describe("RoleAssignments picker", () => {
test("shows only user-kind principals, not agents or workflows", () => {
const markup = renderToStaticMarkup(
Expand Down Expand Up @@ -106,3 +130,46 @@ describe("RoleAssignments picker", () => {
expect(markup).not.toContain("<optgroup");
});
});

describe("RolesTable Actions column", () => {
test("Actions cells opt out of page-fill nowrap-ellipsis clipping", () => {
const markup = renderToStaticMarkup(
<RolesTable
roles={[
{
id: "role_owner",
tenantId: "tnt_1",
name: "owner",
isSystem: true,
...timestamps,
},
{
id: "role_billing",
tenantId: "tnt_1",
name: "Billing",
isSystem: false,
...timestamps,
},
]}
onDelete={() => undefined}
onRename={() => undefined}
/>,
);

expect(actionsCellOpenTag(markup)).toContain("settings-actions-cell");

const css = readFileSync(
new URL("../src/styles.css", import.meta.url),
"utf8",
);
const cell = ruleFor(css, "settings-actions-cell");
expect(cell).toContain("overflow: visible");
expect(cell).toContain("min-width");
expect(cell).not.toContain("text-overflow: ellipsis");
expect(cell).not.toContain("white-space: nowrap");
expect(cell).not.toContain("overflow: hidden");

const actions = ruleFor(css, "settings-row-actions");
expect(actions).toContain("min-width");
});
});
Loading