From 6beb6febc427b0a3731eb86dcc652535acf3514b Mon Sep 17 00:00:00 2001 From: Saumya Pandey Date: Thu, 10 Jun 2021 06:18:48 +0530 Subject: [PATCH] fix: Use friendly urls for collections (#2162) Co-authored-by: Tom Moor --- app/components/DocumentBreadcrumb.js | 3 +- app/models/Collection.js | 1 + app/routes/authenticated.js | 7 +- app/scenes/Collection.js | 68 ++++++++++----- app/scenes/Document/components/DataLoader.js | 5 +- app/scenes/Document/components/Document.js | 11 +-- app/scenes/DocumentDelete.js | 7 +- app/scenes/DocumentNew.js | 87 ++++++++++---------- app/stores/BaseStore.js | 3 +- app/stores/CollectionsStore.js | 30 ++++++- app/utils/routeHelpers.js | 32 ++++--- server/api/collections.js | 2 +- server/models/Collection.js | 21 ++++- server/models/Collection.test.js | 54 +++++++++++- server/models/Document.js | 6 +- server/models/Document.test.js | 14 +++- server/presenters/collection.js | 1 + shared/i18n/locales/en_US/translation.json | 1 + shared/utils/routeHelpers.js | 2 + 19 files changed, 243 insertions(+), 112 deletions(-) diff --git a/app/components/DocumentBreadcrumb.js b/app/components/DocumentBreadcrumb.js index 5f3fdebb..166c5b9b 100644 --- a/app/components/DocumentBreadcrumb.js +++ b/app/components/DocumentBreadcrumb.js @@ -67,6 +67,7 @@ const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => { id: document.collectionId, name: t("Deleted Collection"), color: "currentColor", + url: "deleted-collection", }; } @@ -89,7 +90,7 @@ const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => { output.push({ icon: , title: collection.name, - to: collectionUrl(collection.id), + to: collectionUrl(collection.url), }); } diff --git a/app/models/Collection.js b/app/models/Collection.js index ce8f4a66..c9b73b69 100644 --- a/app/models/Collection.js +++ b/app/models/Collection.js @@ -24,6 +24,7 @@ export default class Collection extends BaseModel { deletedAt: ?string; sort: { field: string, direction: "asc" | "desc" }; url: string; + urlId: string; @computed get isEmpty(): boolean { diff --git a/app/routes/authenticated.js b/app/routes/authenticated.js index 5bc7caa1..aead763a 100644 --- a/app/routes/authenticated.js +++ b/app/routes/authenticated.js @@ -51,9 +51,10 @@ export default function AuthenticatedRoutes() { - - - + + + + { + if (collection) { + const canonicalUrl = updateCollectionUrl(match.url, collection); + if (match.url !== canonicalUrl) { + history.replace(canonicalUrl); + } + } + }, [collection, history, id, match.url]); React.useEffect(() => { if (collection) { @@ -59,8 +81,10 @@ function CollectionScene() { React.useEffect(() => { setError(null); - documents.fetchPinned({ collectionId }); - }, [documents, collectionId]); + if (collection) { + documents.fetchPinned({ collectionId: collection.id }); + } + }, [documents, collection]); React.useEffect(() => { async function load() { @@ -68,7 +92,7 @@ function CollectionScene() { try { setError(null); setFetching(true); - await collections.fetch(collectionId); + await collections.fetch(id); } catch (err) { setError(err); } finally { @@ -77,7 +101,7 @@ function CollectionScene() { } } load(); - }, [collections, isFetching, collection, error, collectionId, can]); + }, [collections, isFetching, collection, error, id, can]); useUnmount(ui.clearActiveCollection); @@ -124,7 +148,7 @@ function CollectionScene() { source="collection" placeholder={`${t("Search in collection")}…`} label={`${t("Search in collection")}…`} - collectionId={collectionId} + collectionId={collection.id} /> {can.update && ( @@ -257,27 +281,27 @@ function CollectionScene() { )} - + {t("Documents")} - + {t("Recently updated")} - + {t("Recently published")} - + {t("Least recently updated")} {t("A–Z")} - + - + - + - + - + - + { const isMove = this.props.location.pathname.match(/move$/); const canRedirect = !revisionId && !isMove && !shareId; if (canRedirect) { - const canonicalUrl = updateDocumentUrl( - this.props.match.url, - document.url - ); + const canonicalUrl = updateDocumentUrl(this.props.match.url, document); if (this.props.location.pathname !== canonicalUrl) { this.props.history.replace(canonicalUrl); } diff --git a/app/scenes/Document/components/Document.js b/app/scenes/Document/components/Document.js index c5b2c0bf..d96eef77 100644 --- a/app/scenes/Document/components/Document.js +++ b/app/scenes/Document/components/Document.js @@ -36,7 +36,6 @@ import { isCustomDomain } from "utils/domains"; import { emojiToUrl } from "utils/emoji"; import { meta } from "utils/keyboard"; import { - collectionUrl, documentMoveUrl, documentHistoryUrl, editDocumentUrl, @@ -291,15 +290,7 @@ class DocumentScene extends React.Component { }; goBack = () => { - let url; - if (this.props.document.url) { - url = this.props.document.url; - } else if (this.props.match.params.id) { - url = collectionUrl(this.props.match.params.id); - } - if (url) { - this.props.history.push(url); - } + this.props.history.push(this.props.document.url); }; render() { diff --git a/app/scenes/DocumentDelete.js b/app/scenes/DocumentDelete.js index c3919899..1b1ba032 100644 --- a/app/scenes/DocumentDelete.js +++ b/app/scenes/DocumentDelete.js @@ -17,12 +17,13 @@ type Props = { function DocumentDelete({ document, onSubmit }: Props) { const { t } = useTranslation(); - const { ui, documents } = useStores(); + const { ui, documents, collections } = useStores(); const history = useHistory(); const [isDeleting, setDeleting] = React.useState(false); const [isArchiving, setArchiving] = React.useState(false); const { showToast } = ui; const canArchive = !document.isDraft && !document.isArchived; + const collection = collections.get(document.collectionId); const handleSubmit = React.useCallback( async (ev: SyntheticEvent<>) => { @@ -45,7 +46,7 @@ function DocumentDelete({ document, onSubmit }: Props) { } // otherwise, redirect to the collection home - history.push(collectionUrl(document.collectionId)); + history.push(collectionUrl(collection?.url || "/")); } onSubmit(); } catch (err) { @@ -54,7 +55,7 @@ function DocumentDelete({ document, onSubmit }: Props) { setDeleting(false); } }, - [showToast, onSubmit, ui, document, documents, history] + [showToast, onSubmit, ui, document, documents, history, collection] ); const handleArchive = React.useCallback( diff --git a/app/scenes/DocumentNew.js b/app/scenes/DocumentNew.js index 75645ba9..bceca9e1 100644 --- a/app/scenes/DocumentNew.js +++ b/app/scenes/DocumentNew.js @@ -1,58 +1,57 @@ // @flow -import { inject } from "mobx-react"; +import { observer } from "mobx-react"; import queryString from "query-string"; import * as React from "react"; -import { - type RouterHistory, - type Location, - type Match, -} from "react-router-dom"; -import DocumentsStore from "stores/DocumentsStore"; -import UiStore from "stores/UiStore"; +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useHistory, useLocation, useRouteMatch } from "react-router-dom"; import CenteredContent from "components/CenteredContent"; import Flex from "components/Flex"; import LoadingPlaceholder from "components/LoadingPlaceholder"; +import useStores from "hooks/useStores"; import { editDocumentUrl } from "utils/routeHelpers"; -type Props = { - history: RouterHistory, - location: Location, - documents: DocumentsStore, - ui: UiStore, - match: Match, -}; +function DocumentNew() { + const history = useHistory(); + const location = useLocation(); + const match = useRouteMatch(); + const { t } = useTranslation(); + const { documents, ui, collections } = useStores(); + const id = match.params.id || ""; -class DocumentNew extends React.Component { - async componentDidMount() { - const params = queryString.parse(this.props.location.search); + useEffect(() => { + async function createDocument() { + const params = queryString.parse(location.search); + try { + const collection = await collections.fetch(id); - try { - const document = await this.props.documents.create({ - collectionId: this.props.match.params.id, - parentDocumentId: params.parentDocumentId, - templateId: params.templateId, - template: params.template, - title: "", - text: "", - }); - this.props.history.replace(editDocumentUrl(document)); - } catch (err) { - this.props.ui.showToast("Couldn’t create the document, try again?", { - type: "error", - }); - this.props.history.goBack(); + const document = await documents.create({ + collectionId: collection.id, + parentDocumentId: params.parentDocumentId, + templateId: params.templateId, + template: params.template, + title: "", + text: "", + }); + + history.replace(editDocumentUrl(document)); + } catch (err) { + ui.showToast(t("Couldn’t create the document, try again?"), { + type: "error", + }); + history.goBack(); + } } - } + createDocument(); + }); - render() { - return ( - - - - - - ); - } + return ( + + + + + + ); } -export default inject("documents", "ui")(DocumentNew); +export default observer(DocumentNew); diff --git a/app/stores/BaseStore.js b/app/stores/BaseStore.js index 58dc071f..a5e072cf 100644 --- a/app/stores/BaseStore.js +++ b/app/stores/BaseStore.js @@ -139,7 +139,8 @@ export default class BaseStore { throw new Error(`Cannot fetch ${this.modelName}`); } - let item = this.data.get(id); + const item = this.data.get(id); + if (item && !options.force) return item; this.isFetching = true; diff --git a/app/stores/CollectionsStore.js b/app/stores/CollectionsStore.js index 11f200da..1beb29db 100644 --- a/app/stores/CollectionsStore.js +++ b/app/stores/CollectionsStore.js @@ -1,6 +1,6 @@ // @flow import invariant from "invariant"; -import { concat, last } from "lodash"; +import { concat, find, last } from "lodash"; import { computed, action } from "mobx"; import Collection from "models/Collection"; import BaseStore from "./BaseStore"; @@ -126,6 +126,30 @@ export default class CollectionsStore extends BaseStore { return result; } + @action + async fetch(id: string, options: Object = {}): Promise<*> { + const item = this.get(id) || this.getByUrl(id); + + if (item && !options.force) return item; + + this.isFetching = true; + + try { + const res = await client.post(`/collections.info`, { id }); + invariant(res && res.data, "Collection not available"); + + this.addPolicies(res.policies); + return this.add(res.data); + } catch (err) { + if (err.statusCode === 403) { + this.remove(id); + } + throw err; + } finally { + this.isFetching = false; + } + } + getPathForDocument(documentId: string): ?DocumentPath { return this.pathsToDocuments.find((path) => path.id === documentId); } @@ -135,6 +159,10 @@ export default class CollectionsStore extends BaseStore { if (path) return path.title; } + getByUrl(url: string): ?Collection { + return find(this.orderedData, (col: Collection) => url.endsWith(col.urlId)); + } + delete = async (collection: Collection) => { await super.delete(collection); diff --git a/app/utils/routeHelpers.js b/app/utils/routeHelpers.js index 85214ea1..f31557d3 100644 --- a/app/utils/routeHelpers.js +++ b/app/utils/routeHelpers.js @@ -1,5 +1,6 @@ // @flow import queryString from "query-string"; +import Collection from "models/Collection"; import Document from "models/Document"; export function homeUrl(): string { @@ -11,13 +12,23 @@ export function starredUrl(): string { } export function newCollectionUrl(): string { - return "/collections/new"; + return "/collection/new"; } -export function collectionUrl(collectionId: string, section: ?string): string { - const path = `/collections/${collectionId}`; - if (section) return `${path}/${section}`; - return path; +export function collectionUrl(url: string, section: ?string): string { + if (section) return `${url}/${section}`; + return url; +} + +export function updateCollectionUrl( + oldUrl: string, + collection: Collection +): string { + // Update url to match the current one + return oldUrl.replace( + new RegExp("/collection/[0-9a-zA-Z-_~]*"), + collection.url + ); } export function documentUrl(doc: Document): string { @@ -42,14 +53,9 @@ export function documentHistoryUrl(doc: Document, revisionId?: string): string { * Replace full url's document part with the new one in case * the document slug has been updated */ -export function updateDocumentUrl(oldUrl: string, newUrl: string): string { +export function updateDocumentUrl(oldUrl: string, document: Document): string { // Update url to match the current one - const urlParts = oldUrl.trim().split("/"); - const actions = urlParts.slice(3); - if (actions[0]) { - return [newUrl, actions].join("/"); - } - return newUrl; + return oldUrl.replace(new RegExp("/doc/[0-9a-zA-Z-_~]*"), document.url); } export function newDocumentUrl( @@ -60,7 +66,7 @@ export function newDocumentUrl( template?: boolean, } ): string { - return `/collections/${collectionId}/new?${queryString.stringify(params)}`; + return `/collection/${collectionId}/new?${queryString.stringify(params)}`; } export function searchUrl( diff --git a/server/api/collections.js b/server/api/collections.js index a2657596..70eec6d1 100644 --- a/server/api/collections.js +++ b/server/api/collections.js @@ -115,7 +115,7 @@ router.post("collections.create", auth(), async (ctx) => { router.post("collections.info", auth(), async (ctx) => { const { id } = ctx.body; - ctx.assertUuid(id, "id is required"); + ctx.assertPresent(id, "id is required"); const user = ctx.state.user; const collection = await Collection.scope({ diff --git a/server/models/Collection.js b/server/models/Collection.js index 62ab9f88..ea8d4a21 100644 --- a/server/models/Collection.js +++ b/server/models/Collection.js @@ -1,13 +1,13 @@ // @flow import { find, findIndex, concat, remove, uniq } from "lodash"; import randomstring from "randomstring"; -import slug from "slug"; +import isUUID from "validator/lib/isUUID"; +import { SLUG_URL_REGEX } from "../../shared/utils/routeHelpers"; import { Op, DataTypes, sequelize } from "../sequelize"; +import slugify from "../utils/slugify"; import CollectionUser from "./CollectionUser"; import Document from "./Document"; -slug.defaults.mode = "rfc3986"; - const Collection = sequelize.define( "collection", { @@ -72,7 +72,9 @@ const Collection = sequelize.define( }, getterMethods: { url() { - return `/collections/${this.id}`; + if (!this.name) return `/collection/untitled-${this.urlId}`; + + return `/collection/${slugify(this.name)}-${this.urlId}`; }, }, } @@ -223,6 +225,17 @@ Collection.addHook("afterCreate", (model: Collection, options) => { // Class methods +Collection.findByPk = async function (id, options = {}) { + if (isUUID(id)) { + return this.findOne({ where: { id }, ...options }); + } else if (id.match(SLUG_URL_REGEX)) { + return this.findOne({ + where: { urlId: id.match(SLUG_URL_REGEX)[1] }, + ...options, + }); + } +}; + // get all the membership relationshps a user could have with the collection Collection.membershipUserIds = async (collectionId: string) => { const collection = await Collection.scope("withAllMemberships").findByPk( diff --git a/server/models/Collection.test.js b/server/models/Collection.test.js index 3858d799..7027bff4 100644 --- a/server/models/Collection.test.js +++ b/server/models/Collection.test.js @@ -1,4 +1,5 @@ /* eslint-disable flowtype/require-valid-file-annotation */ +import randomstring from "randomstring"; import { v4 as uuidv4 } from "uuid"; import { Collection, Document } from "../models"; import { @@ -9,6 +10,7 @@ import { buildDocument, } from "../test/factories"; import { flushdb, seed } from "../test/support"; +import slugify from "../utils/slugify"; beforeEach(() => flushdb()); beforeEach(jest.resetAllMocks); @@ -16,7 +18,7 @@ beforeEach(jest.resetAllMocks); describe("#url", () => { test("should return correct url for the collection", () => { const collection = new Collection({ id: "1234" }); - expect(collection.url).toBe("/collections/1234"); + expect(collection.url).toBe(`/collection/untitled-${collection.urlId}`); }); }); @@ -416,3 +418,53 @@ describe("#membershipUserIds", () => { expect(membershipUserIds.length).toBe(6); }); }); + +describe("#findByPk", () => { + test("should return collection with collection Id", async () => { + const collection = await buildCollection(); + const response = await Collection.findByPk(collection.id); + + expect(response.id).toBe(collection.id); + }); + + test("should return collection when urlId is present", async () => { + const collection = await buildCollection(); + const id = `${slugify(collection.name)}-${collection.urlId}`; + + const response = await Collection.findByPk(id); + + expect(response.id).toBe(collection.id); + }); + + test("should return undefined when incorrect uuid type", async () => { + const collection = await buildCollection(); + const response = await Collection.findByPk(collection.id + "-incorrect"); + + expect(response).toBe(undefined); + }); + + test("should return undefined when incorrect urlId length", async () => { + const collection = await buildCollection(); + const id = `${slugify(collection.name)}-${collection.urlId}incorrect`; + + const response = await Collection.findByPk(id); + + expect(response).toBe(undefined); + }); + + test("should return null when no collection is found with uuid", async () => { + const response = await Collection.findByPk( + "a9e71a81-7342-4ea3-9889-9b9cc8f667da" + ); + + expect(response).toBe(null); + }); + + test("should return null when no collection is found with urlId", async () => { + const id = `${slugify("test collection")}-${randomstring.generate(15)}`; + + const response = await Collection.findByPk(id); + + expect(response).toBe(null); + }); +}); diff --git a/server/models/Document.js b/server/models/Document.js index daba170b..269a2ee9 100644 --- a/server/models/Document.js +++ b/server/models/Document.js @@ -7,6 +7,7 @@ import MarkdownSerializer from "slate-md-serializer"; import isUUID from "validator/lib/isUUID"; import { MAX_TITLE_LENGTH } from "../../shared/constants"; import parseTitle from "../../shared/utils/parseTitle"; +import { SLUG_URL_REGEX } from "../../shared/utils/routeHelpers"; import unescape from "../../shared/utils/unescape"; import { Collection, User } from "../models"; import { DataTypes, sequelize } from "../sequelize"; @@ -14,7 +15,6 @@ import slugify from "../utils/slugify"; import Revision from "./Revision"; const Op = Sequelize.Op; -const URL_REGEX = /^[0-9a-zA-Z-_~]*-([a-zA-Z0-9]{10,15})$/; const serializer = new MarkdownSerializer(); export const DOCUMENT_VERSION = 2; @@ -216,10 +216,10 @@ Document.findByPk = async function (id, options = {}) { where: { id }, ...options, }); - } else if (id.match(URL_REGEX)) { + } else if (id.match(SLUG_URL_REGEX)) { return scope.findOne({ where: { - urlId: id.match(URL_REGEX)[1], + urlId: id.match(SLUG_URL_REGEX)[1], }, ...options, }); diff --git a/server/models/Document.test.js b/server/models/Document.test.js index c57fee54..891107c9 100644 --- a/server/models/Document.test.js +++ b/server/models/Document.test.js @@ -6,7 +6,8 @@ import { buildTeam, buildUser, } from "../test/factories"; -import { flushdb } from "../test/support"; +import { flushdb, seed } from "../test/support"; +import slugify from "../utils/slugify"; beforeEach(() => flushdb()); beforeEach(jest.resetAllMocks); @@ -307,3 +308,14 @@ describe("#delete", () => { expect(document.deletedAt).toBeTruthy(); }); }); + +describe("#findByPk", () => { + test("should return document when urlId is correct", async () => { + const { document } = await seed(); + const id = `${slugify(document.title)}-${document.urlId}`; + + const response = await Document.findByPk(id); + + expect(response.id).toBe(document.id); + }); +}); diff --git a/server/presenters/collection.js b/server/presenters/collection.js index 7a5737d3..6ae94e2d 100644 --- a/server/presenters/collection.js +++ b/server/presenters/collection.js @@ -24,6 +24,7 @@ export default function present(collection: Collection) { const data = { id: collection.id, url: collection.url, + urlId: collection.urlId, name: collection.name, description: collection.description, sort: collection.sort, diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 65b469d6..9d959e57 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -309,6 +309,7 @@ "Deleting": "Deleting", "I’m sure – Delete": "I’m sure – Delete", "Archiving": "Archiving", + "Couldn’t create the document, try again?": "Couldn’t create the document, try again?", "Search documents": "Search documents", "No documents found for your filters.": "No documents found for your filters.", "You’ve not got any drafts at the moment.": "You’ve not got any drafts at the moment.", diff --git a/shared/utils/routeHelpers.js b/shared/utils/routeHelpers.js index 8bf94623..dcf4122a 100644 --- a/shared/utils/routeHelpers.js +++ b/shared/utils/routeHelpers.js @@ -61,3 +61,5 @@ export function settings(): string { export function groupSettings(): string { return `/settings/groups`; } + +export const SLUG_URL_REGEX = /^[0-9a-zA-Z-_~]*-([a-zA-Z0-9]{10,15})$/;