feat: move boards between workspaces (#458)
* feat: add ability to move boards between workspaces Implements the "Move to workspace" feature (#344) allowing users to relocate a board and all its contents (lists, cards, labels, checklists, comments, activity) to a different workspace. Key design decisions: - Card member assignments are cleared on move (they reference workspace-scoped members that may not exist in the target workspace) - Comments and activity history are preserved (they reference global user IDs, not workspace members) - Slug conflicts in the target workspace are auto-resolved by appending a UID suffix - Permission model: requires board:edit in source workspace and board:create in target workspace - Templates and archived boards cannot be moved Co-Authored-By: Claude <noreply@anthropic.com> * refactor: consolidate board queries in move mutation Address review feedback: - Consolidate 3 separate board queries into a single findFirst() that fetches all needed fields (id, name, slug, type, isArchived, workspaceId, createdBy) - Fix slug fallback to use board.name instead of publicId for human-readable URLs Co-Authored-By: Claude <noreply@anthropic.com> * fix: filter guest workspaces from move board destination list Guests typically lack board:create permission in the target workspace, so showing them as destinations leads to a confusing server rejection. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: extract getBoardForMove repo function Moves the inline board query from the move mutation into the repo layer, consistent with how every other board mutation fetches data. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add unit tests for board.move mutation 10 test cases covering auth, validation, permissions, slug conflict resolution, and the happy path. Follows webhook.test.ts patterns. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: mark locale files as linguist-generated GitHub will now auto-collapse compiled translation files (messages.json, messages.ts, messages.po) in PR diffs and exclude them from language stats. This makes PRs that touch i18n strings much easier to review. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: remove locale file changes from PR Reverts locale file diffs and .gitattributes to match main, per review feedback. The locale changes were unrelated translation updates that inflated the PR diff. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: remove locale file changes from PR Per @hjball's review: locale compilation/translations are handled automatically on merge to main, so this PR shouldn't carry them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: align locale files with upstream/main Previous removal commit used local main, which had drifted from upstream. Re-syncing to upstream/main so the PR carries no locale diff. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(board-move): tighten deletedAt handling per review Three changes addressing @hjball's review comments, all about the schema treating deletedAt as optional metadata while the move-board flow needs it as a load-bearing invariant. 1. getBoardForMove now filters isNull(deletedAt). Moving a tombstoned board has no defensible semantics. Replaces the implicit "the board exists in the table" check with an explicit "the board is not soft-deleted" check. 2. Move-flow's clearing of cardToWorkspaceMembers now spans every card under every list ever associated with this board, including soft-deleted ones. If we leave member assignments on a deleted card and that card is later restored, the assignments would resurrect rogue references to workspace members from the OLD workspace. Removed the isNull filters on both lists and cards in that loop. 3. Move-flow now refuses to move into a soft-deleted target workspace. workspaceRepo.getByPublicId did not previously project deletedAt; extended its column selection so the call-site guard in board.move can check it. (A wider fix to make the repo treat deleted-as-not-found across all 14+ callers is left for a separate PR — narrow scope here.) Plus one regression test: throws NOT_FOUND when target workspace is soft-deleted. All 11 board-move tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
HiArrowRightOnRectangle,
|
||||
HiEllipsisHorizontal,
|
||||
HiLink,
|
||||
HiOutlineDocumentDuplicate,
|
||||
@@ -119,6 +120,17 @@ export default function BoardDropdown({
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isTemplate && canEditBoard
|
||||
? [
|
||||
{
|
||||
label: t`Move to workspace`,
|
||||
action: () => openModal("MOVE_BOARD"),
|
||||
icon: (
|
||||
<HiArrowRightOnRectangle className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: isFavorite
|
||||
? t`Remove from favorites`
|
||||
|
||||
113
apps/web/src/views/board/components/MoveBoardForm.tsx
Normal file
113
apps/web/src/views/board/components/MoveBoardForm.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useState } from "react";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export function MoveBoardForm({
|
||||
boardPublicId,
|
||||
}: {
|
||||
boardPublicId: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { closeModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const { workspace, availableWorkspaces, switchWorkspace } = useWorkspace();
|
||||
const [targetWorkspacePublicId, setTargetWorkspacePublicId] = useState("");
|
||||
|
||||
const otherWorkspaces = availableWorkspaces.filter(
|
||||
(ws) => ws.publicId !== workspace.publicId && ws.role !== "guest",
|
||||
);
|
||||
|
||||
const moveBoard = api.board.move.useMutation({
|
||||
onSuccess: () => {
|
||||
const targetWorkspace = availableWorkspaces.find(
|
||||
(ws) => ws.publicId === targetWorkspacePublicId,
|
||||
);
|
||||
closeModal();
|
||||
showPopup({
|
||||
header: t`Board moved`,
|
||||
message: t`The board has been moved to ${targetWorkspace?.name ?? "the workspace"}.`,
|
||||
icon: "success",
|
||||
});
|
||||
if (targetWorkspace) {
|
||||
switchWorkspace(targetWorkspace);
|
||||
} else {
|
||||
router.push("/boards");
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
header: t`Unable to move board`,
|
||||
message: error.message,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleMoveBoard = () => {
|
||||
if (!targetWorkspacePublicId) return;
|
||||
moveBoard.mutate({
|
||||
boardPublicId,
|
||||
targetWorkspacePublicId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div className="flex w-full flex-col justify-between pb-4">
|
||||
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
|
||||
{t`Move board to another workspace`}
|
||||
</h2>
|
||||
{otherWorkspaces.length === 0 ? (
|
||||
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`You don't have any other workspaces to move this board to.`}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<label
|
||||
htmlFor="target-workspace"
|
||||
className="mb-2 text-sm font-medium text-light-900 dark:text-dark-900"
|
||||
>
|
||||
{t`Destination workspace`}
|
||||
</label>
|
||||
<select
|
||||
id="target-workspace"
|
||||
value={targetWorkspacePublicId}
|
||||
onChange={(e) => setTargetWorkspacePublicId(e.target.value)}
|
||||
className="block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-sm shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:leading-6"
|
||||
>
|
||||
<option value="">{t`Select a workspace`}</option>
|
||||
{otherWorkspaces.map((ws) => (
|
||||
<option key={ws.publicId} value={ws.publicId}>
|
||||
{ws.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-3 text-sm text-light-800 dark:text-dark-800">
|
||||
{t`Card member assignments will be cleared when moving to a different workspace.`}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||
<Button onClick={() => closeModal()} variant="secondary">
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
{otherWorkspaces.length > 0 && (
|
||||
<Button
|
||||
onClick={handleMoveBoard}
|
||||
isLoading={moveBoard.isPending}
|
||||
disabled={!targetWorkspacePublicId}
|
||||
>
|
||||
{t`Move board`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -46,6 +46,7 @@ import { CardContextMembersModal } from "./components/CardContextMembersModal";
|
||||
import { CardContextMenu } from "./components/CardContextMenu";
|
||||
import { CardContextMoveListModal } from "./components/CardContextMoveListModal";
|
||||
import { DeleteBoardConfirmation } from "./components/DeleteBoardConfirmation";
|
||||
import { MoveBoardForm } from "./components/MoveBoardForm";
|
||||
import { DeleteListConfirmation } from "./components/DeleteListConfirmation";
|
||||
import Filters from "./components/Filters";
|
||||
import List from "./components/List";
|
||||
@@ -460,6 +461,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "MOVE_BOARD"}
|
||||
>
|
||||
<MoveBoardForm boardPublicId={boardId ?? ""} />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "CREATE_TEMPLATE"}
|
||||
|
||||
Reference in New Issue
Block a user