mirror of
https://github.com/outline/outline.git
synced 2026-08-03 13:27:25 +03:00
feat: Add ability to duplicate collection (#13197)
* feat: Add ability to duplicate collection * Add confirmation dialog
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 @@
|
||||
"I’m sure – Delete": "I’m 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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user