feat: add "Copy document" dialog (#6009)

This commit is contained in:
Tom Moor
2023-10-16 19:13:57 -04:00
committed by GitHub
parent 1ce0d3470e
commit faf97401e6
17 changed files with 415 additions and 41 deletions

View File

@@ -6,7 +6,7 @@ type Props = {
id?: string;
urlId?: string;
title: string;
emoji?: string;
emoji?: string | null;
text?: string;
state?: Buffer;
publish?: boolean;

View File

@@ -0,0 +1,84 @@
import { sequelize } from "@server/storage/database";
import { buildDocument, buildUser } from "@server/test/factories";
import documentDuplicator from "./documentDuplicator";
describe("documentDuplicator", () => {
const ip = "127.0.0.1";
it("should duplicate existing document", async () => {
const user = await buildUser();
const original = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const response = await sequelize.transaction((transaction) =>
documentDuplicator({
document: original,
collection: original.collection,
transaction,
user,
ip,
})
);
expect(response).toHaveLength(1);
expect(response[0].title).toEqual(original.title);
expect(response[0].text).toEqual(original.text);
expect(response[0].emoji).toEqual(original.emoji);
});
it("should duplicate document with title override", async () => {
const user = await buildUser();
const original = await buildDocument({
userId: user.id,
teamId: user.teamId,
emoji: "👋",
});
const response = await sequelize.transaction((transaction) =>
documentDuplicator({
document: original,
collection: original.collection,
title: "New title",
transaction,
user,
ip,
})
);
expect(response).toHaveLength(1);
expect(response[0].title).toEqual("New title");
expect(response[0].text).toEqual(original.text);
expect(response[0].emoji).toEqual(original.emoji);
});
it("should duplicate child documents with recursive=true", async () => {
const user = await buildUser();
const original = await buildDocument({
userId: user.id,
teamId: user.teamId,
emoji: "👋",
});
await buildDocument({
userId: user.id,
teamId: user.teamId,
parentDocumentId: original.id,
collection: original.collection,
});
const response = await sequelize.transaction((transaction) =>
documentDuplicator({
document: original,
collection: original.collection,
user,
transaction,
recursive: true,
ip,
})
);
expect(response).toHaveLength(2);
});
});

View File

@@ -0,0 +1,97 @@
import { Transaction, Op } from "sequelize";
import { User, Collection, Document } from "@server/models";
import documentCreator from "./documentCreator";
type Props = {
/** The user who is creating the document */
user: User;
/** The document to duplicate */
document: Document;
/** The collection to add the duplicated document to */
collection?: Collection | null;
/** Override of the parent document to add the duplicate to */
parentDocumentId?: string;
/** Override of the duplicated document title */
title?: string;
/** Override of the duplicated document publish state */
publish?: boolean;
/** Whether to duplicate child documents */
recursive?: boolean;
/** The database transaction to use for the creation */
transaction?: Transaction;
/** The IP address of the request */
ip: string;
};
export default async function documentDuplicator({
user,
document,
collection,
parentDocumentId,
title,
publish,
recursive,
transaction,
ip,
}: Props): Promise<Document[]> {
const newDocuments: Document[] = [];
const sharedProperties = {
user,
collectionId: collection?.id,
publish: publish ?? !!document.publishedAt,
ip,
transaction,
};
const duplicated = await documentCreator({
parentDocumentId: parentDocumentId ?? document.parentDocumentId,
emoji: document.emoji,
template: document.template,
title: title ?? document.title,
text: document.text,
...sharedProperties,
});
duplicated.collection = collection;
newDocuments.push(duplicated);
async function duplicateChildDocuments(
original: Document,
duplicated: Document
) {
const childDocuments = await original.findChildDocuments(
{
archivedAt: original.archivedAt
? {
[Op.ne]: null,
}
: {
[Op.eq]: null,
},
},
{
transaction,
}
);
for (const childDocument of childDocuments) {
const duplicatedChildDocument = await documentCreator({
parentDocumentId: duplicated.id,
emoji: childDocument.emoji,
title: childDocument.title,
text: childDocument.text,
...sharedProperties,
});
duplicatedChildDocument.collection = collection;
newDocuments.push(duplicatedChildDocument);
await duplicateChildDocuments(childDocument, duplicatedChildDocument);
}
}
if (recursive && !document.template) {
await duplicateChildDocuments(document, duplicated);
}
return newDocuments;
}

View File

@@ -167,7 +167,7 @@ export default async function loadDocument({
}
const childDocumentIds =
(await share.document?.getChildDocumentIds({
(await share.document?.findAllChildDocumentIds({
archivedAt: {
[Op.is]: null,
},

View File

@@ -137,7 +137,7 @@ async function documentMover({
if (collectionChanged) {
// Efficiently find the ID's of all the documents that are children of
// the moved document and update in one query
const childDocumentIds = await document.getChildDocumentIds();
const childDocumentIds = await document.findAllChildDocumentIds();
if (collectionId) {
// Reload the collection to get relationship data

View File

@@ -110,7 +110,7 @@ describe("#save", () => {
});
});
describe("#getChildDocumentIds", () => {
describe("#findAllChildDocumentIds", () => {
test("should return empty array if no children", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
@@ -124,7 +124,7 @@ describe("#getChildDocumentIds", () => {
collectionId: collection.id,
title: "test",
});
const results = await document.getChildDocumentIds();
const results = await document.findAllChildDocumentIds();
expect(results.length).toBe(0);
});
@@ -155,7 +155,7 @@ describe("#getChildDocumentIds", () => {
parentDocumentId: document2.id,
title: "test",
});
const results = await document.getChildDocumentIds();
const results = await document.findAllChildDocumentIds();
expect(results.length).toBe(2);
expect(results[0]).toBe(document2.id);
expect(results[1]).toBe(document3.id);

View File

@@ -537,19 +537,37 @@ class Document extends ParanoidModel {
return compact(users);
};
/**
* Find all of the child documents for this document
*
* @param options FindOptions
* @returns A promise that resolve to a list of documents
*/
findChildDocuments = async (
where?: Omit<WhereOptions<Document>, "parentDocumentId">,
options?: FindOptions<Document>
): Promise<Document[]> =>
await (this.constructor as typeof Document).findAll({
where: {
parentDocumentId: this.id,
...where,
},
...options,
});
/**
* Calculate all of the document ids that are children of this document by
* iterating through parentDocumentId references in the most efficient way.
* recursively iterating through parentDocumentId references in the most efficient way.
*
* @param where query options to further filter the documents
* @param options FindOptions
* @returns A promise that resolves to a list of document ids
*/
getChildDocumentIds = async (
findAllChildDocumentIds = async (
where?: Omit<WhereOptions<Document>, "parentDocumentId">,
options?: FindOptions<Document>
): Promise<string[]> => {
const getChildDocumentIds = async (
const findAllChildDocumentIds = async (
...parentDocumentId: string[]
): Promise<string[]> => {
const childDocuments = await (
@@ -568,14 +586,14 @@ class Document extends ParanoidModel {
if (childDocumentIds.length > 0) {
return [
...childDocumentIds,
...(await getChildDocumentIds(...childDocumentIds)),
...(await findAllChildDocumentIds(...childDocumentIds)),
];
}
return childDocumentIds;
};
return getChildDocumentIds(this.id);
return findAllChildDocumentIds(this.id);
};
archiveWithChildren = async (

View File

@@ -96,7 +96,7 @@ export default class SearchHelper {
const sharedDocument = await options.share.$get("document");
invariant(sharedDocument, "Cannot find document for share");
const childDocumentIds = await sharedDocument.getChildDocumentIds({
const childDocumentIds = await sharedDocument.findAllChildDocumentIds({
archivedAt: {
[Op.is]: null,
},

View File

@@ -10,6 +10,7 @@ import { TeamPreference } from "@shared/types";
import { subtractDate } from "@shared/utils/date";
import slugify from "@shared/utils/slugify";
import documentCreator from "@server/commands/documentCreator";
import documentDuplicator from "@server/commands/documentDuplicator";
import documentImporter from "@server/commands/documentImporter";
import documentLoader from "@server/commands/documentLoader";
import documentMover from "@server/commands/documentMover";
@@ -1011,6 +1012,66 @@ router.post(
}
);
router.post(
"documents.duplicate",
auth(),
validate(T.DocumentsDuplicateSchema),
transaction(),
async (ctx: APIContext<T.DocumentsDuplicateReq>) => {
const { transaction } = ctx.state;
const { id, title, publish, recursive, collectionId, parentDocumentId } =
ctx.input.body;
const { user } = ctx.state.auth;
const document = await Document.findByPk(id, {
userId: user.id,
});
authorize(user, "read", document);
const collection = collectionId
? await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId)
: document?.collection;
if (collection) {
authorize(user, "updateDocument", collection);
}
if (parentDocumentId) {
const parent = await Document.findByPk(parentDocumentId, {
userId: user.id,
});
authorize(user, "update", parent);
if (!parent.publishedAt) {
throw InvalidRequestError("Cannot duplicate document inside a draft");
}
}
const response = await documentDuplicator({
user,
collection,
document,
title,
publish,
transaction,
recursive,
parentDocumentId,
ip: ctx.request.ip,
});
ctx.body = {
data: {
documents: await Promise.all(
response.map((document) => presentDocument(document))
),
},
policies: presentPolicies(user, response),
};
}
);
router.post(
"documents.move",
auth(),
@@ -1176,7 +1237,7 @@ router.post(
});
authorize(user, "unpublish", document);
const childDocumentIds = await document.getChildDocumentIds();
const childDocumentIds = await document.findAllChildDocumentIds();
if (childDocumentIds.length > 0) {
throw InvalidRequestError(
"Cannot unpublish document with child documents"

View File

@@ -174,6 +174,23 @@ export const DocumentsSearchSchema = BaseSchema.extend({
export type DocumentsSearchReq = z.infer<typeof DocumentsSearchSchema>;
export const DocumentsDuplicateSchema = BaseSchema.extend({
body: BaseIdSchema.extend({
/** New document title */
title: z.string().optional(),
/** Whether child documents should also be duplicated */
recursive: z.boolean().optional(),
/** Whether the new document should be published */
publish: z.boolean().optional(),
/** Id of the collection to which the document should be copied */
collectionId: z.string().uuid().optional(),
/** Id of the parent document to which the document should be copied */
parentDocumentId: z.string().uuid().optional(),
}),
});
export type DocumentsDuplicateReq = z.infer<typeof DocumentsDuplicateSchema>;
export const DocumentsTemplatizeSchema = BaseSchema.extend({
body: BaseIdSchema,
});