fix: Use friendly urls for collections (#2162)

Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
Saumya Pandey 2021-06-10 06:18:48 +05:30 committed by GitHub
parent a6d4d4ea36
commit 6beb6febc4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 243 additions and 112 deletions

View File

@ -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: <CollectionIcon collection={collection} expanded />,
title: collection.name,
to: collectionUrl(collection.id),
to: collectionUrl(collection.url),
});
}

View File

@ -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 {

View File

@ -51,9 +51,10 @@ export default function AuthenticatedRoutes() {
<Route exact path="/drafts" component={Drafts} />
<Route exact path="/archive" component={Archive} />
<Route exact path="/trash" component={Trash} />
<Route exact path="/collections/:id/new" component={DocumentNew} />
<Route exact path="/collections/:id/:tab" component={Collection} />
<Route exact path="/collections/:id" component={Collection} />
<Redirect exact from="/collections/*" to="/collection/*" />
<Route exact path="/collection/:id/new" component={DocumentNew} />
<Route exact path="/collection/:id/:tab" component={Collection} />
<Route exact path="/collection/:id" component={Collection} />
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
<Route
exact

View File

@ -4,7 +4,15 @@ import { NewDocumentIcon, PlusIcon, PinIcon, MoreIcon } from "outline-icons";
import * as React from "react";
import Dropzone from "react-dropzone";
import { useTranslation, Trans } from "react-i18next";
import { useParams, Redirect, Link, Switch, Route } from "react-router-dom";
import {
useParams,
Redirect,
Link,
Switch,
Route,
useHistory,
useRouteMatch,
} from "react-router-dom";
import styled, { css } from "styled-components";
import CollectionPermissions from "scenes/CollectionPermissions";
import Search from "scenes/Search";
@ -29,6 +37,8 @@ import Subheading from "components/Subheading";
import Tab from "components/Tab";
import Tabs from "components/Tabs";
import Tooltip from "components/Tooltip";
import Collection from "../models/Collection";
import { updateCollectionUrl } from "../utils/routeHelpers";
import useCurrentTeam from "hooks/useCurrentTeam";
import useImportDocument from "hooks/useImportDocument";
import useStores from "hooks/useStores";
@ -38,6 +48,8 @@ import { newDocumentUrl, collectionUrl } from "utils/routeHelpers";
function CollectionScene() {
const params = useParams();
const history = useHistory();
const match = useRouteMatch();
const { t } = useTranslation();
const { documents, policies, collections, ui } = useStores();
const team = useCurrentTeam();
@ -45,11 +57,21 @@ function CollectionScene() {
const [error, setError] = React.useState();
const [permissionsModalOpen, setPermissionsModalOpen] = React.useState(false);
const collectionId = params.id || "";
const collection = collections.get(collectionId);
const can = policies.abilities(collectionId || "");
const id = params.id || "";
const collection: ?Collection =
collections.getByUrl(id) || collections.get(id);
const can = policies.abilities(collection?.id || "");
const canUser = policies.abilities(team.id);
const { handleFiles, isImporting } = useImportDocument(collectionId);
const { handleFiles, isImporting } = useImportDocument(collection?.id || "");
React.useEffect(() => {
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}
/>
</Action>
{can.update && (
@ -257,27 +281,27 @@ function CollectionScene() {
)}
<Tabs>
<Tab to={collectionUrl(collection.id)} exact>
<Tab to={collectionUrl(collection.url)} exact>
{t("Documents")}
</Tab>
<Tab to={collectionUrl(collection.id, "updated")} exact>
<Tab to={collectionUrl(collection.url, "updated")} exact>
{t("Recently updated")}
</Tab>
<Tab to={collectionUrl(collection.id, "published")} exact>
<Tab to={collectionUrl(collection.url, "published")} exact>
{t("Recently published")}
</Tab>
<Tab to={collectionUrl(collection.id, "old")} exact>
<Tab to={collectionUrl(collection.url, "old")} exact>
{t("Least recently updated")}
</Tab>
<Tab
to={collectionUrl(collection.id, "alphabetical")}
to={collectionUrl(collection.url, "alphabetical")}
exact
>
{t("AZ")}
</Tab>
</Tabs>
<Switch>
<Route path={collectionUrl(collection.id, "alphabetical")}>
<Route path={collectionUrl(collection.url, "alphabetical")}>
<PaginatedDocumentList
key="alphabetical"
documents={documents.alphabeticalInCollection(
@ -288,7 +312,7 @@ function CollectionScene() {
showPin
/>
</Route>
<Route path={collectionUrl(collection.id, "old")}>
<Route path={collectionUrl(collection.url, "old")}>
<PaginatedDocumentList
key="old"
documents={documents.leastRecentlyUpdatedInCollection(
@ -299,12 +323,12 @@ function CollectionScene() {
showPin
/>
</Route>
<Route path={collectionUrl(collection.id, "recent")}>
<Route path={collectionUrl(collection.url, "recent")}>
<Redirect
to={collectionUrl(collection.id, "published")}
to={collectionUrl(collection.url, "published")}
/>
</Route>
<Route path={collectionUrl(collection.id, "published")}>
<Route path={collectionUrl(collection.url, "published")}>
<PaginatedDocumentList
key="published"
documents={documents.recentlyPublishedInCollection(
@ -316,7 +340,7 @@ function CollectionScene() {
showPin
/>
</Route>
<Route path={collectionUrl(collection.id, "updated")}>
<Route path={collectionUrl(collection.url, "updated")}>
<PaginatedDocumentList
key="updated"
documents={documents.recentlyUpdatedInCollection(
@ -327,7 +351,7 @@ function CollectionScene() {
showPin
/>
</Route>
<Route path={collectionUrl(collection.id)} exact>
<Route path={collectionUrl(collection.url)} exact>
<PaginatedDocumentList
documents={documents.rootInCollection(collection.id)}
fetch={documents.fetchPage}

View File

@ -214,10 +214,7 @@ class DataLoader extends React.Component<Props> {
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);
}

View File

@ -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<Props> {
};
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() {

View File

@ -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(

View File

@ -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<Props> {
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("Couldnt 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("Couldnt create the document, try again?"), {
type: "error",
});
history.goBack();
}
}
}
createDocument();
});
render() {
return (
<Flex column auto>
<CenteredContent>
<LoadingPlaceholder />
</CenteredContent>
</Flex>
);
}
return (
<Flex column auto>
<CenteredContent>
<LoadingPlaceholder />
</CenteredContent>
</Flex>
);
}
export default inject("documents", "ui")(DocumentNew);
export default observer(DocumentNew);

View File

@ -139,7 +139,8 @@ export default class BaseStore<T: BaseModel> {
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;

View File

@ -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<Collection> {
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<Collection> {
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);

View File

@ -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(

View File

@ -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({

View File

@ -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(

View File

@ -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);
});
});

View File

@ -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,
});

View File

@ -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);
});
});

View File

@ -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,

View File

@ -309,6 +309,7 @@
"Deleting": "Deleting",
"Im sure  Delete": "Im sure  Delete",
"Archiving": "Archiving",
"Couldnt create the document, try again?": "Couldnt create the document, try again?",
"Search documents": "Search documents",
"No documents found for your filters.": "No documents found for your filters.",
"Youve not got any drafts at the moment.": "Youve not got any drafts at the moment.",

View File

@ -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})$/;