mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 10:25:03 +00:00
fix(app): finish an inline rename when the row shows the accepted title
The saved phase treated any title change as confirmation of this save. A rename from another client delivered before ours closed the field early and showed that title, and saving a title the row already had never changed it, leaving the field read-only and the row's handlers off. The daemon's response carries the accepted title; the field now closes once the row shows it. That rule also covers a submit matching the current title, so the special-case comparison for it is gone.
This commit is contained in:
@@ -249,9 +249,7 @@ test.describe("Sidebar workspace rename", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("submitting the title another client just set closes the field without saving", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("submitting the title another client just set closes the field", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-external-same-" });
|
||||
|
||||
try {
|
||||
@@ -265,11 +263,11 @@ test.describe("Sidebar workspace rename", () => {
|
||||
await expect(input).toBeFocused();
|
||||
await renameFromAnotherClient(page, gate, workspace, "External title");
|
||||
|
||||
// The daemon accepts it, but the title does not change, so no renamed workspace follows.
|
||||
await input.fill("External title");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(input).toHaveCount(0);
|
||||
await expect(row).toContainText("External title");
|
||||
expect(gate.getClientRequestCount("workspace.title.set.request")).toBe(0);
|
||||
|
||||
// The row is a row again: its menu opens and renames.
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
@@ -279,6 +277,49 @@ test.describe("Sidebar workspace rename", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("a rename elsewhere delivered while saving does not close the field", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-external-pending-" });
|
||||
|
||||
try {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
|
||||
const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId));
|
||||
|
||||
await chooseRenameFromKebab(page, workspace.workspaceId);
|
||||
await expect(input).toBeFocused();
|
||||
await input.fill("Submitted title");
|
||||
|
||||
// Another client's rename lands just before ours. The page receives, in daemon order: that
|
||||
// rename, our response, our renamed workspace. Each is held until the previous is rendered.
|
||||
gate.holdNextServerMessage("workspace_update");
|
||||
await workspace.client.setWorkspaceTitle(workspace.workspaceId, "External title");
|
||||
await gate.waitForHeldServerMessage("workspace_update");
|
||||
gate.holdNextServerMessage("workspace.title.set.response");
|
||||
gate.holdNextServerMessage("workspace_update");
|
||||
await page.keyboard.press("Enter");
|
||||
await gate.waitForHeldServerMessage("workspace.title.set.response");
|
||||
|
||||
// Releases the oldest held update: the other client's. Ours stays held behind it.
|
||||
gate.releaseHeldServerMessage("workspace_update");
|
||||
await gate.waitForHeldServerMessage("workspace_update");
|
||||
await waitForTwoFrames(page);
|
||||
await expect(input).toHaveValue("Submitted title");
|
||||
|
||||
gate.releaseHeldServerMessage("workspace.title.set.response");
|
||||
await waitForTwoFrames(page);
|
||||
await expect(input).toHaveValue("Submitted title");
|
||||
await expect(row).not.toContainText("External title");
|
||||
|
||||
gate.releaseHeldServerMessage("workspace_update");
|
||||
await expect(input).toHaveCount(0);
|
||||
await expect(row).toContainText("Submitted title");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("below the lg breakpoint renaming opens the modal", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-modal-" });
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import {
|
||||
@@ -30,6 +31,12 @@ export function WorkspaceRenameModal({
|
||||
}: WorkspaceRenameModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const setWorkspaceTitle = useSetWorkspaceTitle(workspace);
|
||||
const submit = useCallback(
|
||||
async (title: string) => {
|
||||
await setWorkspaceTitle(title);
|
||||
},
|
||||
[setWorkspaceTitle],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveRenameModal
|
||||
@@ -39,7 +46,7 @@ export function WorkspaceRenameModal({
|
||||
placeholder={workspace.name}
|
||||
submitLabel={t("sidebar.workspace.rename.submit")}
|
||||
onClose={onClose}
|
||||
onSubmit={setWorkspaceTitle}
|
||||
onSubmit={submit}
|
||||
testID={testID}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -16,10 +16,10 @@ export function workspaceRenameInitialValue(workspace: RenamableWorkspace): stri
|
||||
return workspace.title ?? workspace.name;
|
||||
}
|
||||
|
||||
/** Resolves once the daemon accepts the title; rejects with a displayable message. */
|
||||
/** Resolves to the title the daemon accepted; rejects with a displayable message. */
|
||||
export function useSetWorkspaceTitle(
|
||||
workspace: Pick<RenamableWorkspace, "serverId" | "workspaceId">,
|
||||
): (title: string) => Promise<void> {
|
||||
): (title: string) => Promise<string | null> {
|
||||
const { t } = useTranslation();
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (title: string) => {
|
||||
@@ -28,7 +28,11 @@ export function useSetWorkspaceTitle(
|
||||
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
|
||||
}
|
||||
const trimmed = title.trim();
|
||||
await client.setWorkspaceTitle(workspace.workspaceId, trimmed.length === 0 ? null : trimmed);
|
||||
const accepted = await client.setWorkspaceTitle(
|
||||
workspace.workspaceId,
|
||||
trimmed.length === 0 ? null : trimmed,
|
||||
);
|
||||
return accepted.title;
|
||||
},
|
||||
});
|
||||
return mutation.mutateAsync;
|
||||
|
||||
@@ -19,12 +19,12 @@ export interface SidebarRenamableWorkspace extends RenamableWorkspace {
|
||||
*
|
||||
* `saving` and `saved` keep the field on screen holding the submitted text. The daemon answers
|
||||
* the request before it pushes the updated workspace, so closing on the answer would show the
|
||||
* old title for a beat. `saved` ends when the row's title moves off the one it had at submit,
|
||||
* which is not the one editing started from when another client renamed it in between.
|
||||
* old title for a beat. `saved` ends when the row shows the title the daemon accepted. Any other
|
||||
* title change is a different client's rename, delivered before ours.
|
||||
*/
|
||||
type InlineRenameSession = { kind: "inline"; initialValue: string } & (
|
||||
| { phase: "editing" }
|
||||
| { phase: "saving" | "saved"; titleBeforeSave: string | null }
|
||||
| { phase: "editing" | "saving" }
|
||||
| { phase: "saved"; acceptedTitle: string | null }
|
||||
);
|
||||
|
||||
type RenameSession = { kind: "closed" } | { kind: "modal" } | InlineRenameSession;
|
||||
@@ -51,7 +51,7 @@ export function useSidebarWorkspaceRename(
|
||||
if (
|
||||
session.kind === "inline" &&
|
||||
session.phase === "saved" &&
|
||||
currentTitle !== session.titleBeforeSave
|
||||
currentTitle === session.acceptedTitle
|
||||
) {
|
||||
setSession(CLOSED);
|
||||
}
|
||||
@@ -71,22 +71,21 @@ export function useSidebarWorkspaceRename(
|
||||
(text: string) => {
|
||||
if (session.kind !== "inline") return;
|
||||
const title = text.trim();
|
||||
// Saving the title the row already has would never move it, so `saved` would never end.
|
||||
if (title.length === 0 || title === session.initialValue || title === currentTitle) {
|
||||
if (title.length === 0 || title === session.initialValue) {
|
||||
setSession(CLOSED);
|
||||
return;
|
||||
}
|
||||
setSession({
|
||||
kind: "inline",
|
||||
initialValue: session.initialValue,
|
||||
phase: "saving",
|
||||
titleBeforeSave: currentTitle,
|
||||
});
|
||||
setSession({ kind: "inline", initialValue: session.initialValue, phase: "saving" });
|
||||
setWorkspaceTitle(title).then(
|
||||
() =>
|
||||
(acceptedTitle) =>
|
||||
setSession((previous) =>
|
||||
previous.kind === "inline" && previous.phase === "saving"
|
||||
? { ...previous, phase: "saved" }
|
||||
? {
|
||||
kind: "inline",
|
||||
initialValue: previous.initialValue,
|
||||
phase: "saved",
|
||||
acceptedTitle,
|
||||
}
|
||||
: previous,
|
||||
),
|
||||
(error: unknown) => {
|
||||
@@ -95,7 +94,7 @@ export function useSidebarWorkspaceRename(
|
||||
},
|
||||
);
|
||||
},
|
||||
[currentTitle, session, setWorkspaceTitle, toast],
|
||||
[session, setWorkspaceTitle, toast],
|
||||
);
|
||||
|
||||
const titleEditor =
|
||||
|
||||
Reference in New Issue
Block a user