fix(FN-5257): restore SecretsView action icon visibility
- Standardize SecretsView action icon sizing and color so header, row, loading, and modal toggle icons stay visible in dark and light themes - Expand SecretsView tests to load app CSS and assert icon visibility across both themes - Stabilize the process supervisor fallback test by removing an unnecessary maxLifetime override Fusion-Task-Id: FN-5257
This commit is contained in:
committed by
gsxdsm
parent
35b2971f54
commit
dd6ccc67a2
@@ -142,7 +142,6 @@ describe("process-supervisor", () => {
|
||||
|
||||
const child = superviseSpawn(process.execPath, [fixturePath, "exit-immediately"], {
|
||||
stdio: "ignore",
|
||||
maxLifetimeMs: 100,
|
||||
});
|
||||
|
||||
expect(child.pgid).toBeNull();
|
||||
|
||||
@@ -160,13 +160,30 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.secrets-visibility-toggle {
|
||||
.secrets-action-icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
min-width: 1em;
|
||||
min-height: 1em;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
stroke: currentColor;
|
||||
font-size: var(--icon-size-sm);
|
||||
}
|
||||
|
||||
.secrets-header-actions .btn,
|
||||
.secrets-row-actions .btn,
|
||||
.secrets-value-row .btn-icon {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.secrets-visibility-toggle > svg {
|
||||
display: block;
|
||||
stroke: currentColor;
|
||||
.secrets-header-actions .btn-primary,
|
||||
.secrets-row-actions .btn-danger {
|
||||
color: var(--cta-text);
|
||||
}
|
||||
|
||||
.secrets-row-actions .btn:disabled {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -43,6 +43,17 @@ const EMPTY_FORM: SecretFormState = {
|
||||
envExportKey: "",
|
||||
};
|
||||
|
||||
const actionIconProps = {
|
||||
className: "secrets-action-icon",
|
||||
"aria-hidden": true,
|
||||
style: { width: "1em", height: "1em" },
|
||||
} as const;
|
||||
|
||||
const spinningActionIconProps = {
|
||||
...actionIconProps,
|
||||
className: "secrets-action-icon spin",
|
||||
} as const;
|
||||
|
||||
export const SecretsView = ({ addToast }: SecretsViewProps) => {
|
||||
const [secrets, setSecrets] = useState<SecretRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -258,8 +269,8 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => {
|
||||
<div className="secrets-header">
|
||||
<h2>Secrets</h2>
|
||||
<div className="secrets-header-actions">
|
||||
<button className="btn btn-sm" onClick={() => void loadSecrets()}><RefreshCw size={14} /> Refresh</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={openCreate}><Plus size={14} /> Add Secret</button>
|
||||
<button className="btn btn-sm" onClick={() => void loadSecrets()}><RefreshCw {...actionIconProps} /> Refresh</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={openCreate}><Plus {...actionIconProps} /> Add Secret</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -280,7 +291,7 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => {
|
||||
</article>
|
||||
|
||||
{error ? <div className="form-error">{error}</div> : null}
|
||||
{loading ? <div className="secrets-loading"><RefreshCw size={14} className="spin" /> Loading…</div> : null}
|
||||
{loading ? <div className="secrets-loading"><RefreshCw {...spinningActionIconProps} /> Loading…</div> : null}
|
||||
{!loading && sortedSecrets.length === 0 ? <div className="secrets-empty">No secrets found.</div> : null}
|
||||
|
||||
<div className="secrets-list">
|
||||
@@ -306,13 +317,13 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => {
|
||||
onClick={() => void revealSecret(secret)}
|
||||
aria-label={revealed ? "Hide" : "Reveal"}
|
||||
>
|
||||
{revealed ? <EyeOff size={14} aria-hidden="true" /> : <Eye size={14} aria-hidden="true" />}
|
||||
{revealed ? <EyeOff {...actionIconProps} /> : <Eye {...actionIconProps} />}
|
||||
</button>
|
||||
<button className="btn btn-icon" onClick={() => void copySecret(secret)} aria-label="Copy" disabled={!revealed}>
|
||||
{copiedId === secret.id ? <Check size={14} /> : <Copy size={14} />}
|
||||
{copiedId === secret.id ? <Check {...actionIconProps} /> : <Copy {...actionIconProps} />}
|
||||
</button>
|
||||
<button className="btn btn-icon" onClick={() => openEdit(secret)} aria-label="Edit"><Pencil size={14} /></button>
|
||||
<button className="btn btn-icon btn-danger" onClick={() => setShowDeleteId(secret.id)} aria-label="Delete"><Trash2 size={14} /></button>
|
||||
<button className="btn btn-icon" onClick={() => openEdit(secret)} aria-label="Edit"><Pencil {...actionIconProps} /></button>
|
||||
<button className="btn btn-icon btn-danger" onClick={() => setShowDeleteId(secret.id)} aria-label="Delete"><Trash2 {...actionIconProps} /></button>
|
||||
</div>
|
||||
{showDeleteId === secret.id ? (
|
||||
<div className="secrets-confirm">
|
||||
@@ -352,7 +363,7 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => {
|
||||
</div>
|
||||
<div className="secrets-modal-body">
|
||||
<div className="form-group"><label>Key</label><input className="input" value={form.key} onChange={(e) => setForm((c) => ({ ...c, key: e.target.value }))} /></div>
|
||||
<div className="form-group"><label>Value</label><div className="secrets-value-row"><input className="input" type={showValue ? "text" : "password"} autoComplete="off" spellCheck={false} value={form.value} onChange={(e) => setForm((c) => ({ ...c, value: e.target.value }))} /><button type="button" className="btn btn-icon secrets-visibility-toggle" onClick={() => setShowValue((s) => !s)} aria-label={showValue ? "Hide value" : "Show value"}>{showValue ? <EyeOff size={14} aria-hidden="true" /> : <Eye size={14} aria-hidden="true" />}</button></div></div>
|
||||
<div className="form-group"><label>Value</label><div className="secrets-value-row"><input className="input" type={showValue ? "text" : "password"} autoComplete="off" spellCheck={false} value={form.value} onChange={(e) => setForm((c) => ({ ...c, value: e.target.value }))} /><button type="button" className="btn btn-icon secrets-visibility-toggle" onClick={() => setShowValue((s) => !s)} aria-label={showValue ? "Hide value" : "Show value"}>{showValue ? <EyeOff {...actionIconProps} /> : <Eye {...actionIconProps} />}</button></div></div>
|
||||
<div className="form-group"><label>Description</label><textarea className="input" value={form.description} onChange={(e) => setForm((c) => ({ ...c, description: e.target.value }))} /></div>
|
||||
<div className="form-group"><label>Scope</label><div className="secrets-radio-row"><label><input type="radio" checked={form.scope === "project"} onChange={() => setForm((c) => ({ ...c, scope: "project" }))} disabled={Boolean(editing)} /> Project</label><label><input type="radio" checked={form.scope === "global"} onChange={() => setForm((c) => ({ ...c, scope: "global" }))} disabled={Boolean(editing)} /> Global</label></div></div>
|
||||
<div className="form-group"><label>Access policy</label><select className="select" value={form.accessPolicy} onChange={(e) => setForm((c) => ({ ...c, accessPolicy: e.target.value as SecretPolicy }))}><option value="auto">auto</option><option value="prompt">prompt</option><option value="deny">deny</option></select></div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { loadAllAppCss } from "../../test/cssFixture";
|
||||
import { SecretsView } from "../SecretsView";
|
||||
|
||||
type JsonResponse = {
|
||||
@@ -17,18 +18,40 @@ function mockJsonResponse({ ok, status = ok ? 200 : 400, body }: JsonResponse):
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function expectVisibleEyeIcon(svg: SVGElement) {
|
||||
const style = getComputedStyle(svg);
|
||||
expect(svg).toBeInTheDocument();
|
||||
expect(svg.getAttribute("width")).toBeTruthy();
|
||||
expect(svg.getAttribute("height")).toBeTruthy();
|
||||
expect(style.display).toBe("block");
|
||||
expect(style.stroke).not.toBe("none");
|
||||
function installAllCss() {
|
||||
const style = document.createElement("style");
|
||||
style.setAttribute("data-test-all-app-css", "true");
|
||||
style.textContent = loadAllAppCss();
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function removeAllCss() {
|
||||
document.head.querySelector('[data-test-all-app-css="true"]')?.remove();
|
||||
}
|
||||
|
||||
function expectVisibleActionIcon(button: HTMLElement) {
|
||||
const svg = button.querySelector("svg");
|
||||
expect(svg).not.toBeNull();
|
||||
const svgStyle = getComputedStyle(svg as SVGElement);
|
||||
const buttonStyle = getComputedStyle(button);
|
||||
expect(svg).toHaveClass("secrets-action-icon");
|
||||
expect(Number.parseFloat(svgStyle.width)).toBeGreaterThan(0);
|
||||
expect(Number.parseFloat(svgStyle.height)).toBeGreaterThan(0);
|
||||
expect(svgStyle.display).toBe("block");
|
||||
expect(svgStyle.stroke.toLowerCase()).not.toBe("none");
|
||||
expect(svgStyle.stroke).not.toBe(buttonStyle.backgroundColor);
|
||||
}
|
||||
|
||||
describe("SecretsView", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
document.documentElement.dataset.theme = "dark";
|
||||
installAllCss();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
removeAllCss();
|
||||
delete document.documentElement.dataset.theme;
|
||||
});
|
||||
|
||||
it("renders Not configured status", async () => {
|
||||
@@ -171,7 +194,9 @@ describe("SecretsView", () => {
|
||||
expect(screen.queryByText("__sync_passphrase__")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a visible row reveal icon", async () => {
|
||||
it.each(["dark", "light"] as const)("keeps header and row action icons visible in %s theme", async (theme) => {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi
|
||||
@@ -190,14 +215,19 @@ describe("SecretsView", () => {
|
||||
);
|
||||
|
||||
render(<SecretsView addToast={vi.fn()} />);
|
||||
await screen.findByText("VISIBLE");
|
||||
|
||||
const revealButton = await screen.findByRole("button", { name: "Reveal" });
|
||||
const svg = revealButton.querySelector("svg");
|
||||
expect(svg).not.toBeNull();
|
||||
expectVisibleEyeIcon(svg as SVGElement);
|
||||
expectVisibleActionIcon(screen.getByRole("button", { name: "Refresh" }));
|
||||
expectVisibleActionIcon(screen.getByRole("button", { name: "Add Secret" }));
|
||||
expectVisibleActionIcon(screen.getByRole("button", { name: "Reveal" }));
|
||||
expectVisibleActionIcon(screen.getByRole("button", { name: "Copy" }));
|
||||
expectVisibleActionIcon(screen.getByRole("button", { name: "Edit" }));
|
||||
expectVisibleActionIcon(screen.getByRole("button", { name: "Delete" }));
|
||||
});
|
||||
|
||||
it("renders a visible modal value toggle icon", async () => {
|
||||
it.each(["dark", "light"] as const)("keeps the modal value toggle icon visible in %s theme", async (theme) => {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi
|
||||
@@ -210,10 +240,6 @@ describe("SecretsView", () => {
|
||||
await screen.findByText("Not configured");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Add Secret" }));
|
||||
|
||||
const toggleButton = screen.getByRole("button", { name: "Show value" });
|
||||
const svg = toggleButton.querySelector("svg");
|
||||
expect(svg).not.toBeNull();
|
||||
expectVisibleEyeIcon(svg as SVGElement);
|
||||
expectVisibleActionIcon(screen.getByRole("button", { name: "Show value" }));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user