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, NewDocumentIcon,
ShapesIcon, ShapesIcon,
ImportIcon, ImportIcon,
PinIcon,
} from "outline-icons"; } from "outline-icons";
import * as React from "react"; import * as React from "react";
import DocumentTemplatize from "~/scenes/DocumentTemplatize"; import DocumentTemplatize from "~/scenes/DocumentTemplatize";
@@ -16,7 +17,7 @@ import { createAction } from "~/actions";
import { DocumentSection } from "~/actions/sections"; import { DocumentSection } from "~/actions/sections";
import getDataTransferFiles from "~/utils/getDataTransferFiles"; import getDataTransferFiles from "~/utils/getDataTransferFiles";
import history from "~/utils/history"; import history from "~/utils/history";
import { newDocumentPath } from "~/utils/routeHelpers"; import { homePath, newDocumentPath } from "~/utils/routeHelpers";
export const openDocument = createAction({ export const openDocument = createAction({
name: ({ t }) => t("Open document"), 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({ export const printDocument = createAction({
name: ({ t, isContextMenu }) => name: ({ t, isContextMenu }) =>
isContextMenu ? t("Print") : t("Print document"), isContextMenu ? t("Print") : t("Print document"),
@@ -234,4 +301,6 @@ export const rootDocumentActions = [
unstarDocument, unstarDocument,
duplicateDocument, duplicateDocument,
printDocument, printDocument,
pinDocument,
pinDocumentToHome,
]; ];

View File

@@ -10,19 +10,26 @@ type Props = {
collection: Collection; collection: Collection;
expanded?: boolean; expanded?: boolean;
size?: number; size?: number;
color?: string;
}; };
function ResolvedCollectionIcon({ collection, expanded, size }: Props) { function ResolvedCollectionIcon({
collection,
color: inputColor,
expanded,
size,
}: Props) {
const { ui } = useStores(); const { ui } = useStores();
// If the chosen icon color is very dark then we invert it in dark mode // 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. // otherwise it will be impossible to see against the dark background.
const color = const color =
ui.resolvedTheme === "dark" && collection.color !== "currentColor" inputColor ||
(ui.resolvedTheme === "dark" && collection.color !== "currentColor"
? getLuminance(collection.color) > 0.09 ? getLuminance(collection.color) > 0.09
? collection.color ? collection.color
: "currentColor" : "currentColor"
: collection.color; : collection.color);
if (collection.icon && collection.icon !== "collection") { if (collection.icon && collection.icon !== "collection") {
try { try {

View File

@@ -1,18 +1,17 @@
import { ExpandedIcon } from "outline-icons"; import { ExpandedIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router-dom"; import { Link } from "react-router-dom";
import { import {
useMenuState, useMenuState,
MenuButton, MenuButton,
MenuItem as BaseMenuItem, MenuItem as BaseMenuItem,
} from "reakit/Menu"; } from "reakit/Menu";
import styled from "styled-components"; import styled from "styled-components";
import { $Shape } from "utility-types";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import MenuIconWrapper from "~/components/MenuIconWrapper"; import MenuIconWrapper from "~/components/MenuIconWrapper";
import { actionToMenuItem } from "~/actions"; import { actionToMenuItem } from "~/actions";
import useStores from "~/hooks/useStores"; import useActionContext from "~/hooks/useActionContext";
import { import {
Action, Action,
ActionContext, ActionContext,
@@ -27,7 +26,7 @@ import ContextMenu from ".";
type Props = { type Props = {
actions?: (Action | MenuSeparator | MenuHeading)[]; actions?: (Action | MenuSeparator | MenuHeading)[];
context?: $Shape<ActionContext>; context?: Partial<ActionContext>;
items?: TMenuItem[]; items?: TMenuItem[];
}; };
@@ -90,20 +89,9 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
} }
function Template({ items, actions, context, ...menu }: Props) { function Template({ items, actions, context, ...menu }: Props) {
const { t } = useTranslation(); const ctx = useActionContext({
const location = useLocation();
const stores = useStores();
const { ui } = stores;
const ctx = {
t,
isCommandBar: false,
isContextMenu: true, isContextMenu: true,
activeCollectionId: ui.activeCollectionId, });
activeDocumentId: ui.activeDocumentId,
location,
stores,
...context,
};
const templateItems = actions const templateItems = actions
? actions.map((item) => ? 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 }>` const Modified = styled.span<{ highlight?: boolean }>`
color: ${(props) => props.theme.textTertiary};
font-weight: ${(props) => (props.highlight ? "600" : "400")}; font-weight: ${(props) => (props.highlight ? "600" : "400")};
`; `;

View File

@@ -12,7 +12,6 @@ type Props = {
showParentDocuments?: boolean; showParentDocuments?: boolean;
showCollection?: boolean; showCollection?: boolean;
showPublished?: boolean; showPublished?: boolean;
showPin?: boolean;
showDraft?: boolean; showDraft?: boolean;
showTemplate?: boolean; showTemplate?: boolean;
}; };
@@ -33,7 +32,12 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
fetch={fetch} fetch={fetch}
options={options} options={options}
renderItem={(item) => ( 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 invariant from "invariant";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
import { actionToMenuItem } from "~/actions"; import { actionToMenuItem } from "~/actions";
import useStores from "~/hooks/useStores"; import useActionContext from "~/hooks/useActionContext";
import { Action } from "~/types"; import { Action } from "~/types";
import SidebarLink from "./SidebarLink"; import SidebarLink from "./SidebarLink";
@@ -14,18 +12,12 @@ type Props = {
}; };
function SidebarAction({ action, ...rest }: Props) { function SidebarAction({ action, ...rest }: Props) {
const stores = useStores(); const context = useActionContext({
const { t } = useTranslation();
const location = useLocation();
const context = {
isContextMenu: false, isContextMenu: false,
isCommandBar: false, isCommandBar: false,
activeCollectionId: undefined, activeCollectionId: undefined,
activeDocumentId: undefined, activeDocumentId: undefined,
location, });
stores,
t,
};
const menuItem = actionToMenuItem(action, context); const menuItem = actionToMenuItem(action, context);
invariant(menuItem.type === "button", "passed action must be a button"); invariant(menuItem.type === "button", "passed action must be a button");

View File

@@ -71,6 +71,7 @@ class SocketProvider extends React.Component<Props> {
documents, documents,
collections, collections,
groups, groups,
pins,
memberships, memberships,
policies, policies,
presence, 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) => { this.socket.on("documents.star", (event: any) => {
documents.starredIds.set(event.documentId, true); 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 { useRegisterActions } from "kbar";
import { flattenDeep } from "lodash"; import { flattenDeep } from "lodash";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { actionToKBar } from "~/actions"; import { actionToKBar } from "~/actions";
import useStores from "~/hooks/useStores";
import { Action } from "~/types"; 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[]) { export default function useCommandBarActions(actions: Action[]) {
const stores = useStores();
const { t } = useTranslation();
const location = useLocation(); const location = useLocation();
const context = { const context = useActionContext({
t,
isCommandBar: true, isCommandBar: true,
isContextMenu: false, });
activeCollectionId: stores.ui.activeCollectionId,
activeDocumentId: stores.ui.activeDocumentId,
location,
stores,
};
const registerable = flattenDeep( const registerable = flattenDeep(
actions.map((action) => actionToKBar(action, context)) actions.map((action) => actionToKBar(action, context))

View File

@@ -1,7 +1,6 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { import {
EditIcon, EditIcon,
PinIcon,
StarredIcon, StarredIcon,
UnstarredIcon, UnstarredIcon,
DuplicateIcon, DuplicateIcon,
@@ -39,6 +38,12 @@ import Template from "~/components/ContextMenu/Template";
import Flex from "~/components/Flex"; import Flex from "~/components/Flex";
import Modal from "~/components/Modal"; import Modal from "~/components/Modal";
import Toggle from "~/components/Toggle"; 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 useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts"; import useToasts from "~/hooks/useToasts";
@@ -72,7 +77,6 @@ function DocumentMenu({
modal = true, modal = true,
showToggleEmbeds, showToggleEmbeds,
showDisplayOptions, showDisplayOptions,
showPin,
label, label,
onOpen, onOpen,
onClose, onClose,
@@ -87,6 +91,11 @@ function DocumentMenu({
unstable_flip: true, unstable_flip: true,
}); });
const history = useHistory(); const history = useHistory();
const context = useActionContext({
isContextMenu: true,
activeDocumentId: document.id,
activeCollectionId: document.collectionId,
});
const { t } = useTranslation(); const { t } = useTranslation();
const [renderModals, setRenderModals] = React.useState(false); const [renderModals, setRenderModals] = React.useState(false);
const [showDeleteModal, setShowDeleteModal] = React.useState(false); const [showDeleteModal, setShowDeleteModal] = React.useState(false);
@@ -305,20 +314,6 @@ function DocumentMenu({
...restoreItems, ...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", type: "button",
title: t("Unstar"), title: t("Unstar"),
@@ -333,6 +328,8 @@ function DocumentMenu({
visible: !document.isStarred && !!can.star, visible: !document.isStarred && !!can.star,
icon: <StarredIcon />, icon: <StarredIcon />,
}, },
actionToMenuItem(pinDocumentToHome, context),
actionToMenuItem(pinDocument, context),
{ {
type: "separator", type: "separator",
}, },

View File

@@ -1,5 +1,4 @@
import { addDays, differenceInDays } from "date-fns"; import { addDays, differenceInDays } from "date-fns";
import invariant from "invariant";
import { floor } from "lodash"; import { floor } from "lodash";
import { action, computed, observable } from "mobx"; import { action, computed, observable } from "mobx";
import parseTitle from "@shared/utils/parseTitle"; import parseTitle from "@shared/utils/parseTitle";
@@ -72,8 +71,6 @@ export default class Document extends BaseModel {
updatedBy: User; updatedBy: User;
pinned: boolean;
publishedAt: string | undefined; publishedAt: string | undefined;
archivedAt: string; archivedAt: string;
@@ -240,31 +237,23 @@ export default class Document extends BaseModel {
}; };
@action @action
pin = async () => { pin = async (collectionId?: string) => {
this.pinned = true; await this.store.rootStore.pins.create({
documentId: this.id,
try { ...(collectionId ? { collectionId } : {}),
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;
}
}; };
@action @action
unpin = async () => { unpin = async (collectionId?: string) => {
this.pinned = false; const pin = this.store.rootStore.pins.orderedData.find(
(pin) =>
pin.documentId === this.id &&
(pin.collectionId === collectionId ||
(!collectionId && !pin.collectionId))
);
try { await pin?.delete();
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;
}
}; };
@action @action
@@ -387,6 +376,21 @@ export default class Document extends BaseModel {
return result; 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 @computed
get isActive(): boolean { get isActive(): boolean {
return !this.isDeleted && !this.isTemplate && !this.isArchived; 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 DocumentNew from "~/scenes/DocumentNew";
import Drafts from "~/scenes/Drafts"; import Drafts from "~/scenes/Drafts";
import Error404 from "~/scenes/Error404"; import Error404 from "~/scenes/Error404";
import Home from "~/scenes/Home";
import Search from "~/scenes/Search"; import Search from "~/scenes/Search";
import Templates from "~/scenes/Templates"; import Templates from "~/scenes/Templates";
import Trash from "~/scenes/Trash"; import Trash from "~/scenes/Trash";
@@ -30,6 +29,13 @@ const Document = React.lazy(
"~/scenes/Document" "~/scenes/Document"
) )
); );
const Home = React.lazy(
() =>
import(
/* webpackChunkName: "home" */
"~/scenes/Home"
)
);
const NotFound = () => <Search notFound />; const NotFound = () => <Search notFound />;

View File

@@ -1,78 +1,50 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { NewDocumentIcon, PlusIcon, PinIcon, MoreIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import Dropzone from "react-dropzone"; import { useTranslation } from "react-i18next";
import { useTranslation, Trans } from "react-i18next";
import { import {
useParams, useParams,
Redirect, Redirect,
Link,
Switch, Switch,
Route, Route,
useHistory, useHistory,
useRouteMatch, useRouteMatch,
} from "react-router-dom"; } from "react-router-dom";
import styled, { css } from "styled-components";
import Collection from "~/models/Collection"; import Collection from "~/models/Collection";
import CollectionPermissions from "~/scenes/CollectionPermissions";
import Search from "~/scenes/Search"; import Search from "~/scenes/Search";
import { Action, Separator } from "~/components/Actions";
import Badge from "~/components/Badge"; import Badge from "~/components/Badge";
import Button from "~/components/Button";
import CenteredContent from "~/components/CenteredContent"; import CenteredContent from "~/components/CenteredContent";
import CollectionDescription from "~/components/CollectionDescription"; import CollectionDescription from "~/components/CollectionDescription";
import CollectionIcon from "~/components/CollectionIcon"; import CollectionIcon from "~/components/CollectionIcon";
import DocumentList from "~/components/DocumentList";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading"; import Heading from "~/components/Heading";
import HelpText from "~/components/HelpText";
import InputSearchPage from "~/components/InputSearchPage";
import PlaceholderList from "~/components/List/Placeholder"; import PlaceholderList from "~/components/List/Placeholder";
import LoadingIndicator from "~/components/LoadingIndicator";
import Modal from "~/components/Modal";
import PaginatedDocumentList from "~/components/PaginatedDocumentList"; import PaginatedDocumentList from "~/components/PaginatedDocumentList";
import PinnedDocuments from "~/components/PinnedDocuments";
import PlaceholderText from "~/components/PlaceholderText"; import PlaceholderText from "~/components/PlaceholderText";
import Scene from "~/components/Scene"; import Scene from "~/components/Scene";
import Subheading from "~/components/Subheading";
import Tab from "~/components/Tab"; import Tab from "~/components/Tab";
import Tabs from "~/components/Tabs"; import Tabs from "~/components/Tabs";
import Tooltip from "~/components/Tooltip"; import Tooltip from "~/components/Tooltip";
import { editCollection } from "~/actions/definitions/collections"; import { editCollection } from "~/actions/definitions/collections";
import useBoolean from "~/hooks/useBoolean";
import useCommandBarActions from "~/hooks/useCommandBarActions"; import useCommandBarActions from "~/hooks/useCommandBarActions";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useImportDocument from "~/hooks/useImportDocument";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts"; import { collectionUrl, updateCollectionUrl } from "~/utils/routeHelpers";
import CollectionMenu from "~/menus/CollectionMenu"; import Actions from "./Collection/Actions";
import { import DropToImport from "./Collection/DropToImport";
newDocumentPath, import Empty from "./Collection/Empty";
collectionUrl,
updateCollectionUrl,
} from "~/utils/routeHelpers";
function CollectionScene() { function CollectionScene() {
const params = useParams<{ id?: string }>(); const params = useParams<{ id?: string }>();
const history = useHistory(); const history = useHistory();
const match = useRouteMatch(); const match = useRouteMatch();
const { t } = useTranslation(); const { t } = useTranslation();
const { documents, policies, collections, ui } = useStores(); const { documents, pins, policies, collections, ui } = useStores();
const { showToast } = useToasts();
const team = useCurrentTeam();
const [isFetching, setFetching] = React.useState(false); const [isFetching, setFetching] = React.useState(false);
const [error, setError] = React.useState<Error | undefined>(); const [error, setError] = React.useState<Error | undefined>();
const [
permissionsModalOpen,
handlePermissionsModalOpen,
handlePermissionsModalClose,
] = useBoolean();
const id = params.id || ""; const id = params.id || "";
const collection: Collection | null | undefined = const collection: Collection | null | undefined =
collections.getByUrl(id) || collections.get(id); collections.getByUrl(id) || collections.get(id);
const can = policies.abilities(collection?.id || ""); const can = policies.abilities(collection?.id || "");
const canUser = policies.abilities(team.id);
const { handleFiles, isImporting } = useImportDocument(collection?.id || "");
React.useEffect(() => { React.useEffect(() => {
if (collection) { if (collection) {
@@ -94,11 +66,11 @@ function CollectionScene() {
setError(undefined); setError(undefined);
if (collection) { if (collection) {
documents.fetchPinned({ pins.fetchPage({
collectionId: collection.id, collectionId: collection.id,
}); });
} }
}, [documents, collection]); }, [pins, collection]);
React.useEffect(() => { React.useEffect(() => {
async function load() { async function load() {
@@ -117,27 +89,13 @@ function CollectionScene() {
load(); load();
}, [collections, isFetching, collection, error, id, can]); }, [collections, isFetching, collection, error, id, can]);
useCommandBarActions([editCollection]);
const handleRejection = React.useCallback(() => { useCommandBarActions([editCollection]);
showToast(
t("Document not supported try Markdown, Plain text, HTML, or Word"),
{
type: "error",
}
);
}, [t, showToast]);
if (!collection && error) { if (!collection && error) {
return <Search notFound />; return <Search notFound />;
} }
const pinnedDocuments = collection
? documents.pinnedInCollection(collection.id)
: [];
const collectionName = collection ? collection.name : "";
const hasPinnedDocuments = !!pinnedDocuments.length;
return collection ? ( return collection ? (
<Scene <Scene
centered={false} centered={false}
@@ -149,246 +107,127 @@ function CollectionScene() {
{collection.name} {collection.name}
</> </>
} }
actions={ actions={<Actions collection={collection} />}
<> >
<Action> <DropToImport
<InputSearchPage accept={documents.importFileTypes.join(", ")}
source="collection" disabled={!can.update}
placeholder={`${t("Search in collection")}`} collectionId={collection.id}
label={`${t("Search in collection")}`} >
collectionId={collection.id} <CenteredContent withStickyHeader>
/> {collection.isEmpty ? (
</Action> <Empty collection={collection} />
{can.update && ( ) : (
<> <>
<Action> <Heading>
<Tooltip <CollectionIcon collection={collection} size={40} expanded />{" "}
tooltip={t("New document")} {collection.name}{" "}
shortcut="n" {!collection.permission && (
delay={500} <Tooltip
placement="bottom" tooltip={t(
> "This collection is only visible to those given access"
<Button )}
as={Link} placement="bottom"
to={collection ? newDocumentPath(collection.id) : ""}
disabled={!collection}
icon={<PlusIcon />}
> >
{t("New doc")} <Badge>{t("Private")}</Badge>
</Button> </Tooltip>
</Tooltip> )}
</Action> </Heading>
<Separator /> <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> </CenteredContent>
<CollectionMenu </DropToImport>
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>
</Scene> </Scene>
) : ( ) : (
<CenteredContent> <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); 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 InputSearchPage from "~/components/InputSearchPage";
import LanguagePrompt from "~/components/LanguagePrompt"; import LanguagePrompt from "~/components/LanguagePrompt";
import PaginatedDocumentList from "~/components/PaginatedDocumentList"; import PaginatedDocumentList from "~/components/PaginatedDocumentList";
import PinnedDocuments from "~/components/PinnedDocuments";
import Scene from "~/components/Scene"; import Scene from "~/components/Scene";
import Tab from "~/components/Tab"; import Tab from "~/components/Tab";
import Tabs from "~/components/Tabs"; import Tabs from "~/components/Tabs";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser"; import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import NewDocumentMenu from "~/menus/NewDocumentMenu"; import NewDocumentMenu from "~/menus/NewDocumentMenu";
function Home() { function Home() {
const { documents, ui } = useStores(); const { documents, pins, policies, ui } = useStores();
const team = useCurrentTeam();
const user = useCurrentUser(); const user = useCurrentUser();
const userId = user?.id; const userId = user?.id;
const { t } = useTranslation(); const { t } = useTranslation();
React.useEffect(() => {
pins.fetchPage();
}, [pins]);
const canManageTeam = policies.abilities(team.id).manage;
return ( return (
<Scene <Scene
icon={<HomeIcon color="currentColor" />} icon={<HomeIcon color="currentColor" />}
@@ -39,6 +48,7 @@ function Home() {
> >
{!ui.languagePromptDismissed && <LanguagePrompt />} {!ui.languagePromptDismissed && <LanguagePrompt />}
<Heading>{t("Home")}</Heading> <Heading>{t("Home")}</Heading>
<PinnedDocuments pins={pins.home} canUpdate={canManageTeam} />
<Tabs> <Tabs>
<Tab to="/home" exact> <Tab to="/home" exact>
{t("Recently viewed")} {t("Recently viewed")}

View File

@@ -18,9 +18,10 @@ import {
} from "~/types"; } from "~/types";
import { client } from "~/utils/ApiClient"; 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 = { export type SearchParams = {
offset?: number; 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[] { publishedInCollection(collectionId: string): Document[] {
return filter( return filter(
this.all, this.all,
@@ -296,7 +290,7 @@ export default class DocumentsStore extends BaseStore<Document> {
fetchNamedPage = async ( fetchNamedPage = async (
request = "list", request = "list",
options: FetchPageParams | undefined options: FetchPageParams | undefined
): Promise<Document[] | undefined> => { ): Promise<Document[]> => {
this.isFetching = true; this.isFetching = true;
try { try {
@@ -377,11 +371,6 @@ export default class DocumentsStore extends BaseStore<Document> {
return this.fetchNamedPage("drafts", options); return this.fetchNamedPage("drafts", options);
}; };
@action
fetchPinned = (options?: FetchParams): Promise<any> => {
return this.fetchNamedPage("pinned", options);
};
@action @action
fetchOwned = (options?: PaginationParams): Promise<any> => { fetchOwned = (options?: PaginationParams): Promise<any> => {
return this.fetchNamedPage("list", options); return this.fetchNamedPage("list", options);
@@ -732,18 +721,6 @@ export default class DocumentsStore extends BaseStore<Document> {
if (collection) collection.refresh(); 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) => { star = async (document: Document) => {
this.starredIds.set(document.id, true); 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 IntegrationsStore from "./IntegrationsStore";
import MembershipsStore from "./MembershipsStore"; import MembershipsStore from "./MembershipsStore";
import NotificationSettingsStore from "./NotificationSettingsStore"; import NotificationSettingsStore from "./NotificationSettingsStore";
import PinsStore from "./PinsStore";
import PoliciesStore from "./PoliciesStore"; import PoliciesStore from "./PoliciesStore";
import RevisionsStore from "./RevisionsStore"; import RevisionsStore from "./RevisionsStore";
import SearchesStore from "./SearchesStore"; import SearchesStore from "./SearchesStore";
@@ -35,6 +36,7 @@ export default class RootStore {
memberships: MembershipsStore; memberships: MembershipsStore;
notificationSettings: NotificationSettingsStore; notificationSettings: NotificationSettingsStore;
presence: DocumentPresenceStore; presence: DocumentPresenceStore;
pins: PinsStore;
policies: PoliciesStore; policies: PoliciesStore;
revisions: RevisionsStore; revisions: RevisionsStore;
searches: SearchesStore; searches: SearchesStore;
@@ -59,6 +61,7 @@ export default class RootStore {
this.groupMemberships = new GroupMembershipsStore(this); this.groupMemberships = new GroupMembershipsStore(this);
this.integrations = new IntegrationsStore(this); this.integrations = new IntegrationsStore(this);
this.memberships = new MembershipsStore(this); this.memberships = new MembershipsStore(this);
this.pins = new PinsStore(this);
this.notificationSettings = new NotificationSettingsStore(this); this.notificationSettings = new NotificationSettingsStore(this);
this.presence = new DocumentPresenceStore(); this.presence = new DocumentPresenceStore();
this.revisions = new RevisionsStore(this); this.revisions = new RevisionsStore(this);
@@ -84,6 +87,7 @@ export default class RootStore {
this.memberships.clear(); this.memberships.clear();
this.notificationSettings.clear(); this.notificationSettings.clear();
this.presence.clear(); this.presence.clear();
this.pins.clear();
this.policies.clear(); this.policies.clear();
this.revisions.clear(); this.revisions.clear();
this.searches.clear(); this.searches.clear();

View File

@@ -68,8 +68,10 @@ export type MenuItem =
export type ActionContext = { export type ActionContext = {
isContextMenu: boolean; isContextMenu: boolean;
isCommandBar: boolean; isCommandBar: boolean;
activeCollectionId: string | null | undefined; activeCollectionId: string | undefined;
activeDocumentId: string | null | undefined; activeDocumentId: string | undefined;
currentUserId: string | undefined;
currentTeamId: string | undefined;
location: Location; location: Location;
stores: RootStore; stores: RootStore;
event?: Event; event?: Event;

View File

@@ -4,6 +4,8 @@ declare module "boundless-arrow-key-navigation";
declare module "string-replace-to-array"; declare module "string-replace-to-array";
declare module "sequelize-encrypted";
declare module "styled-components-breakpoint"; declare module "styled-components-breakpoint";
declare module "formidable/lib/file"; declare module "formidable/lib/file";

View File

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

View File

@@ -47,6 +47,9 @@
"@babel/preset-react": "^7.16.0", "@babel/preset-react": "^7.16.0",
"@bull-board/api": "^3.5.0", "@bull-board/api": "^3.5.0",
"@bull-board/koa": "^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/provider": "^1.0.0-alpha.21",
"@hocuspocus/server": "^1.0.0-alpha.78", "@hocuspocus/server": "^1.0.0-alpha.78",
"@outlinewiki/koa-passport": "^4.1.4", "@outlinewiki/koa-passport": "^4.1.4",

View File

@@ -1,7 +1,8 @@
import { Transaction } from "sequelize"; 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 parseAttachmentIds from "@server/utils/parseAttachmentIds";
import { sequelize } from "../sequelize"; import { sequelize } from "../sequelize";
import pinDestroyer from "./pinDestroyer";
async function copyAttachments( async function copyAttachments(
document: Document, 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 // @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; user: User;
document: Document; document: any;
collectionId: string; collectionId: string;
parentDocumentId?: string | null; parentDocumentId?: string | null;
index?: number; index?: number;
ip: string; ip: string;
}) { }) {
let transaction: Transaction | undefined; 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 collectionChanged = collectionId !== document.collectionId;
const previousCollectionId = document.collectionId;
const result = { const result = {
collections: [], collections: [],
documents: [], documents: [],
collectionChanged, 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 (document.template) {
if (!collectionChanged) { if (!collectionChanged) {
return result; 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; 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; 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; 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; document.updatedBy = user;
// @ts-expect-error ts-migrate(2339) FIXME: Property 'save' does not exist on type 'Document'.
await document.save(); 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 // @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); result.documents.push(document);
@@ -89,7 +84,6 @@ export default async function documentMover({
transaction = await sequelize.transaction(); transaction = await sequelize.transaction();
// remove from original collection // 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, { const collection = await Collection.findByPk(document.collectionId, {
transaction, transaction,
paranoid: false, paranoid: false,
@@ -107,9 +101,7 @@ export default async function documentMover({
// We need to compensate for this when reordering // We need to compensate for this when reordering
const toIndex = const toIndex =
index !== undefined && 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 && 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 && document.collectionId === collectionId &&
fromIndex < index fromIndex < index
? index - 1 ? index - 1
@@ -121,21 +113,17 @@ export default async function documentMover({
await collection.save({ await collection.save({
transaction, transaction,
}); });
// @ts-expect-error ts-migrate(2339) FIXME: Property 'text' does not exist on type 'Document'.
document.text = await copyAttachments(document, { document.text = await copyAttachments(document, {
transaction, transaction,
}); });
} }
// add to new collection (may be the same) // 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; 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; 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; 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; 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 // @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 const newCollection: Collection = collectionChanged
? await Collection.scope({ ? 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); 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({ await document.save({
transaction, 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; 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 // @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); result.documents.push(document);
@@ -208,10 +208,8 @@ export default async function documentMover({
await Event.create({ await Event.create({
name: "documents.move", name: "documents.move",
actorId: user.id, actorId: user.id,
// @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'Document'.
documentId: document.id, documentId: document.id,
collectionId, 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, teamId: document.teamId,
data: { data: {
title: document.title, title: document.title,
@@ -222,6 +220,7 @@ export default async function documentMover({
}, },
ip, ip,
}); });
// we need to send all updated models back to the client // we need to send all updated models back to the client
return result; 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", as: "updatedBy",
foreignKey: "lastModifiedById", foreignKey: "lastModifiedById",
}); });
/** Deprecated use Pins relationship instead */
Document.belongsTo(models.User, { Document.belongsTo(models.User, {
as: "pinnedBy", as: "pinnedBy",
foreignKey: "pinnedById", 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: string, paranoid = true) => {
Document.addScope("withCollection", (userId, paranoid = true) => {
if (userId) { if (userId) {
return { return {
include: [ 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: string) => {
Document.addScope("withViews", (userId) => {
if (!userId) return {}; if (!userId) return {};
return { return {
include: [ 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: string) => ({
Document.addScope("withStarred", (userId) => ({
include: [ include: [
{ {
model: models.Star, 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 (
Document.findByPk = async function (id, options = {}) { id: string,
options: {
userId?: string;
paranoid?: boolean;
} = {}
) {
// allow default preloading of collection membership if `userId` is passed in find options // allow default preloading of collection membership if `userId` is passed in find options
// almost every endpoint needs the collection membership to determine policy permissions. // almost every endpoint needs the collection membership to determine policy permissions.
const scope = this.scope( const scope = this.scope(
"withUnpublished", "withUnpublished",
{ {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'userId' does not exist on type '{}'.
method: ["withCollection", options.userId, options.paranoid], method: ["withCollection", options.userId, options.paranoid],
}, },
{ {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'userId' does not exist on type '{}'.
method: ["withViews", options.userId], method: ["withViews", options.userId],
} }
); );
@@ -275,10 +293,13 @@ Document.findByPk = async function (id, options = {}) {
}, },
...options, ...options,
}); });
} else if (id.match(SLUG_URL_REGEX)) { }
const match = id.match(SLUG_URL_REGEX);
if (match) {
return scope.findOne({ return scope.findOne({
where: { where: {
urlId: id.match(SLUG_URL_REGEX)[1], urlId: match[1],
}, },
...options, ...options,
}); });

View File

@@ -71,8 +71,6 @@ Event.ACTIVITY_EVENTS = [
"documents.publish", "documents.publish",
"documents.archive", "documents.archive",
"documents.unarchive", "documents.unarchive",
"documents.pin",
"documents.unpin",
"documents.move", "documents.move",
"documents.delete", "documents.delete",
"documents.permanent_delete", "documents.permanent_delete",
@@ -99,8 +97,6 @@ Event.AUDIT_EVENTS = [
"documents.update", "documents.update",
"documents.archive", "documents.archive",
"documents.unarchive", "documents.unarchive",
"documents.pin",
"documents.unpin",
"documents.move", "documents.move",
"documents.delete", "documents.delete",
"documents.permanent_delete", "documents.permanent_delete",
@@ -108,6 +104,9 @@ Event.AUDIT_EVENTS = [
"groups.create", "groups.create",
"groups.update", "groups.update",
"groups.delete", "groups.delete",
"pins.create",
"pins.update",
"pins.delete",
"revisions.create", "revisions.create",
"shares.create", "shares.create",
"shares.update", "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 IntegrationAuthentication from "./IntegrationAuthentication";
import Notification from "./Notification"; import Notification from "./Notification";
import NotificationSetting from "./NotificationSetting"; import NotificationSetting from "./NotificationSetting";
import Pin from "./Pin";
import Revision from "./Revision"; import Revision from "./Revision";
import SearchQuery from "./SearchQuery"; import SearchQuery from "./SearchQuery";
import Share from "./Share"; import Share from "./Share";
@@ -39,6 +40,7 @@ const models = {
IntegrationAuthentication, IntegrationAuthentication,
Notification, Notification,
NotificationSetting, NotificationSetting,
Pin,
Revision, Revision,
SearchQuery, SearchQuery,
Share, Share,
@@ -73,6 +75,7 @@ export {
IntegrationAuthentication, IntegrationAuthentication,
Notification, Notification,
NotificationSetting, NotificationSetting,
Pin,
Revision, Revision,
SearchQuery, SearchQuery,
Share, Share,

View File

@@ -90,6 +90,15 @@ allow(User, ["pin", "unpin"], Document, (user, document) => {
return user.teamId === document.teamId; 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) => { allow(User, "delete", Document, (user, document) => {
if (user.isViewer) return false; if (user.isViewer) return false;
if (document.deletedAt) return false; if (document.deletedAt) return false;

View File

@@ -14,6 +14,7 @@ import "./collection";
import "./document"; import "./document";
import "./integration"; import "./integration";
import "./notificationSetting"; import "./notificationSetting";
import "./pins";
import "./searchQuery"; import "./searchQuery";
import "./share"; import "./share";
import "./user"; 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( export default async function present(
document: any, document: any,
options: Options | null | undefined options: Options | null | undefined = {}
) { ) {
options = { options = {
isPublic: false, isPublic: false,
@@ -58,7 +58,6 @@ export default async function present(
starred: document.starred ? !!document.starred.length : undefined, starred: document.starred ? !!document.starred.length : undefined,
revision: document.revisionCount, revision: document.revisionCount,
fullWidth: document.fullWidth, fullWidth: document.fullWidth,
pinned: undefined,
collectionId: undefined, collectionId: undefined,
parentDocumentId: undefined, parentDocumentId: undefined,
lastViewedAt: undefined, lastViewedAt: undefined,
@@ -69,8 +68,6 @@ export default async function present(
} }
if (!options.isPublic) { 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.collectionId = document.collectionId;
data.parentDocumentId = document.parentDocumentId; 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 // @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 presentIntegration from "./integration";
import presentMembership from "./membership"; import presentMembership from "./membership";
import presentNotificationSetting from "./notificationSetting"; import presentNotificationSetting from "./notificationSetting";
import presentPin from "./pin";
import presentPolicies from "./policy"; import presentPolicies from "./policy";
import presentRevision from "./revision"; import presentRevision from "./revision";
import presentSearchQuery from "./searchQuery"; import presentSearchQuery from "./searchQuery";
@@ -37,6 +38,7 @@ export {
presentMembership, presentMembership,
presentNotificationSetting, presentNotificationSetting,
presentSlackAttachment, presentSlackAttachment,
presentPin,
presentPolicies, presentPolicies,
presentGroupMembership, presentGroupMembership,
presentCollectionGroupMembership, 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, Group,
CollectionGroup, CollectionGroup,
GroupUser, GroupUser,
Pin,
} from "@server/models"; } from "@server/models";
import { presentPin } from "@server/presenters";
import { Op } from "@server/sequelize"; import { Op } from "@server/sequelize";
import { Event } from "../../types"; import { Event } from "../../types";
@@ -81,8 +83,6 @@ export default class WebsocketsProcessor {
}); });
} }
case "documents.pin":
case "documents.unpin":
case "documents.update": { case "documents.update": {
const document = await Document.findByPk(event.documentId, { const document = await Document.findByPk(event.documentId, {
paranoid: false, paranoid: false,
@@ -334,6 +334,30 @@ export default class WebsocketsProcessor {
.emit("fileOperations.update", event.data); .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.create":
case "groups.update": { case "groups.update": {
const group = await Group.findByPk(event.modelId, { 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`] = ` exports[`#documents.restore should require authentication 1`] = `
Object { Object {
"error": "authentication_required", "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`] = ` exports[`#documents.unstar should require authentication 1`] = `
Object { Object {
"error": "authentication_required", "error": "authentication_required",

View File

@@ -57,26 +57,24 @@ router.post("collections.create", auth(), async (ctx) => {
const user = ctx.state.user; const user = ctx.state.user;
authorize(user, "createCollection", user.team); 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) { if (index) {
assertIndexCharacters( assertIndexCharacters(index);
index,
"Index characters must be between x20 to x7E ASCII"
);
} else { } 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( index = fractionalIndex(
null, null,
collections.length ? collections[0].index : null collections.length ? collections[0].index : null
@@ -648,10 +646,7 @@ router.post("collections.move", auth(), async (ctx) => {
const id = ctx.body.id; const id = ctx.body.id;
let index = ctx.body.index; let index = ctx.body.index;
assertPresent(index, "index is required"); assertPresent(index, "index is required");
assertIndexCharacters( assertIndexCharacters(index);
index,
"Index characters must be between x20 to x7E ASCII"
);
assertUuid(id, "id must be a uuid"); assertUuid(id, "id must be a uuid");
const user = ctx.state.user; const user = ctx.state.user;
const collection = await Collection.findByPk(id); const collection = await Collection.findByPk(id);

View File

@@ -843,68 +843,7 @@ describe("#documents.list", () => {
expect(body).toMatchSnapshot(); 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", () => { describe("#documents.drafts", () => {
it("should return unpublished documents", async () => { it("should return unpublished documents", async () => {
const { user, document } = await seed(); const { user, document } = await seed();
@@ -1534,39 +1473,7 @@ describe("#documents.starred", () => {
expect(body).toMatchSnapshot(); 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", () => { describe("#documents.move", () => {
it("should move the document", async () => { it("should move the document", async () => {
const { user, document } = await seed(); const { user, document } = await seed();
@@ -1807,41 +1714,7 @@ describe("#documents.restore", () => {
expect(res.status).toEqual(403); 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", () => { describe("#documents.star", () => {
it("should star the document", async () => { it("should star the document", async () => {
const { user, document } = await seed(); const { user, document } = await seed();

View File

@@ -142,22 +142,8 @@ router.post("documents.list", auth(), pagination(), async (ctx) => {
} }
assertSort(sort, Document); assertSort(sort, Document);
// add the users starred state to the response by default
const starredScope = { const documents = await Document.defaultScopeWithUser(user.id).findAll({
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, where,
order: [[sort, direction]], order: [[sort, direction]],
offset: ctx.state.pagination.offset, 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) => { router.post("documents.archived", auth(), pagination(), async (ctx) => {
const { sort = "updatedAt" } = ctx.body; const { sort = "updatedAt" } = ctx.body;
@@ -807,7 +742,6 @@ router.post("documents.restore", auth(), async (ctx) => {
} }
ctx.body = { ctx.body = {
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
data: await presentDocument(document), data: await presentDocument(document),
policies: presentPolicies(user, [document]), policies: presentPolicies(user, [document]),
}; };
@@ -916,7 +850,6 @@ router.post("documents.search", auth(), pagination(), async (ctx) => {
const data = await Promise.all( const data = await Promise.all(
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'result' implicitly has an 'any' type. // @ts-expect-error ts-migrate(7006) FIXME: Parameter 'result' implicitly has an 'any' type.
results.map(async (result) => { results.map(async (result) => {
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
const document = await presentDocument(result.document); const document = await presentDocument(result.document);
return { ...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) => { router.post("documents.star", auth(), async (ctx) => {
const { id } = ctx.body; const { id } = ctx.body;
assertPresent(id, "id is required"); assertPresent(id, "id is required");
@@ -1095,7 +972,6 @@ router.post("documents.templatize", auth(), async (ctx) => {
userId: user.id, userId: user.id,
}); });
ctx.body = { ctx.body = {
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
data: await presentDocument(document), data: await presentDocument(document),
policies: presentPolicies(user, [document]), policies: presentPolicies(user, [document]),
}; };
@@ -1218,7 +1094,6 @@ router.post("documents.update", auth(), async (ctx) => {
document.updatedBy = user; document.updatedBy = user;
document.collection = collection; document.collection = collection;
ctx.body = { ctx.body = {
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
data: await presentDocument(document), data: await presentDocument(document),
policies: presentPolicies(user, [document]), policies: presentPolicies(user, [document]),
}; };
@@ -1271,7 +1146,6 @@ router.post("documents.move", auth(), async (ctx) => {
ctx.body = { ctx.body = {
data: { data: {
documents: await Promise.all( documents: await Promise.all(
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
documents.map((document) => presentDocument(document)) documents.map((document) => presentDocument(document))
), ),
collections: await Promise.all( collections: await Promise.all(
@@ -1303,7 +1177,6 @@ router.post("documents.archive", auth(), async (ctx) => {
ip: ctx.request.ip, ip: ctx.request.ip,
}); });
ctx.body = { ctx.body = {
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
data: await presentDocument(document), data: await presentDocument(document),
policies: presentPolicies(user, [document]), policies: presentPolicies(user, [document]),
}; };
@@ -1388,7 +1261,6 @@ router.post("documents.unpublish", auth(), async (ctx) => {
ip: ctx.request.ip, ip: ctx.request.ip,
}); });
ctx.body = { ctx.body = {
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
data: await presentDocument(document), data: await presentDocument(document),
policies: presentPolicies(user, [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 // @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; document.collection = collection;
return (ctx.body = { return (ctx.body = {
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
data: await presentDocument(document), data: await presentDocument(document),
policies: presentPolicies(user, [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 // @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; document.collection = collection;
return (ctx.body = { return (ctx.body = {
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
data: await presentDocument(document), data: await presentDocument(document),
policies: presentPolicies(user, [document]), policies: presentPolicies(user, [document]),
}); });

View File

@@ -18,6 +18,7 @@ import integrations from "./integrations";
import apiWrapper from "./middlewares/apiWrapper"; import apiWrapper from "./middlewares/apiWrapper";
import editor from "./middlewares/editor"; import editor from "./middlewares/editor";
import notificationSettings from "./notificationSettings"; import notificationSettings from "./notificationSettings";
import pins from "./pins";
import revisions from "./revisions"; import revisions from "./revisions";
import searches from "./searches"; import searches from "./searches";
import shares from "./shares"; import shares from "./shares";
@@ -50,6 +51,7 @@ router.use("/", events.routes());
router.use("/", users.routes()); router.use("/", users.routes());
router.use("/", collections.routes()); router.use("/", collections.routes());
router.use("/", documents.routes()); router.use("/", documents.routes());
router.use("/", pins.routes());
router.use("/", revisions.routes()); router.use("/", revisions.routes());
router.use("/", views.routes()); router.use("/", views.routes());
router.use("/", hooks.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"; 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 EncryptedField from "sequelize-encrypted";
import Logger from "./logging/logger"; import Logger from "./logging/logger";

View File

@@ -40,8 +40,6 @@ export type DocumentEvent =
| "documents.publish" | "documents.publish"
| "documents.delete" | "documents.delete"
| "documents.permanent_delete" | "documents.permanent_delete"
| "documents.pin"
| "documents.unpin"
| "documents.archive" | "documents.archive"
| "documents.unarchive" | "documents.unarchive"
| "documents.restore" | "documents.restore"
@@ -240,9 +238,19 @@ export type TeamEvent = {
ip: string; 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 = export type Event =
| UserEvent | UserEvent
| DocumentEvent | DocumentEvent
| PinEvent
| CollectionEvent | CollectionEvent
| CollectionImportEvent | CollectionImportEvent
| CollectionExportAllEvent | 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)) { if (!validateIndexCharacters(value)) {
throw ValidationError(message); throw ValidationError(message);
} }

View File

@@ -15,6 +15,10 @@
"Duplicate": "Duplicate", "Duplicate": "Duplicate",
"Duplicate document": "Duplicate document", "Duplicate document": "Duplicate document",
"Document duplicated": "Document duplicated", "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": "Print",
"Print document": "Print document", "Print document": "Print document",
"Import document": "Import 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", "Edits you make will sync once youre online": "Edits you make will sync once youre online",
"Submenu": "Submenu", "Submenu": "Submenu",
"Deleted Collection": "Deleted Collection", "Deleted Collection": "Deleted Collection",
"Unpin": "Unpin",
"History": "History", "History": "History",
"Oh weird, there's nothing here": "Oh weird, there's nothing here", "Oh weird, there's nothing here": "Oh weird, there's nothing here",
"New": "New", "New": "New",
@@ -234,8 +239,6 @@
"Document options": "Document options", "Document options": "Document options",
"Restore": "Restore", "Restore": "Restore",
"Choose a collection": "Choose a collection", "Choose a collection": "Choose a collection",
"Unpin": "Unpin",
"Pin to collection": "Pin to collection",
"Unpublish": "Unpublish", "Unpublish": "Unpublish",
"Permanently delete": "Permanently delete", "Permanently delete": "Permanently delete",
"Move": "Move", "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\".", "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", "Documents": "Documents",
"The document archive is empty at the moment.": "The document archive is empty at the moment.", "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", "This collection is only visible to those given access": "This collection is only visible to those given access",
"Private": "Private", "Private": "Private",
"Pinned": "Pinned",
"Recently updated": "Recently updated", "Recently updated": "Recently updated",
"Recently published": "Recently published", "Recently published": "Recently published",
"Least recently updated": "Least recently updated", "Least recently updated": "Least recently updated",
"AZ": "AZ", "AZ": "AZ",
"Search in collection": "Search in collection",
"Drop documents to import": "Drop documents to import", "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.", "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", "Deleting": "Deleting",
"Im sure Delete": "Im sure Delete", "Im sure Delete": "Im sure Delete",

View File

@@ -16,6 +16,7 @@ const colors = {
white: "#FFF", white: "#FFF",
white10: "rgba(255, 255, 255, 0.1)", white10: "rgba(255, 255, 255, 0.1)",
white50: "rgba(255, 255, 255, 0.5)", white50: "rgba(255, 255, 255, 0.5)",
white75: "rgba(255, 255, 255, 0.75)",
black: "#000", black: "#000",
black05: "rgba(0, 0, 0, 0.05)", black05: "rgba(0, 0, 0, 0.05)",
black10: "rgba(0, 0, 0, 0.1)", black10: "rgba(0, 0, 0, 0.1)",

View File

@@ -1133,6 +1133,45 @@
enabled "2.0.x" enabled "2.0.x"
kuler "^2.0.0" 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": "@emotion/is-prop-valid@^0.8.2", "@emotion/is-prop-valid@^0.8.8":
version "0.8.8" version "0.8.8"
resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a" 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" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== 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" version "2.3.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01"
integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==