fix: Use friendly urls for collections (#2162)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
@ -67,6 +67,7 @@ const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
|
|||||||
id: document.collectionId,
|
id: document.collectionId,
|
||||||
name: t("Deleted Collection"),
|
name: t("Deleted Collection"),
|
||||||
color: "currentColor",
|
color: "currentColor",
|
||||||
|
url: "deleted-collection",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,7 +90,7 @@ const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
|
|||||||
output.push({
|
output.push({
|
||||||
icon: <CollectionIcon collection={collection} expanded />,
|
icon: <CollectionIcon collection={collection} expanded />,
|
||||||
title: collection.name,
|
title: collection.name,
|
||||||
to: collectionUrl(collection.id),
|
to: collectionUrl(collection.url),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -24,6 +24,7 @@ export default class Collection extends BaseModel {
|
|||||||
deletedAt: ?string;
|
deletedAt: ?string;
|
||||||
sort: { field: string, direction: "asc" | "desc" };
|
sort: { field: string, direction: "asc" | "desc" };
|
||||||
url: string;
|
url: string;
|
||||||
|
urlId: string;
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
get isEmpty(): boolean {
|
get isEmpty(): boolean {
|
||||||
|
@ -51,9 +51,10 @@ export default function AuthenticatedRoutes() {
|
|||||||
<Route exact path="/drafts" component={Drafts} />
|
<Route exact path="/drafts" component={Drafts} />
|
||||||
<Route exact path="/archive" component={Archive} />
|
<Route exact path="/archive" component={Archive} />
|
||||||
<Route exact path="/trash" component={Trash} />
|
<Route exact path="/trash" component={Trash} />
|
||||||
<Route exact path="/collections/:id/new" component={DocumentNew} />
|
<Redirect exact from="/collections/*" to="/collection/*" />
|
||||||
<Route exact path="/collections/:id/:tab" component={Collection} />
|
<Route exact path="/collection/:id/new" component={DocumentNew} />
|
||||||
<Route exact path="/collections/:id" component={Collection} />
|
<Route exact path="/collection/:id/:tab" component={Collection} />
|
||||||
|
<Route exact path="/collection/:id" component={Collection} />
|
||||||
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
|
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
|
||||||
<Route
|
<Route
|
||||||
exact
|
exact
|
||||||
|
@ -4,7 +4,15 @@ import { NewDocumentIcon, PlusIcon, PinIcon, MoreIcon } from "outline-icons";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import Dropzone from "react-dropzone";
|
import Dropzone from "react-dropzone";
|
||||||
import { useTranslation, Trans } from "react-i18next";
|
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 styled, { css } from "styled-components";
|
||||||
import CollectionPermissions from "scenes/CollectionPermissions";
|
import CollectionPermissions from "scenes/CollectionPermissions";
|
||||||
import Search from "scenes/Search";
|
import Search from "scenes/Search";
|
||||||
@ -29,6 +37,8 @@ import Subheading from "components/Subheading";
|
|||||||
import Tab from "components/Tab";
|
import Tab from "components/Tab";
|
||||||
import Tabs from "components/Tabs";
|
import Tabs from "components/Tabs";
|
||||||
import Tooltip from "components/Tooltip";
|
import Tooltip from "components/Tooltip";
|
||||||
|
import Collection from "../models/Collection";
|
||||||
|
import { updateCollectionUrl } from "../utils/routeHelpers";
|
||||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||||
import useImportDocument from "hooks/useImportDocument";
|
import useImportDocument from "hooks/useImportDocument";
|
||||||
import useStores from "hooks/useStores";
|
import useStores from "hooks/useStores";
|
||||||
@ -38,6 +48,8 @@ import { newDocumentUrl, collectionUrl } from "utils/routeHelpers";
|
|||||||
|
|
||||||
function CollectionScene() {
|
function CollectionScene() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const history = useHistory();
|
||||||
|
const match = useRouteMatch();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { documents, policies, collections, ui } = useStores();
|
const { documents, policies, collections, ui } = useStores();
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
@ -45,11 +57,21 @@ function CollectionScene() {
|
|||||||
const [error, setError] = React.useState();
|
const [error, setError] = React.useState();
|
||||||
const [permissionsModalOpen, setPermissionsModalOpen] = React.useState(false);
|
const [permissionsModalOpen, setPermissionsModalOpen] = React.useState(false);
|
||||||
|
|
||||||
const collectionId = params.id || "";
|
const id = params.id || "";
|
||||||
const collection = collections.get(collectionId);
|
const collection: ?Collection =
|
||||||
const can = policies.abilities(collectionId || "");
|
collections.getByUrl(id) || collections.get(id);
|
||||||
|
const can = policies.abilities(collection?.id || "");
|
||||||
const canUser = policies.abilities(team.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(() => {
|
React.useEffect(() => {
|
||||||
if (collection) {
|
if (collection) {
|
||||||
@ -59,8 +81,10 @@ function CollectionScene() {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setError(null);
|
setError(null);
|
||||||
documents.fetchPinned({ collectionId });
|
if (collection) {
|
||||||
}, [documents, collectionId]);
|
documents.fetchPinned({ collectionId: collection.id });
|
||||||
|
}
|
||||||
|
}, [documents, collection]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
@ -68,7 +92,7 @@ function CollectionScene() {
|
|||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
setFetching(true);
|
setFetching(true);
|
||||||
await collections.fetch(collectionId);
|
await collections.fetch(id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err);
|
setError(err);
|
||||||
} finally {
|
} finally {
|
||||||
@ -77,7 +101,7 @@ function CollectionScene() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
load();
|
load();
|
||||||
}, [collections, isFetching, collection, error, collectionId, can]);
|
}, [collections, isFetching, collection, error, id, can]);
|
||||||
|
|
||||||
useUnmount(ui.clearActiveCollection);
|
useUnmount(ui.clearActiveCollection);
|
||||||
|
|
||||||
@ -124,7 +148,7 @@ function CollectionScene() {
|
|||||||
source="collection"
|
source="collection"
|
||||||
placeholder={`${t("Search in collection")}…`}
|
placeholder={`${t("Search in collection")}…`}
|
||||||
label={`${t("Search in collection")}…`}
|
label={`${t("Search in collection")}…`}
|
||||||
collectionId={collectionId}
|
collectionId={collection.id}
|
||||||
/>
|
/>
|
||||||
</Action>
|
</Action>
|
||||||
{can.update && (
|
{can.update && (
|
||||||
@ -257,27 +281,27 @@ function CollectionScene() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Tabs>
|
<Tabs>
|
||||||
<Tab to={collectionUrl(collection.id)} exact>
|
<Tab to={collectionUrl(collection.url)} exact>
|
||||||
{t("Documents")}
|
{t("Documents")}
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab to={collectionUrl(collection.id, "updated")} exact>
|
<Tab to={collectionUrl(collection.url, "updated")} exact>
|
||||||
{t("Recently updated")}
|
{t("Recently updated")}
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab to={collectionUrl(collection.id, "published")} exact>
|
<Tab to={collectionUrl(collection.url, "published")} exact>
|
||||||
{t("Recently published")}
|
{t("Recently published")}
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab to={collectionUrl(collection.id, "old")} exact>
|
<Tab to={collectionUrl(collection.url, "old")} exact>
|
||||||
{t("Least recently updated")}
|
{t("Least recently updated")}
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
to={collectionUrl(collection.id, "alphabetical")}
|
to={collectionUrl(collection.url, "alphabetical")}
|
||||||
exact
|
exact
|
||||||
>
|
>
|
||||||
{t("A–Z")}
|
{t("A–Z")}
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path={collectionUrl(collection.id, "alphabetical")}>
|
<Route path={collectionUrl(collection.url, "alphabetical")}>
|
||||||
<PaginatedDocumentList
|
<PaginatedDocumentList
|
||||||
key="alphabetical"
|
key="alphabetical"
|
||||||
documents={documents.alphabeticalInCollection(
|
documents={documents.alphabeticalInCollection(
|
||||||
@ -288,7 +312,7 @@ function CollectionScene() {
|
|||||||
showPin
|
showPin
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path={collectionUrl(collection.id, "old")}>
|
<Route path={collectionUrl(collection.url, "old")}>
|
||||||
<PaginatedDocumentList
|
<PaginatedDocumentList
|
||||||
key="old"
|
key="old"
|
||||||
documents={documents.leastRecentlyUpdatedInCollection(
|
documents={documents.leastRecentlyUpdatedInCollection(
|
||||||
@ -299,12 +323,12 @@ function CollectionScene() {
|
|||||||
showPin
|
showPin
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path={collectionUrl(collection.id, "recent")}>
|
<Route path={collectionUrl(collection.url, "recent")}>
|
||||||
<Redirect
|
<Redirect
|
||||||
to={collectionUrl(collection.id, "published")}
|
to={collectionUrl(collection.url, "published")}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path={collectionUrl(collection.id, "published")}>
|
<Route path={collectionUrl(collection.url, "published")}>
|
||||||
<PaginatedDocumentList
|
<PaginatedDocumentList
|
||||||
key="published"
|
key="published"
|
||||||
documents={documents.recentlyPublishedInCollection(
|
documents={documents.recentlyPublishedInCollection(
|
||||||
@ -316,7 +340,7 @@ function CollectionScene() {
|
|||||||
showPin
|
showPin
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path={collectionUrl(collection.id, "updated")}>
|
<Route path={collectionUrl(collection.url, "updated")}>
|
||||||
<PaginatedDocumentList
|
<PaginatedDocumentList
|
||||||
key="updated"
|
key="updated"
|
||||||
documents={documents.recentlyUpdatedInCollection(
|
documents={documents.recentlyUpdatedInCollection(
|
||||||
@ -327,7 +351,7 @@ function CollectionScene() {
|
|||||||
showPin
|
showPin
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path={collectionUrl(collection.id)} exact>
|
<Route path={collectionUrl(collection.url)} exact>
|
||||||
<PaginatedDocumentList
|
<PaginatedDocumentList
|
||||||
documents={documents.rootInCollection(collection.id)}
|
documents={documents.rootInCollection(collection.id)}
|
||||||
fetch={documents.fetchPage}
|
fetch={documents.fetchPage}
|
||||||
|
@ -214,10 +214,7 @@ class DataLoader extends React.Component<Props> {
|
|||||||
const isMove = this.props.location.pathname.match(/move$/);
|
const isMove = this.props.location.pathname.match(/move$/);
|
||||||
const canRedirect = !revisionId && !isMove && !shareId;
|
const canRedirect = !revisionId && !isMove && !shareId;
|
||||||
if (canRedirect) {
|
if (canRedirect) {
|
||||||
const canonicalUrl = updateDocumentUrl(
|
const canonicalUrl = updateDocumentUrl(this.props.match.url, document);
|
||||||
this.props.match.url,
|
|
||||||
document.url
|
|
||||||
);
|
|
||||||
if (this.props.location.pathname !== canonicalUrl) {
|
if (this.props.location.pathname !== canonicalUrl) {
|
||||||
this.props.history.replace(canonicalUrl);
|
this.props.history.replace(canonicalUrl);
|
||||||
}
|
}
|
||||||
|
@ -36,7 +36,6 @@ import { isCustomDomain } from "utils/domains";
|
|||||||
import { emojiToUrl } from "utils/emoji";
|
import { emojiToUrl } from "utils/emoji";
|
||||||
import { meta } from "utils/keyboard";
|
import { meta } from "utils/keyboard";
|
||||||
import {
|
import {
|
||||||
collectionUrl,
|
|
||||||
documentMoveUrl,
|
documentMoveUrl,
|
||||||
documentHistoryUrl,
|
documentHistoryUrl,
|
||||||
editDocumentUrl,
|
editDocumentUrl,
|
||||||
@ -291,15 +290,7 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
goBack = () => {
|
goBack = () => {
|
||||||
let url;
|
this.props.history.push(this.props.document.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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
@ -17,12 +17,13 @@ type Props = {
|
|||||||
|
|
||||||
function DocumentDelete({ document, onSubmit }: Props) {
|
function DocumentDelete({ document, onSubmit }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { ui, documents } = useStores();
|
const { ui, documents, collections } = useStores();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const [isDeleting, setDeleting] = React.useState(false);
|
const [isDeleting, setDeleting] = React.useState(false);
|
||||||
const [isArchiving, setArchiving] = React.useState(false);
|
const [isArchiving, setArchiving] = React.useState(false);
|
||||||
const { showToast } = ui;
|
const { showToast } = ui;
|
||||||
const canArchive = !document.isDraft && !document.isArchived;
|
const canArchive = !document.isDraft && !document.isArchived;
|
||||||
|
const collection = collections.get(document.collectionId);
|
||||||
|
|
||||||
const handleSubmit = React.useCallback(
|
const handleSubmit = React.useCallback(
|
||||||
async (ev: SyntheticEvent<>) => {
|
async (ev: SyntheticEvent<>) => {
|
||||||
@ -45,7 +46,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// otherwise, redirect to the collection home
|
// otherwise, redirect to the collection home
|
||||||
history.push(collectionUrl(document.collectionId));
|
history.push(collectionUrl(collection?.url || "/"));
|
||||||
}
|
}
|
||||||
onSubmit();
|
onSubmit();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -54,7 +55,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
|
|||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[showToast, onSubmit, ui, document, documents, history]
|
[showToast, onSubmit, ui, document, documents, history, collection]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleArchive = React.useCallback(
|
const handleArchive = React.useCallback(
|
||||||
|
@ -1,50 +1,50 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import { inject } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import queryString from "query-string";
|
import queryString from "query-string";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {
|
import { useEffect } from "react";
|
||||||
type RouterHistory,
|
import { useTranslation } from "react-i18next";
|
||||||
type Location,
|
import { useHistory, useLocation, useRouteMatch } from "react-router-dom";
|
||||||
type Match,
|
|
||||||
} from "react-router-dom";
|
|
||||||
import DocumentsStore from "stores/DocumentsStore";
|
|
||||||
import UiStore from "stores/UiStore";
|
|
||||||
import CenteredContent from "components/CenteredContent";
|
import CenteredContent from "components/CenteredContent";
|
||||||
import Flex from "components/Flex";
|
import Flex from "components/Flex";
|
||||||
import LoadingPlaceholder from "components/LoadingPlaceholder";
|
import LoadingPlaceholder from "components/LoadingPlaceholder";
|
||||||
|
import useStores from "hooks/useStores";
|
||||||
import { editDocumentUrl } from "utils/routeHelpers";
|
import { editDocumentUrl } from "utils/routeHelpers";
|
||||||
|
|
||||||
type Props = {
|
function DocumentNew() {
|
||||||
history: RouterHistory,
|
const history = useHistory();
|
||||||
location: Location,
|
const location = useLocation();
|
||||||
documents: DocumentsStore,
|
const match = useRouteMatch();
|
||||||
ui: UiStore,
|
const { t } = useTranslation();
|
||||||
match: Match,
|
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 {
|
try {
|
||||||
const document = await this.props.documents.create({
|
const collection = await collections.fetch(id);
|
||||||
collectionId: this.props.match.params.id,
|
|
||||||
|
const document = await documents.create({
|
||||||
|
collectionId: collection.id,
|
||||||
parentDocumentId: params.parentDocumentId,
|
parentDocumentId: params.parentDocumentId,
|
||||||
templateId: params.templateId,
|
templateId: params.templateId,
|
||||||
template: params.template,
|
template: params.template,
|
||||||
title: "",
|
title: "",
|
||||||
text: "",
|
text: "",
|
||||||
});
|
});
|
||||||
this.props.history.replace(editDocumentUrl(document));
|
|
||||||
|
history.replace(editDocumentUrl(document));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.props.ui.showToast("Couldn’t create the document, try again?", {
|
ui.showToast(t("Couldn’t create the document, try again?"), {
|
||||||
type: "error",
|
type: "error",
|
||||||
});
|
});
|
||||||
this.props.history.goBack();
|
history.goBack();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
createDocument();
|
||||||
|
});
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
return (
|
||||||
<Flex column auto>
|
<Flex column auto>
|
||||||
<CenteredContent>
|
<CenteredContent>
|
||||||
@ -52,7 +52,6 @@ class DocumentNew extends React.Component<Props> {
|
|||||||
</CenteredContent>
|
</CenteredContent>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default inject("documents", "ui")(DocumentNew);
|
export default observer(DocumentNew);
|
||||||
|
@ -139,7 +139,8 @@ export default class BaseStore<T: BaseModel> {
|
|||||||
throw new Error(`Cannot fetch ${this.modelName}`);
|
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;
|
if (item && !options.force) return item;
|
||||||
|
|
||||||
this.isFetching = true;
|
this.isFetching = true;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import invariant from "invariant";
|
import invariant from "invariant";
|
||||||
import { concat, last } from "lodash";
|
import { concat, find, last } from "lodash";
|
||||||
import { computed, action } from "mobx";
|
import { computed, action } from "mobx";
|
||||||
import Collection from "models/Collection";
|
import Collection from "models/Collection";
|
||||||
import BaseStore from "./BaseStore";
|
import BaseStore from "./BaseStore";
|
||||||
@ -126,6 +126,30 @@ export default class CollectionsStore extends BaseStore<Collection> {
|
|||||||
return result;
|
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 {
|
getPathForDocument(documentId: string): ?DocumentPath {
|
||||||
return this.pathsToDocuments.find((path) => path.id === documentId);
|
return this.pathsToDocuments.find((path) => path.id === documentId);
|
||||||
}
|
}
|
||||||
@ -135,6 +159,10 @@ export default class CollectionsStore extends BaseStore<Collection> {
|
|||||||
if (path) return path.title;
|
if (path) return path.title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getByUrl(url: string): ?Collection {
|
||||||
|
return find(this.orderedData, (col: Collection) => url.endsWith(col.urlId));
|
||||||
|
}
|
||||||
|
|
||||||
delete = async (collection: Collection) => {
|
delete = async (collection: Collection) => {
|
||||||
await super.delete(collection);
|
await super.delete(collection);
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import queryString from "query-string";
|
import queryString from "query-string";
|
||||||
|
import Collection from "models/Collection";
|
||||||
import Document from "models/Document";
|
import Document from "models/Document";
|
||||||
|
|
||||||
export function homeUrl(): string {
|
export function homeUrl(): string {
|
||||||
@ -11,13 +12,23 @@ export function starredUrl(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function newCollectionUrl(): string {
|
export function newCollectionUrl(): string {
|
||||||
return "/collections/new";
|
return "/collection/new";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function collectionUrl(collectionId: string, section: ?string): string {
|
export function collectionUrl(url: string, section: ?string): string {
|
||||||
const path = `/collections/${collectionId}`;
|
if (section) return `${url}/${section}`;
|
||||||
if (section) return `${path}/${section}`;
|
return url;
|
||||||
return path;
|
}
|
||||||
|
|
||||||
|
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 {
|
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
|
* Replace full url's document part with the new one in case
|
||||||
* the document slug has been updated
|
* 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
|
// Update url to match the current one
|
||||||
const urlParts = oldUrl.trim().split("/");
|
return oldUrl.replace(new RegExp("/doc/[0-9a-zA-Z-_~]*"), document.url);
|
||||||
const actions = urlParts.slice(3);
|
|
||||||
if (actions[0]) {
|
|
||||||
return [newUrl, actions].join("/");
|
|
||||||
}
|
|
||||||
return newUrl;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function newDocumentUrl(
|
export function newDocumentUrl(
|
||||||
@ -60,7 +66,7 @@ export function newDocumentUrl(
|
|||||||
template?: boolean,
|
template?: boolean,
|
||||||
}
|
}
|
||||||
): string {
|
): string {
|
||||||
return `/collections/${collectionId}/new?${queryString.stringify(params)}`;
|
return `/collection/${collectionId}/new?${queryString.stringify(params)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function searchUrl(
|
export function searchUrl(
|
||||||
|
@ -115,7 +115,7 @@ router.post("collections.create", auth(), async (ctx) => {
|
|||||||
|
|
||||||
router.post("collections.info", auth(), async (ctx) => {
|
router.post("collections.info", auth(), async (ctx) => {
|
||||||
const { id } = ctx.body;
|
const { id } = ctx.body;
|
||||||
ctx.assertUuid(id, "id is required");
|
ctx.assertPresent(id, "id is required");
|
||||||
|
|
||||||
const user = ctx.state.user;
|
const user = ctx.state.user;
|
||||||
const collection = await Collection.scope({
|
const collection = await Collection.scope({
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import { find, findIndex, concat, remove, uniq } from "lodash";
|
import { find, findIndex, concat, remove, uniq } from "lodash";
|
||||||
import randomstring from "randomstring";
|
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 { Op, DataTypes, sequelize } from "../sequelize";
|
||||||
|
import slugify from "../utils/slugify";
|
||||||
import CollectionUser from "./CollectionUser";
|
import CollectionUser from "./CollectionUser";
|
||||||
import Document from "./Document";
|
import Document from "./Document";
|
||||||
|
|
||||||
slug.defaults.mode = "rfc3986";
|
|
||||||
|
|
||||||
const Collection = sequelize.define(
|
const Collection = sequelize.define(
|
||||||
"collection",
|
"collection",
|
||||||
{
|
{
|
||||||
@ -72,7 +72,9 @@ const Collection = sequelize.define(
|
|||||||
},
|
},
|
||||||
getterMethods: {
|
getterMethods: {
|
||||||
url() {
|
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
|
// 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
|
// get all the membership relationshps a user could have with the collection
|
||||||
Collection.membershipUserIds = async (collectionId: string) => {
|
Collection.membershipUserIds = async (collectionId: string) => {
|
||||||
const collection = await Collection.scope("withAllMemberships").findByPk(
|
const collection = await Collection.scope("withAllMemberships").findByPk(
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||||
|
import randomstring from "randomstring";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { Collection, Document } from "../models";
|
import { Collection, Document } from "../models";
|
||||||
import {
|
import {
|
||||||
@ -9,6 +10,7 @@ import {
|
|||||||
buildDocument,
|
buildDocument,
|
||||||
} from "../test/factories";
|
} from "../test/factories";
|
||||||
import { flushdb, seed } from "../test/support";
|
import { flushdb, seed } from "../test/support";
|
||||||
|
import slugify from "../utils/slugify";
|
||||||
|
|
||||||
beforeEach(() => flushdb());
|
beforeEach(() => flushdb());
|
||||||
beforeEach(jest.resetAllMocks);
|
beforeEach(jest.resetAllMocks);
|
||||||
@ -16,7 +18,7 @@ beforeEach(jest.resetAllMocks);
|
|||||||
describe("#url", () => {
|
describe("#url", () => {
|
||||||
test("should return correct url for the collection", () => {
|
test("should return correct url for the collection", () => {
|
||||||
const collection = new Collection({ id: "1234" });
|
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);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -7,6 +7,7 @@ import MarkdownSerializer from "slate-md-serializer";
|
|||||||
import isUUID from "validator/lib/isUUID";
|
import isUUID from "validator/lib/isUUID";
|
||||||
import { MAX_TITLE_LENGTH } from "../../shared/constants";
|
import { MAX_TITLE_LENGTH } from "../../shared/constants";
|
||||||
import parseTitle from "../../shared/utils/parseTitle";
|
import parseTitle from "../../shared/utils/parseTitle";
|
||||||
|
import { SLUG_URL_REGEX } from "../../shared/utils/routeHelpers";
|
||||||
import unescape from "../../shared/utils/unescape";
|
import unescape from "../../shared/utils/unescape";
|
||||||
import { Collection, User } from "../models";
|
import { Collection, User } from "../models";
|
||||||
import { DataTypes, sequelize } from "../sequelize";
|
import { DataTypes, sequelize } from "../sequelize";
|
||||||
@ -14,7 +15,6 @@ import slugify from "../utils/slugify";
|
|||||||
import Revision from "./Revision";
|
import Revision from "./Revision";
|
||||||
|
|
||||||
const Op = Sequelize.Op;
|
const Op = Sequelize.Op;
|
||||||
const URL_REGEX = /^[0-9a-zA-Z-_~]*-([a-zA-Z0-9]{10,15})$/;
|
|
||||||
const serializer = new MarkdownSerializer();
|
const serializer = new MarkdownSerializer();
|
||||||
|
|
||||||
export const DOCUMENT_VERSION = 2;
|
export const DOCUMENT_VERSION = 2;
|
||||||
@ -216,10 +216,10 @@ Document.findByPk = async function (id, options = {}) {
|
|||||||
where: { id },
|
where: { id },
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
} else if (id.match(URL_REGEX)) {
|
} else if (id.match(SLUG_URL_REGEX)) {
|
||||||
return scope.findOne({
|
return scope.findOne({
|
||||||
where: {
|
where: {
|
||||||
urlId: id.match(URL_REGEX)[1],
|
urlId: id.match(SLUG_URL_REGEX)[1],
|
||||||
},
|
},
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
|
@ -6,7 +6,8 @@ import {
|
|||||||
buildTeam,
|
buildTeam,
|
||||||
buildUser,
|
buildUser,
|
||||||
} from "../test/factories";
|
} from "../test/factories";
|
||||||
import { flushdb } from "../test/support";
|
import { flushdb, seed } from "../test/support";
|
||||||
|
import slugify from "../utils/slugify";
|
||||||
|
|
||||||
beforeEach(() => flushdb());
|
beforeEach(() => flushdb());
|
||||||
beforeEach(jest.resetAllMocks);
|
beforeEach(jest.resetAllMocks);
|
||||||
@ -307,3 +308,14 @@ describe("#delete", () => {
|
|||||||
expect(document.deletedAt).toBeTruthy();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -24,6 +24,7 @@ export default function present(collection: Collection) {
|
|||||||
const data = {
|
const data = {
|
||||||
id: collection.id,
|
id: collection.id,
|
||||||
url: collection.url,
|
url: collection.url,
|
||||||
|
urlId: collection.urlId,
|
||||||
name: collection.name,
|
name: collection.name,
|
||||||
description: collection.description,
|
description: collection.description,
|
||||||
sort: collection.sort,
|
sort: collection.sort,
|
||||||
|
@ -309,6 +309,7 @@
|
|||||||
"Deleting": "Deleting",
|
"Deleting": "Deleting",
|
||||||
"I’m sure – Delete": "I’m sure – Delete",
|
"I’m sure – Delete": "I’m sure – Delete",
|
||||||
"Archiving": "Archiving",
|
"Archiving": "Archiving",
|
||||||
|
"Couldn’t create the document, try again?": "Couldn’t create the document, try again?",
|
||||||
"Search documents": "Search documents",
|
"Search documents": "Search documents",
|
||||||
"No documents found for your filters.": "No documents found for your filters.",
|
"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.",
|
"You’ve not got any drafts at the moment.": "You’ve not got any drafts at the moment.",
|
||||||
|
@ -61,3 +61,5 @@ export function settings(): string {
|
|||||||
export function groupSettings(): string {
|
export function groupSettings(): string {
|
||||||
return `/settings/groups`;
|
return `/settings/groups`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const SLUG_URL_REGEX = /^[0-9a-zA-Z-_~]*-([a-zA-Z0-9]{10,15})$/;
|
||||||
|
Reference in New Issue
Block a user