feat: Pin to home (#2880)

This commit is contained in:
Tom Moor
2021-12-30 16:54:02 -08:00
committed by GitHub
parent 5be2eb75f3
commit eb0c324da8
57 changed files with 1884 additions and 819 deletions

View File

@@ -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: <PinIcon />,
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: <PinIcon />,
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,
];

View File

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

View File

@@ -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<ActionContext>;
context?: Partial<ActionContext>;
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) =>

View File

@@ -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 (
<Reorderable
ref={setNodeRef}
style={style}
$isDragging={isDragging}
{...attributes}
>
<AnimatePresence
initial={{ opacity: 0, scale: 0.95 }}
animate={{
opacity: 1,
scale: 1,
transition: {
type: "spring",
bounce: 0.6,
},
}}
exit={{ opacity: 0, scale: 0.95 }}
>
<DocumentLink
dir={document.dir}
style={{
background:
collection?.color && getLuminance(collection.color) < 0.6
? collection.color
: undefined,
}}
$isDragging={isDragging}
to={{
pathname: document.url,
state: {
title: document.titleWithDefault,
},
}}
>
<Content justify="space-between" column>
{collection?.icon &&
collection?.icon !== "collection" &&
!pin?.collectionId ? (
<CollectionIcon collection={collection} color="white" />
) : (
<DocumentIcon color="white" />
)}
<div>
<Heading dir={document.dir}>{document.titleWithDefault}</Heading>
<StyledDocumentMeta document={document} />
</div>
</Content>
</DocumentLink>
{canUpdatePin && (
<Actions dir={document.dir} gap={4}>
{!isDragging && pin && (
<Tooltip tooltip={t("Unpin")}>
<PinButton onClick={handleUnpin}>
<CloseIcon color="currentColor" />
</PinButton>
</Tooltip>
)}
<DragHandle $isDragging={isDragging} {...listeners}>
:::
</DragHandle>
</Actions>
)}
</AnimatePresence>
</Reorderable>
);
}
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);

View File

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

View File

@@ -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<Props>(function PaginatedDocumentList({
fetch={fetch}
options={options}
renderItem={(item) => (
<DocumentListItem key={item.id} document={item} {...rest} />
<DocumentListItem
key={item.id}
document={item}
showPin={!!options?.collectionId}
{...rest}
/>
)}
/>
);

View File

@@ -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 (
<DndContext
sensors={sensors}
modifiers={[restrictToParentElement]}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={items} strategy={rectSortingStrategy}>
<List>
<AnimatePresence initial={false}>
{items.map((documentId) => {
const document = documents.get(documentId);
const pin = pins.find((pin) => pin.documentId === documentId);
return document ? (
<DocumentCard
key={documentId}
document={document}
canUpdatePin={canUpdate}
pin={pin}
{...rest}
/>
) : null;
})}
</AnimatePresence>
</List>
</SortableContext>
</DndContext>
);
}
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);

View File

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

View File

@@ -71,6 +71,7 @@ class SocketProvider extends React.Component<Props> {
documents,
collections,
groups,
pins,
memberships,
policies,
presence,
@@ -260,6 +261,18 @@ class SocketProvider extends React.Component<Props> {
}
});
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);
});

View File

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

View File

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

View File

@@ -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: <PinIcon />,
},
{
type: "button",
title: t("Pin to collection"),
onClick: document.pin,
visible: !!(showPin && !document.pinned && can.pin),
icon: <PinIcon />,
},
{
type: "button",
title: t("Unstar"),
@@ -333,6 +328,8 @@ function DocumentMenu({
visible: !document.isStarred && !!can.star,
icon: <StarredIcon />,
},
actionToMenuItem(pinDocumentToHome, context),
actionToMenuItem(pinDocument, context),
{
type: "separator",
},

View File

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

18
app/models/Pin.ts Normal file
View File

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

View File

@@ -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 = () => <Search notFound />;

View File

@@ -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<Error | undefined>();
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 <Search notFound />;
}
const pinnedDocuments = collection
? documents.pinnedInCollection(collection.id)
: [];
const collectionName = collection ? collection.name : "";
const hasPinnedDocuments = !!pinnedDocuments.length;
return collection ? (
<Scene
centered={false}
@@ -149,246 +107,127 @@ function CollectionScene() {
{collection.name}
</>
}
actions={
<>
<Action>
<InputSearchPage
source="collection"
placeholder={`${t("Search in collection")}`}
label={`${t("Search in collection")}`}
collectionId={collection.id}
/>
</Action>
{can.update && (
actions={<Actions collection={collection} />}
>
<DropToImport
accept={documents.importFileTypes.join(", ")}
disabled={!can.update}
collectionId={collection.id}
>
<CenteredContent withStickyHeader>
{collection.isEmpty ? (
<Empty collection={collection} />
) : (
<>
<Action>
<Tooltip
tooltip={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
>
<Button
as={Link}
to={collection ? newDocumentPath(collection.id) : ""}
disabled={!collection}
icon={<PlusIcon />}
<Heading>
<CollectionIcon collection={collection} size={40} expanded />{" "}
{collection.name}{" "}
{!collection.permission && (
<Tooltip
tooltip={t(
"This collection is only visible to those given access"
)}
placement="bottom"
>
{t("New doc")}
</Button>
</Tooltip>
</Action>
<Separator />
<Badge>{t("Private")}</Badge>
</Tooltip>
)}
</Heading>
<CollectionDescription collection={collection} />
<PinnedDocuments
pins={pins.inCollection(collection.id)}
canUpdate={can.update}
/>
<Tabs>
<Tab to={collectionUrl(collection.url)} exact>
{t("Documents")}
</Tab>
<Tab to={collectionUrl(collection.url, "updated")} exact>
{t("Recently updated")}
</Tab>
<Tab to={collectionUrl(collection.url, "published")} exact>
{t("Recently published")}
</Tab>
<Tab to={collectionUrl(collection.url, "old")} exact>
{t("Least recently updated")}
</Tab>
<Tab to={collectionUrl(collection.url, "alphabetical")} exact>
{t("AZ")}
</Tab>
</Tabs>
<Switch>
<Route path={collectionUrl(collection.url, "alphabetical")}>
<PaginatedDocumentList
key="alphabetical"
documents={documents.alphabeticalInCollection(
collection.id
)}
fetch={documents.fetchAlphabetical}
options={{
collectionId: collection.id,
}}
/>
</Route>
<Route path={collectionUrl(collection.url, "old")}>
<PaginatedDocumentList
key="old"
documents={documents.leastRecentlyUpdatedInCollection(
collection.id
)}
fetch={documents.fetchLeastRecentlyUpdated}
options={{
collectionId: collection.id,
}}
/>
</Route>
<Route path={collectionUrl(collection.url, "recent")}>
<Redirect to={collectionUrl(collection.url, "published")} />
</Route>
<Route path={collectionUrl(collection.url, "published")}>
<PaginatedDocumentList
key="published"
documents={documents.recentlyPublishedInCollection(
collection.id
)}
fetch={documents.fetchRecentlyPublished}
options={{
collectionId: collection.id,
}}
showPublished
/>
</Route>
<Route path={collectionUrl(collection.url, "updated")}>
<PaginatedDocumentList
key="updated"
documents={documents.recentlyUpdatedInCollection(
collection.id
)}
fetch={documents.fetchRecentlyUpdated}
options={{
collectionId: collection.id,
}}
/>
</Route>
<Route path={collectionUrl(collection.url)} exact>
<PaginatedDocumentList
documents={documents.rootInCollection(collection.id)}
fetch={documents.fetchPage}
options={{
collectionId: collection.id,
parentDocumentId: null,
sort: collection.sort.field,
direction: "ASC",
}}
showParentDocuments
/>
</Route>
</Switch>
</>
)}
<Action>
<CollectionMenu
collection={collection}
placement="bottom-end"
label={(props) => (
<Button
icon={<MoreIcon />}
{...props}
borderOnHover
neutral
small
/>
)}
/>
</Action>
</>
}
>
<Dropzone
accept={documents.importFileTypes.join(", ")}
onDropAccepted={handleFiles}
onDropRejected={handleRejection}
disabled={!can.update}
noClick
multiple
>
{({ getRootProps, getInputProps, isDragActive }) => (
<DropzoneContainer
{...getRootProps()}
isDragActive={isDragActive}
tabIndex={-1}
>
<input {...getInputProps()} />
{isImporting && <LoadingIndicator />}
<CenteredContent withStickyHeader>
{collection.isEmpty ? (
<Centered column>
<HelpText>
<Trans
defaults="<em>{{ collectionName }}</em> doesnt contain any
documents yet."
values={{
collectionName,
}}
components={{
em: <strong />,
}}
/>
<br />
{canUser.createDocument && (
<Trans>Get started by creating a new one!</Trans>
)}
</HelpText>
<Empty>
{canUser.createDocument && (
<Link to={newDocumentPath(collection.id)}>
<Button icon={<NewDocumentIcon color="currentColor" />}>
{t("Create a document")}
</Button>
</Link>
)}
&nbsp;&nbsp;
<Button onClick={handlePermissionsModalOpen} neutral>
{t("Manage permissions")}
</Button>
</Empty>
<Modal
title={t("Collection permissions")}
onRequestClose={handlePermissionsModalClose}
isOpen={permissionsModalOpen}
>
<CollectionPermissions collection={collection} />
</Modal>
</Centered>
) : (
<>
<Heading>
<CollectionIcon
collection={collection}
size={40}
expanded
/>{" "}
{collection.name}{" "}
{!collection.permission && (
<Tooltip
tooltip={t(
"This collection is only visible to those given access"
)}
placement="bottom"
>
<Badge>{t("Private")}</Badge>
</Tooltip>
)}
</Heading>
<CollectionDescription collection={collection} />
{hasPinnedDocuments && (
<>
<Subheading sticky>
<TinyPinIcon size={18} color="currentColor" />{" "}
{t("Pinned")}
</Subheading>
<DocumentList documents={pinnedDocuments} showPin />
</>
)}
<Tabs>
<Tab to={collectionUrl(collection.url)} exact>
{t("Documents")}
</Tab>
<Tab to={collectionUrl(collection.url, "updated")} exact>
{t("Recently updated")}
</Tab>
<Tab to={collectionUrl(collection.url, "published")} exact>
{t("Recently published")}
</Tab>
<Tab to={collectionUrl(collection.url, "old")} exact>
{t("Least recently updated")}
</Tab>
<Tab
to={collectionUrl(collection.url, "alphabetical")}
exact
>
{t("AZ")}
</Tab>
</Tabs>
<Switch>
<Route path={collectionUrl(collection.url, "alphabetical")}>
<PaginatedDocumentList
key="alphabetical"
documents={documents.alphabeticalInCollection(
collection.id
)}
fetch={documents.fetchAlphabetical}
options={{
collectionId: collection.id,
}}
showPin
/>
</Route>
<Route path={collectionUrl(collection.url, "old")}>
<PaginatedDocumentList
key="old"
documents={documents.leastRecentlyUpdatedInCollection(
collection.id
)}
fetch={documents.fetchLeastRecentlyUpdated}
options={{
collectionId: collection.id,
}}
showPin
/>
</Route>
<Route path={collectionUrl(collection.url, "recent")}>
<Redirect
to={collectionUrl(collection.url, "published")}
/>
</Route>
<Route path={collectionUrl(collection.url, "published")}>
<PaginatedDocumentList
key="published"
documents={documents.recentlyPublishedInCollection(
collection.id
)}
fetch={documents.fetchRecentlyPublished}
options={{
collectionId: collection.id,
}}
showPublished
showPin
/>
</Route>
<Route path={collectionUrl(collection.url, "updated")}>
<PaginatedDocumentList
key="updated"
documents={documents.recentlyUpdatedInCollection(
collection.id
)}
fetch={documents.fetchRecentlyUpdated}
options={{
collectionId: collection.id,
}}
showPin
/>
</Route>
<Route path={collectionUrl(collection.url)} exact>
<PaginatedDocumentList
documents={documents.rootInCollection(collection.id)}
fetch={documents.fetchPage}
options={{
collectionId: collection.id,
parentDocumentId: null,
sort: collection.sort.field,
direction: "ASC",
}}
showParentDocuments
showPin
/>
</Route>
</Switch>
</>
)}
<DropMessage>{t("Drop documents to import")}</DropMessage>
</CenteredContent>
</DropzoneContainer>
)}
</Dropzone>
</CenteredContent>
</DropToImport>
</Scene>
) : (
<CenteredContent>
@@ -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);

View File

@@ -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 (
<>
<Action>
<InputSearchPage
source="collection"
placeholder={`${t("Search in collection")}`}
label={`${t("Search in collection")}`}
collectionId={collection.id}
/>
</Action>
{can.update && (
<>
<Action>
<Tooltip
tooltip={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
>
<Button
as={Link}
to={collection ? newDocumentPath(collection.id) : ""}
disabled={!collection}
icon={<PlusIcon />}
>
{t("New doc")}
</Button>
</Tooltip>
</Action>
<Separator />
</>
)}
<Action>
<CollectionMenu
collection={collection}
placement="bottom-end"
label={(props) => (
<Button
icon={<MoreIcon />}
{...props}
borderOnHover
neutral
small
/>
)}
/>
</Action>
</>
);
}
export default observer(Actions);

View File

@@ -0,0 +1,98 @@
import { observer } from "mobx-react";
import * as React from "react";
import Dropzone from "react-dropzone";
import { useTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import HelpText from "~/components/HelpText";
import LoadingIndicator from "~/components/LoadingIndicator";
import useImportDocument from "~/hooks/useImportDocument";
import useToasts from "~/hooks/useToasts";
type Props = {
children: React.ReactNode;
disabled: boolean;
accept: string;
collectionId: string;
};
function DropToImport({ children, disabled, accept, collectionId }: Props) {
const { handleFiles, isImporting } = useImportDocument(collectionId);
const { showToast } = useToasts();
const { t } = useTranslation();
const handleRejection = React.useCallback(() => {
showToast(
t("Document not supported try Markdown, Plain text, HTML, or Word"),
{
type: "error",
}
);
}, [t, showToast]);
return (
<Dropzone
accept={accept}
onDropAccepted={handleFiles}
onDropRejected={handleRejection}
disabled={disabled}
noClick
multiple
>
{({ getRootProps, getInputProps, isDragActive }) => (
<DropzoneContainer
{...getRootProps()}
isDragActive={isDragActive}
tabIndex={-1}
>
<input {...getInputProps()} />
{isImporting && <LoadingIndicator />}
{children}
<DropMessage>{t("Drop documents to import")}</DropMessage>
</DropzoneContainer>
)}
</Dropzone>
);
}
const DropMessage = styled(HelpText)`
opacity: 0;
pointer-events: none;
`;
const DropzoneContainer = styled.div<{ isDragActive?: boolean }>`
outline-color: transparent !important;
height: calc(100% - 56px);
position: relative;
${({ isDragActive, theme }) =>
isDragActive &&
css`
&:after {
display: block;
content: "";
position: absolute;
top: 24px;
right: 24px;
left: 24px;
height: 85vh;
background: ${theme.background};
border-radius: 8px;
border: 1px dashed ${theme.divider};
box-shadow: 0 0 0 100px white;
z-index: 1;
}
${DropMessage} {
opacity: 1;
z-index: 2;
position: absolute;
text-align: center;
top: 50vh;
left: 50%;
transform: translateX(-50%);
}
`}
`;
export default observer(DropToImport);

View File

@@ -0,0 +1,89 @@
import { observer } from "mobx-react";
import { NewDocumentIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import Collection from "~/models/Collection";
import CollectionPermissions from "~/scenes/CollectionPermissions";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import HelpText from "~/components/HelpText";
import Modal from "~/components/Modal";
import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import { newDocumentPath } from "~/utils/routeHelpers";
type Props = {
collection: Collection;
};
function EmptyCollection({ collection }: Props) {
const { policies } = useStores();
const { t } = useTranslation();
const team = useCurrentTeam();
const can = policies.abilities(team.id);
const collectionName = collection ? collection.name : "";
const [
permissionsModalOpen,
handlePermissionsModalOpen,
handlePermissionsModalClose,
] = useBoolean();
return (
<Centered column>
<HelpText>
<Trans
defaults="<em>{{ collectionName }}</em> doesnt contain any
documents yet."
values={{
collectionName,
}}
components={{
em: <strong />,
}}
/>
<br />
{can.createDocument && (
<Trans>Get started by creating a new one!</Trans>
)}
</HelpText>
<Empty>
{can.createDocument && (
<Link to={newDocumentPath(collection.id)}>
<Button icon={<NewDocumentIcon color="currentColor" />}>
{t("Create a document")}
</Button>
</Link>
)}
&nbsp;&nbsp;
<Button onClick={handlePermissionsModalOpen} neutral>
{t("Manage permissions")}
</Button>
</Empty>
<Modal
title={t("Collection permissions")}
onRequestClose={handlePermissionsModalClose}
isOpen={permissionsModalOpen}
>
<CollectionPermissions collection={collection} />
</Modal>
</Centered>
);
}
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);

View File

@@ -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 (
<Scene
icon={<HomeIcon color="currentColor" />}
@@ -39,6 +48,7 @@ function Home() {
>
{!ui.languagePromptDismissed && <LanguagePrompt />}
<Heading>{t("Home")}</Heading>
<PinnedDocuments pins={pins.home} canUpdate={canManageTeam} />
<Tabs>
<Tab to="/home" exact>
{t("Recently viewed")}

View File

@@ -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<Document> {
);
}
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<Document> {
fetchNamedPage = async (
request = "list",
options: FetchPageParams | undefined
): Promise<Document[] | undefined> => {
): Promise<Document[]> => {
this.isFetching = true;
try {
@@ -377,11 +371,6 @@ export default class DocumentsStore extends BaseStore<Document> {
return this.fetchNamedPage("drafts", options);
};
@action
fetchPinned = (options?: FetchParams): Promise<any> => {
return this.fetchNamedPage("pinned", options);
};
@action
fetchOwned = (options?: PaginationParams): Promise<any> => {
return this.fetchNamedPage("list", options);
@@ -732,18 +721,6 @@ export default class DocumentsStore extends BaseStore<Document> {
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);

57
app/stores/PinsStore.ts Normal file
View File

@@ -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<Pin> {
constructor(rootStore: RootStore) {
super(rootStore, Pin);
}
@action
fetchPage = async (params?: FetchParams | undefined): Promise<void> => {
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;
});
}
}

View File

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

View File

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

View File

@@ -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";

View File

@@ -80,6 +80,7 @@ declare module "styled-components" {
white: string;
white10: string;
white50: string;
white75: string;
black: string;
black05: string;
black10: string;

View File

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

View File

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

View File

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

View File

@@ -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<any> {
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;
}

View File

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

View File

@@ -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<any> {
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;
}

View File

@@ -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<any> {
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;
}

View File

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

View File

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

View File

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

50
server/models/Pin.ts Normal file
View File

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ import "./collection";
import "./document";
import "./integration";
import "./notificationSetting";
import "./pins";
import "./searchQuery";
import "./share";
import "./user";

9
server/policies/pins.ts Normal file
View File

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

View File

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

View File

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

10
server/presenters/pin.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

156
server/routes/api/pins.ts Normal file
View File

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

View File

@@ -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";

View File

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

View File

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

View File

@@ -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 youre online": "Edits you make will sync once youre 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",
"<em>{{ collectionName }}</em> doesnt contain any\n documents yet.": "<em>{{ collectionName }}</em> doesnt 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",
"AZ": "AZ",
"Search in collection": "Search in collection",
"Drop documents to import": "Drop documents to import",
"<em>{{ collectionName }}</em> doesnt contain any\n documents yet.": "<em>{{ collectionName }}</em> doesnt 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 <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.": "Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.",
"Deleting": "Deleting",
"Im sure Delete": "Im sure Delete",

View File

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

View File

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