Files
PaperClipAI/ui/src/components/ProjectProperties.save-indicator.test.tsx
T
842efe0181 fix(ui): stack project field save indicator below its label (#14765)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The web UI has a project detail page. A properties panel on that
page lets a user edit the project name, description, and other fields.
> - Each field shows a "Saving...", "Saved", or "Failed" indicator while
an edit is in progress.
> - The indicator was rendered next to the label text in a fixed 80px
label column. The "Description" label almost fills that column, so the
indicator spilled into the value column and covered the description
text.
> - A user cannot read the description while the indicator is visible.
This looks broken and it hides content.
> - This pull request stacks the indicator directly below the label
text, so it stays inside the label column.
> - The benefit is that the indicator never covers the field value, for
the description and for every other labelled field.

## Linked Issues or Issue Description

No public GitHub issue exists for this bug. The issue is described here.

**What happened?**

When a user edits a project description in the project properties panel,
the "Saving..." and "Saved" indicator appears next to the "Description"
label. The label column is 80px wide. The indicator does not fit, so it
overflows into the value column and overlaps the description text.

**Expected behavior**

The "Saving..." / "Saved" / "Failed" indicator must appear directly
below the "Description" label. It must not overlap the description text
or any other field value.

**Steps to reproduce**

1. Open a project in the Paperclip web UI.
2. Click the Description field in the properties panel and change the
text.
3. Click outside the field to save.
4. Look at the "Description" label while the "Saving..." and then
"Saved" indicator is visible. The indicator overlaps the description
text.

**Paperclip version or commit**

master at `5edf55d7350c7f08c9dd132c7e0f1421fa0bf2fb`.

**Deployment mode**

Local development (`pnpm dev`). The bug is in the UI layout, so it
applies to every deployment mode.

## What Changed

- `ui/src/components/ProjectProperties.tsx`: `FieldLabel` now renders
the label text and the `SaveIndicator` in a vertical flex column
(`flex-col`) instead of a horizontal row. The indicator sits directly
below the label and stays inside the 80px label column. This applies to
every labelled property row (Name, Description, Env, and so on), so no
label can overflow.
- `ui/src/components/ProjectProperties.save-indicator.test.tsx`: new
regression test. It asserts that the indicator is a stacked sibling
under the Description label for the `saving` and `saved` states, and
that no indicator renders for the `idle` state.

## Verification

- Run `pnpm --filter @paperclipai/ui exec vitest run
src/components/ProjectProperties` from the repo root. All
ProjectProperties tests pass, including the new save-indicator test.
- The new test fails against the previous inline layout (2 of 3 cases
fail) and passes with this change (3 of 3 cases pass).
- The existing `ProjectProperties.concurrency`,
`ProjectProperties.managed-sandbox`, and `ProjectDetail` tests pass (18
tests).
- `tsc -b` in `ui/` reports no errors in the changed files.
- Manual check: open a project, edit the description, and save. The
"Saving..." and "Saved" indicator now appears below the "Description"
label and does not cover the description text.

## Risks

- Low risk. The change is a single flex-direction swap on the label
wrapper in one component.
- Every labelled row in the project properties panel gets a slightly
taller label cell while an indicator is visible. This is intentional and
it matches the requested layout.
- No data, API, or migration changes.

## Model Used

- Claude Fable 5.1 (Anthropic), model id `claude-fable-5-1`, with
extended thinking and tool use, run through Claude Code inside a
Paperclip agent session. A human reviewed the change and the pull
request text.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Bender (Fable) <bender@paperclip.local>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 18:34:41 -07:00

134 lines
5.1 KiB
TypeScript

// @vitest-environment jsdom
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Project } from "@paperclipai/shared";
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ProjectProperties, type ProjectFieldSaveState } from "./ProjectProperties";
import { TooltipProvider } from "@/components/ui/tooltip";
import { queryKeys } from "../lib/queryKeys";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function act(callback: () => void) {
flushSync(() => {
callback();
});
}
const noop = vi.hoisted(() => () => undefined);
vi.mock("../api/projects", () => ({ projectsApi: { createWorkspace: vi.fn(), removeWorkspace: vi.fn(), updateWorkspace: vi.fn() } }));
vi.mock("../api/goals", () => ({ goalsApi: { list: vi.fn().mockResolvedValue([]) } }));
vi.mock("../api/secrets", () => ({ secretsApi: { list: vi.fn().mockResolvedValue([]), listUserSecretDefinitions: vi.fn().mockResolvedValue([]), create: vi.fn() } }));
vi.mock("../api/environments", () => ({ environmentsApi: { list: vi.fn().mockResolvedValue([]) } }));
vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: vi.fn().mockResolvedValue({ enableIsolatedWorkspaces: true }) } }));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({ companies: [{ id: "company-1", issuePrefix: "PAP" }], selectedCompanyId: "company-1", setSelectedCompanyId: vi.fn() }),
}));
// Heavy children that are unrelated to the save indicator.
vi.mock("./environment-variables-editor", () => ({ EnvironmentVariablesEditor: () => null }));
vi.mock("./InlineEditor", () => ({ InlineEditor: ({ value }: { value?: ReactNode }) => <div>{value}</div> }));
vi.mock("./PathInstructionsModal", () => ({ ChoosePathButton: () => null }));
function makeProject(overrides: Partial<Project> = {}): Project {
return {
id: "project-1",
urlKey: "project-1",
name: "Test project",
description: "A long description that fills the value column",
status: "in_progress",
goalIds: [],
goals: [],
env: null,
codebase: { workspaceId: null, repoUrl: null, repoRef: null, defaultRef: null, repoName: null },
primaryWorkspace: null,
workspaces: [],
executionWorkspacePolicy: { enabled: true, defaultMode: "shared_workspace", allowIssueOverride: true },
...overrides,
} as unknown as Project;
}
function primedClient() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
client.setQueryData(queryKeys.health, { hiddenSettings: [] });
client.setQueryData(queryKeys.instance.experimentalSettings, { enableIsolatedWorkspaces: true });
return client;
}
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.clearAllMocks();
});
function render(getFieldSaveState: (field: string) => ProjectFieldSaveState) {
act(() => {
root.render(
<QueryClientProvider client={primedClient()}>
<TooltipProvider>
<ProjectProperties
project={makeProject()}
onFieldUpdate={noop}
getFieldSaveState={getFieldSaveState}
onArchive={noop}
/>
</TooltipProvider>
</QueryClientProvider>,
);
});
}
function labelSpan(text: string): HTMLSpanElement {
const span = Array.from(container.querySelectorAll("span")).find((el) => el.textContent === text);
if (!span) throw new Error(`Label "${text}" not rendered`);
return span;
}
describe("ProjectProperties save indicator placement", () => {
it("stacks the Saving indicator below the Description label instead of beside it", () => {
render((field) => (field === "description" ? "saving" : "idle"));
const label = labelSpan("Description");
const wrapper = label.parentElement;
expect(wrapper).not.toBeNull();
// The label column is a fixed 80px wide; laying the indicator out inline
// overflows into the description text. It must be a stacked column.
expect(wrapper!.className).toContain("flex-col");
expect(wrapper!.className).not.toContain("items-center");
const indicator = label.nextElementSibling;
expect(indicator).not.toBeNull();
expect(indicator!.textContent).toContain("Saving");
expect(wrapper!.contains(indicator!)).toBe(true);
});
it("renders the Saved indicator in the same stacked position once the save completes", () => {
render((field) => (field === "description" ? "saved" : "idle"));
const label = labelSpan("Description");
expect(label.parentElement!.className).toContain("flex-col");
expect(label.nextElementSibling?.textContent).toContain("Saved");
});
it("renders no indicator when the field is idle", () => {
render(() => "idle");
const label = labelSpan("Description");
expect(label.nextElementSibling).toBeNull();
});
});