diff --git a/CHANGELOG.md b/CHANGELOG.md index b4580f75..27a00954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 page), so any previously-live duplicate `/l/` link begins returning the generic 404 after upgrade — intended, but not undoable by `down()`. (#226, #227) +- **Typing a custom address already used by another page no longer looks like a + dead end.** The share modal previously flagged such a name with a red "This + address is already in use" error, hiding the fact that saving offers to MOVE + the address to the current page. The field now shows an informational hint — + "This address is in use. Saving will move it to this page." — and keeps Save + enabled, so the existing reassign-confirm flow (`409 ALIAS_REASSIGN_REQUIRED` → + "Move custom address?") is discoverable instead of reading as terminal. (#227) ## [0.94.0] - 2026-06-26 diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 95ebdd15..ffbfd0cb 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1333,6 +1333,7 @@ "A short, memorable link you can point at any shared page.": "A short, memorable link you can point at any shared page.", "Use 2-60 lowercase letters, digits and hyphens": "Use 2-60 lowercase letters, digits and hyphens", "This address is already in use": "This address is already in use", + "This address is in use. Saving will move it to this page.": "This address is in use. Saving will move it to this page.", "Move custom address?": "Move custom address?", "Move here": "Move here", "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?", diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index acd1a7c3..f0b99071 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -1190,6 +1190,7 @@ "A short, memorable link you can point at any shared page.": "Короткая запоминающаяся ссылка, которую можно направить на любую опубликованную страницу.", "Use 2-60 lowercase letters, digits and hyphens": "Используйте 2–60 строчных букв, цифр и дефисов", "This address is already in use": "Этот адрес уже занят", + "This address is in use. Saving will move it to this page.": "Этот адрес уже используется. При сохранении он будет перемещён на эту страницу.", "Move custom address?": "Переместить пользовательский адрес?", "Move here": "Переместить сюда", "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "Адрес «{{alias}}» сейчас указывает на «{{title}}». Переместить его на эту страницу?", diff --git a/apps/client/src/features/share/components/share-alias-section.test.tsx b/apps/client/src/features/share/components/share-alias-section.test.tsx new file mode 100644 index 00000000..f83e00c7 --- /dev/null +++ b/apps/client/src/features/share/components/share-alias-section.test.tsx @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import type { IShareAlias } from "@/features/share/types/share.types"; + +// matchMedia / storage are stubbed globally in vitest.setup.ts. + +// The mutation + query hooks reach react-query/network; the availability probe +// hits the API. Stub them so the section renders in isolation and we can drive +// the exact branches (taken name -> hint, 409 -> reassign modal). +const setMutateAsync = vi.fn(); +let currentAlias: IShareAlias | null = null; +let availabilityResult: { + valid: boolean; + available: boolean; + currentPageId: string | null; +} = { valid: true, available: true, currentPageId: null }; + +vi.mock("@/features/share/queries/share-query.ts", () => ({ + useShareAliasForPageQuery: () => ({ data: currentAlias }), + useSetShareAliasMutation: () => ({ + mutateAsync: setMutateAsync, + isPending: false, + }), + useRemoveShareAliasMutation: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), +})); + +vi.mock("@/features/share/services/share-service.ts", () => ({ + checkShareAliasAvailability: vi.fn(async () => availabilityResult), +})); + +import ShareAliasSection from "./share-alias-section"; + +const aliasRow = (alias: string, pageId: string): IShareAlias => ({ + id: `alias-${alias}`, + workspaceId: "ws-1", + alias, + pageId, + creatorId: "user-1", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +}); + +function renderSection(pageId = "page-Y") { + return render( + + + , + ); +} + +describe("ShareAliasSection — taken-name handling is never a dead end", () => { + beforeEach(() => { + setMutateAsync.mockReset(); + currentAlias = null; + availabilityResult = { valid: true, available: true, currentPageId: null }; + }); + + it("shows a 'will move it here' HINT (not a terminal error) when the name belongs to another page, and keeps Save enabled", async () => { + // Page Y already owns "bee"; the user retypes a name owned by page X. + currentAlias = aliasRow("bee", "page-Y"); + availabilityResult = { + valid: true, + available: false, + currentPageId: "page-X", + }; + + renderSection("page-Y"); + const input = screen.getByPlaceholderText("my-page") as HTMLInputElement; + fireEvent.change(input, { target: { value: "test2" } }); + + // The reassign hint replaces the old dead-end red error. + await waitFor( + () => + expect( + screen.getByText( + "This address is in use. Saving will move it to this page.", + ), + ).toBeDefined(), + { timeout: 2000 }, + ); + // The old terminal "already in use" error must NOT be shown. + expect(screen.queryByText("This address is already in use")).toBeNull(); + + // Save stays enabled so the confirm-reassign flow can run. + const saveBtn = screen.getByRole("button", { + name: "Save", + }) as HTMLButtonElement; + expect(saveBtn.disabled).toBe(false); + }); + + it("opens the reassign-confirm modal on a 409 ALIAS_REASSIGN_REQUIRED (path forward, not a dead end)", async () => { + currentAlias = aliasRow("bee", "page-Y"); + availabilityResult = { + valid: true, + available: false, + currentPageId: "page-X", + }; + // The server rejects the un-confirmed save asking the client to confirm. + setMutateAsync.mockRejectedValueOnce({ + status: 409, + response: { + status: 409, + data: { + code: "ALIAS_REASSIGN_REQUIRED", + currentPageId: "page-X", + currentPageTitle: "Alias Test Page X", + }, + }, + }); + + renderSection("page-Y"); + const input = screen.getByPlaceholderText("my-page") as HTMLInputElement; + fireEvent.change(input, { target: { value: "test2" } }); + + const saveBtn = screen.getByRole("button", { + name: "Save", + }) as HTMLButtonElement; + await waitFor(() => expect(saveBtn.disabled).toBe(false), { + timeout: 2000, + }); + fireEvent.click(saveBtn); + + // First save sent WITHOUT confirmReassign. + await waitFor(() => + expect(setMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ alias: "test2", confirmReassign: false }), + ), + ); + + // The "Move custom address?" confirm modal must appear (the path forward). + await waitFor(() => + expect(screen.getByText("Move custom address?")).toBeDefined(), + ); + expect(screen.getByRole("button", { name: "Move here" })).toBeDefined(); + + // Confirming retries WITH confirmReassign: true. + setMutateAsync.mockResolvedValueOnce(aliasRow("test2", "page-Y")); + fireEvent.click(screen.getByRole("button", { name: "Move here" })); + await waitFor(() => + expect(setMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ alias: "test2", confirmReassign: true }), + ), + ); + }); +}); diff --git a/apps/client/src/features/share/components/share-alias-section.tsx b/apps/client/src/features/share/components/share-alias-section.tsx index 082b9c23..a3938548 100644 --- a/apps/client/src/features/share/components/share-alias-section.tsx +++ b/apps/client/src/features/share/components/share-alias-section.tsx @@ -120,8 +120,13 @@ export default function ShareAliasSection({ }; const showInvalid = normalized.length > 0 && !isValid; - const showTaken = - isValid && !unchanged && availability && !availability.available; + // The typed name is already in use by ANOTHER page. This is NOT a dead end: + // hitting Save triggers the server's 409 `ALIAS_REASSIGN_REQUIRED` and opens + // the "Move custom address?" confirm modal that retargets the address here. + // So surface it as an informational hint (not a terminal red error) and keep + // Save enabled, instead of looking like the address is unusable. + const reassignable = + isValid && !unchanged && !!availability && !availability.available; // The slug prefix (e.g. "docs.example.com/l/") is static for the session. const prefixLabel = aliasPrefixLabel(); @@ -198,9 +203,12 @@ export default function ShareAliasSection({ error={ showInvalid ? t("Use 2-60 lowercase letters, digits and hyphens") - : showTaken - ? t("This address is already in use") - : undefined + : undefined + } + description={ + reassignable + ? t("This address is in use. Saving will move it to this page.") + : undefined } />