From 0856f5f6aec3a2b5333062219b154e327ba8e710 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 10 Sep 2023 15:46:12 -0400 Subject: [PATCH] Move template management to settings (#5811) --- app/actions/definitions/documents.tsx | 83 +++++++++++++++++-- app/actions/definitions/navigation.tsx | 24 +++--- app/components/DocumentBreadcrumb.tsx | 6 +- app/components/DocumentListItem.tsx | 30 +------ app/components/NavLink.tsx | 19 ++++- app/components/Sidebar/App.tsx | 29 +------ app/components/Sidebar/Settings.tsx | 9 +- app/components/Tab.tsx | 50 ++++++++--- app/hooks/useImportDocument.ts | 3 +- app/hooks/useSettingsConfig.ts | 54 +++++++----- app/menus/DocumentMenu.tsx | 17 ++-- app/menus/NewTemplateMenu.tsx | 11 ++- app/menus/TemplatesMenu.tsx | 5 +- app/models/Document.ts | 20 ++++- app/routes/authenticated.tsx | 56 +++++++------ app/routes/settings.tsx | 19 ++++- .../Document/components/DocumentTitle.tsx | 4 +- app/scenes/Document/components/Header.tsx | 68 +++++++-------- app/scenes/DocumentNew.tsx | 30 +++++-- app/scenes/{ => Settings}/Templates.tsx | 45 +++++----- app/utils/routeHelpers.ts | 17 ++-- server/commands/documentCreator.ts | 3 + server/models/Collection.test.ts | 2 +- server/models/Collection.ts | 2 +- server/models/Document.test.ts | 2 +- server/models/Document.ts | 2 +- server/policies/document.ts | 39 +++++++-- server/routes/api/documents/documents.ts | 2 +- server/routes/api/revisions/revisions.ts | 2 +- server/utils/slugify.ts | 9 -- shared/i18n/locales/en_US/translation.json | 21 +++-- shared/utils/slugify.ts | 16 ++++ 32 files changed, 432 insertions(+), 267 deletions(-) rename app/scenes/{ => Settings}/Templates.tsx (59%) delete mode 100644 server/utils/slugify.ts create mode 100644 shared/utils/slugify.ts diff --git a/app/actions/definitions/documents.tsx b/app/actions/definitions/documents.tsx index a3f8917e3..193a13645 100644 --- a/app/actions/definitions/documents.tsx +++ b/app/actions/definitions/documents.tsx @@ -42,6 +42,7 @@ import { homePath, newDocumentPath, searchPath, + documentPath, } from "~/utils/routeHelpers"; export const openDocument = createAction({ @@ -86,6 +87,48 @@ export const createDocument = createAction({ }), }); +export const createDocumentFromTemplate = createAction({ + name: ({ t }) => t("New from template"), + analyticsName: "New document", + section: DocumentSection, + icon: , + keywords: "create", + visible: ({ currentTeamId, activeDocumentId, stores }) => + !!currentTeamId && + !!activeDocumentId && + stores.policies.abilities(currentTeamId).createDocument && + !stores.documents.get(activeDocumentId)?.template, + perform: ({ activeCollectionId, activeDocumentId, inStarredSection }) => + history.push( + newDocumentPath(activeCollectionId, { templateId: activeDocumentId }), + { + starred: inStarredSection, + } + ), +}); + +export const createNestedDocument = createAction({ + name: ({ t }) => t("New nested document"), + analyticsName: "New document", + section: DocumentSection, + icon: , + keywords: "create", + visible: ({ currentTeamId, activeDocumentId, stores }) => + !!currentTeamId && + !!activeDocumentId && + stores.policies.abilities(currentTeamId).createDocument && + stores.policies.abilities(activeDocumentId).createChildDocument, + perform: ({ activeCollectionId, activeDocumentId, inStarredSection }) => + history.push( + newDocumentPath(activeCollectionId, { + parentDocumentId: activeDocumentId, + }), + { + starred: inStarredSection, + } + ), +}); + export const starDocument = createAction({ name: ({ t }) => t("Star"), analyticsName: "Star document", @@ -165,9 +208,14 @@ export const publishDocument = createAction({ await document.save(undefined, { publish: true, }); - stores.toasts.showToast(t("Document published"), { - type: "success", - }); + stores.toasts.showToast( + t("Published {{ documentName }}", { + documentName: document.noun, + }), + { + type: "success", + } + ); } else if (document) { stores.dialogs.openModal({ title: t("Publish document"), @@ -195,12 +243,20 @@ export const unpublishDocument = createAction({ } const document = stores.documents.get(activeDocumentId); + if (!document) { + return; + } - await document?.unpublish(); + await document.unpublish(); - stores.toasts.showToast(t("Document unpublished"), { - type: "success", - }); + stores.toasts.showToast( + t("Unpublished {{ documentName }}", { + documentName: document.noun, + }), + { + type: "success", + } + ); }, }); @@ -366,7 +422,7 @@ export const duplicateDocument = createAction({ invariant(document, "Document must exist"); const duped = await document.duplicate(); // when duplicating, go straight to the duplicated document content - history.push(duped.url); + history.push(documentPath(duped)); stores.toasts.showToast(t("Document duplicated"), { type: "success", }); @@ -775,7 +831,16 @@ export const openDocumentInsights = createAction({ icon: , visible: ({ activeDocumentId, stores }) => { const can = stores.policies.abilities(activeDocumentId ?? ""); - return !!activeDocumentId && can.read; + const document = activeDocumentId + ? stores.documents.get(activeDocumentId) + : undefined; + + return ( + !!activeDocumentId && + can.read && + !document?.isTemplate && + !document?.isDeleted + ); }, perform: ({ activeDocumentId, stores }) => { if (!activeDocumentId) { diff --git a/app/actions/definitions/navigation.tsx b/app/actions/definitions/navigation.tsx index d2f0c6dda..14e5c86f4 100644 --- a/app/actions/definitions/navigation.tsx +++ b/app/actions/definitions/navigation.tsx @@ -6,12 +6,12 @@ import { EditIcon, OpenIcon, SettingsIcon, - ShapesIcon, KeyboardIcon, EmailIcon, LogoutIcon, ProfileIcon, BrowserIcon, + ShapesIcon, } from "outline-icons"; import * as React from "react"; import { isMac } from "@shared/utils/browser"; @@ -33,7 +33,6 @@ import { homePath, searchPath, draftsPath, - templatesPath, archivePath, trashPath, settingsPath, @@ -67,15 +66,6 @@ export const navigateToDrafts = createAction({ visible: ({ location }) => location.pathname !== draftsPath(), }); -export const navigateToTemplates = createAction({ - name: ({ t }) => t("Templates"), - analyticsName: "Navigate to templates", - section: NavigationSection, - icon: , - perform: () => history.push(templatesPath()), - visible: ({ location }) => location.pathname !== templatesPath(), -}); - export const navigateToArchive = createAction({ name: ({ t }) => t("Archive"), analyticsName: "Navigate to archive", @@ -103,7 +93,7 @@ export const navigateToSettings = createAction({ icon: , visible: ({ stores }) => stores.policies.abilities(stores.auth.team?.id || "").update, - perform: () => history.push(settingsPath("details")), + perform: () => history.push(settingsPath()), }); export const navigateToProfileSettings = createAction({ @@ -115,6 +105,15 @@ export const navigateToProfileSettings = createAction({ perform: () => history.push(settingsPath()), }); +export const navigateToTemplateSettings = createAction({ + name: ({ t }) => t("Templates"), + analyticsName: "Navigate to template settings", + section: NavigationSection, + iconInContextMenu: false, + icon: , + perform: () => history.push(settingsPath("templates")), +}); + export const navigateToNotificationSettings = createAction({ name: ({ t }) => t("Notifications"), analyticsName: "Navigate to notification settings", @@ -216,7 +215,6 @@ export const logout = createAction({ export const rootNavigationActions = [ navigateToHome, navigateToDrafts, - navigateToTemplates, navigateToArchive, navigateToTrash, downloadApp, diff --git a/app/components/DocumentBreadcrumb.tsx b/app/components/DocumentBreadcrumb.tsx index 21886b631..974c7cb42 100644 --- a/app/components/DocumentBreadcrumb.tsx +++ b/app/components/DocumentBreadcrumb.tsx @@ -12,7 +12,7 @@ import { MenuInternalLink } from "~/types"; import { archivePath, collectionPath, - templatesPath, + settingsPath, trashPath, } from "~/utils/routeHelpers"; import EmojiIcon from "./Icons/EmojiIcon"; @@ -44,12 +44,12 @@ function useCategory(document: Document): MenuInternalLink | null { }; } - if (document.isTemplate) { + if (document.template) { return { type: "route", icon: , title: t("Templates"), - to: templatesPath(), + to: settingsPath("templates"), }; } diff --git a/app/components/DocumentListItem.tsx b/app/components/DocumentListItem.tsx index 43d6b28a1..9f220f76e 100644 --- a/app/components/DocumentListItem.tsx +++ b/app/components/DocumentListItem.tsx @@ -1,5 +1,4 @@ import { observer } from "mobx-react"; -import { PlusIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; @@ -9,7 +8,6 @@ import breakpoint from "styled-components-breakpoint"; import { s } from "@shared/styles"; import Document from "~/models/Document"; import Badge from "~/components/Badge"; -import Button from "~/components/Button"; import DocumentMeta from "~/components/DocumentMeta"; import EventBoundary from "~/components/EventBoundary"; import Flex from "~/components/Flex"; @@ -18,12 +16,10 @@ import NudeButton from "~/components/NudeButton"; import StarButton, { AnimatedStar } from "~/components/Star"; import Tooltip from "~/components/Tooltip"; import useBoolean from "~/hooks/useBoolean"; -import useCurrentTeam from "~/hooks/useCurrentTeam"; import useCurrentUser from "~/hooks/useCurrentUser"; -import usePolicy from "~/hooks/usePolicy"; import DocumentMenu from "~/menus/DocumentMenu"; import { hover } from "~/styles"; -import { newDocumentPath } from "~/utils/routeHelpers"; +import { documentPath } from "~/utils/routeHelpers"; import EmojiIcon from "./Icons/EmojiIcon"; type Props = { @@ -52,7 +48,6 @@ function DocumentListItem( ) { const { t } = useTranslation(); const user = useCurrentUser(); - const team = useCurrentTeam(); const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean(); const { @@ -72,8 +67,6 @@ function DocumentListItem( !!document.title.toLowerCase().includes(highlight.toLowerCase()); const canStar = !document.isDraft && !document.isArchived && !document.isTemplate; - const can = usePolicy(team); - const canCollection = usePolicy(document.collectionId); return ( - {document.isTemplate && - !document.isArchived && - !document.isDeleted && - can.createDocument && - canCollection.update && ( - <> - -   - - )} & { [x: string]: string | undefined; }> | boolean - | null + | null, + location: LocationDescriptorObject ) => React.ReactNode; + /** + * If true, the tab will only be active if the path matches exactly. + */ exact?: boolean; + /** + * CSS properties to apply to the link when it is active. + */ activeStyle?: React.CSSProperties; + /** + * The path to match against the current location. + */ to: LocationDescriptor; }; @@ -25,7 +35,10 @@ function NavLinkWithChildrenFunc( {({ match, location }) => ( {children - ? children(rest.isActive ? rest.isActive(match, location) : match) + ? children( + rest.isActive ? rest.isActive(match, location) : match, + location + ) : null} )} diff --git a/app/components/Sidebar/App.tsx b/app/components/Sidebar/App.tsx index 97fd3841e..00c8949d4 100644 --- a/app/components/Sidebar/App.tsx +++ b/app/components/Sidebar/App.tsx @@ -1,11 +1,5 @@ import { observer } from "mobx-react"; -import { - EditIcon, - SearchIcon, - ShapesIcon, - HomeIcon, - SidebarIcon, -} from "outline-icons"; +import { EditIcon, SearchIcon, HomeIcon, SidebarIcon } from "outline-icons"; import * as React from "react"; import { DndProvider } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; @@ -21,12 +15,7 @@ import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import OrganizationMenu from "~/menus/OrganizationMenu"; import { metaDisplay } from "~/utils/keyboard"; -import { - homePath, - draftsPath, - templatesPath, - searchPath, -} from "~/utils/routeHelpers"; +import { homePath, draftsPath, searchPath } from "~/utils/routeHelpers"; import TeamLogo from "../TeamLogo"; import Tooltip from "../Tooltip"; import Sidebar from "./Sidebar"; @@ -52,7 +41,6 @@ function AppSidebar() { React.useEffect(() => { if (!user.isViewer) { void documents.fetchDrafts(); - void documents.fetchTemplates(); } }, [documents, user.isViewer]); @@ -138,19 +126,6 @@ function AppSidebar() {
{can.createDocument && ( <> - } - exact={false} - label={t("Templates")} - active={ - documents.active - ? documents.active.isTemplate && - !documents.active.isDeleted && - !documents.active.isArchived - : undefined - } - /> diff --git a/app/components/Sidebar/Settings.tsx b/app/components/Sidebar/Settings.tsx index 60b7cce2f..fe4cc7de5 100644 --- a/app/components/Sidebar/Settings.tsx +++ b/app/components/Sidebar/Settings.tsx @@ -3,7 +3,7 @@ import { observer } from "mobx-react"; import { BackIcon, SidebarIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import { useHistory } from "react-router-dom"; +import { useHistory, useLocation } from "react-router-dom"; import styled from "styled-components"; import Flex from "~/components/Flex"; import Scrollable from "~/components/Scrollable"; @@ -11,6 +11,7 @@ import useSettingsConfig from "~/hooks/useSettingsConfig"; import useStores from "~/hooks/useStores"; import isCloudHosted from "~/utils/isCloudHosted"; import { metaDisplay } from "~/utils/keyboard"; +import { settingsPath } from "~/utils/routeHelpers"; import Tooltip from "../Tooltip"; import Sidebar from "./Sidebar"; import Header from "./components/Header"; @@ -25,6 +26,7 @@ function SettingsSidebar() { const { ui } = useStores(); const { t } = useTranslation(); const history = useHistory(); + const location = useLocation(); const configs = useSettingsConfig(); const groupedConfig = groupBy(configs, "group"); @@ -62,6 +64,11 @@ function SettingsSidebar() { } label={item.name} /> diff --git a/app/components/Tab.tsx b/app/components/Tab.tsx index 71774a1ff..ff2400bc4 100644 --- a/app/components/Tab.tsx +++ b/app/components/Tab.tsx @@ -1,4 +1,7 @@ import { m } from "framer-motion"; +import { LocationDescriptor } from "history"; +import isEqual from "lodash/isEqual"; +import queryString from "query-string"; import * as React from "react"; import styled, { useTheme } from "styled-components"; import { s } from "@shared/styles"; @@ -6,8 +9,19 @@ import NavLink from "~/components/NavLink"; import { hover } from "~/styles"; type Props = Omit, "children"> & { - to: string; + /** + * The path to match against the current location. + */ + to: LocationDescriptor; + /** + * If true, the tab will only be active if the path matches exactly. + */ exact?: boolean; + /** + * If true, the tab will only be active if the query string matches exactly. + * By default query string parameters are ignored for location mathing. + */ + exactQueryString?: boolean; children?: React.ReactNode; }; @@ -45,24 +59,38 @@ const transition = { damping: 30, }; -const Tab: React.FC = ({ children, ...rest }: Props) => { +const Tab: React.FC = ({ + children, + exact, + exactQueryString, + ...rest +}: Props) => { const theme = useTheme(); const activeStyle = { color: theme.textSecondary, }; return ( - - {(match) => ( + + {(match, location) => ( <> {children} - {match && ( - - )} + {match && + (!exactQueryString || + isEqual( + queryString.parse(location.search ?? ""), + queryString.parse(rest.to.search as string) + )) && ( + + )} )} diff --git a/app/hooks/useImportDocument.ts b/app/hooks/useImportDocument.ts index ddd1d4d8e..5c0cb4607 100644 --- a/app/hooks/useImportDocument.ts +++ b/app/hooks/useImportDocument.ts @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import { useHistory } from "react-router-dom"; import useStores from "~/hooks/useStores"; import useToasts from "~/hooks/useToasts"; +import { documentPath } from "~/utils/routeHelpers"; let importingLock = false; @@ -50,7 +51,7 @@ export default function useImportDocument( }); if (redirect) { - history.push(doc.url); + history.push(documentPath(doc)); } } } catch (err) { diff --git a/app/hooks/useSettingsConfig.ts b/app/hooks/useSettingsConfig.ts index 34e5499f1..57859ed74 100644 --- a/app/hooks/useSettingsConfig.ts +++ b/app/hooks/useSettingsConfig.ts @@ -12,38 +12,43 @@ import { SettingsIcon, ExportIcon, ImportIcon, + ShapesIcon, + Icon, } from "outline-icons"; -import React from "react"; +import React, { ComponentProps } from "react"; import { useTranslation } from "react-i18next"; import { integrationSettingsPath } from "@shared/utils/routeHelpers"; -import ApiKeys from "~/scenes/Settings/ApiKeys"; -import Details from "~/scenes/Settings/Details"; -import Export from "~/scenes/Settings/Export"; -import Features from "~/scenes/Settings/Features"; -import GoogleAnalytics from "~/scenes/Settings/GoogleAnalytics"; -import Groups from "~/scenes/Settings/Groups"; -import Import from "~/scenes/Settings/Import"; -import Members from "~/scenes/Settings/Members"; -import Notifications from "~/scenes/Settings/Notifications"; -import Preferences from "~/scenes/Settings/Preferences"; -import Profile from "~/scenes/Settings/Profile"; -import Security from "~/scenes/Settings/Security"; -import SelfHosted from "~/scenes/Settings/SelfHosted"; -import Shares from "~/scenes/Settings/Shares"; -import Zapier from "~/scenes/Settings/Zapier"; import GoogleIcon from "~/components/Icons/GoogleIcon"; import ZapierIcon from "~/components/Icons/ZapierIcon"; import PluginLoader from "~/utils/PluginLoader"; import isCloudHosted from "~/utils/isCloudHosted"; +import lazy from "~/utils/lazyWithRetry"; import { settingsPath } from "~/utils/routeHelpers"; import useCurrentTeam from "./useCurrentTeam"; import usePolicy from "./usePolicy"; +const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys")); +const Details = lazy(() => import("~/scenes/Settings/Details")); +const Export = lazy(() => import("~/scenes/Settings/Export")); +const Features = lazy(() => import("~/scenes/Settings/Features")); +const GoogleAnalytics = lazy(() => import("~/scenes/Settings/GoogleAnalytics")); +const Groups = lazy(() => import("~/scenes/Settings/Groups")); +const Import = lazy(() => import("~/scenes/Settings/Import")); +const Members = lazy(() => import("~/scenes/Settings/Members")); +const Notifications = lazy(() => import("~/scenes/Settings/Notifications")); +const Preferences = lazy(() => import("~/scenes/Settings/Preferences")); +const Profile = lazy(() => import("~/scenes/Settings/Profile")); +const Security = lazy(() => import("~/scenes/Settings/Security")); +const SelfHosted = lazy(() => import("~/scenes/Settings/SelfHosted")); +const Shares = lazy(() => import("~/scenes/Settings/Shares")); +const Templates = lazy(() => import("~/scenes/Settings/Templates")); +const Zapier = lazy(() => import("~/scenes/Settings/Zapier")); + export type ConfigItem = { name: string; path: string; - icon: React.FC; - component: React.ComponentType; + icon: React.FC>; + component: React.ComponentType; enabled: boolean; group: string; }; @@ -55,6 +60,7 @@ const useSettingsConfig = () => { const config = React.useMemo(() => { const items: ConfigItem[] = [ + // Account { name: t("Profile"), path: settingsPath(), @@ -87,7 +93,7 @@ const useSettingsConfig = () => { group: t("Account"), icon: CodeIcon, }, - // Team group + // Workspace { name: t("Details"), path: settingsPath("details"), @@ -128,6 +134,14 @@ const useSettingsConfig = () => { group: t("Workspace"), icon: GroupIcon, }, + { + name: t("Templates"), + path: settingsPath("templates"), + component: Templates, + enabled: true, + group: t("Workspace"), + icon: ShapesIcon, + }, { name: t("Shared Links"), path: settingsPath("shares"), @@ -152,6 +166,7 @@ const useSettingsConfig = () => { group: t("Workspace"), icon: ExportIcon, }, + // Integrations { name: t("Self Hosted"), path: integrationSettingsPath("self-hosted"), @@ -190,6 +205,7 @@ const useSettingsConfig = () => { const item = { name: t(plugin.config.name), path: integrationSettingsPath(plugin.id), + // TODO: Remove hardcoding of plugin id here group: plugin.id === "collections" ? t("Workspace") : t("Integrations"), component: plugin.settings, enabled: enabledInDeployment && hasSettings && can.update, diff --git a/app/menus/DocumentMenu.tsx b/app/menus/DocumentMenu.tsx index 94806a293..af37db970 100644 --- a/app/menus/DocumentMenu.tsx +++ b/app/menus/DocumentMenu.tsx @@ -1,5 +1,5 @@ import { observer } from "mobx-react"; -import { EditIcon, NewDocumentIcon, RestoreIcon } from "outline-icons"; +import { EditIcon, RestoreIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { useHistory } from "react-router-dom"; @@ -38,6 +38,8 @@ import { unpublishDocument, printDocument, openDocumentComments, + createDocumentFromTemplate, + createNestedDocument, } from "~/actions/definitions/documents"; import useActionContext from "~/hooks/useActionContext"; import useCurrentUser from "~/hooks/useCurrentUser"; @@ -47,7 +49,7 @@ import useRequest from "~/hooks/useRequest"; import useStores from "~/hooks/useStores"; import useToasts from "~/hooks/useToasts"; import { MenuItem } from "~/types"; -import { documentEditPath, newDocumentPath } from "~/utils/routeHelpers"; +import { documentEditPath } from "~/utils/routeHelpers"; type Props = { document: Document; @@ -266,15 +268,7 @@ function DocumentMenu({ visible: !!can.update && user.separateEditMode, icon: , }, - { - type: "route", - title: t("New nested document"), - to: newDocumentPath(document.collectionId, { - parentDocumentId: document.id, - }), - visible: !!can.createChildDocument, - icon: , - }, + actionToMenuItem(createNestedDocument, context), actionToMenuItem(importDocument, context), actionToMenuItem(createTemplate, context), actionToMenuItem(duplicateDocument, context), @@ -283,6 +277,7 @@ function DocumentMenu({ actionToMenuItem(archiveDocument, context), actionToMenuItem(moveDocument, context), actionToMenuItem(pinDocument, context), + actionToMenuItem(createDocumentFromTemplate, context), { type: "separator", }, diff --git a/app/menus/NewTemplateMenu.tsx b/app/menus/NewTemplateMenu.tsx index 188d44216..defe590ac 100644 --- a/app/menus/NewTemplateMenu.tsx +++ b/app/menus/NewTemplateMenu.tsx @@ -14,7 +14,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import { MenuItem } from "~/types"; -import { newDocumentPath } from "~/utils/routeHelpers"; +import { newTemplatePath } from "~/utils/routeHelpers"; function NewTemplateMenu() { const menu = useMenuState({ @@ -24,6 +24,11 @@ function NewTemplateMenu() { const team = useCurrentTeam(); const { collections, policies } = useStores(); const can = usePolicy(team); + React.useEffect(() => { + void collections.fetchPage({ + limit: 100, + }); + }, [collections]); const items = React.useMemo( () => @@ -33,9 +38,7 @@ function NewTemplateMenu() { if (can.update) { filtered.push({ type: "route", - to: newDocumentPath(collection.id, { - template: true, - }), + to: newTemplatePath(collection.id), title: {collection.name}, icon: , }); diff --git a/app/menus/TemplatesMenu.tsx b/app/menus/TemplatesMenu.tsx index af59d91fb..6d4937410 100644 --- a/app/menus/TemplatesMenu.tsx +++ b/app/menus/TemplatesMenu.tsx @@ -10,6 +10,7 @@ import Button from "~/components/Button"; import ContextMenu from "~/components/ContextMenu"; import MenuItem from "~/components/ContextMenu/MenuItem"; import Separator from "~/components/ContextMenu/Separator"; +import EmojiIcon from "~/components/Icons/EmojiIcon"; import useCurrentUser from "~/hooks/useCurrentUser"; import useStores from "~/hooks/useStores"; import { replaceTitleVariables } from "~/utils/date"; @@ -43,7 +44,9 @@ function TemplatesMenu({ onSelectTemplate, document }: Props) { onSelectTemplate(template)} - icon={} + icon={ + template.emoji ? : + } {...menu} > diff --git a/app/models/Document.ts b/app/models/Document.ts index 60bb92c8d..6012e312e 100644 --- a/app/models/Document.ts +++ b/app/models/Document.ts @@ -1,13 +1,16 @@ import { addDays, differenceInDays } from "date-fns"; +import { t } from "i18next"; import floor from "lodash/floor"; import { action, autorun, computed, observable, set } from "mobx"; import { ExportContentType } from "@shared/types"; import type { NavigationNode } from "@shared/types"; import Storage from "@shared/utils/Storage"; import { isRTL } from "@shared/utils/rtl"; +import slugify from "@shared/utils/slugify"; import DocumentsStore from "~/stores/DocumentsStore"; import User from "~/models/User"; import { client } from "~/utils/ApiClient"; +import { settingsPath } from "~/utils/routeHelpers"; import View from "./View"; import ParanoidModel from "./base/ParanoidModel"; import Field from "./decorators/Field"; @@ -122,6 +125,9 @@ export default class Document extends ParanoidModel { @observable archivedAt: string; + /** + * @deprecated Use path instead + */ @observable url: string; @@ -153,9 +159,21 @@ export default class Document extends ParanoidModel { return isRTL(this.title); } + @computed + get path(): string { + const prefix = this.template ? settingsPath("templates") : "/doc"; + + if (!this.title) { + return `${prefix}/untitled-${this.urlId}`; + } + + const slugifiedTitle = slugify(this.title); + return `${prefix}/${slugifiedTitle}-${this.urlId}`; + } + @computed get noun(): string { - return this.template ? "template" : "document"; + return this.template ? t("template") : t("document"); } @computed diff --git a/app/routes/authenticated.tsx b/app/routes/authenticated.tsx index 08133c906..7e52170da 100644 --- a/app/routes/authenticated.tsx +++ b/app/routes/authenticated.tsx @@ -10,25 +10,34 @@ import Route from "~/components/ProfiledRoute"; import WebsocketProvider from "~/components/WebsocketProvider"; import useCurrentTeam from "~/hooks/useCurrentTeam"; import usePolicy from "~/hooks/usePolicy"; -import lazyWithRetry from "~/utils/lazyWithRetry"; -import { matchDocumentSlug as slug } from "~/utils/routeHelpers"; +import lazy from "~/utils/lazyWithRetry"; +import { + archivePath, + draftsPath, + homePath, + searchPath, + settingsPath, + matchDocumentSlug as slug, + trashPath, +} from "~/utils/routeHelpers"; -const SettingsRoutes = lazyWithRetry(() => import("./settings")); -const Archive = lazyWithRetry(() => import("~/scenes/Archive")); -const Collection = lazyWithRetry(() => import("~/scenes/Collection")); -const Document = lazyWithRetry(() => import("~/scenes/Document")); -const Drafts = lazyWithRetry(() => import("~/scenes/Drafts")); -const Home = lazyWithRetry(() => import("~/scenes/Home")); -const Templates = lazyWithRetry(() => import("~/scenes/Templates")); -const Search = lazyWithRetry(() => import("~/scenes/Search")); -const Trash = lazyWithRetry(() => import("~/scenes/Trash")); +const SettingsRoutes = lazy(() => import("./settings")); +const Archive = lazy(() => import("~/scenes/Archive")); +const Collection = lazy(() => import("~/scenes/Collection")); +const Document = lazy(() => import("~/scenes/Document")); +const Drafts = lazy(() => import("~/scenes/Drafts")); +const Home = lazy(() => import("~/scenes/Home")); +const Search = lazy(() => import("~/scenes/Search")); +const Trash = lazy(() => import("~/scenes/Trash")); const RedirectDocument = ({ match, }: RouteComponentProps<{ documentSlug: string }>) => ( ); @@ -49,24 +58,18 @@ function AuthenticatedRoutes() { > {can.createDocument && ( - + )} {can.createDocument && ( - + )} {can.createDocument && ( - + )} - {can.createDocument && ( - - )} - {can.createDocument && ( - - )} - - - - + + + + @@ -81,8 +84,7 @@ function AuthenticatedRoutes() { - - + diff --git a/app/routes/settings.tsx b/app/routes/settings.tsx index 1e1ac4e08..91f68c548 100644 --- a/app/routes/settings.tsx +++ b/app/routes/settings.tsx @@ -1,8 +1,13 @@ import * as React from "react"; -import { Switch } from "react-router-dom"; +import { RouteComponentProps, Switch } from "react-router-dom"; +import DocumentNew from "~/scenes/DocumentNew"; import Error404 from "~/scenes/Error404"; import Route from "~/components/ProfiledRoute"; import useSettingsConfig from "~/hooks/useSettingsConfig"; +import lazy from "~/utils/lazyWithRetry"; +import { matchDocumentSlug, settingsPath } from "~/utils/routeHelpers"; + +const Document = lazy(() => import("~/scenes/Document")); export default function SettingsRoutes() { const configs = useSettingsConfig(); @@ -17,6 +22,18 @@ export default function SettingsRoutes() { component={config.component} /> ))} + + ( + + )} + /> ); diff --git a/app/scenes/Document/components/DocumentTitle.tsx b/app/scenes/Document/components/DocumentTitle.tsx index 0b109be2a..d0375155c 100644 --- a/app/scenes/Document/components/DocumentTitle.tsx +++ b/app/scenes/Document/components/DocumentTitle.tsx @@ -29,7 +29,7 @@ const EmojiPicker = React.lazy(() => import("~/components/EmojiPicker")); type Props = { /** ID of the associated document */ documentId: string; - /** Document to display */ + /** Title to display */ title: string; /** Emoji to display */ emoji?: string | null; @@ -247,7 +247,7 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle( value={title} $emojiPickerIsOpen={emojiPickerIsOpen} $containsEmoji={!!emoji} - autoFocus={!document.title} + autoFocus={!title} maxLength={DocumentValidation.maxTitleLength} readOnly={readOnly} dir="auto" diff --git a/app/scenes/Document/components/Header.tsx b/app/scenes/Document/components/Header.tsx index 7056abe55..b9839afec 100644 --- a/app/scenes/Document/components/Header.tsx +++ b/app/scenes/Document/components/Header.tsx @@ -26,6 +26,7 @@ import EmojiIcon from "~/components/Icons/EmojiIcon"; import Star from "~/components/Star"; import Tooltip from "~/components/Tooltip"; import { publishDocument } from "~/actions/definitions/documents"; +import { navigateToTemplateSettings } from "~/actions/definitions/navigation"; import { restoreRevision } from "~/actions/definitions/revisions"; import useActionContext from "~/hooks/useActionContext"; import useMobile from "~/hooks/useMobile"; @@ -36,7 +37,7 @@ import NewChildDocumentMenu from "~/menus/NewChildDocumentMenu"; import TableOfContentsMenu from "~/menus/TableOfContentsMenu"; import TemplatesMenu from "~/menus/TemplatesMenu"; import { metaDisplay } from "~/utils/keyboard"; -import { newDocumentPath, documentEditPath } from "~/utils/routeHelpers"; +import { documentEditPath } from "~/utils/routeHelpers"; import ObservingBanner from "./ObservingBanner"; import PublicBreadcrumb from "./PublicBreadcrumb"; import ShareButton from "./ShareButton"; @@ -243,31 +244,43 @@ function DocumentHeader({ {!isEditing && !isDeleted && !isRevision && - (!isMobile || !isTemplate) && + !isTemplate && + !isMobile && document.collectionId && ( )} {isEditing && ( - <> - - + + - - - + {isDraft ? t("Save draft") : t("Done editing")} + + + + )} + {isTemplate && ( + + + )} {can.update && !isEditing && @@ -296,23 +309,6 @@ function DocumentHeader({ /> )} - {can.update && - !isEditing && - isTemplate && - !isDraft && - !isRevision && ( - - - - )} {revision && revision.createdAt !== document.updatedAt && ( (); const { t } = useTranslation(); const { documents, collections } = useStores(); const { showToast } = useToasts(); - const id = match.params.id || ""; + const id = match.params.id || query.get("collectionId"); useEffect(() => { async function createDocument() { - const params = queryString.parse(location.search); - const parentDocumentId = params.parentDocumentId?.toString(); + const parentDocumentId = query.get("parentDocumentId") ?? undefined; const parentDocument = parentDocumentId ? documents.get(parentDocumentId) : undefined; @@ -37,12 +44,17 @@ function DocumentNew() { collectionId: collection?.id, parentDocumentId, fullWidth: parentDocument?.fullWidth, - templateId: params.templateId?.toString(), - template: params.template === "true" ? true : false, + templateId: query.get("templateId") ?? undefined, + template, title: "", text: "", }); - history.replace(documentEditPath(document), location.state); + history.replace( + template || !user.separateEditMode + ? documentPath(document) + : documentEditPath(document), + location.state + ); } catch (err) { showToast(t("Couldn’t create the document, try again?"), { type: "error", diff --git a/app/scenes/Templates.tsx b/app/scenes/Settings/Templates.tsx similarity index 59% rename from app/scenes/Templates.tsx rename to app/scenes/Settings/Templates.tsx index 27586454d..56ada3ffe 100644 --- a/app/scenes/Templates.tsx +++ b/app/scenes/Settings/Templates.tsx @@ -1,8 +1,8 @@ import { observer } from "mobx-react"; import { ShapesIcon } from "outline-icons"; +import queryString from "query-string"; import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { RouteComponentProps } from "react-router-dom"; +import { Trans, useTranslation } from "react-i18next"; import { Action } from "~/components/Actions"; import Empty from "~/components/Empty"; import Heading from "~/components/Heading"; @@ -10,18 +10,18 @@ import PaginatedDocumentList from "~/components/PaginatedDocumentList"; import Scene from "~/components/Scene"; import Tab from "~/components/Tab"; import Tabs from "~/components/Tabs"; -import useCurrentTeam from "~/hooks/useCurrentTeam"; -import usePolicy from "~/hooks/usePolicy"; +import Text from "~/components/Text"; +import useQuery from "~/hooks/useQuery"; import useStores from "~/hooks/useStores"; import NewTemplateMenu from "~/menus/NewTemplateMenu"; +import { settingsPath } from "~/utils/routeHelpers"; -function Templates(props: RouteComponentProps<{ sort: string }>) { +function Templates() { const { documents } = useStores(); const { t } = useTranslation(); - const team = useCurrentTeam(); + const param = useQuery(); const { fetchTemplates, templates, templatesAlphabetical } = documents; - const { sort } = props.match.params; - const can = usePolicy(team); + const sort = param.get("sort") || "recent"; return ( ) { } > {t("Templates")} + + + You can create templates to help your team create consistent and + accurate documentation. + + + - + {t("Recently updated")} - + {t("Alphabetical")} } - empty={ - - {t("There are no templates just yet.")}{" "} - {can.createDocument && - t( - "You can create templates to help your team create consistent and accurate documentation." - )} - - } + empty={{t("There are no templates just yet.")}} fetch={fetchTemplates} documents={sort === "alphabetical" ? templatesAlphabetical : templates} showCollection diff --git a/app/utils/routeHelpers.ts b/app/utils/routeHelpers.ts index 7183b2fc8..c32d6abc4 100644 --- a/app/utils/routeHelpers.ts +++ b/app/utils/routeHelpers.ts @@ -11,10 +11,6 @@ export function draftsPath(): string { return "/drafts"; } -export function templatesPath(): string { - return "/templates"; -} - export function archivePath(): string { return "/archive"; } @@ -50,22 +46,22 @@ export function updateCollectionPath( } export function documentPath(doc: Document): string { - return doc.url; + return doc.path; } export function documentEditPath(doc: Document): string { - return `${doc.url}/edit`; + return `${documentPath(doc)}/edit`; } export function documentInsightsPath(doc: Document): string { - return `${doc.url}/insights`; + return `${documentPath(doc)}/insights`; } export function documentHistoryPath( doc: Document, revisionId?: string ): string { - let base = `${doc.url}/history`; + let base = `${documentPath(doc)}/history`; if (revisionId) { base += `/${revisionId}`; } @@ -84,12 +80,15 @@ export function updateDocumentPath(oldUrl: string, document: Document): string { ); } +export function newTemplatePath(collectionId: string) { + return settingsPath("templates") + `/new?collectionId=${collectionId}`; +} + export function newDocumentPath( collectionId?: string | null, params: { parentDocumentId?: string; templateId?: string; - template?: boolean; } = {} ): string { return collectionId diff --git a/server/commands/documentCreator.ts b/server/commands/documentCreator.ts index 52cbc9643..bd3853b48 100644 --- a/server/commands/documentCreator.ts +++ b/server/commands/documentCreator.ts @@ -6,6 +6,7 @@ type Props = { id?: string; urlId?: string; title: string; + emoji?: string; text?: string; state?: Buffer; publish?: boolean; @@ -28,6 +29,7 @@ type Props = { export default async function documentCreator({ title = "", text = "", + emoji, state, id, urlId, @@ -81,6 +83,7 @@ export default async function documentCreator({ fullWidth, publishedAt, importId, + emoji: templateDocument ? templateDocument.emoji : emoji, title: templateDocument ? DocumentHelper.replaceTemplateVariables(templateDocument.title, user) : title, diff --git a/server/models/Collection.test.ts b/server/models/Collection.test.ts index 78a32f213..3bd8947b7 100644 --- a/server/models/Collection.test.ts +++ b/server/models/Collection.test.ts @@ -1,5 +1,6 @@ import randomstring from "randomstring"; import { v4 as uuidv4 } from "uuid"; +import slugify from "@shared/utils/slugify"; import { buildUser, buildGroup, @@ -7,7 +8,6 @@ import { buildTeam, buildDocument, } from "@server/test/factories"; -import slugify from "@server/utils/slugify"; import Collection from "./Collection"; import Document from "./Document"; diff --git a/server/models/Collection.ts b/server/models/Collection.ts index 7eca29624..c2977a265 100644 --- a/server/models/Collection.ts +++ b/server/models/Collection.ts @@ -27,9 +27,9 @@ import isUUID from "validator/lib/isUUID"; import type { CollectionSort } from "@shared/types"; import { CollectionPermission, NavigationNode } from "@shared/types"; import { sortNavigationNodes } from "@shared/utils/collections"; +import slugify from "@shared/utils/slugify"; import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers"; import { CollectionValidation } from "@shared/validations"; -import slugify from "@server/utils/slugify"; import CollectionGroup from "./CollectionGroup"; import CollectionUser from "./CollectionUser"; import Document from "./Document"; diff --git a/server/models/Document.test.ts b/server/models/Document.test.ts index 1c84a45b1..0ee877e35 100644 --- a/server/models/Document.test.ts +++ b/server/models/Document.test.ts @@ -1,3 +1,4 @@ +import slugify from "@shared/utils/slugify"; import Document from "@server/models/Document"; import { buildDocument, @@ -6,7 +7,6 @@ import { buildTeam, buildUser, } from "@server/test/factories"; -import slugify from "@server/utils/slugify"; beforeEach(() => { jest.resetAllMocks(); diff --git a/server/models/Document.ts b/server/models/Document.ts index d8ac4e736..f0fec704c 100644 --- a/server/models/Document.ts +++ b/server/models/Document.ts @@ -34,9 +34,9 @@ import { import isUUID from "validator/lib/isUUID"; import type { NavigationNode } from "@shared/types"; import getTasks from "@shared/utils/getTasks"; +import slugify from "@shared/utils/slugify"; import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers"; import { DocumentValidation } from "@shared/validations"; -import slugify from "@server/utils/slugify"; import Backlink from "./Backlink"; import Collection from "./Collection"; import FileOperation from "./FileOperation"; diff --git a/server/policies/document.ts b/server/policies/document.ts index f39286725..fd4b897fb 100644 --- a/server/policies/document.ts +++ b/server/policies/document.ts @@ -179,11 +179,27 @@ allow(User, "move", Document, (user, document) => { return user.teamId === document.teamId; }); -allow(User, ["pin", "unpin"], Document, (user, document) => { - if (!document || document.isDraft) { +allow(User, "pin", Document, (user, document) => { + if ( + !document || + document.isDraft || + !document.isActive || + document.template + ) { return false; } - if (document.template) { + invariant( + document.collection, + "collection is missing, did you forget to include in the query scope?" + ); + if (cannot(user, "update", document.collection)) { + return false; + } + return user.teamId === document.teamId; +}); + +allow(User, "unpin", Document, (user, document) => { + if (!document || document.isDraft || document.template) { return false; } invariant( @@ -197,10 +213,12 @@ allow(User, ["pin", "unpin"], Document, (user, document) => { }); allow(User, ["subscribe", "unsubscribe"], Document, (user, document) => { - if (!document || !document.isActive || document.isDraft) { - return false; - } - if (document.template) { + if ( + !document || + !document.isActive || + document.isDraft || + document.template + ) { return false; } invariant( @@ -284,7 +302,12 @@ allow(User, "restore", Document, (user, document) => { }); allow(User, "archive", Document, (user, document) => { - if (!document || !document.isActive || document.isDraft) { + if ( + !document || + !document.isActive || + document.isDraft || + document.template + ) { return false; } invariant( diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index 5c0844b0a..eb77701e6 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -9,6 +9,7 @@ import { Op, ScopeOptions, WhereOptions } from "sequelize"; import { TeamPreference } from "@shared/types"; import { subtractDate } from "@shared/utils/date"; import { bytesToHumanReadable } from "@shared/utils/files"; +import slugify from "@shared/utils/slugify"; import documentCreator from "@server/commands/documentCreator"; import documentImporter from "@server/commands/documentImporter"; import documentLoader from "@server/commands/documentLoader"; @@ -55,7 +56,6 @@ import ZipHelper from "@server/utils/ZipHelper"; import { getFileFromRequest } from "@server/utils/koa"; import parseAttachmentIds from "@server/utils/parseAttachmentIds"; import { getTeamFromContext } from "@server/utils/passport"; -import slugify from "@server/utils/slugify"; import { assertPresent } from "@server/validation"; import pagination from "../middlewares/pagination"; import * as T from "./schema"; diff --git a/server/routes/api/revisions/revisions.ts b/server/routes/api/revisions/revisions.ts index e34201f6d..2b8996f9e 100644 --- a/server/routes/api/revisions/revisions.ts +++ b/server/routes/api/revisions/revisions.ts @@ -1,6 +1,7 @@ import Router from "koa-router"; import { Op } from "sequelize"; import { RevisionHelper } from "@shared/utils/RevisionHelper"; +import slugify from "@shared/utils/slugify"; import { ValidationError } from "@server/errors"; import auth from "@server/middlewares/authentication"; import validate from "@server/middlewares/validate"; @@ -9,7 +10,6 @@ import DocumentHelper from "@server/models/helpers/DocumentHelper"; import { authorize } from "@server/policies"; import { presentRevision } from "@server/presenters"; import { APIContext } from "@server/types"; -import slugify from "@server/utils/slugify"; import pagination from "../middlewares/pagination"; import * as T from "./schema"; diff --git a/server/utils/slugify.ts b/server/utils/slugify.ts deleted file mode 100644 index c4c087fe8..000000000 --- a/server/utils/slugify.ts +++ /dev/null @@ -1,9 +0,0 @@ -import slug from "slug"; - -slug.defaults.mode = "rfc3986"; - -export default function slugify(text: string): string { - return slug(text, { - remove: /[.]/g, - }); -} diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 8b65ee206..4dfb2432b 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -18,11 +18,13 @@ "Development": "Development", "Open document": "Open document", "New document": "New document", + "New from template": "New from template", + "New nested document": "New nested document", "Publish": "Publish", - "Document published": "Document published", + "Published {{ documentName }}": "Published {{ documentName }}", "Publish document": "Publish document", "Unpublish": "Unpublish", - "Document unpublished": "Document unpublished", + "Unpublished {{ documentName }}": "Unpublished {{ documentName }}", "Subscribe": "Subscribe", "Subscribed to document notifications": "Subscribed to document notifications", "Unsubscribe": "Unsubscribe", @@ -61,10 +63,10 @@ "Insights": "Insights", "Home": "Home", "Drafts": "Drafts", - "Templates": "Templates", "Trash": "Trash", "Settings": "Settings", "Profile": "Profile", + "Templates": "Templates", "Notifications": "Notifications", "Preferences": "Preferences", "API documentation": "API documentation", @@ -139,7 +141,6 @@ "Only visible to you": "Only visible to you", "Draft": "Draft", "Template": "Template", - "New doc": "New doc", "You updated": "You updated", "{{ userName }} updated": "{{ userName }} updated", "You deleted": "You deleted", @@ -230,9 +231,9 @@ "No results for {{query}}": "No results for {{query}}", "Logo": "Logo", "Move document": "Move document", + "New doc": "New doc", "You can't reorder documents in an alphabetically sorted collection": "You can't reorder documents in an alphabetically sorted collection", "Collections": "Collections", - "New nested document": "New nested document", "Document not supported – try Markdown, Plain text, HTML, or Word": "Document not supported – try Markdown, Plain text, HTML, or Word", "Empty": "Empty", "Go back": "Go back", @@ -403,6 +404,8 @@ "Resend invite": "Resend invite", "Revoke invite": "Revoke invite", "Activate account": "Activate account", + "template": "template", + "document": "document", "published": "published", "edited": "edited", "created the collection": "created the collection", @@ -508,7 +511,6 @@ "Archived": "Archived", "Save draft": "Save draft", "Done editing": "Done editing", - "New from template": "New from template", "Restore version": "Restore version", "No history yet": "No history yet", "Stats": "Stats", @@ -580,6 +582,7 @@ "Document permanently deleted": "Document permanently deleted", "Are you sure you want to permanently delete the {{ documentTitle }} document? This action is immediate and cannot be undone.": "Are you sure you want to permanently delete the {{ documentTitle }} document? This action is immediate and cannot be undone.", "Select a location to publish": "Select a location to publish", + "Document published": "Document published", "Couldn’t publish the document, try again?": "Couldn’t publish the document, try again?", "Publish in {{ location }}": "Publish in {{ location }}", "view and edit access": "view and edit access", @@ -873,6 +876,9 @@ "Sharing is currently disabled.": "Sharing is currently disabled.", "You can globally enable and disable public document sharing in the security settings.": "You can globally enable and disable public document sharing in the security settings.", "Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.": "Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.", + "You can create templates to help your team create consistent and accurate documentation.": "You can create templates to help your team create consistent and accurate documentation.", + "Alphabetical": "Alphabetical", + "There are no templates just yet.": "There are no templates just yet.", "Zapier is a platform that allows {{appName}} to easily integrate with thousands of other business tools. Automate your workflows, sync data, and more.": "Zapier is a platform that allows {{appName}} to easily integrate with thousands of other business tools. Automate your workflows, sync data, and more.", "A confirmation code has been sent to your email address, please enter the code below to permanantly destroy this workspace.": "A confirmation code has been sent to your email address, please enter the code below to permanantly destroy this workspace.", "Confirmation code": "Confirmation code", @@ -881,9 +887,6 @@ "Workspace name": "Workspace name", "Your are creating a new workspace using your current account — {{email}}": "Your are creating a new workspace using your current account — {{email}}", "To create a workspace under another email please sign up from the homepage": "To create a workspace under another email please sign up from the homepage", - "Alphabetical": "Alphabetical", - "There are no templates just yet.": "There are no templates just yet.", - "You can create templates to help your team create consistent and accurate documentation.": "You can create templates to help your team create consistent and accurate documentation.", "Trash is empty at the moment.": "Trash is empty at the moment.", "A confirmation code has been sent to your email address, please enter the code below to permanantly destroy your account.": "A confirmation code has been sent to your email address, please enter the code below to permanantly destroy your account.", "Are you sure? Deleting your account will destroy identifying data associated with your user and cannot be undone. You will be immediately logged out of {{appName}} and all your API tokens will be revoked.": "Are you sure? Deleting your account will destroy identifying data associated with your user and cannot be undone. You will be immediately logged out of {{appName}} and all your API tokens will be revoked.", diff --git a/shared/utils/slugify.ts b/shared/utils/slugify.ts new file mode 100644 index 000000000..054677233 --- /dev/null +++ b/shared/utils/slugify.ts @@ -0,0 +1,16 @@ +import slug from "slug"; + +slug.defaults.mode = "rfc3986"; + +/** + * Convert a string to a slug that can be used in a URL in kebab-case format, + * and remove periods. + * + * @param text The text to convert + * @returns The slugified text + */ +export default function slugify(text: string): string { + return slug(text, { + remove: /[.]/g, + }); +}