From eb0c324da88ab3c3e3ab03fa7ac1e5c11f77e8a6 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 30 Dec 2021 16:54:02 -0800 Subject: [PATCH] feat: Pin to home (#2880) --- app/actions/definitions/documents.tsx | 71 ++- app/components/CollectionIcon.tsx | 13 +- app/components/ContextMenu/Template.tsx | 22 +- app/components/DocumentCard.tsx | 253 ++++++++++ app/components/DocumentMeta.tsx | 1 - app/components/PaginatedDocumentList.tsx | 8 +- app/components/PinnedDocuments.tsx | 137 +++++ .../Sidebar/components/SidebarAction.tsx | 14 +- app/components/SocketProvider.tsx | 13 + app/hooks/useActionContext.ts | 32 ++ app/hooks/useCommandBarActions.ts | 21 +- app/menus/DocumentMenu.tsx | 29 +- app/models/Document.ts | 52 +- app/models/Pin.ts | 18 + app/routes/authenticated.tsx | 8 +- app/scenes/Collection.tsx | 472 +++++------------- app/scenes/Collection/Actions.tsx | 75 +++ app/scenes/Collection/DropToImport.tsx | 98 ++++ app/scenes/Collection/Empty.tsx | 89 ++++ app/scenes/Home.tsx | 12 +- app/stores/DocumentsStore.ts | 33 +- app/stores/PinsStore.ts | 57 +++ app/stores/RootStore.ts | 4 + app/types.ts | 6 +- app/typings/index.d.ts | 2 + app/typings/styled-components.d.ts | 1 + package.json | 3 + server/commands/documentMover.ts | 43 +- server/commands/pinCreator.test.ts | 55 ++ server/commands/pinCreator.ts | 97 ++++ server/commands/pinDestroyer.test.ts | 38 ++ server/commands/pinDestroyer.ts | 54 ++ server/commands/pinUpdater.ts | 53 ++ .../migrations/20211221031430-create-pins.js | 98 ++++ server/models/Document.ts | 45 +- server/models/Event.ts | 7 +- server/models/Pin.ts | 50 ++ server/models/index.ts | 3 + server/policies/document.ts | 9 + server/policies/index.ts | 1 + server/policies/pins.ts | 9 + server/presenters/document.ts | 5 +- server/presenters/index.ts | 2 + server/presenters/pin.ts | 10 + server/queues/processors/websockets.ts | 28 +- .../api/__snapshots__/documents.test.ts.snap | 18 - server/routes/api/collections.ts | 37 +- server/routes/api/documents.test.ts | 127 ----- server/routes/api/documents.ts | 134 +---- server/routes/api/index.ts | 2 + server/routes/api/pins.ts | 156 ++++++ server/sequelize.ts | 1 - server/types.ts | 12 +- server/validation.ts | 5 +- shared/i18n/locales/en_US/translation.json | 18 +- shared/theme.ts | 1 + yarn.lock | 41 +- 57 files changed, 1884 insertions(+), 819 deletions(-) create mode 100644 app/components/DocumentCard.tsx create mode 100644 app/components/PinnedDocuments.tsx create mode 100644 app/hooks/useActionContext.ts create mode 100644 app/models/Pin.ts create mode 100644 app/scenes/Collection/Actions.tsx create mode 100644 app/scenes/Collection/DropToImport.tsx create mode 100644 app/scenes/Collection/Empty.tsx create mode 100644 app/stores/PinsStore.ts create mode 100644 server/commands/pinCreator.test.ts create mode 100644 server/commands/pinCreator.ts create mode 100644 server/commands/pinDestroyer.test.ts create mode 100644 server/commands/pinDestroyer.ts create mode 100644 server/commands/pinUpdater.ts create mode 100644 server/migrations/20211221031430-create-pins.js create mode 100644 server/models/Pin.ts create mode 100644 server/policies/pins.ts create mode 100644 server/presenters/pin.ts create mode 100644 server/routes/api/pins.ts diff --git a/app/actions/definitions/documents.tsx b/app/actions/definitions/documents.tsx index 32c83ab5d..e1253800f 100644 --- a/app/actions/definitions/documents.tsx +++ b/app/actions/definitions/documents.tsx @@ -9,6 +9,7 @@ import { NewDocumentIcon, ShapesIcon, ImportIcon, + PinIcon, } from "outline-icons"; import * as React from "react"; import DocumentTemplatize from "~/scenes/DocumentTemplatize"; @@ -16,7 +17,7 @@ import { createAction } from "~/actions"; import { DocumentSection } from "~/actions/sections"; import getDataTransferFiles from "~/utils/getDataTransferFiles"; import history from "~/utils/history"; -import { newDocumentPath } from "~/utils/routeHelpers"; +import { homePath, newDocumentPath } from "~/utils/routeHelpers"; export const openDocument = createAction({ name: ({ t }) => t("Open document"), @@ -133,6 +134,72 @@ export const duplicateDocument = createAction({ }, }); +/** + * Pin a document to a collection. Pinned documents will be displayed at the top + * of the collection for all collection members to see. + */ +export const pinDocument = createAction({ + name: ({ t }) => t("Pin to collection"), + section: DocumentSection, + icon: , + visible: ({ activeCollectionId, activeDocumentId, stores }) => { + if (!activeDocumentId || !activeCollectionId) { + return false; + } + + const document = stores.documents.get(activeDocumentId); + return ( + !!stores.policies.abilities(activeDocumentId).pin && !document?.pinned + ); + }, + perform: async ({ activeDocumentId, activeCollectionId, t, stores }) => { + if (!activeDocumentId || !activeCollectionId) { + return; + } + + const document = stores.documents.get(activeDocumentId); + await document?.pin(document.collectionId); + + const collection = stores.collections.get(activeCollectionId); + + if (!collection || !location.pathname.startsWith(collection?.url)) { + stores.toasts.showToast(t("Pinned to collection")); + } + }, +}); + +/** + * Pin a document to team home. Pinned documents will be displayed at the top + * of the home screen for all team members to see. + */ +export const pinDocumentToHome = createAction({ + name: ({ t }) => t("Pin to home"), + section: DocumentSection, + icon: , + visible: ({ activeDocumentId, currentTeamId, stores }) => { + if (!currentTeamId || !activeDocumentId) { + return false; + } + + const document = stores.documents.get(activeDocumentId); + + return ( + !!stores.policies.abilities(activeDocumentId).pinToHome && + !document?.pinnedToHome + ); + }, + perform: async ({ activeDocumentId, location, t, stores }) => { + if (!activeDocumentId) return; + const document = stores.documents.get(activeDocumentId); + + await document?.pin(); + + if (location.pathname !== homePath()) { + stores.toasts.showToast(t("Pinned to team home")); + } + }, +}); + export const printDocument = createAction({ name: ({ t, isContextMenu }) => isContextMenu ? t("Print") : t("Print document"), @@ -234,4 +301,6 @@ export const rootDocumentActions = [ unstarDocument, duplicateDocument, printDocument, + pinDocument, + pinDocumentToHome, ]; diff --git a/app/components/CollectionIcon.tsx b/app/components/CollectionIcon.tsx index b8b295b9a..427e55491 100644 --- a/app/components/CollectionIcon.tsx +++ b/app/components/CollectionIcon.tsx @@ -10,19 +10,26 @@ type Props = { collection: Collection; expanded?: boolean; size?: number; + color?: string; }; -function ResolvedCollectionIcon({ collection, expanded, size }: Props) { +function ResolvedCollectionIcon({ + collection, + color: inputColor, + expanded, + size, +}: Props) { const { ui } = useStores(); // If the chosen icon color is very dark then we invert it in dark mode // otherwise it will be impossible to see against the dark background. const color = - ui.resolvedTheme === "dark" && collection.color !== "currentColor" + inputColor || + (ui.resolvedTheme === "dark" && collection.color !== "currentColor" ? getLuminance(collection.color) > 0.09 ? collection.color : "currentColor" - : collection.color; + : collection.color); if (collection.icon && collection.icon !== "collection") { try { diff --git a/app/components/ContextMenu/Template.tsx b/app/components/ContextMenu/Template.tsx index 62f7ed170..0997f3fd8 100644 --- a/app/components/ContextMenu/Template.tsx +++ b/app/components/ContextMenu/Template.tsx @@ -1,18 +1,17 @@ import { ExpandedIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import { Link, useLocation } from "react-router-dom"; +import { Link } from "react-router-dom"; import { useMenuState, MenuButton, MenuItem as BaseMenuItem, } from "reakit/Menu"; import styled from "styled-components"; -import { $Shape } from "utility-types"; import Flex from "~/components/Flex"; import MenuIconWrapper from "~/components/MenuIconWrapper"; import { actionToMenuItem } from "~/actions"; -import useStores from "~/hooks/useStores"; +import useActionContext from "~/hooks/useActionContext"; import { Action, ActionContext, @@ -27,7 +26,7 @@ import ContextMenu from "."; type Props = { actions?: (Action | MenuSeparator | MenuHeading)[]; - context?: $Shape; + context?: Partial; items?: TMenuItem[]; }; @@ -90,20 +89,9 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] { } function Template({ items, actions, context, ...menu }: Props) { - const { t } = useTranslation(); - const location = useLocation(); - const stores = useStores(); - const { ui } = stores; - const ctx = { - t, - isCommandBar: false, + const ctx = useActionContext({ isContextMenu: true, - activeCollectionId: ui.activeCollectionId, - activeDocumentId: ui.activeDocumentId, - location, - stores, - ...context, - }; + }); const templateItems = actions ? actions.map((item) => diff --git a/app/components/DocumentCard.tsx b/app/components/DocumentCard.tsx new file mode 100644 index 000000000..b3e3eac05 --- /dev/null +++ b/app/components/DocumentCard.tsx @@ -0,0 +1,253 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { m } from "framer-motion"; +import { observer } from "mobx-react"; +import { CloseIcon, DocumentIcon } from "outline-icons"; +import { getLuminance, transparentize } from "polished"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import styled, { css } from "styled-components"; +import Document from "~/models/Document"; +import Pin from "~/models/Pin"; +import DocumentMeta from "~/components/DocumentMeta"; +import Flex from "~/components/Flex"; +import NudeButton from "~/components/NudeButton"; +import useStores from "~/hooks/useStores"; +import CollectionIcon from "./CollectionIcon"; +import Tooltip from "./Tooltip"; + +type Props = { + pin: Pin | undefined; + document: Document; + canUpdatePin?: boolean; +}; + +function DocumentCard(props: Props) { + const { t } = useTranslation(); + const { collections } = useStores(); + const { document, pin, canUpdatePin } = props; + const collection = collections.get(document.collectionId); + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: props.document.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + const handleUnpin = React.useCallback(() => { + pin?.delete(); + }, [pin]); + + return ( + + + + + {collection?.icon && + collection?.icon !== "collection" && + !pin?.collectionId ? ( + + ) : ( + + )} +
+ {document.titleWithDefault} + + +
+
+
+ {canUpdatePin && ( + + {!isDragging && pin && ( + + + + + + )} + + ::: + + + )} +
+
+ ); +} + +const PinButton = styled(NudeButton)` + color: ${(props) => props.theme.white75}; + + &:hover, + &:active { + color: ${(props) => props.theme.white}; + } +`; + +const Actions = styled(Flex)` + position: absolute; + top: 12px; + right: ${(props) => (props.dir === "rtl" ? "auto" : "12px")}; + left: ${(props) => (props.dir === "rtl" ? "12px" : "auto")}; + opacity: 0; + transition: opacity 100ms ease-in-out; + + // move actions above content + z-index: 2; +`; + +const DragHandle = styled.div<{ $isDragging: boolean }>` + cursor: ${(props) => (props.$isDragging ? "grabbing" : "grab")}; + padding: 0 4px; + font-weight: bold; + color: ${(props) => props.theme.white75}; + line-height: 1.35; + + &:hover, + &:active { + color: ${(props) => props.theme.white}; + } +`; + +const AnimatePresence = m.div; + +const Reorderable = styled.div<{ $isDragging: boolean }>` + position: relative; + user-select: none; + border-radius: 8px; + + // move above other cards when dragging + z-index: ${(props) => (props.$isDragging ? 1 : "inherit")}; + transform: scale(${(props) => (props.$isDragging ? "1.025" : "1")}); + box-shadow: ${(props) => + props.$isDragging ? "0 0 20px rgba(0,0,0,0.3);" : "0 0 0 rgba(0,0,0,0)"}; + + &:hover ${Actions} { + opacity: 1; + } +`; + +const Content = styled(Flex)` + min-width: 0; + height: 100%; + + // move content above ::after + position: relative; + z-index: 1; +`; + +const StyledDocumentMeta = styled(DocumentMeta)` + color: ${(props) => transparentize(0.25, props.theme.white)} !important; +`; + +const DocumentLink = styled(Link)<{ + $menuOpen?: boolean; + $isDragging?: boolean; +}>` + position: relative; + display: block; + padding: 12px; + border-radius: 8px; + height: 160px; + background: ${(props) => props.theme.slate}; + color: ${(props) => props.theme.white}; + transition: transform 50ms ease-in-out; + + &:after { + content: ""; + display: block; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.1)); + border-radius: 8px; + pointer-events: none; + } + + ${Actions} { + opacity: 0; + } + + &:hover, + &:active, + &:focus, + &:focus-within { + ${Actions} { + opacity: 1; + } + + ${(props) => + !props.$isDragging && + css` + &:after { + background: rgba(0, 0, 0, 0.1); + } + `} + } + + ${(props) => + props.$menuOpen && + css` + background: ${(props) => props.theme.listItemHoverBackground}; + + ${Actions} { + opacity: 1; + } + `} +`; + +const Heading = styled.h3` + margin-top: 0; + margin-bottom: 0.35em; + line-height: 22px; + max-height: 66px; // 3*line-height + overflow: hidden; + + color: ${(props) => props.theme.white}; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, + Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; +`; + +export default observer(DocumentCard); diff --git a/app/components/DocumentMeta.tsx b/app/components/DocumentMeta.tsx index 087961761..8eb995f32 100644 --- a/app/components/DocumentMeta.tsx +++ b/app/components/DocumentMeta.tsx @@ -26,7 +26,6 @@ const Viewed = styled.span` `; const Modified = styled.span<{ highlight?: boolean }>` - color: ${(props) => props.theme.textTertiary}; font-weight: ${(props) => (props.highlight ? "600" : "400")}; `; diff --git a/app/components/PaginatedDocumentList.tsx b/app/components/PaginatedDocumentList.tsx index 9d02e694b..74b20e728 100644 --- a/app/components/PaginatedDocumentList.tsx +++ b/app/components/PaginatedDocumentList.tsx @@ -12,7 +12,6 @@ type Props = { showParentDocuments?: boolean; showCollection?: boolean; showPublished?: boolean; - showPin?: boolean; showDraft?: boolean; showTemplate?: boolean; }; @@ -33,7 +32,12 @@ const PaginatedDocumentList = React.memo(function PaginatedDocumentList({ fetch={fetch} options={options} renderItem={(item) => ( - + )} /> ); diff --git a/app/components/PinnedDocuments.tsx b/app/components/PinnedDocuments.tsx new file mode 100644 index 000000000..8d7da34ab --- /dev/null +++ b/app/components/PinnedDocuments.tsx @@ -0,0 +1,137 @@ +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, +} from "@dnd-kit/core"; +import { restrictToParentElement } from "@dnd-kit/modifiers"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + rectSortingStrategy, +} from "@dnd-kit/sortable"; +import fractionalIndex from "fractional-index"; +import { AnimatePresence } from "framer-motion"; +import { observer } from "mobx-react"; +import * as React from "react"; +import styled from "styled-components"; +import breakpoint from "styled-components-breakpoint"; +import Pin from "~/models/Pin"; +import DocumentCard from "~/components/DocumentCard"; +import useStores from "~/hooks/useStores"; + +type Props = { + /** Pins to display */ + pins: Pin[]; + /** Maximum number of pins to display */ + limit?: number; + /** Whether the user has permission to update pins */ + canUpdate?: boolean; +}; + +function PinnedDocuments({ limit, pins, canUpdate, ...rest }: Props) { + const { documents } = useStores(); + const [items, setItems] = React.useState(pins.map((pin) => pin.documentId)); + + React.useEffect(() => { + setItems(pins.map((pin) => pin.documentId)); + }, [pins]); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + const handleDragEnd = React.useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + setItems((items) => { + const activePos = items.indexOf(active.id); + const overPos = items.indexOf(over.id); + + const overIndex = pins[overPos]?.index || null; + const nextIndex = pins[overPos + 1]?.index || null; + const prevIndex = pins[overPos - 1]?.index || null; + const pin = pins[activePos]; + + // Update the order on the backend, revert if the call fails + pin + .save({ + index: + overPos === 0 + ? fractionalIndex(null, overIndex) + : activePos > overPos + ? fractionalIndex(prevIndex, overIndex) + : fractionalIndex(overIndex, nextIndex), + }) + .catch(() => setItems(items)); + + // Update the order in state immediately + return arrayMove(items, activePos, overPos); + }); + } + }, + [pins] + ); + + return ( + + + + + {items.map((documentId) => { + const document = documents.get(documentId); + const pin = pins.find((pin) => pin.documentId === documentId); + + return document ? ( + + ) : null; + })} + + + + + ); +} + +const List = styled.div` + display: grid; + column-gap: 8px; + row-gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + padding: 0; + list-style: none; + + &:not(:empty) { + margin: 16px 0 32px; + } + + ${breakpoint("tablet")` + grid-template-columns: repeat(3, minmax(0, 1fr)); + `}; + + ${breakpoint("desktop")` + grid-template-columns: repeat(4, minmax(0, 1fr)); + `}; +`; + +export default observer(PinnedDocuments); diff --git a/app/components/Sidebar/components/SidebarAction.tsx b/app/components/Sidebar/components/SidebarAction.tsx index e8c604661..82ecd7c56 100644 --- a/app/components/Sidebar/components/SidebarAction.tsx +++ b/app/components/Sidebar/components/SidebarAction.tsx @@ -1,10 +1,8 @@ import invariant from "invariant"; import { observer } from "mobx-react"; import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { useLocation } from "react-router"; import { actionToMenuItem } from "~/actions"; -import useStores from "~/hooks/useStores"; +import useActionContext from "~/hooks/useActionContext"; import { Action } from "~/types"; import SidebarLink from "./SidebarLink"; @@ -14,18 +12,12 @@ type Props = { }; function SidebarAction({ action, ...rest }: Props) { - const stores = useStores(); - const { t } = useTranslation(); - const location = useLocation(); - const context = { + const context = useActionContext({ isContextMenu: false, isCommandBar: false, activeCollectionId: undefined, activeDocumentId: undefined, - location, - stores, - t, - }; + }); const menuItem = actionToMenuItem(action, context); invariant(menuItem.type === "button", "passed action must be a button"); diff --git a/app/components/SocketProvider.tsx b/app/components/SocketProvider.tsx index d75025c63..1f305e40c 100644 --- a/app/components/SocketProvider.tsx +++ b/app/components/SocketProvider.tsx @@ -71,6 +71,7 @@ class SocketProvider extends React.Component { documents, collections, groups, + pins, memberships, policies, presence, @@ -260,6 +261,18 @@ class SocketProvider extends React.Component { } }); + this.socket.on("pins.create", (event: any) => { + pins.add(event); + }); + + this.socket.on("pins.update", (event: any) => { + pins.add(event); + }); + + this.socket.on("pins.delete", (event: any) => { + pins.remove(event.modelId); + }); + this.socket.on("documents.star", (event: any) => { documents.starredIds.set(event.documentId, true); }); diff --git a/app/hooks/useActionContext.ts b/app/hooks/useActionContext.ts new file mode 100644 index 000000000..c5be36548 --- /dev/null +++ b/app/hooks/useActionContext.ts @@ -0,0 +1,32 @@ +import { useTranslation } from "react-i18next"; +import { useLocation } from "react-router"; +import useStores from "~/hooks/useStores"; +import { ActionContext } from "~/types"; + +/** + * Hook to get the current action context, an object that is passed to all + * action definitions. + * + * @param overrides Overides of the default action context. + * @returns The current action context. + */ +export default function useActionContext( + overrides?: Partial +): ActionContext { + const stores = useStores(); + const { t } = useTranslation(); + const location = useLocation(); + + return { + isContextMenu: false, + isCommandBar: false, + activeCollectionId: stores.ui.activeCollectionId, + activeDocumentId: stores.ui.activeDocumentId, + currentUserId: stores.auth.user?.id, + currentTeamId: stores.auth.team?.id, + ...overrides, + location, + stores, + t, + }; +} diff --git a/app/hooks/useCommandBarActions.ts b/app/hooks/useCommandBarActions.ts index df457f387..42869366c 100644 --- a/app/hooks/useCommandBarActions.ts +++ b/app/hooks/useCommandBarActions.ts @@ -1,24 +1,21 @@ import { useRegisterActions } from "kbar"; import { flattenDeep } from "lodash"; -import { useTranslation } from "react-i18next"; import { useLocation } from "react-router-dom"; import { actionToKBar } from "~/actions"; -import useStores from "~/hooks/useStores"; import { Action } from "~/types"; +import useActionContext from "./useActionContext"; +/** + * Hook to add actions to the command bar while the hook is inside a mounted + * component. + * + * @param actions actions to make available + */ export default function useCommandBarActions(actions: Action[]) { - const stores = useStores(); - const { t } = useTranslation(); const location = useLocation(); - const context = { - t, + const context = useActionContext({ isCommandBar: true, - isContextMenu: false, - activeCollectionId: stores.ui.activeCollectionId, - activeDocumentId: stores.ui.activeDocumentId, - location, - stores, - }; + }); const registerable = flattenDeep( actions.map((action) => actionToKBar(action, context)) diff --git a/app/menus/DocumentMenu.tsx b/app/menus/DocumentMenu.tsx index 49ea6cc96..08f9693d0 100644 --- a/app/menus/DocumentMenu.tsx +++ b/app/menus/DocumentMenu.tsx @@ -1,7 +1,6 @@ import { observer } from "mobx-react"; import { EditIcon, - PinIcon, StarredIcon, UnstarredIcon, DuplicateIcon, @@ -39,6 +38,12 @@ import Template from "~/components/ContextMenu/Template"; import Flex from "~/components/Flex"; import Modal from "~/components/Modal"; import Toggle from "~/components/Toggle"; +import { actionToMenuItem } from "~/actions"; +import { + pinDocument, + pinDocumentToHome, +} from "~/actions/definitions/documents"; +import useActionContext from "~/hooks/useActionContext"; import useCurrentTeam from "~/hooks/useCurrentTeam"; import useStores from "~/hooks/useStores"; import useToasts from "~/hooks/useToasts"; @@ -72,7 +77,6 @@ function DocumentMenu({ modal = true, showToggleEmbeds, showDisplayOptions, - showPin, label, onOpen, onClose, @@ -87,6 +91,11 @@ function DocumentMenu({ unstable_flip: true, }); const history = useHistory(); + const context = useActionContext({ + isContextMenu: true, + activeDocumentId: document.id, + activeCollectionId: document.collectionId, + }); const { t } = useTranslation(); const [renderModals, setRenderModals] = React.useState(false); const [showDeleteModal, setShowDeleteModal] = React.useState(false); @@ -305,20 +314,6 @@ function DocumentMenu({ ...restoreItems, ], }, - { - type: "button", - title: t("Unpin"), - onClick: document.unpin, - visible: !!(showPin && document.pinned && can.unpin), - icon: , - }, - { - type: "button", - title: t("Pin to collection"), - onClick: document.pin, - visible: !!(showPin && !document.pinned && can.pin), - icon: , - }, { type: "button", title: t("Unstar"), @@ -333,6 +328,8 @@ function DocumentMenu({ visible: !document.isStarred && !!can.star, icon: , }, + actionToMenuItem(pinDocumentToHome, context), + actionToMenuItem(pinDocument, context), { type: "separator", }, diff --git a/app/models/Document.ts b/app/models/Document.ts index 0a72e9d0c..cd71736b7 100644 --- a/app/models/Document.ts +++ b/app/models/Document.ts @@ -1,5 +1,4 @@ import { addDays, differenceInDays } from "date-fns"; -import invariant from "invariant"; import { floor } from "lodash"; import { action, computed, observable } from "mobx"; import parseTitle from "@shared/utils/parseTitle"; @@ -72,8 +71,6 @@ export default class Document extends BaseModel { updatedBy: User; - pinned: boolean; - publishedAt: string | undefined; archivedAt: string; @@ -240,31 +237,23 @@ export default class Document extends BaseModel { }; @action - pin = async () => { - this.pinned = true; - - try { - const res = await this.store.pin(this); - invariant(res && res.data, "Data should be available"); - this.updateFromJson(res.data); - } catch (err) { - this.pinned = false; - throw err; - } + pin = async (collectionId?: string) => { + await this.store.rootStore.pins.create({ + documentId: this.id, + ...(collectionId ? { collectionId } : {}), + }); }; @action - unpin = async () => { - this.pinned = false; + unpin = async (collectionId?: string) => { + const pin = this.store.rootStore.pins.orderedData.find( + (pin) => + pin.documentId === this.id && + (pin.collectionId === collectionId || + (!collectionId && !pin.collectionId)) + ); - try { - const res = await this.store.unpin(this); - invariant(res && res.data, "Data should be available"); - this.updateFromJson(res.data); - } catch (err) { - this.pinned = true; - throw err; - } + await pin?.delete(); }; @action @@ -387,6 +376,21 @@ export default class Document extends BaseModel { return result; }; + @computed + get pinned(): boolean { + return !!this.store.rootStore.pins.orderedData.find( + (pin) => + pin.documentId === this.id && pin.collectionId === this.collectionId + ); + } + + @computed + get pinnedToHome(): boolean { + return !!this.store.rootStore.pins.orderedData.find( + (pin) => pin.documentId === this.id && !pin.collectionId + ); + } + @computed get isActive(): boolean { return !this.isDeleted && !this.isTemplate && !this.isArchived; diff --git a/app/models/Pin.ts b/app/models/Pin.ts new file mode 100644 index 000000000..7a62270b2 --- /dev/null +++ b/app/models/Pin.ts @@ -0,0 +1,18 @@ +import { observable } from "mobx"; +import BaseModel from "./BaseModel"; +import Field from "./decorators/Field"; + +class Pin extends BaseModel { + id: string; + collectionId: string; + documentId: string; + + @observable + @Field + index: string; + + createdAt: string; + updatedAt: string; +} + +export default Pin; diff --git a/app/routes/authenticated.tsx b/app/routes/authenticated.tsx index 3db374155..da211f9ea 100644 --- a/app/routes/authenticated.tsx +++ b/app/routes/authenticated.tsx @@ -5,7 +5,6 @@ import Collection from "~/scenes/Collection"; import DocumentNew from "~/scenes/DocumentNew"; import Drafts from "~/scenes/Drafts"; import Error404 from "~/scenes/Error404"; -import Home from "~/scenes/Home"; import Search from "~/scenes/Search"; import Templates from "~/scenes/Templates"; import Trash from "~/scenes/Trash"; @@ -30,6 +29,13 @@ const Document = React.lazy( "~/scenes/Document" ) ); +const Home = React.lazy( + () => + import( + /* webpackChunkName: "home" */ + "~/scenes/Home" + ) +); const NotFound = () => ; diff --git a/app/scenes/Collection.tsx b/app/scenes/Collection.tsx index 35dc17049..3eb49bc19 100644 --- a/app/scenes/Collection.tsx +++ b/app/scenes/Collection.tsx @@ -1,78 +1,50 @@ import { observer } from "mobx-react"; -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 { useTranslation } from "react-i18next"; import { useParams, Redirect, - Link, Switch, Route, useHistory, useRouteMatch, } from "react-router-dom"; -import styled, { css } from "styled-components"; import Collection from "~/models/Collection"; -import CollectionPermissions from "~/scenes/CollectionPermissions"; import Search from "~/scenes/Search"; -import { Action, Separator } from "~/components/Actions"; import Badge from "~/components/Badge"; -import Button from "~/components/Button"; import CenteredContent from "~/components/CenteredContent"; import CollectionDescription from "~/components/CollectionDescription"; import CollectionIcon from "~/components/CollectionIcon"; -import DocumentList from "~/components/DocumentList"; -import Flex from "~/components/Flex"; import Heading from "~/components/Heading"; -import HelpText from "~/components/HelpText"; -import InputSearchPage from "~/components/InputSearchPage"; import PlaceholderList from "~/components/List/Placeholder"; -import LoadingIndicator from "~/components/LoadingIndicator"; -import Modal from "~/components/Modal"; import PaginatedDocumentList from "~/components/PaginatedDocumentList"; +import PinnedDocuments from "~/components/PinnedDocuments"; import PlaceholderText from "~/components/PlaceholderText"; import Scene from "~/components/Scene"; -import Subheading from "~/components/Subheading"; import Tab from "~/components/Tab"; import Tabs from "~/components/Tabs"; import Tooltip from "~/components/Tooltip"; import { editCollection } from "~/actions/definitions/collections"; -import useBoolean from "~/hooks/useBoolean"; import useCommandBarActions from "~/hooks/useCommandBarActions"; -import useCurrentTeam from "~/hooks/useCurrentTeam"; -import useImportDocument from "~/hooks/useImportDocument"; import useStores from "~/hooks/useStores"; -import useToasts from "~/hooks/useToasts"; -import CollectionMenu from "~/menus/CollectionMenu"; -import { - newDocumentPath, - collectionUrl, - updateCollectionUrl, -} from "~/utils/routeHelpers"; +import { collectionUrl, updateCollectionUrl } from "~/utils/routeHelpers"; +import Actions from "./Collection/Actions"; +import DropToImport from "./Collection/DropToImport"; +import Empty from "./Collection/Empty"; function CollectionScene() { const params = useParams<{ id?: string }>(); const history = useHistory(); const match = useRouteMatch(); const { t } = useTranslation(); - const { documents, policies, collections, ui } = useStores(); - const { showToast } = useToasts(); - const team = useCurrentTeam(); + const { documents, pins, policies, collections, ui } = useStores(); const [isFetching, setFetching] = React.useState(false); const [error, setError] = React.useState(); - const [ - permissionsModalOpen, - handlePermissionsModalOpen, - handlePermissionsModalClose, - ] = useBoolean(); const id = params.id || ""; const collection: Collection | null | undefined = collections.getByUrl(id) || collections.get(id); const can = policies.abilities(collection?.id || ""); - const canUser = policies.abilities(team.id); - const { handleFiles, isImporting } = useImportDocument(collection?.id || ""); React.useEffect(() => { if (collection) { @@ -94,11 +66,11 @@ function CollectionScene() { setError(undefined); if (collection) { - documents.fetchPinned({ + pins.fetchPage({ collectionId: collection.id, }); } - }, [documents, collection]); + }, [pins, collection]); React.useEffect(() => { async function load() { @@ -117,27 +89,13 @@ function CollectionScene() { load(); }, [collections, isFetching, collection, error, id, can]); - useCommandBarActions([editCollection]); - const handleRejection = React.useCallback(() => { - showToast( - t("Document not supported – try Markdown, Plain text, HTML, or Word"), - { - type: "error", - } - ); - }, [t, showToast]); + useCommandBarActions([editCollection]); if (!collection && error) { return ; } - const pinnedDocuments = collection - ? documents.pinnedInCollection(collection.id) - : []; - const collectionName = collection ? collection.name : ""; - const hasPinnedDocuments = !!pinnedDocuments.length; - return collection ? ( } - actions={ - <> - - - - {can.update && ( + actions={} + > + + + {collection.isEmpty ? ( + + ) : ( <> - - - - - - + {t("Private")} + + )} + + + + + + + + {t("Documents")} + + + {t("Recently updated")} + + + {t("Recently published")} + + + {t("Least recently updated")} + + + {t("A–Z")} + + + + + + + + + + + + + + + + + + + + + + )} - - ( - - - )} -    - - - - - - - ) : ( - <> - - {" "} - {collection.name}{" "} - {!collection.permission && ( - - {t("Private")} - - )} - - - - {hasPinnedDocuments && ( - <> - - {" "} - {t("Pinned")} - - - - )} - - - - {t("Documents")} - - - {t("Recently updated")} - - - {t("Recently published")} - - - {t("Least recently updated")} - - - {t("A–Z")} - - - - - - - - - - - - - - - - - - - - - - - - )} - {t("Drop documents to import")} - - - )} - + + ) : ( @@ -400,61 +239,4 @@ function CollectionScene() { ); } -const DropMessage = styled(HelpText)` - opacity: 0; - pointer-events: none; -`; - -const DropzoneContainer = styled.div<{ isDragActive?: boolean }>` - outline-color: transparent !important; - min-height: calc(100% - 56px); - position: relative; - - ${({ isDragActive, theme }) => - isDragActive && - css` - &:after { - display: block; - content: ""; - position: absolute; - top: 24px; - right: 24px; - bottom: 24px; - left: 24px; - background: ${theme.background}; - border-radius: 8px; - border: 1px dashed ${theme.divider}; - z-index: 1; - } - - ${DropMessage} { - opacity: 1; - z-index: 2; - position: absolute; - text-align: center; - top: 50%; - left: 50%; - transform: translateX(-50%); - } - `} -`; - -const Centered = styled(Flex)` - text-align: center; - margin: 40vh auto 0; - max-width: 380px; - transform: translateY(-50%); -`; - -const TinyPinIcon = styled(PinIcon)` - position: relative; - top: 4px; - opacity: 0.8; -`; - -const Empty = styled(Flex)` - justify-content: center; - margin: 10px 0; -`; - export default observer(CollectionScene); diff --git a/app/scenes/Collection/Actions.tsx b/app/scenes/Collection/Actions.tsx new file mode 100644 index 000000000..b2b7bae3e --- /dev/null +++ b/app/scenes/Collection/Actions.tsx @@ -0,0 +1,75 @@ +import { observer } from "mobx-react"; +import { MoreIcon, PlusIcon } from "outline-icons"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import Collection from "~/models/Collection"; +import { Action, Separator } from "~/components/Actions"; +import Button from "~/components/Button"; +import InputSearchPage from "~/components/InputSearchPage"; +import Tooltip from "~/components/Tooltip"; +import useStores from "~/hooks/useStores"; +import CollectionMenu from "~/menus/CollectionMenu"; +import { newDocumentPath } from "~/utils/routeHelpers"; + +type Props = { + collection: Collection; +}; + +function Actions({ collection }: Props) { + const { t } = useTranslation(); + const { policies } = useStores(); + const can = policies.abilities(collection.id); + + return ( + <> + + + + {can.update && ( + <> + + + + + + + + )} + + ( + + + )} +    + + + + + + + ); +} + +const Centered = styled(Flex)` + text-align: center; + margin: 40vh auto 0; + max-width: 380px; + transform: translateY(-50%); +`; + +const Empty = styled(Flex)` + justify-content: center; + margin: 10px 0; +`; + +export default observer(EmptyCollection); diff --git a/app/scenes/Home.tsx b/app/scenes/Home.tsx index 2d3d182b3..b05e356a0 100644 --- a/app/scenes/Home.tsx +++ b/app/scenes/Home.tsx @@ -9,19 +9,28 @@ import Heading from "~/components/Heading"; import InputSearchPage from "~/components/InputSearchPage"; import LanguagePrompt from "~/components/LanguagePrompt"; import PaginatedDocumentList from "~/components/PaginatedDocumentList"; +import PinnedDocuments from "~/components/PinnedDocuments"; import Scene from "~/components/Scene"; import Tab from "~/components/Tab"; import Tabs from "~/components/Tabs"; +import useCurrentTeam from "~/hooks/useCurrentTeam"; import useCurrentUser from "~/hooks/useCurrentUser"; import useStores from "~/hooks/useStores"; import NewDocumentMenu from "~/menus/NewDocumentMenu"; function Home() { - const { documents, ui } = useStores(); + const { documents, pins, policies, ui } = useStores(); + const team = useCurrentTeam(); const user = useCurrentUser(); const userId = user?.id; const { t } = useTranslation(); + React.useEffect(() => { + pins.fetchPage(); + }, [pins]); + + const canManageTeam = policies.abilities(team.id).manage; + return ( } @@ -39,6 +48,7 @@ function Home() { > {!ui.languagePromptDismissed && } {t("Home")} + {t("Recently viewed")} diff --git a/app/stores/DocumentsStore.ts b/app/stores/DocumentsStore.ts index 223defa31..fa6f7f469 100644 --- a/app/stores/DocumentsStore.ts +++ b/app/stores/DocumentsStore.ts @@ -18,9 +18,10 @@ import { } from "~/types"; import { client } from "~/utils/ApiClient"; -type FetchParams = PaginationParams & { collectionId: string }; - -type FetchPageParams = PaginationParams & { template?: boolean }; +type FetchPageParams = PaginationParams & { + template?: boolean; + collectionId?: string; +}; export type SearchParams = { offset?: number; @@ -127,13 +128,6 @@ export default class DocumentsStore extends BaseStore { ); } - pinnedInCollection(collectionId: string): Document[] { - return filter( - this.recentlyUpdatedInCollection(collectionId), - (document) => document.pinned - ); - } - publishedInCollection(collectionId: string): Document[] { return filter( this.all, @@ -296,7 +290,7 @@ export default class DocumentsStore extends BaseStore { fetchNamedPage = async ( request = "list", options: FetchPageParams | undefined - ): Promise => { + ): Promise => { this.isFetching = true; try { @@ -377,11 +371,6 @@ export default class DocumentsStore extends BaseStore { return this.fetchNamedPage("drafts", options); }; - @action - fetchPinned = (options?: FetchParams): Promise => { - return this.fetchNamedPage("pinned", options); - }; - @action fetchOwned = (options?: PaginationParams): Promise => { return this.fetchNamedPage("list", options); @@ -732,18 +721,6 @@ export default class DocumentsStore extends BaseStore { if (collection) collection.refresh(); }; - pin = (document: Document) => { - return client.post("/documents.pin", { - id: document.id, - }); - }; - - unpin = (document: Document) => { - return client.post("/documents.unpin", { - id: document.id, - }); - }; - star = async (document: Document) => { this.starredIds.set(document.id, true); diff --git a/app/stores/PinsStore.ts b/app/stores/PinsStore.ts new file mode 100644 index 000000000..41e1cb2c1 --- /dev/null +++ b/app/stores/PinsStore.ts @@ -0,0 +1,57 @@ +import invariant from "invariant"; +import { action, runInAction, computed } from "mobx"; +import Pin from "~/models/Pin"; +import { PaginationParams } from "~/types"; +import { client } from "~/utils/ApiClient"; +import BaseStore from "./BaseStore"; +import RootStore from "./RootStore"; + +type FetchParams = PaginationParams & { collectionId?: string }; + +export default class PinsStore extends BaseStore { + constructor(rootStore: RootStore) { + super(rootStore, Pin); + } + + @action + fetchPage = async (params?: FetchParams | undefined): Promise => { + this.isFetching = true; + + try { + const res = await client.post(`/pins.list`, params); + invariant(res && res.data, "Data not available"); + runInAction(`PinsStore#fetchPage`, () => { + res.data.documents.forEach(this.rootStore.documents.add); + res.data.pins.forEach(this.add); + this.addPolicies(res.policies); + this.isLoaded = true; + }); + } finally { + this.isFetching = false; + } + }; + + inCollection = (collectionId: string) => { + return computed(() => this.orderedData) + .get() + .filter((pin) => pin.collectionId === collectionId); + }; + + @computed + get home() { + return this.orderedData.filter((pin) => !pin.collectionId); + } + + @computed + get orderedData(): Pin[] { + const pins = Array.from(this.data.values()); + + return pins.sort((a, b) => { + if (a.index === b.index) { + return a.updatedAt > b.updatedAt ? -1 : 1; + } + + return a.index < b.index ? -1 : 1; + }); + } +} diff --git a/app/stores/RootStore.ts b/app/stores/RootStore.ts index 181ea3fe5..d5a9d9721 100644 --- a/app/stores/RootStore.ts +++ b/app/stores/RootStore.ts @@ -12,6 +12,7 @@ import GroupsStore from "./GroupsStore"; import IntegrationsStore from "./IntegrationsStore"; import MembershipsStore from "./MembershipsStore"; import NotificationSettingsStore from "./NotificationSettingsStore"; +import PinsStore from "./PinsStore"; import PoliciesStore from "./PoliciesStore"; import RevisionsStore from "./RevisionsStore"; import SearchesStore from "./SearchesStore"; @@ -35,6 +36,7 @@ export default class RootStore { memberships: MembershipsStore; notificationSettings: NotificationSettingsStore; presence: DocumentPresenceStore; + pins: PinsStore; policies: PoliciesStore; revisions: RevisionsStore; searches: SearchesStore; @@ -59,6 +61,7 @@ export default class RootStore { this.groupMemberships = new GroupMembershipsStore(this); this.integrations = new IntegrationsStore(this); this.memberships = new MembershipsStore(this); + this.pins = new PinsStore(this); this.notificationSettings = new NotificationSettingsStore(this); this.presence = new DocumentPresenceStore(); this.revisions = new RevisionsStore(this); @@ -84,6 +87,7 @@ export default class RootStore { this.memberships.clear(); this.notificationSettings.clear(); this.presence.clear(); + this.pins.clear(); this.policies.clear(); this.revisions.clear(); this.searches.clear(); diff --git a/app/types.ts b/app/types.ts index e927f071f..1cc3ebd97 100644 --- a/app/types.ts +++ b/app/types.ts @@ -68,8 +68,10 @@ export type MenuItem = export type ActionContext = { isContextMenu: boolean; isCommandBar: boolean; - activeCollectionId: string | null | undefined; - activeDocumentId: string | null | undefined; + activeCollectionId: string | undefined; + activeDocumentId: string | undefined; + currentUserId: string | undefined; + currentTeamId: string | undefined; location: Location; stores: RootStore; event?: Event; diff --git a/app/typings/index.d.ts b/app/typings/index.d.ts index 841810a5d..173f34caf 100644 --- a/app/typings/index.d.ts +++ b/app/typings/index.d.ts @@ -4,6 +4,8 @@ declare module "boundless-arrow-key-navigation"; declare module "string-replace-to-array"; +declare module "sequelize-encrypted"; + declare module "styled-components-breakpoint"; declare module "formidable/lib/file"; diff --git a/app/typings/styled-components.d.ts b/app/typings/styled-components.d.ts index 43e78310c..1613be8da 100644 --- a/app/typings/styled-components.d.ts +++ b/app/typings/styled-components.d.ts @@ -80,6 +80,7 @@ declare module "styled-components" { white: string; white10: string; white50: string; + white75: string; black: string; black05: string; black10: string; diff --git a/package.json b/package.json index ef596fda3..c743c49e7 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,9 @@ "@babel/preset-react": "^7.16.0", "@bull-board/api": "^3.5.0", "@bull-board/koa": "^3.5.0", + "@dnd-kit/core": "^4.0.3", + "@dnd-kit/modifiers": "^4.0.0", + "@dnd-kit/sortable": "^5.1.0", "@hocuspocus/provider": "^1.0.0-alpha.21", "@hocuspocus/server": "^1.0.0-alpha.78", "@outlinewiki/koa-passport": "^4.1.4", diff --git a/server/commands/documentMover.ts b/server/commands/documentMover.ts index 5301d568f..6c6098167 100644 --- a/server/commands/documentMover.ts +++ b/server/commands/documentMover.ts @@ -1,7 +1,8 @@ import { Transaction } from "sequelize"; -import { Document, Attachment, Collection, User, Event } from "@server/models"; +import { Document, Attachment, Collection, Pin, Event } from "@server/models"; import parseAttachmentIds from "@server/utils/parseAttachmentIds"; import { sequelize } from "../sequelize"; +import pinDestroyer from "./pinDestroyer"; async function copyAttachments( document: Document, @@ -51,36 +52,30 @@ export default async function documentMover({ }: { // @ts-expect-error ts-migrate(2749) FIXME: 'User' refers to a value, but is being used as a t... Remove this comment to see the full error message user: User; - document: Document; + document: any; collectionId: string; parentDocumentId?: string | null; index?: number; ip: string; }) { let transaction: Transaction | undefined; - // @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type 'Do... Remove this comment to see the full error message const collectionChanged = collectionId !== document.collectionId; + const previousCollectionId = document.collectionId; const result = { collections: [], documents: [], collectionChanged, }; - // @ts-expect-error ts-migrate(2339) FIXME: Property 'template' does not exist on type 'Docume... Remove this comment to see the full error message if (document.template) { if (!collectionChanged) { return result; } - // @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type 'Do... Remove this comment to see the full error message document.collectionId = collectionId; - // @ts-expect-error ts-migrate(2339) FIXME: Property 'parentDocumentId' does not exist on type... Remove this comment to see the full error message document.parentDocumentId = null; - // @ts-expect-error ts-migrate(2339) FIXME: Property 'lastModifiedById' does not exist on type... Remove this comment to see the full error message document.lastModifiedById = user.id; - // @ts-expect-error ts-migrate(2339) FIXME: Property 'updatedBy' does not exist on type 'Docum... Remove this comment to see the full error message document.updatedBy = user; - // @ts-expect-error ts-migrate(2339) FIXME: Property 'save' does not exist on type 'Document'. await document.save(); // @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'Document' is not assignable to p... Remove this comment to see the full error message result.documents.push(document); @@ -89,7 +84,6 @@ export default async function documentMover({ transaction = await sequelize.transaction(); // remove from original collection - // @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type 'Do... Remove this comment to see the full error message const collection = await Collection.findByPk(document.collectionId, { transaction, paranoid: false, @@ -107,9 +101,7 @@ export default async function documentMover({ // We need to compensate for this when reordering const toIndex = index !== undefined && - // @ts-expect-error ts-migrate(2339) FIXME: Property 'parentDocumentId' does not exist on type... Remove this comment to see the full error message document.parentDocumentId === parentDocumentId && - // @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type 'Do... Remove this comment to see the full error message document.collectionId === collectionId && fromIndex < index ? index - 1 @@ -121,21 +113,17 @@ export default async function documentMover({ await collection.save({ transaction, }); - // @ts-expect-error ts-migrate(2339) FIXME: Property 'text' does not exist on type 'Document'. document.text = await copyAttachments(document, { transaction, }); } // add to new collection (may be the same) - // @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type 'Do... Remove this comment to see the full error message document.collectionId = collectionId; - // @ts-expect-error ts-migrate(2339) FIXME: Property 'parentDocumentId' does not exist on type... Remove this comment to see the full error message document.parentDocumentId = parentDocumentId; - // @ts-expect-error ts-migrate(2339) FIXME: Property 'lastModifiedById' does not exist on type... Remove this comment to see the full error message document.lastModifiedById = user.id; - // @ts-expect-error ts-migrate(2339) FIXME: Property 'updatedBy' does not exist on type 'Docum... Remove this comment to see the full error message document.updatedBy = user; + // @ts-expect-error ts-migrate(2749) FIXME: 'Collection' refers to a value, but is being used ... Remove this comment to see the full error message const newCollection: Collection = collectionChanged ? await Collection.scope({ @@ -180,15 +168,27 @@ export default async function documentMover({ ); }; - // @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'Document'. await loopChildren(document.id); + + const pin = await Pin.findOne({ + where: { + documentId: document.id, + collectionId: previousCollectionId, + }, + }); + + if (pin) { + await pinDestroyer({ + user, + pin, + ip, + }); + } } - // @ts-expect-error ts-migrate(2339) FIXME: Property 'save' does not exist on type 'Document'. await document.save({ transaction, }); - // @ts-expect-error ts-migrate(2339) FIXME: Property 'collection' does not exist on type 'Docu... Remove this comment to see the full error message document.collection = newCollection; // @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'Document' is not assignable to p... Remove this comment to see the full error message result.documents.push(document); @@ -208,10 +208,8 @@ export default async function documentMover({ await Event.create({ name: "documents.move", actorId: user.id, - // @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'Document'. documentId: document.id, collectionId, - // @ts-expect-error ts-migrate(2339) FIXME: Property 'teamId' does not exist on type 'Document... Remove this comment to see the full error message teamId: document.teamId, data: { title: document.title, @@ -222,6 +220,7 @@ export default async function documentMover({ }, ip, }); + // we need to send all updated models back to the client return result; } diff --git a/server/commands/pinCreator.test.ts b/server/commands/pinCreator.test.ts new file mode 100644 index 000000000..94d87e44b --- /dev/null +++ b/server/commands/pinCreator.test.ts @@ -0,0 +1,55 @@ +import { Event } from "@server/models"; +import { buildDocument, buildUser } from "@server/test/factories"; +import { flushdb } from "@server/test/support"; +import pinCreator from "./pinCreator"; + +beforeEach(() => flushdb()); +describe("pinCreator", () => { + const ip = "127.0.0.1"; + + it("should create pin to home", async () => { + const user = await buildUser(); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + }); + + const pin = await pinCreator({ + documentId: document.id, + user, + ip, + }); + + const event = await Event.findOne(); + expect(pin.documentId).toEqual(document.id); + expect(pin.collectionId).toEqual(null); + expect(pin.createdById).toEqual(user.id); + expect(pin.index).toEqual("P"); + expect(event.name).toEqual("pins.create"); + expect(event.modelId).toEqual(pin.id); + }); + + it("should create pin to collection", async () => { + const user = await buildUser(); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + }); + + const pin = await pinCreator({ + documentId: document.id, + collectionId: document.collectionId, + user, + ip, + }); + + const event = await Event.findOne(); + expect(pin.documentId).toEqual(document.id); + expect(pin.collectionId).toEqual(document.collectionId); + expect(pin.createdById).toEqual(user.id); + expect(pin.index).toEqual("P"); + expect(event.name).toEqual("pins.create"); + expect(event.modelId).toEqual(pin.id); + expect(event.collectionId).toEqual(pin.collectionId); + }); +}); diff --git a/server/commands/pinCreator.ts b/server/commands/pinCreator.ts new file mode 100644 index 000000000..e8308f81f --- /dev/null +++ b/server/commands/pinCreator.ts @@ -0,0 +1,97 @@ +import fractionalIndex from "fractional-index"; +import { ValidationError } from "@server/errors"; +import { Pin, Event } from "@server/models"; +import { sequelize, Op } from "@server/sequelize"; + +const MAX_PINS = 8; + +type Props = { + /** The user creating the pin */ + user: any; + /** The document to pin */ + documentId: string; + /** The collection to pin the document in. If no collection is provided then it will be pinned to home */ + collectionId?: string | undefined; + /** The index to pin the document at. If no index is provided then it will be pinned to the end of the collection */ + index?: string; + /** The IP address of the user creating the pin */ + ip: string; +}; + +/** + * This command creates a "pinned" document via the pin relation. A document can + * be pinned to a collection or to the home screen. + * + * @param Props The properties of the pin to create + * @returns Pin The pin that was created + */ +export default async function pinCreator({ + user, + documentId, + collectionId, + ip, + ...rest +}: Props): Promise { + let { index } = rest; + const where = { + teamId: user.teamId, + ...(collectionId ? { collectionId } : { collectionId: { [Op.eq]: null } }), + }; + + const count = await Pin.count({ where }); + if (count >= MAX_PINS) { + throw ValidationError(`You cannot pin more than ${MAX_PINS} documents`); + } + + if (!index) { + const pins = await Pin.findAll({ + where, + attributes: ["id", "index", "updatedAt"], + limit: 1, + order: [ + // using LC_COLLATE:"C" because we need byte order to drive the sorting + // find only the last pin so we can create an index after it + sequelize.literal('"pins"."index" collate "C" DESC'), + ["updatedAt", "ASC"], + ], + }); + + // create a pin at the end of the list + index = fractionalIndex(pins.length ? pins[0].index : null, null); + } + + const transaction = await sequelize.transaction(); + let pin; + + try { + pin = await Pin.create( + { + createdById: user.id, + teamId: user.teamId, + collectionId, + documentId, + index, + }, + { transaction } + ); + + await Event.create( + { + name: "pins.create", + modelId: pin.id, + teamId: user.teamId, + actorId: user.id, + documentId, + collectionId, + ip, + }, + { transaction } + ); + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + + return pin; +} diff --git a/server/commands/pinDestroyer.test.ts b/server/commands/pinDestroyer.test.ts new file mode 100644 index 000000000..38dc23341 --- /dev/null +++ b/server/commands/pinDestroyer.test.ts @@ -0,0 +1,38 @@ +import { Pin, Event } from "@server/models"; +import { buildDocument, buildUser } from "@server/test/factories"; +import { flushdb } from "@server/test/support"; +import pinDestroyer from "./pinDestroyer"; + +beforeEach(() => flushdb()); +describe("pinCreator", () => { + const ip = "127.0.0.1"; + + it("should destroy existing pin", async () => { + const user = await buildUser(); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + }); + + const pin = await Pin.create({ + teamId: document.teamId, + documentId: document.id, + collectionId: document.collectionId, + createdById: user.id, + index: "P", + }); + + await pinDestroyer({ + pin, + user, + ip, + }); + + const count = await Pin.count(); + expect(count).toEqual(0); + + const event = await Event.findOne(); + expect(event.name).toEqual("pins.delete"); + expect(event.modelId).toEqual(pin.id); + }); +}); diff --git a/server/commands/pinDestroyer.ts b/server/commands/pinDestroyer.ts new file mode 100644 index 000000000..0d1c4cd3d --- /dev/null +++ b/server/commands/pinDestroyer.ts @@ -0,0 +1,54 @@ +import { Transaction } from "sequelize"; +import { Event } from "@server/models"; +import { sequelize } from "@server/sequelize"; + +type Props = { + /** The user destroying the pin */ + user: any; + /** The pin to destroy */ + pin: any; + /** The IP address of the user creating the pin */ + ip: string; + /** Optional existing transaction */ + transaction?: Transaction; +}; + +/** + * This command destroys a document pin. This just removes the pin itself and + * does not touch the document + * + * @param Props The properties of the pin to destroy + * @returns void + */ +export default async function pinDestroyer({ + user, + pin, + ip, + transaction: t, +}: Props): Promise { + const transaction = t || (await sequelize.transaction()); + + try { + await Event.create( + { + name: "pins.delete", + modelId: pin.id, + teamId: user.teamId, + actorId: user.id, + documentId: pin.documentId, + collectionId: pin.collectionId, + ip, + }, + { transaction } + ); + + await pin.destroy({ transaction }); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + + return pin; +} diff --git a/server/commands/pinUpdater.ts b/server/commands/pinUpdater.ts new file mode 100644 index 000000000..637aa0d2a --- /dev/null +++ b/server/commands/pinUpdater.ts @@ -0,0 +1,53 @@ +import { Event } from "@server/models"; +import { sequelize } from "@server/sequelize"; + +type Props = { + /** The user updating the pin */ + user: any; + /** The existing pin */ + pin: any; + /** The index to pin the document at */ + index?: string; + /** The IP address of the user creating the pin */ + ip: string; +}; + +/** + * This command updates a "pinned" document. A pin can only be moved to a new + * index (reordered) once created. + * + * @param Props The properties of the pin to update + * @returns Pin The updated pin + */ +export default async function pinUpdater({ + user, + pin, + index, + ip, +}: Props): Promise { + const transaction = await sequelize.transaction(); + + try { + pin.index = index; + await pin.save({ transaction }); + + await Event.create( + { + name: "pins.update", + modelId: pin.id, + teamId: user.teamId, + actorId: user.id, + documentId: pin.documentId, + collectionId: pin.collectionId, + ip, + }, + { transaction } + ); + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + + return pin; +} diff --git a/server/migrations/20211221031430-create-pins.js b/server/migrations/20211221031430-create-pins.js new file mode 100644 index 000000000..3547f350c --- /dev/null +++ b/server/migrations/20211221031430-create-pins.js @@ -0,0 +1,98 @@ +"use strict"; + +const { v4 } = require("uuid"); + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable("pins", { + id: { + type: Sequelize.UUID, + allowNull: false, + primaryKey: true, + }, + documentId: { + type: Sequelize.UUID, + allowNull: false, + onDelete: "cascade", + references: { + model: "documents", + }, + }, + collectionId: { + type: Sequelize.UUID, + allowNull: true, + onDelete: "cascade", + references: { + model: "collections", + }, + }, + teamId: { + type: Sequelize.UUID, + allowNull: false, + onDelete: "cascade", + references: { + model: "teams", + }, + }, + createdById: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: "users", + }, + }, + index: { + type: Sequelize.STRING, + allowNull: true, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + }, + }); + await queryInterface.addIndex("pins", ["collectionId"]); + + const createdAt = new Date(); + const [documents] = await queryInterface.sequelize.query(`SELECT "id","collectionId","teamId","pinnedById" FROM documents WHERE "pinnedById" IS NOT NULL`); + + for (const document of documents) { + await queryInterface.sequelize.query(` + INSERT INTO pins ( + "id", + "documentId", + "collectionId", + "teamId", + "createdById", + "createdAt", + "updatedAt" + ) + VALUES ( + :id, + :documentId, + :collectionId, + :teamId, + :createdById, + :createdAt, + :updatedAt + ) + `, { + replacements: { + id: v4(), + documentId: document.id, + collectionId: document.collectionId, + teamId: document.teamId, + createdById: document.pinnedById, + updatedAt: createdAt, + createdAt, + }, + }); + } + }, + down: async (queryInterface, Sequelize) => { + return queryInterface.dropTable("pins"); + }, +}; diff --git a/server/models/Document.ts b/server/models/Document.ts index 1312783c4..a588b0883 100644 --- a/server/models/Document.ts +++ b/server/models/Document.ts @@ -142,6 +142,7 @@ Document.associate = (models) => { as: "updatedBy", foreignKey: "lastModifiedById", }); + /** Deprecated – use Pins relationship instead */ Document.belongsTo(models.User, { as: "pinnedBy", foreignKey: "pinnedById", @@ -180,8 +181,7 @@ Document.associate = (models) => { }, }, }); - // @ts-expect-error ts-migrate(7006) FIXME: Parameter 'userId' implicitly has an 'any' type. - Document.addScope("withCollection", (userId, paranoid = true) => { + Document.addScope("withCollection", (userId: string, paranoid = true) => { if (userId) { return { include: [ @@ -219,8 +219,7 @@ Document.associate = (models) => { }, ], }); - // @ts-expect-error ts-migrate(7006) FIXME: Parameter 'userId' implicitly has an 'any' type. - Document.addScope("withViews", (userId) => { + Document.addScope("withViews", (userId: string) => { if (!userId) return {}; return { include: [ @@ -236,8 +235,7 @@ Document.associate = (models) => { ], }; }); - // @ts-expect-error ts-migrate(7006) FIXME: Parameter 'userId' implicitly has an 'any' type. - Document.addScope("withStarred", (userId) => ({ + Document.addScope("withStarred", (userId: string) => ({ include: [ { model: models.Star, @@ -250,20 +248,40 @@ Document.associate = (models) => { }, ], })); + Document.defaultScopeWithUser = (userId: string) => { + const starredScope = { + method: ["withStarred", userId], + }; + const collectionScope = { + method: ["withCollection", userId], + }; + const viewScope = { + method: ["withViews", userId], + }; + return Document.scope( + "defaultScope", + starredScope, + collectionScope, + viewScope + ); + }; }; -// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'id' implicitly has an 'any' type. -Document.findByPk = async function (id, options = {}) { +Document.findByPk = async function ( + id: string, + options: { + userId?: string; + paranoid?: boolean; + } = {} +) { // allow default preloading of collection membership if `userId` is passed in find options // almost every endpoint needs the collection membership to determine policy permissions. const scope = this.scope( "withUnpublished", { - // @ts-expect-error ts-migrate(2339) FIXME: Property 'userId' does not exist on type '{}'. method: ["withCollection", options.userId, options.paranoid], }, { - // @ts-expect-error ts-migrate(2339) FIXME: Property 'userId' does not exist on type '{}'. method: ["withViews", options.userId], } ); @@ -275,10 +293,13 @@ Document.findByPk = async function (id, options = {}) { }, ...options, }); - } else if (id.match(SLUG_URL_REGEX)) { + } + + const match = id.match(SLUG_URL_REGEX); + if (match) { return scope.findOne({ where: { - urlId: id.match(SLUG_URL_REGEX)[1], + urlId: match[1], }, ...options, }); diff --git a/server/models/Event.ts b/server/models/Event.ts index 5b3a28f9d..7620ea854 100644 --- a/server/models/Event.ts +++ b/server/models/Event.ts @@ -71,8 +71,6 @@ Event.ACTIVITY_EVENTS = [ "documents.publish", "documents.archive", "documents.unarchive", - "documents.pin", - "documents.unpin", "documents.move", "documents.delete", "documents.permanent_delete", @@ -99,8 +97,6 @@ Event.AUDIT_EVENTS = [ "documents.update", "documents.archive", "documents.unarchive", - "documents.pin", - "documents.unpin", "documents.move", "documents.delete", "documents.permanent_delete", @@ -108,6 +104,9 @@ Event.AUDIT_EVENTS = [ "groups.create", "groups.update", "groups.delete", + "pins.create", + "pins.update", + "pins.delete", "revisions.create", "shares.create", "shares.update", diff --git a/server/models/Pin.ts b/server/models/Pin.ts new file mode 100644 index 000000000..4a33ac08e --- /dev/null +++ b/server/models/Pin.ts @@ -0,0 +1,50 @@ +import { DataTypes, sequelize } from "../sequelize"; + +const Pin = sequelize.define( + "pins", + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + teamId: { + type: DataTypes.UUID, + }, + documentId: { + type: DataTypes.UUID, + }, + collectionId: { + type: DataTypes.UUID, + defaultValue: null, + }, + index: { + type: DataTypes.STRING, + defaultValue: null, + }, + }, + { + timestamps: true, + } +); + +Pin.associate = (models: any) => { + Pin.belongsTo(models.Document, { + as: "document", + foreignKey: "documentId", + }); + Pin.belongsTo(models.Collection, { + as: "collection", + foreignKey: "collectionId", + }); + Pin.belongsTo(models.Team, { + as: "team", + foreignKey: "teamId", + }); + Pin.belongsTo(models.User, { + as: "createdBy", + foreignKey: "createdById", + }); +}; + +export default Pin; diff --git a/server/models/index.ts b/server/models/index.ts index 1c1083718..5b40e7737 100644 --- a/server/models/index.ts +++ b/server/models/index.ts @@ -14,6 +14,7 @@ import Integration from "./Integration"; import IntegrationAuthentication from "./IntegrationAuthentication"; import Notification from "./Notification"; import NotificationSetting from "./NotificationSetting"; +import Pin from "./Pin"; import Revision from "./Revision"; import SearchQuery from "./SearchQuery"; import Share from "./Share"; @@ -39,6 +40,7 @@ const models = { IntegrationAuthentication, Notification, NotificationSetting, + Pin, Revision, SearchQuery, Share, @@ -73,6 +75,7 @@ export { IntegrationAuthentication, Notification, NotificationSetting, + Pin, Revision, SearchQuery, Share, diff --git a/server/policies/document.ts b/server/policies/document.ts index 1f55928b7..9d4499416 100644 --- a/server/policies/document.ts +++ b/server/policies/document.ts @@ -90,6 +90,15 @@ allow(User, ["pin", "unpin"], Document, (user, document) => { return user.teamId === document.teamId; }); +allow(User, ["pinToHome"], Document, (user, document) => { + if (document.archivedAt) return false; + if (document.deletedAt) return false; + if (document.template) return false; + if (!document.publishedAt) return false; + + return user.teamId === document.teamId && user.isAdmin; +}); + allow(User, "delete", Document, (user, document) => { if (user.isViewer) return false; if (document.deletedAt) return false; diff --git a/server/policies/index.ts b/server/policies/index.ts index 4e99dcf9f..653ec29c1 100644 --- a/server/policies/index.ts +++ b/server/policies/index.ts @@ -14,6 +14,7 @@ import "./collection"; import "./document"; import "./integration"; import "./notificationSetting"; +import "./pins"; import "./searchQuery"; import "./share"; import "./user"; diff --git a/server/policies/pins.ts b/server/policies/pins.ts new file mode 100644 index 000000000..14a10916e --- /dev/null +++ b/server/policies/pins.ts @@ -0,0 +1,9 @@ +import { User, Pin } from "@server/models"; +import policy from "./policy"; + +const { allow } = policy; + +allow(User, ["update", "delete"], Pin, (user, pin) => { + if (user.teamId === pin.teamId && user.isAdmin) return true; + return false; +}); diff --git a/server/presenters/document.ts b/server/presenters/document.ts index 2780fca2d..1efd1a8d1 100644 --- a/server/presenters/document.ts +++ b/server/presenters/document.ts @@ -26,7 +26,7 @@ async function replaceImageAttachments(text: string) { export default async function present( document: any, - options: Options | null | undefined + options: Options | null | undefined = {} ) { options = { isPublic: false, @@ -58,7 +58,6 @@ export default async function present( starred: document.starred ? !!document.starred.length : undefined, revision: document.revisionCount, fullWidth: document.fullWidth, - pinned: undefined, collectionId: undefined, parentDocumentId: undefined, lastViewedAt: undefined, @@ -69,8 +68,6 @@ export default async function present( } if (!options.isPublic) { - // @ts-expect-error ts-migrate(2322) FIXME: Type 'boolean' is not assignable to type 'undefine... Remove this comment to see the full error message - data.pinned = !!document.pinnedById; data.collectionId = document.collectionId; data.parentDocumentId = document.parentDocumentId; // @ts-expect-error ts-migrate(2322) FIXME: Type 'UserPresentation | null | undefined' is not ... Remove this comment to see the full error message diff --git a/server/presenters/index.ts b/server/presenters/index.ts index 6c4a8e812..483163d73 100644 --- a/server/presenters/index.ts +++ b/server/presenters/index.ts @@ -10,6 +10,7 @@ import presentGroupMembership from "./groupMembership"; import presentIntegration from "./integration"; import presentMembership from "./membership"; import presentNotificationSetting from "./notificationSetting"; +import presentPin from "./pin"; import presentPolicies from "./policy"; import presentRevision from "./revision"; import presentSearchQuery from "./searchQuery"; @@ -37,6 +38,7 @@ export { presentMembership, presentNotificationSetting, presentSlackAttachment, + presentPin, presentPolicies, presentGroupMembership, presentCollectionGroupMembership, diff --git a/server/presenters/pin.ts b/server/presenters/pin.ts new file mode 100644 index 000000000..8363f1d7d --- /dev/null +++ b/server/presenters/pin.ts @@ -0,0 +1,10 @@ +export default function present(pin: any) { + return { + id: pin.id, + documentId: pin.documentId, + collectionId: pin.collectionId, + index: pin.index, + createdAt: pin.createdAt, + updatedAt: pin.updatedAt, + }; +} diff --git a/server/queues/processors/websockets.ts b/server/queues/processors/websockets.ts index 6af83ca07..5bf7c2893 100644 --- a/server/queues/processors/websockets.ts +++ b/server/queues/processors/websockets.ts @@ -5,7 +5,9 @@ import { Group, CollectionGroup, GroupUser, + Pin, } from "@server/models"; +import { presentPin } from "@server/presenters"; import { Op } from "@server/sequelize"; import { Event } from "../../types"; @@ -81,8 +83,6 @@ export default class WebsocketsProcessor { }); } - case "documents.pin": - case "documents.unpin": case "documents.update": { const document = await Document.findByPk(event.documentId, { paranoid: false, @@ -334,6 +334,30 @@ export default class WebsocketsProcessor { .emit("fileOperations.update", event.data); } + case "pins.create": + case "pins.update": { + const pin = await Pin.findByPk(event.modelId); + return socketio + .to( + pin.collectionId + ? `collection-${pin.collectionId}` + : `team-${pin.teamId}` + ) + .emit(event.name, presentPin(pin)); + } + + case "pins.delete": { + return socketio + .to( + event.collectionId + ? `collection-${event.collectionId}` + : `team-${event.teamId}` + ) + .emit(event.name, { + modelId: event.modelId, + }); + } + case "groups.create": case "groups.update": { const group = await Group.findByPk(event.modelId, { diff --git a/server/routes/api/__snapshots__/documents.test.ts.snap b/server/routes/api/__snapshots__/documents.test.ts.snap index 3a157720a..a52eb069b 100644 --- a/server/routes/api/__snapshots__/documents.test.ts.snap +++ b/server/routes/api/__snapshots__/documents.test.ts.snap @@ -26,15 +26,6 @@ Object { } `; -exports[`#documents.pin should require authentication 1`] = ` -Object { - "error": "authentication_required", - "message": "Authentication required", - "ok": false, - "status": 401, -} -`; - exports[`#documents.restore should require authentication 1`] = ` Object { "error": "authentication_required", @@ -71,15 +62,6 @@ Object { } `; -exports[`#documents.unpin should require authentication 1`] = ` -Object { - "error": "authentication_required", - "message": "Authentication required", - "ok": false, - "status": 401, -} -`; - exports[`#documents.unstar should require authentication 1`] = ` Object { "error": "authentication_required", diff --git a/server/routes/api/collections.ts b/server/routes/api/collections.ts index bbbb543d8..816ed262a 100644 --- a/server/routes/api/collections.ts +++ b/server/routes/api/collections.ts @@ -57,26 +57,24 @@ router.post("collections.create", auth(), async (ctx) => { const user = ctx.state.user; authorize(user, "createCollection", user.team); - const collections = await Collection.findAll({ - where: { - teamId: user.teamId, - deletedAt: null, - }, - attributes: ["id", "index", "updatedAt"], - limit: 1, - order: [ - // using LC_COLLATE:"C" because we need byte order to drive the sorting - sequelize.literal('"collection"."index" collate "C"'), - ["updatedAt", "DESC"], - ], - }); if (index) { - assertIndexCharacters( - index, - "Index characters must be between x20 to x7E ASCII" - ); + assertIndexCharacters(index); } else { + const collections = await Collection.findAll({ + where: { + teamId: user.teamId, + deletedAt: null, + }, + attributes: ["id", "index", "updatedAt"], + limit: 1, + order: [ + // using LC_COLLATE:"C" because we need byte order to drive the sorting + sequelize.literal('"collection"."index" collate "C"'), + ["updatedAt", "DESC"], + ], + }); + index = fractionalIndex( null, collections.length ? collections[0].index : null @@ -648,10 +646,7 @@ router.post("collections.move", auth(), async (ctx) => { const id = ctx.body.id; let index = ctx.body.index; assertPresent(index, "index is required"); - assertIndexCharacters( - index, - "Index characters must be between x20 to x7E ASCII" - ); + assertIndexCharacters(index); assertUuid(id, "id must be a uuid"); const user = ctx.state.user; const collection = await Collection.findByPk(id); diff --git a/server/routes/api/documents.test.ts b/server/routes/api/documents.test.ts index 3a8e75aee..5923bca78 100644 --- a/server/routes/api/documents.test.ts +++ b/server/routes/api/documents.test.ts @@ -843,68 +843,7 @@ describe("#documents.list", () => { expect(body).toMatchSnapshot(); }); }); -describe("#documents.pinned", () => { - it("should return pinned documents", async () => { - const { user, document } = await seed(); - document.pinnedById = user.id; - await document.save(); - const res = await server.post("/api/documents.pinned", { - body: { - token: user.getJwtToken(), - collectionId: document.collectionId, - }, - }); - const body = await res.json(); - expect(res.status).toEqual(200); - expect(body.data.length).toEqual(1); - expect(body.data[0].id).toEqual(document.id); - }); - it("should return pinned documents in private collections member of", async () => { - const { user, collection, document } = await seed(); - collection.permission = null; - await collection.save(); - document.pinnedById = user.id; - await document.save(); - await CollectionUser.create({ - collectionId: collection.id, - userId: user.id, - createdById: user.id, - permission: "read_write", - }); - const res = await server.post("/api/documents.pinned", { - body: { - token: user.getJwtToken(), - collectionId: document.collectionId, - }, - }); - const body = await res.json(); - expect(res.status).toEqual(200); - expect(body.data.length).toEqual(1); - expect(body.data[0].id).toEqual(document.id); - }); - - it("should not return pinned documents in private collections not a member of", async () => { - const collection = await buildCollection({ - permission: null, - }); - const user = await buildUser({ - teamId: collection.teamId, - }); - const res = await server.post("/api/documents.pinned", { - body: { - token: user.getJwtToken(), - collectionId: collection.id, - }, - }); - expect(res.status).toEqual(403); - }); - - it("should require authentication", async () => { - const res = await server.post("/api/documents.pinned"); - expect(res.status).toEqual(401); - }); -}); describe("#documents.drafts", () => { it("should return unpublished documents", async () => { const { user, document } = await seed(); @@ -1534,39 +1473,7 @@ describe("#documents.starred", () => { expect(body).toMatchSnapshot(); }); }); -describe("#documents.pin", () => { - it("should pin the document", async () => { - const { user, document } = await seed(); - const res = await server.post("/api/documents.pin", { - body: { - token: user.getJwtToken(), - id: document.id, - }, - }); - const body = await res.json(); - expect(res.status).toEqual(200); - expect(body.data.pinned).toEqual(true); - }); - it("should require authentication", async () => { - const res = await server.post("/api/documents.pin"); - const body = await res.json(); - expect(res.status).toEqual(401); - expect(body).toMatchSnapshot(); - }); - - it("should require authorization", async () => { - const { document } = await seed(); - const user = await buildUser(); - const res = await server.post("/api/documents.pin", { - body: { - token: user.getJwtToken(), - id: document.id, - }, - }); - expect(res.status).toEqual(403); - }); -}); describe("#documents.move", () => { it("should move the document", async () => { const { user, document } = await seed(); @@ -1807,41 +1714,7 @@ describe("#documents.restore", () => { expect(res.status).toEqual(403); }); }); -describe("#documents.unpin", () => { - it("should unpin the document", async () => { - const { user, document } = await seed(); - document.pinnedBy = user; - await document.save(); - const res = await server.post("/api/documents.unpin", { - body: { - token: user.getJwtToken(), - id: document.id, - }, - }); - const body = await res.json(); - expect(res.status).toEqual(200); - expect(body.data.pinned).toEqual(false); - }); - it("should require authentication", async () => { - const res = await server.post("/api/documents.unpin"); - const body = await res.json(); - expect(res.status).toEqual(401); - expect(body).toMatchSnapshot(); - }); - - it("should require authorization", async () => { - const { document } = await seed(); - const user = await buildUser(); - const res = await server.post("/api/documents.unpin", { - body: { - token: user.getJwtToken(), - id: document.id, - }, - }); - expect(res.status).toEqual(403); - }); -}); describe("#documents.star", () => { it("should star the document", async () => { const { user, document } = await seed(); diff --git a/server/routes/api/documents.ts b/server/routes/api/documents.ts index 6bed62fcd..b1a192bbd 100644 --- a/server/routes/api/documents.ts +++ b/server/routes/api/documents.ts @@ -142,22 +142,8 @@ router.post("documents.list", auth(), pagination(), async (ctx) => { } assertSort(sort, Document); - // add the users starred state to the response by default - const starredScope = { - method: ["withStarred", user.id], - }; - const collectionScope = { - method: ["withCollection", user.id], - }; - const viewScope = { - method: ["withViews", user.id], - }; - const documents = await Document.scope( - "defaultScope", - starredScope, - collectionScope, - viewScope - ).findAll({ + + const documents = await Document.defaultScopeWithUser(user.id).findAll({ where, order: [[sort, direction]], offset: ctx.state.pagination.offset, @@ -185,57 +171,6 @@ router.post("documents.list", auth(), pagination(), async (ctx) => { }; }); -router.post("documents.pinned", auth(), pagination(), async (ctx) => { - const { collectionId, sort = "updatedAt" } = ctx.body; - let direction = ctx.body.direction; - if (direction !== "ASC") direction = "DESC"; - - assertUuid(collectionId, "collectionId is required"); - assertSort(sort, Document); - - const user = ctx.state.user; - const collection = await Collection.scope({ - method: ["withMembership", user.id], - }).findByPk(collectionId); - authorize(user, "read", collection); - const starredScope = { - method: ["withStarred", user.id], - }; - const collectionScope = { - method: ["withCollection", user.id], - }; - const viewScope = { - method: ["withViews", user.id], - }; - const documents = await Document.scope( - "defaultScope", - starredScope, - collectionScope, - viewScope - ).findAll({ - where: { - teamId: user.teamId, - collectionId, - pinnedById: { - [Op.ne]: null, - }, - }, - order: [[sort, direction]], - offset: ctx.state.pagination.offset, - limit: ctx.state.pagination.limit, - }); - const data = await Promise.all( - // @ts-expect-error ts-migrate(7006) FIXME: Parameter 'document' implicitly has an 'any' type. - documents.map((document) => presentDocument(document)) - ); - const policies = presentPolicies(user, documents); - ctx.body = { - pagination: ctx.state.pagination, - data, - policies, - }; -}); - router.post("documents.archived", auth(), pagination(), async (ctx) => { const { sort = "updatedAt" } = ctx.body; @@ -807,7 +742,6 @@ router.post("documents.restore", auth(), async (ctx) => { } ctx.body = { - // @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. data: await presentDocument(document), policies: presentPolicies(user, [document]), }; @@ -916,7 +850,6 @@ router.post("documents.search", auth(), pagination(), async (ctx) => { const data = await Promise.all( // @ts-expect-error ts-migrate(7006) FIXME: Parameter 'result' implicitly has an 'any' type. results.map(async (result) => { - // @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. const document = await presentDocument(result.document); return { ...result, document }; }) @@ -942,62 +875,6 @@ router.post("documents.search", auth(), pagination(), async (ctx) => { }; }); -router.post("documents.pin", auth(), async (ctx) => { - const { id } = ctx.body; - assertPresent(id, "id is required"); - const user = ctx.state.user; - const document = await Document.findByPk(id, { - userId: user.id, - }); - authorize(user, "pin", document); - document.pinnedById = user.id; - await document.save(); - await Event.create({ - name: "documents.pin", - documentId: document.id, - collectionId: document.collectionId, - teamId: document.teamId, - actorId: user.id, - data: { - title: document.title, - }, - ip: ctx.request.ip, - }); - ctx.body = { - // @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. - data: await presentDocument(document), - policies: presentPolicies(user, [document]), - }; -}); - -router.post("documents.unpin", auth(), async (ctx) => { - const { id } = ctx.body; - assertPresent(id, "id is required"); - const user = ctx.state.user; - const document = await Document.findByPk(id, { - userId: user.id, - }); - authorize(user, "unpin", document); - document.pinnedById = null; - await document.save(); - await Event.create({ - name: "documents.unpin", - documentId: document.id, - collectionId: document.collectionId, - teamId: document.teamId, - actorId: user.id, - data: { - title: document.title, - }, - ip: ctx.request.ip, - }); - ctx.body = { - // @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. - data: await presentDocument(document), - policies: presentPolicies(user, [document]), - }; -}); - router.post("documents.star", auth(), async (ctx) => { const { id } = ctx.body; assertPresent(id, "id is required"); @@ -1095,7 +972,6 @@ router.post("documents.templatize", auth(), async (ctx) => { userId: user.id, }); ctx.body = { - // @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. data: await presentDocument(document), policies: presentPolicies(user, [document]), }; @@ -1218,7 +1094,6 @@ router.post("documents.update", auth(), async (ctx) => { document.updatedBy = user; document.collection = collection; ctx.body = { - // @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. data: await presentDocument(document), policies: presentPolicies(user, [document]), }; @@ -1271,7 +1146,6 @@ router.post("documents.move", auth(), async (ctx) => { ctx.body = { data: { documents: await Promise.all( - // @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. documents.map((document) => presentDocument(document)) ), collections: await Promise.all( @@ -1303,7 +1177,6 @@ router.post("documents.archive", auth(), async (ctx) => { ip: ctx.request.ip, }); ctx.body = { - // @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. data: await presentDocument(document), policies: presentPolicies(user, [document]), }; @@ -1388,7 +1261,6 @@ router.post("documents.unpublish", auth(), async (ctx) => { ip: ctx.request.ip, }); ctx.body = { - // @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. data: await presentDocument(document), policies: presentPolicies(user, [document]), }; @@ -1461,7 +1333,6 @@ router.post("documents.import", auth(), async (ctx) => { // @ts-expect-error ts-migrate(2339) FIXME: Property 'collection' does not exist on type 'Docu... Remove this comment to see the full error message document.collection = collection; return (ctx.body = { - // @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. data: await presentDocument(document), policies: presentPolicies(user, [document]), }); @@ -1537,7 +1408,6 @@ router.post("documents.create", auth(), async (ctx) => { // @ts-expect-error ts-migrate(2339) FIXME: Property 'collection' does not exist on type 'Docu... Remove this comment to see the full error message document.collection = collection; return (ctx.body = { - // @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1. data: await presentDocument(document), policies: presentPolicies(user, [document]), }); diff --git a/server/routes/api/index.ts b/server/routes/api/index.ts index 0cb596640..1456fee25 100644 --- a/server/routes/api/index.ts +++ b/server/routes/api/index.ts @@ -18,6 +18,7 @@ import integrations from "./integrations"; import apiWrapper from "./middlewares/apiWrapper"; import editor from "./middlewares/editor"; import notificationSettings from "./notificationSettings"; +import pins from "./pins"; import revisions from "./revisions"; import searches from "./searches"; import shares from "./shares"; @@ -50,6 +51,7 @@ router.use("/", events.routes()); router.use("/", users.routes()); router.use("/", collections.routes()); router.use("/", documents.routes()); +router.use("/", pins.routes()); router.use("/", revisions.routes()); router.use("/", views.routes()); router.use("/", hooks.routes()); diff --git a/server/routes/api/pins.ts b/server/routes/api/pins.ts new file mode 100644 index 000000000..155585e0f --- /dev/null +++ b/server/routes/api/pins.ts @@ -0,0 +1,156 @@ +import Router from "koa-router"; +import pinCreator from "@server/commands/pinCreator"; +import pinDestroyer from "@server/commands/pinDestroyer"; +import pinUpdater from "@server/commands/pinUpdater"; +import auth from "@server/middlewares/authentication"; +import { Collection, Document, Pin } from "@server/models"; +import policy from "@server/policies"; +import { + presentPin, + presentDocument, + presentPolicies, +} from "@server/presenters"; +import { sequelize, Op } from "@server/sequelize"; +import { assertUuid, assertIndexCharacters } from "@server/validation"; +import pagination from "./middlewares/pagination"; + +const { authorize } = policy; +const router = new Router(); + +router.post("pins.create", auth(), async (ctx) => { + const { documentId, collectionId } = ctx.body; + const { index } = ctx.body; + assertUuid(documentId, "documentId is required"); + + const { user } = ctx.state; + const document = await Document.findByPk(documentId, { + userId: user.id, + }); + authorize(user, "read", document); + + if (collectionId) { + const collection = await Collection.scope({ + method: ["withMembership", user.id], + }).findByPk(collectionId); + authorize(user, "update", collection); + authorize(user, "pin", document); + } else { + authorize(user, "pinToHome", document); + } + + if (index) { + assertIndexCharacters(index); + } + + const pin = await pinCreator({ + user, + documentId, + collectionId, + ip: ctx.request.ip, + index, + }); + + ctx.body = { + data: presentPin(pin), + policies: presentPolicies(user, [pin]), + }; +}); + +router.post("pins.list", auth(), pagination(), async (ctx) => { + const { collectionId } = ctx.body; + const { user } = ctx.state; + + const [pins, collectionIds] = await Promise.all([ + Pin.findAll({ + where: { + ...(collectionId + ? { collectionId } + : { collectionId: { [Op.eq]: null } }), + teamId: user.teamId, + }, + order: [ + sequelize.literal('"pins"."index" collate "C"'), + ["updatedAt", "DESC"], + ], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }), + user.collectionIds(), + ]); + + const documents = await Document.defaultScopeWithUser(user.id).findAll({ + where: { + id: pins.map((pin: any) => pin.documentId), + collectionId: collectionIds, + }, + }); + + const policies = presentPolicies(user, [...documents, ...pins]); + + ctx.body = { + pagination: ctx.state.pagination, + data: { + pins: pins.map(presentPin), + documents: await Promise.all( + documents.map((document: any) => presentDocument(document)) + ), + }, + policies, + }; +}); + +router.post("pins.update", auth(), async (ctx) => { + const { id, index } = ctx.body; + assertUuid(id, "id is required"); + + assertIndexCharacters(index); + + const { user } = ctx.state; + let pin = await Pin.findByPk(id); + const document = await Document.findByPk(pin.documentId, { + userId: user.id, + }); + + if (pin.collectionId) { + authorize(user, "pin", document); + } else { + authorize(user, "update", pin); + } + + pin = await pinUpdater({ + user, + pin, + ip: ctx.request.ip, + index, + }); + + ctx.body = { + data: presentPin(pin), + policies: presentPolicies(user, [pin]), + }; +}); + +router.post("pins.delete", auth(), async (ctx) => { + const { id } = ctx.body; + assertUuid(id, "id is required"); + + const { user } = ctx.state; + const pin = await Pin.findByPk(id); + const document = await Document.findByPk(pin.documentId, { + userId: user.id, + }); + + if (pin.collectionId) { + authorize(user, "unpin", document); + } else { + authorize(user, "delete", pin); + } + + await pinDestroyer({ user, pin, ip: ctx.request.ip }); + + ctx.body = { + success: true, + }; +}); + +export default router; diff --git a/server/sequelize.ts b/server/sequelize.ts index 18451e13c..f2a90f85b 100644 --- a/server/sequelize.ts +++ b/server/sequelize.ts @@ -1,5 +1,4 @@ import Sequelize from "sequelize"; -// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'sequ... Remove this comment to see the full error message import EncryptedField from "sequelize-encrypted"; import Logger from "./logging/logger"; diff --git a/server/types.ts b/server/types.ts index e7ea579ba..c478a7119 100644 --- a/server/types.ts +++ b/server/types.ts @@ -40,8 +40,6 @@ export type DocumentEvent = | "documents.publish" | "documents.delete" | "documents.permanent_delete" - | "documents.pin" - | "documents.unpin" | "documents.archive" | "documents.unarchive" | "documents.restore" @@ -240,9 +238,19 @@ export type TeamEvent = { ip: string; }; +export type PinEvent = { + name: "pins.create" | "pins.update" | "pins.delete"; + teamId: string; + modelId: string; + collectionId?: string; + actorId: string; + ip: string; +}; + export type Event = | UserEvent | DocumentEvent + | PinEvent | CollectionEvent | CollectionImportEvent | CollectionExportAllEvent diff --git a/server/validation.ts b/server/validation.ts index 2a21ad1f2..bb93a696c 100644 --- a/server/validation.ts +++ b/server/validation.ts @@ -83,7 +83,10 @@ export const assertValueInArray = ( } }; -export const assertIndexCharacters = (value: string, message?: string) => { +export const assertIndexCharacters = ( + value: string, + message = "index must be between x20 to x7E ASCII" +) => { if (!validateIndexCharacters(value)) { throw ValidationError(message); } diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index a0d2c6893..f88b8deab 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -15,6 +15,10 @@ "Duplicate": "Duplicate", "Duplicate document": "Duplicate document", "Document duplicated": "Document duplicated", + "Pin to collection": "Pin to collection", + "Pinned to collection": "Pinned to collection", + "Pin to home": "Pin to home", + "Pinned to team home": "Pinned to team home", "Print": "Print", "Print document": "Print document", "Import document": "Import document", @@ -58,6 +62,7 @@ "Edits you make will sync once you’re online": "Edits you make will sync once you’re online", "Submenu": "Submenu", "Deleted Collection": "Deleted Collection", + "Unpin": "Unpin", "History": "History", "Oh weird, there's nothing here": "Oh weird, there's nothing here", "New": "New", @@ -234,8 +239,6 @@ "Document options": "Document options", "Restore": "Restore", "Choose a collection": "Choose a collection", - "Unpin": "Unpin", - "Pin to collection": "Pin to collection", "Unpublish": "Unpublish", "Permanently delete": "Permanently delete", "Move": "Move", @@ -281,19 +284,18 @@ "Name your token something that will help you to remember it's use in the future, for example \"local development\", \"production\", or \"continuous integration\".": "Name your token something that will help you to remember it's use in the future, for example \"local development\", \"production\", or \"continuous integration\".", "Documents": "Documents", "The document archive is empty at the moment.": "The document archive is empty at the moment.", - "Search in collection": "Search in collection", - "{{ collectionName }} doesn’t contain any\n documents yet.": "{{ collectionName }} doesn’t contain any\n documents yet.", - "Get started by creating a new one!": "Get started by creating a new one!", - "Create a document": "Create a document", - "Manage permissions": "Manage permissions", "This collection is only visible to those given access": "This collection is only visible to those given access", "Private": "Private", - "Pinned": "Pinned", "Recently updated": "Recently updated", "Recently published": "Recently published", "Least recently updated": "Least recently updated", "A–Z": "A–Z", + "Search in collection": "Search in collection", "Drop documents to import": "Drop documents to import", + "{{ collectionName }} doesn’t contain any\n documents yet.": "{{ collectionName }} doesn’t contain any\n documents yet.", + "Get started by creating a new one!": "Get started by creating a new one!", + "Create a document": "Create a document", + "Manage permissions": "Manage permissions", "Are you sure about that? Deleting the {{collectionName}} collection is permanent and cannot be restored, however documents within will be moved to the trash.": "Are you sure about that? Deleting the {{collectionName}} collection is permanent and cannot be restored, however documents within will be moved to the trash.", "Deleting": "Deleting", "I’m sure – Delete": "I’m sure – Delete", diff --git a/shared/theme.ts b/shared/theme.ts index b5a410c7b..9054423a8 100644 --- a/shared/theme.ts +++ b/shared/theme.ts @@ -16,6 +16,7 @@ const colors = { white: "#FFF", white10: "rgba(255, 255, 255, 0.1)", white50: "rgba(255, 255, 255, 0.5)", + white75: "rgba(255, 255, 255, 0.75)", black: "#000", black05: "rgba(0, 0, 0, 0.05)", black10: "rgba(0, 0, 0, 0.1)", diff --git a/yarn.lock b/yarn.lock index 2cd9deca3..2a2f32fba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1133,6 +1133,45 @@ enabled "2.0.x" kuler "^2.0.0" +"@dnd-kit/accessibility@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.0.0.tgz#b56e3750414fd907b7d6972b3116aa8f96d07fde" + integrity sha512-QwaQ1IJHQHMMuAGOOYHQSx7h7vMZPfO97aDts8t5N/MY7n2QTDSnW+kF7uRQ1tVBkr6vJ+BqHWG5dlgGvwVjow== + dependencies: + tslib "^2.0.0" + +"@dnd-kit/core@^4.0.3": + version "4.0.3" + resolved "https://registry.yarnpkg.com/@dnd-kit/core/-/core-4.0.3.tgz#49abe3c9b481b6e07909df1781e88b20f3dd25b0" + integrity sha512-uT1uHZxKx3iEkupmLfknMIvbykMJSetoXXmra6sGGvtWy+OMKrWm3axH2c90+JC/q6qaeKs2znd3Qs8GLnCa5Q== + dependencies: + "@dnd-kit/accessibility" "^3.0.0" + "@dnd-kit/utilities" "^3.0.1" + tslib "^2.0.0" + +"@dnd-kit/modifiers@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@dnd-kit/modifiers/-/modifiers-4.0.0.tgz#d1577b806b2319f14a1a0a155f270e672cfca636" + integrity sha512-4OkNTamneH9u3YMJqG6yJ6cwFoEd/4yY9BF39TgmDh9vyMK2MoPZFVAV0vOEm193ZYsPczq3Af5tJFtJhR9jJQ== + dependencies: + "@dnd-kit/utilities" "^3.0.0" + tslib "^2.0.0" + +"@dnd-kit/sortable@^5.1.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@dnd-kit/sortable/-/sortable-5.1.0.tgz#f30ec12c95ca5aa90e2e4d9ef3dbe16b3eb26d69" + integrity sha512-CPyiUHbTrSYzhddfgdeoX0ERg/dEyVKIWx9+4O6uqpoppo84SXCBHVFiFBRVpQ9wtpsXs7prtUAnAUTcvFQTZg== + dependencies: + "@dnd-kit/utilities" "^3.0.0" + tslib "^2.0.0" + +"@dnd-kit/utilities@^3.0.0", "@dnd-kit/utilities@^3.0.1": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-3.0.2.tgz#24fd796491a85c2904e9c97f1fdb42005df645f2" + integrity sha512-J4WpZXKbLJzBkuALqsIy5KmQr6PQk86ixoPKoixzjWj1+XGE5KdA2vga9Vf43EB/Ewpng+E5SmXVLfTs7ukbhw== + dependencies: + tslib "^2.0.0" + "@emotion/is-prop-valid@^0.8.2", "@emotion/is-prop-valid@^0.8.8": version "0.8.8" resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a" @@ -14562,7 +14601,7 @@ tslib@^1.0.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0: +tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0: version "2.3.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==