feat: Add ability to duplicate collection (#13197)

* feat: Add ability to duplicate collection

* Add confirmation dialog
This commit is contained in:
Tom Moor
2026-07-29 21:28:59 -04:00
committed by GitHub
parent f228714c7a
commit ade792f709
16 changed files with 605 additions and 5 deletions
+32
View File
@@ -3,6 +3,7 @@ import {
SortAlphabeticalIcon,
ArchiveIcon,
CollectionIcon,
DuplicateIcon,
EditIcon,
ExportIcon,
ImportIcon,
@@ -26,6 +27,7 @@ import Collection from "~/models/Collection";
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
import { CollectionNew } from "~/components/Collection/CollectionNew";
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
import CollectionDuplicateDialog from "~/components/CollectionDuplicateDialog";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import { DialogTitle } from "~/components/DialogTitle";
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
@@ -146,6 +148,35 @@ export const editCollectionPermissions = createAction({
},
});
export const duplicateCollection = createAction({
name: ({ t, isMenu }) =>
isMenu ? `${t("Duplicate")}` : t("Duplicate collection"),
analyticsName: "Duplicate collection",
section: ActiveCollectionSection,
icon: <DuplicateIcon />,
keywords: "copy",
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.duplicate),
perform: ({ getActiveModel, t, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
stores.dialogs.openModal({
title: (
<DialogTitle title={t("Duplicate collection")} model={collection} />
),
content: (
<CollectionDuplicateDialog
collection={collection}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
},
});
export const importDocument = createAction({
name: ({ t }) => t("Import document"),
analyticsName: "Import document",
@@ -558,6 +589,7 @@ export const rootCollectionActions = [
openCollection,
openCollectionInSplit,
createCollection,
duplicateCollection,
starCollection,
unstarCollection,
subscribeCollection,
+3 -2
View File
@@ -890,7 +890,8 @@ export const copyDocument = createActionWithChildren({
});
export const duplicateDocument = createAction({
name: ({ t, isMenu }) => (isMenu ? t("Duplicate") : t("Duplicate document")),
name: ({ t, isMenu }) =>
isMenu ? `${t("Duplicate")}` : t("Duplicate document"),
analyticsName: "Duplicate document",
section: ActiveDocumentSection,
icon: <DuplicateIcon />,
@@ -906,7 +907,7 @@ export const duplicateDocument = createAction({
invariant(document, "Document must exist");
stores.dialogs.openModal({
title: <DialogTitle title={t("Copy document")} model={document} />,
title: <DialogTitle title={t("Duplicate document")} model={document} />,
content: (
<DocumentCopy
document={document}
@@ -0,0 +1,77 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import { errToString } from "@shared/utils/error";
import { CollectionValidation } from "@shared/validations";
import type Collection from "~/models/Collection";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import Text from "~/components/Text";
type Props = {
collection: Collection;
onSubmit: () => void;
};
function CollectionDuplicateDialog({ collection, onSubmit }: Props) {
const history = useHistory();
const { t } = useTranslation();
const [name, setName] = React.useState(collection.name);
const [isSaving, setIsSaving] = React.useState(false);
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setIsSaving(true);
const toastId = toast.loading(`${t("Duplicating collection")}`);
try {
const duplicated = await collection.duplicate({ name });
setTimeout(() => {
history.push(duplicated.path);
toast.dismiss(toastId);
onSubmit();
setIsSaving(false);
}, 2000);
} catch (err) {
toast.error(errToString(err));
toast.dismiss(toastId);
setIsSaving(false);
}
},
[collection, onSubmit, name, t, history]
);
return (
<form onSubmit={handleSubmit}>
<Text as="p" type="secondary">
<Trans>
A copy of the collection and the documents within it will be created.
</Trans>
</Text>
<Flex column>
<Input
type="text"
label={t("Name")}
onChange={(ev) => setName(ev.target.value)}
value={name}
maxLength={CollectionValidation.maxNameLength}
required
autoSelect
flex
/>
</Flex>
<Flex justify="flex-end">
<Button type="submit" disabled={isSaving || !name}>
{isSaving ? `${t("Duplicating")}` : t("Duplicate")}
</Button>
</Flex>
</form>
);
}
export default observer(CollectionDuplicateDialog);
+2
View File
@@ -3,6 +3,7 @@ import { useMenuAction } from "./useMenuAction";
import { ActionSeparator } from "~/actions";
import {
deleteCollection,
duplicateCollection,
editCollection,
editCollectionPermissions,
starCollection,
@@ -49,6 +50,7 @@ export function useCollectionMenuAction({ collectionId, onRename }: Props) {
editCollection,
editCollectionPermissions,
createTemplate,
duplicateCollection,
sortCollection,
exportCollection,
archiveCollection,
+8
View File
@@ -422,6 +422,14 @@ export default class Collection extends ParanoidModel {
restore = () => this.store.restore(this);
/**
* Duplicates the collection and the published documents within it.
*
* @returns A promise that resolves to the duplicated collection.
*/
duplicate = (options?: { name?: string }) =>
this.store.duplicate(this, options);
export = (format: FileOperationFormat, includeAttachments: boolean) =>
client.post("/collections.export", {
id: this.id,
+17
View File
@@ -93,6 +93,23 @@ export default class CollectionsStore extends Store<Collection> {
});
};
@action
duplicate = async (
collection: Collection,
options?: {
name?: string;
}
): Promise<Collection> => {
const res = await client.post("/collections.duplicate", {
id: collection.id,
...options,
});
invariant(res?.data, "Data should be available");
this.addPolicies(res.policies);
return this.add(res.data);
};
@action
move = async (collectionId: string, index: string) => {
const res = await client.post("/collections.move", {
+69
View File
@@ -0,0 +1,69 @@
import { Collection } from "@server/models";
import DuplicateCollectionDocumentsTask from "@server/queues/tasks/DuplicateCollectionDocumentsTask";
import type { APIContext } from "@server/types";
type Props = {
/** The collection to duplicate */
collection: Collection;
/** Override of the duplicated collection name */
name?: string;
};
/**
* Duplicates a collection into a new collection owned by the acting user. The
* published documents within the collection are duplicated asynchronously by a
* background task once the new collection is committed.
*
* @param ctx the API context containing the acting user and transaction.
* @param props the collection to duplicate and optional overrides.
* @returns the duplicated collection.
*/
export default async function collectionDuplicator(
ctx: APIContext,
{ collection, name }: Props
): Promise<Collection> {
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const duplicated = Collection.build({
name: name ?? collection.name,
content: collection.content,
description: collection.description,
icon: collection.icon,
color: collection.color,
teamId: user.teamId,
createdById: user.id,
permission: collection.permission,
sharing: collection.sharing,
sort: collection.sort,
commenting: collection.commenting,
templateManagement: collection.templateManagement,
sourceMetadata: {
...collection.sourceMetadata,
originalCollectionId: collection.id,
},
});
await duplicated.saveWithCtx(ctx);
const scheduleTask = () =>
new DuplicateCollectionDocumentsTask().schedule({
collectionId: duplicated.id,
originalCollectionId: collection.id,
actorId: user.id,
ip: ctx.context.ip ?? null,
});
if (transaction) {
transaction.afterCommit(() => void scheduleTask());
} else {
await scheduleTask();
}
// we must reload the collection to get memberships for policy presenter
return Collection.findByPk(duplicated.id, {
userId: user.id,
transaction,
rejectOnEmpty: true,
});
}
+8
View File
@@ -46,6 +46,14 @@ allow(User, "read", Collection, (user, collection) => {
return true;
});
allow(User, "duplicate", Collection, (actor, collection) =>
and(
!!collection?.isActive,
can(actor, "read", collection),
can(actor, "createCollection", actor.team)
)
);
allow(User, "download", Collection, (actor, collection) =>
and(
can(actor, "read", collection),
@@ -0,0 +1,121 @@
import { Document } from "@server/models";
import {
buildCollection,
buildDocument,
buildDraftDocument,
buildUser,
} from "@server/test/factories";
import DuplicateCollectionDocumentsTask from "./DuplicateCollectionDocumentsTask";
describe("DuplicateCollectionDocumentsTask", () => {
it("should duplicate documents into the collection", async () => {
const user = await buildUser();
const original = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const parent = await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: original.id,
});
await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: original.id,
parentDocumentId: parent.id,
});
await new DuplicateCollectionDocumentsTask().perform({
collectionId: collection.id,
originalCollectionId: original.id,
actorId: user.id,
ip: null,
});
const documents = await Document.findAll({
where: {
collectionId: collection.id,
},
});
expect(documents).toHaveLength(2);
const duplicatedParent = documents.find((d) => !d.parentDocumentId);
const duplicatedChild = documents.find((d) => !!d.parentDocumentId);
expect(duplicatedParent?.title).toEqual(parent.title);
expect(duplicatedParent?.publishedAt).toBeTruthy();
expect(duplicatedChild?.parentDocumentId).toEqual(duplicatedParent?.id);
});
it("should not duplicate drafts", async () => {
const user = await buildUser();
const original = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: original.id,
});
await buildDraftDocument({
userId: user.id,
teamId: user.teamId,
collectionId: original.id,
});
await new DuplicateCollectionDocumentsTask().perform({
collectionId: collection.id,
originalCollectionId: original.id,
actorId: user.id,
ip: null,
});
const documents = await Document.findAll({
where: {
collectionId: collection.id,
},
});
expect(documents).toHaveLength(1);
});
it("should do nothing when the collection has been deleted", async () => {
const user = await buildUser();
const original = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
deletedAt: new Date(),
});
await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: original.id,
});
await new DuplicateCollectionDocumentsTask().perform({
collectionId: collection.id,
originalCollectionId: original.id,
actorId: user.id,
ip: null,
});
const documents = await Document.findAll({
where: {
collectionId: collection.id,
},
});
expect(documents).toHaveLength(0);
});
});
@@ -0,0 +1,71 @@
import { Op } from "sequelize";
import documentDuplicator from "@server/commands/documentDuplicator";
import { createContext } from "@server/context";
import { Collection, Document, User } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { sequelize } from "@server/storage/database";
import { BaseTask } from "./base/BaseTask";
type Props = {
/** The collection to duplicate documents into */
collectionId: string;
/** The collection to duplicate documents from */
originalCollectionId: string;
/** The user that initiated the duplication */
actorId: string;
/** The IP address of the user that initiated the duplication */
ip: string | null;
};
export default class DuplicateCollectionDocumentsTask extends BaseTask<Props> {
async perform(props: Props) {
const [collection, original, actor] = await Promise.all([
Collection.findByPk(props.collectionId),
Collection.findByPk(props.originalCollectionId),
User.findByPk(props.actorId),
]);
if (!actor || !original || !collection?.isActive) {
return;
}
const rootDocuments = await Document.findAll({
where: {
teamId: original.teamId,
collectionId: original.id,
parentDocumentId: {
[Op.is]: null,
},
archivedAt: {
[Op.is]: null,
},
},
});
if (!rootDocuments.length) {
return;
}
const structure = await original.getCachedDocumentStructure();
const sorted = DocumentHelper.sortDocumentsByStructure(
rootDocuments,
structure ?? []
).reverse(); // we have to reverse since the root documents will be added in reverse order
return sequelize.transaction(async (transaction) => {
const ctx = createContext({
user: actor,
ip: props.ip,
transaction,
});
for (const document of sorted) {
await documentDuplicator(ctx, {
document,
collection,
recursive: true,
});
}
});
}
}
@@ -45,6 +45,15 @@ exports[`#collections.delete > should require authentication 1`] = `
}
`;
exports[`#collections.duplicate > should require authentication 1`] = `
{
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#collections.export > should require authentication 1`] = `
{
"error": "authentication_required",
@@ -8,7 +8,7 @@ import {
buildDocument,
buildTeam,
} from "@server/test/factories";
import { getTestServer } from "@server/test/support";
import { getTestServer, mockTaskSchedule } from "@server/test/support";
const server = getTestServer();
@@ -1441,6 +1441,144 @@ describe("#collections.create", () => {
});
});
describe("#collections.duplicate", () => {
const schedule = mockTaskSchedule();
it("should require authentication", async () => {
const res = await server.post("/api/collections.duplicate");
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it("should duplicate collection with properties", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
userId: user.id,
teamId: team.id,
icon: "flame",
color: "#FF0000",
sharing: false,
});
const res = await server.post("/api/collections.duplicate", user, {
body: {
id: collection.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).not.toEqual(collection.id);
expect(body.data.name).toEqual(collection.name);
expect(body.data.icon).toEqual("flame");
expect(body.data.color).toEqual("#FF0000");
expect(body.data.sharing).toEqual(false);
expect(body.data.permission).toEqual(collection.permission);
expect(body.policies.length).toEqual(1);
expect(body.policies[0].abilities.read).toBeTruthy();
});
it("should allow overriding the name", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
userId: user.id,
teamId: team.id,
});
const res = await server.post("/api/collections.duplicate", user, {
body: {
id: collection.id,
name: "Copied collection",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toEqual("Copied collection");
});
it("should schedule documents in the collection to be duplicated", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
userId: user.id,
teamId: team.id,
});
const res = await server.post("/api/collections.duplicate", user, {
body: {
id: collection.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(schedule).toHaveBeenCalledWith(
expect.objectContaining({
collectionId: body.data.id,
originalCollectionId: collection.id,
actorId: user.id,
})
);
});
it("should require read permission on a private collection", async () => {
const team = await buildTeam();
const owner = await buildUser({ teamId: team.id });
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
userId: owner.id,
teamId: team.id,
permission: null,
});
const res = await server.post("/api/collections.duplicate", user, {
body: {
id: collection.id,
},
});
expect(res.status).toEqual(403);
});
it("should not allow members when collection creation is restricted", async () => {
const team = await buildTeam({
memberCollectionCreate: false,
});
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
userId: user.id,
teamId: team.id,
});
const res = await server.post("/api/collections.duplicate", user, {
body: {
id: collection.id,
},
});
expect(res.status).toEqual(403);
});
it("should not allow duplicating an archived collection", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
userId: user.id,
teamId: team.id,
archivedAt: new Date(),
});
const res = await server.post("/api/collections.duplicate", user, {
body: {
id: collection.id,
},
});
expect(res.status).toEqual(403);
});
});
describe("#collections.update", () => {
it("should require authentication", async () => {
const collection = await buildCollection();
@@ -12,6 +12,7 @@ import {
UserRole,
} from "@shared/types";
import { ImportValidation } from "@shared/validations";
import collectionDuplicator from "@server/commands/collectionDuplicator";
import collectionExporter from "@server/commands/collectionExporter";
import teamUpdater from "@server/commands/teamUpdater";
import auth from "@server/middlewares/authentication";
@@ -106,6 +107,36 @@ router.post(
}
);
router.post(
"collections.duplicate",
rateLimiter(RateLimiterStrategy.TwentyFivePerMinute),
auth(),
validate(T.CollectionsDuplicateSchema),
transaction(),
async (ctx: APIContext<T.CollectionsDuplicateReq>) => {
const { transaction } = ctx.state;
const { id, name } = ctx.input.body;
const { user } = ctx.state.auth;
const collection = await Collection.findByPk(id, {
userId: user.id,
transaction,
rejectOnEmpty: true,
});
authorize(user, "duplicate", collection);
const duplicated = await collectionDuplicator(ctx, {
collection,
name,
});
ctx.body = {
data: await presentCollection(ctx, duplicated),
policies: presentPolicies(user, [duplicated]),
};
}
);
router.post(
"collections.info",
auth(),
+11
View File
@@ -63,6 +63,17 @@ export const CollectionsCreateSchema = BaseSchema.extend({
export type CollectionsCreateReq = z.infer<typeof CollectionsCreateSchema>;
export const CollectionsDuplicateSchema = BaseSchema.extend({
body: BaseIdSchema.extend({
/** New collection name */
name: z.string().optional(),
}),
});
export type CollectionsDuplicateReq = z.infer<
typeof CollectionsDuplicateSchema
>;
export const CollectionsInfoSchema = BaseSchema.extend({
body: BaseIdSchema.extend({
/** Share Id, if available */
+5 -2
View File
@@ -14,6 +14,8 @@
"Permissions": "Permissions",
"Collection permissions": "Collection permissions",
"Share this collection": "Share this collection",
"Duplicate": "Duplicate",
"Duplicate collection": "Duplicate collection",
"Import document": "Import document",
"Uploading": "Uploading",
"Sort in sidebar": "Sort in sidebar",
@@ -80,9 +82,7 @@
"Copy as text": "Copy as text",
"Text copied to clipboard": "Text copied to clipboard",
"Copy public link": "Copy public link",
"Duplicate": "Duplicate",
"Duplicate document": "Duplicate document",
"Copy document": "Copy document",
"collection": "collection",
"Pin to {{collectionName}}": "Pin to {{collectionName}}",
"Pinned to collection": "Pinned to collection",
@@ -245,6 +245,9 @@
"Im sure Delete": "Im sure Delete",
"Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however all published documents within will be moved to the trash.": "Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however all published documents within will be moved to the trash.",
"Also, <em>{{collectionName}}</em> is being used as the start view deleting it will reset the start view to the Home page.": "Also, <em>{{collectionName}}</em> is being used as the start view deleting it will reset the start view to the Home page.",
"Duplicating collection": "Duplicating collection",
"A copy of the collection and the documents within it will be created.": "A copy of the collection and the documents within it will be created.",
"Duplicating": "Duplicating",
"Type a command or search": "Type a command or search",
"Untitled": "Untitled",
"New from template": "New from template",
+2
View File
@@ -390,6 +390,8 @@ export type SourceMetadata = {
trial?: boolean;
/** The ID of the original document when this document was duplicated. */
originalDocumentId?: string;
/** The ID of the original collection when this collection was duplicated. */
originalCollectionId?: string;
};
export type CustomTheme = {