diff --git a/app/actions/definitions/documents.tsx b/app/actions/definitions/documents.tsx index df74c6de4..66ee9d76d 100644 --- a/app/actions/definitions/documents.tsx +++ b/app/actions/definitions/documents.tsx @@ -32,6 +32,7 @@ import DocumentMove from "~/scenes/DocumentMove"; import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete"; import DocumentPublish from "~/scenes/DocumentPublish"; import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog"; +import DuplicateDialog from "~/components/DuplicateDialog"; import { createAction } from "~/actions"; import { DocumentSection } from "~/actions/sections"; import env from "~/env"; @@ -420,11 +421,19 @@ export const duplicateDocument = createAction({ const document = stores.documents.get(activeDocumentId); invariant(document, "Document must exist"); - const duped = await document.duplicate(); - // when duplicating, go straight to the duplicated document content - history.push(documentPath(duped)); - stores.toasts.showToast(t("Document duplicated"), { - type: "success", + + stores.dialogs.openModal({ + title: t("Copy document"), + isCentered: true, + content: ( + { + stores.dialogs.closeAllModals(); + history.push(documentPath(response[0])); + }} + /> + ), }); }, }); diff --git a/app/components/DuplicateDialog.tsx b/app/components/DuplicateDialog.tsx new file mode 100644 index 000000000..534855c5d --- /dev/null +++ b/app/components/DuplicateDialog.tsx @@ -0,0 +1,74 @@ +import { observer } from "mobx-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { DocumentValidation } from "@shared/validations"; +import Document from "~/models/Document"; +import ConfirmationDialog from "~/components/ConfirmationDialog"; +import Input from "./Input"; +import Text from "./Text"; + +type Props = { + /** The original document to duplicate */ + document: Document; + onSubmit: (documents: Document[]) => void; +}; + +function DuplicateDialog({ document, onSubmit }: Props) { + const { t } = useTranslation(); + const defaultTitle = t(`Copy of {{ documentName }}`, { + documentName: document.title, + }); + const [recursive, setRecursive] = React.useState(true); + const [title, setTitle] = React.useState(defaultTitle); + + const handleRecursiveChange = React.useCallback( + (ev: React.ChangeEvent) => { + setRecursive(ev.target.checked); + }, + [] + ); + + const handleTitleChange = React.useCallback( + (ev: React.ChangeEvent) => { + setTitle(ev.target.value); + }, + [] + ); + + const handleSubmit = async () => { + const result = await document.duplicate({ + recursive, + title, + }); + onSubmit(result); + }; + + return ( + + + {document.publishedAt && !document.isTemplate && ( + + )} + + ); +} + +export default observer(DuplicateDialog); diff --git a/app/components/Input.tsx b/app/components/Input.tsx index 14272da3a..458a0f68a 100644 --- a/app/components/Input.tsx +++ b/app/components/Input.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { mergeRefs } from "react-merge-refs"; import { VisuallyHidden } from "reakit/VisuallyHidden"; import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; @@ -121,6 +122,8 @@ export type Props = React.InputHTMLAttributes< margin?: string | number; error?: string; icon?: React.ReactNode; + /** Like autoFocus, but also select any text in the input */ + autoSelect?: boolean; /** Callback is triggered with the CMD+Enter keyboard combo */ onRequestSubmit?: ( ev: React.KeyboardEvent @@ -133,6 +136,7 @@ function Input( props: Props, ref: React.RefObject ) { + const internalRef = React.useRef(); const [focused, setFocused] = React.useState(false); const handleBlur = (ev: React.SyntheticEvent) => { @@ -165,6 +169,12 @@ function Input( } }; + React.useEffect(() => { + if (props.autoSelect && internalRef.current) { + internalRef.current.select(); + } + }, [props.autoSelect, internalRef]); + const { type = "text", icon, @@ -197,7 +207,10 @@ function Input( {icon && {icon}} {type === "textarea" ? ( } + ref={mergeRefs([ + internalRef, + ref as React.RefObject, + ])} onBlur={handleBlur} onFocus={handleFocus} onKeyDown={handleKeyDown} @@ -206,7 +219,10 @@ function Input( /> ) : ( } + ref={mergeRefs([ + internalRef, + ref as React.RefObject, + ])} onBlur={handleBlur} onFocus={handleFocus} onKeyDown={handleKeyDown} diff --git a/app/components/Modal.tsx b/app/components/Modal.tsx index 5cab19eab..93fbb2e9a 100644 --- a/app/components/Modal.tsx +++ b/app/components/Modal.tsx @@ -257,7 +257,7 @@ const Small = styled.div` margin: auto auto; width: 30vw; min-width: 350px; - max-width: 500px; + max-width: 450px; z-index: ${depths.modal}; display: flex; justify-content: center; diff --git a/app/models/Document.ts b/app/models/Document.ts index 3b9217fd6..cd079a48c 100644 --- a/app/models/Document.ts +++ b/app/models/Document.ts @@ -399,7 +399,8 @@ export default class Document extends ParanoidModel { move = (collectionId: string, parentDocumentId?: string | undefined) => this.store.move(this.id, collectionId, parentDocumentId); - duplicate = () => this.store.duplicate(this); + duplicate = (options?: { title?: string; recursive?: boolean }) => + this.store.duplicate(this, options); getSummary = (paragraphs = 4) => { const result = this.text.trim().split("\n").slice(0, paragraphs).join("\n"); diff --git a/app/stores/DocumentsStore.ts b/app/stores/DocumentsStore.ts index 2eb51f8bb..290363d56 100644 --- a/app/stores/DocumentsStore.ts +++ b/app/stores/DocumentsStore.ts @@ -9,7 +9,6 @@ import { DateFilter, NavigationNode, PublicTeam } from "@shared/types"; import { subtractDate } from "@shared/utils/date"; import { bytesToHumanReadable } from "@shared/utils/files"; import naturalSort from "@shared/utils/naturalSort"; -import { DocumentValidation } from "@shared/validations"; import RootStore from "~/stores/RootStore"; import Store from "~/stores/base/Store"; import Document from "~/models/Document"; @@ -558,26 +557,21 @@ export default class DocumentsStore extends Store { }; @action - duplicate = async (document: Document): Promise => { - const append = " (duplicate)"; - const res = await client.post("/documents.create", { - publish: document.isTemplate, - parentDocumentId: null, - collectionId: document.isTemplate ? document.collectionId : null, - template: document.isTemplate, - title: `${document.title.slice( - 0, - DocumentValidation.maxTitleLength - append.length - )}${append}`, - text: document.text, + duplicate = async ( + document: Document, + options?: { + title?: string; + recursive?: boolean; + } + ): Promise => { + const res = await client.post("/documents.duplicate", { + id: document.id, + ...options, }); invariant(res?.data, "Data should be available"); - const collection = this.getCollectionForDocument(document); - if (collection) { - await collection.refresh(); - } + this.addPolicies(res.policies); - return this.add(res.data); + return res.data.documents.map(this.add); }; @action diff --git a/server/commands/documentCreator.ts b/server/commands/documentCreator.ts index 4995ed7a3..022d10b30 100644 --- a/server/commands/documentCreator.ts +++ b/server/commands/documentCreator.ts @@ -6,7 +6,7 @@ type Props = { id?: string; urlId?: string; title: string; - emoji?: string; + emoji?: string | null; text?: string; state?: Buffer; publish?: boolean; diff --git a/server/commands/documentDuplicator.test.ts b/server/commands/documentDuplicator.test.ts new file mode 100644 index 000000000..4c06e1d90 --- /dev/null +++ b/server/commands/documentDuplicator.test.ts @@ -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); + }); +}); diff --git a/server/commands/documentDuplicator.ts b/server/commands/documentDuplicator.ts new file mode 100644 index 000000000..50a6582a6 --- /dev/null +++ b/server/commands/documentDuplicator.ts @@ -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 { + 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; +} diff --git a/server/commands/documentLoader.ts b/server/commands/documentLoader.ts index a1db80663..ab6173f55 100644 --- a/server/commands/documentLoader.ts +++ b/server/commands/documentLoader.ts @@ -167,7 +167,7 @@ export default async function loadDocument({ } const childDocumentIds = - (await share.document?.getChildDocumentIds({ + (await share.document?.findAllChildDocumentIds({ archivedAt: { [Op.is]: null, }, diff --git a/server/commands/documentMover.ts b/server/commands/documentMover.ts index c1608ec80..f2ae5fb8f 100644 --- a/server/commands/documentMover.ts +++ b/server/commands/documentMover.ts @@ -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 diff --git a/server/models/Document.test.ts b/server/models/Document.test.ts index 0ee877e35..70edb539b 100644 --- a/server/models/Document.test.ts +++ b/server/models/Document.test.ts @@ -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); diff --git a/server/models/Document.ts b/server/models/Document.ts index af591674b..0f1302279 100644 --- a/server/models/Document.ts +++ b/server/models/Document.ts @@ -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, "parentDocumentId">, + options?: FindOptions + ): Promise => + 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, "parentDocumentId">, options?: FindOptions ): Promise => { - const getChildDocumentIds = async ( + const findAllChildDocumentIds = async ( ...parentDocumentId: string[] ): Promise => { 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 ( diff --git a/server/models/helpers/SearchHelper.ts b/server/models/helpers/SearchHelper.ts index 8cedb39d7..f4ef49c0b 100644 --- a/server/models/helpers/SearchHelper.ts +++ b/server/models/helpers/SearchHelper.ts @@ -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, }, diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index 0cd391d29..a30ebbf9f 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -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) => { + 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" diff --git a/server/routes/api/documents/schema.ts b/server/routes/api/documents/schema.ts index f8dc3f56c..e8280696d 100644 --- a/server/routes/api/documents/schema.ts +++ b/server/routes/api/documents/schema.ts @@ -174,6 +174,23 @@ export const DocumentsSearchSchema = BaseSchema.extend({ export type DocumentsSearchReq = z.infer; +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; + export const DocumentsTemplatizeSchema = BaseSchema.extend({ body: BaseIdSchema, }); diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 36a8e4285..bd2d22020 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -37,7 +37,7 @@ "Download document": "Download document", "Duplicate": "Duplicate", "Duplicate document": "Duplicate document", - "Document duplicated": "Document duplicated", + "Copy document": "Copy document", "collection": "collection", "Pin to {{collectionName}}": "Pin to {{collectionName}}", "Pinned to collection": "Pinned to collection", @@ -169,6 +169,9 @@ "Currently editing": "Currently editing", "Currently viewing": "Currently viewing", "Viewed {{ timeAgo }} ago": "Viewed {{ timeAgo }} ago", + "Copy of {{ documentName }}": "Copy of {{ documentName }}", + "Title": "Title", + "Include nested documents": "Include nested documents", "Emoji Picker": "Emoji Picker", "Remove": "Remove", "Module failed to load": "Module failed to load",