feat: Pin to home (#2880)
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
NewDocumentIcon,
|
||||
ShapesIcon,
|
||||
ImportIcon,
|
||||
PinIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import DocumentTemplatize from "~/scenes/DocumentTemplatize";
|
||||
@@ -16,7 +17,7 @@ import { createAction } from "~/actions";
|
||||
import { DocumentSection } from "~/actions/sections";
|
||||
import getDataTransferFiles from "~/utils/getDataTransferFiles";
|
||||
import history from "~/utils/history";
|
||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
import { homePath, newDocumentPath } from "~/utils/routeHelpers";
|
||||
|
||||
export const openDocument = createAction({
|
||||
name: ({ t }) => t("Open document"),
|
||||
@@ -133,6 +134,72 @@ export const duplicateDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Pin a document to a collection. Pinned documents will be displayed at the top
|
||||
* of the collection for all collection members to see.
|
||||
*/
|
||||
export const pinDocument = createAction({
|
||||
name: ({ t }) => t("Pin to collection"),
|
||||
section: DocumentSection,
|
||||
icon: <PinIcon />,
|
||||
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId || !activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return (
|
||||
!!stores.policies.abilities(activeDocumentId).pin && !document?.pinned
|
||||
);
|
||||
},
|
||||
perform: async ({ activeDocumentId, activeCollectionId, t, stores }) => {
|
||||
if (!activeDocumentId || !activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
await document?.pin(document.collectionId);
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
if (!collection || !location.pathname.startsWith(collection?.url)) {
|
||||
stores.toasts.showToast(t("Pinned to collection"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Pin a document to team home. Pinned documents will be displayed at the top
|
||||
* of the home screen for all team members to see.
|
||||
*/
|
||||
export const pinDocumentToHome = createAction({
|
||||
name: ({ t }) => t("Pin to home"),
|
||||
section: DocumentSection,
|
||||
icon: <PinIcon />,
|
||||
visible: ({ activeDocumentId, currentTeamId, stores }) => {
|
||||
if (!currentTeamId || !activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
return (
|
||||
!!stores.policies.abilities(activeDocumentId).pinToHome &&
|
||||
!document?.pinnedToHome
|
||||
);
|
||||
},
|
||||
perform: async ({ activeDocumentId, location, t, stores }) => {
|
||||
if (!activeDocumentId) return;
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
await document?.pin();
|
||||
|
||||
if (location.pathname !== homePath()) {
|
||||
stores.toasts.showToast(t("Pinned to team home"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const printDocument = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Print") : t("Print document"),
|
||||
@@ -234,4 +301,6 @@ export const rootDocumentActions = [
|
||||
unstarDocument,
|
||||
duplicateDocument,
|
||||
printDocument,
|
||||
pinDocument,
|
||||
pinDocumentToHome,
|
||||
];
|
||||
|
||||
@@ -10,19 +10,26 @@ type Props = {
|
||||
collection: Collection;
|
||||
expanded?: boolean;
|
||||
size?: number;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
function ResolvedCollectionIcon({ collection, expanded, size }: Props) {
|
||||
function ResolvedCollectionIcon({
|
||||
collection,
|
||||
color: inputColor,
|
||||
expanded,
|
||||
size,
|
||||
}: Props) {
|
||||
const { ui } = useStores();
|
||||
|
||||
// If the chosen icon color is very dark then we invert it in dark mode
|
||||
// otherwise it will be impossible to see against the dark background.
|
||||
const color =
|
||||
ui.resolvedTheme === "dark" && collection.color !== "currentColor"
|
||||
inputColor ||
|
||||
(ui.resolvedTheme === "dark" && collection.color !== "currentColor"
|
||||
? getLuminance(collection.color) > 0.09
|
||||
? collection.color
|
||||
: "currentColor"
|
||||
: collection.color;
|
||||
: collection.color);
|
||||
|
||||
if (collection.icon && collection.icon !== "collection") {
|
||||
try {
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
useMenuState,
|
||||
MenuButton,
|
||||
MenuItem as BaseMenuItem,
|
||||
} from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { $Shape } from "utility-types";
|
||||
import Flex from "~/components/Flex";
|
||||
import MenuIconWrapper from "~/components/MenuIconWrapper";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import {
|
||||
Action,
|
||||
ActionContext,
|
||||
@@ -27,7 +26,7 @@ import ContextMenu from ".";
|
||||
|
||||
type Props = {
|
||||
actions?: (Action | MenuSeparator | MenuHeading)[];
|
||||
context?: $Shape<ActionContext>;
|
||||
context?: Partial<ActionContext>;
|
||||
items?: TMenuItem[];
|
||||
};
|
||||
|
||||
@@ -90,20 +89,9 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
|
||||
}
|
||||
|
||||
function Template({ items, actions, context, ...menu }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const stores = useStores();
|
||||
const { ui } = stores;
|
||||
const ctx = {
|
||||
t,
|
||||
isCommandBar: false,
|
||||
const ctx = useActionContext({
|
||||
isContextMenu: true,
|
||||
activeCollectionId: ui.activeCollectionId,
|
||||
activeDocumentId: ui.activeDocumentId,
|
||||
location,
|
||||
stores,
|
||||
...context,
|
||||
};
|
||||
});
|
||||
|
||||
const templateItems = actions
|
||||
? actions.map((item) =>
|
||||
|
||||
253
app/components/DocumentCard.tsx
Normal file
253
app/components/DocumentCard.tsx
Normal 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);
|
||||
@@ -26,7 +26,6 @@ const Viewed = styled.span`
|
||||
`;
|
||||
|
||||
const Modified = styled.span<{ highlight?: boolean }>`
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
font-weight: ${(props) => (props.highlight ? "600" : "400")};
|
||||
`;
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ type Props = {
|
||||
showParentDocuments?: boolean;
|
||||
showCollection?: boolean;
|
||||
showPublished?: boolean;
|
||||
showPin?: boolean;
|
||||
showDraft?: boolean;
|
||||
showTemplate?: boolean;
|
||||
};
|
||||
@@ -33,7 +32,12 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={(item) => (
|
||||
<DocumentListItem key={item.id} document={item} {...rest} />
|
||||
<DocumentListItem
|
||||
key={item.id}
|
||||
document={item}
|
||||
showPin={!!options?.collectionId}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
137
app/components/PinnedDocuments.tsx
Normal file
137
app/components/PinnedDocuments.tsx
Normal 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);
|
||||
@@ -1,10 +1,8 @@
|
||||
import invariant from "invariant";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { Action } from "~/types";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
@@ -14,18 +12,12 @@ type Props = {
|
||||
};
|
||||
|
||||
function SidebarAction({ action, ...rest }: Props) {
|
||||
const stores = useStores();
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const context = {
|
||||
const context = useActionContext({
|
||||
isContextMenu: false,
|
||||
isCommandBar: false,
|
||||
activeCollectionId: undefined,
|
||||
activeDocumentId: undefined,
|
||||
location,
|
||||
stores,
|
||||
t,
|
||||
};
|
||||
});
|
||||
const menuItem = actionToMenuItem(action, context);
|
||||
invariant(menuItem.type === "button", "passed action must be a button");
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ class SocketProvider extends React.Component<Props> {
|
||||
documents,
|
||||
collections,
|
||||
groups,
|
||||
pins,
|
||||
memberships,
|
||||
policies,
|
||||
presence,
|
||||
@@ -260,6 +261,18 @@ class SocketProvider extends React.Component<Props> {
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("pins.create", (event: any) => {
|
||||
pins.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("pins.update", (event: any) => {
|
||||
pins.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("pins.delete", (event: any) => {
|
||||
pins.remove(event.modelId);
|
||||
});
|
||||
|
||||
this.socket.on("documents.star", (event: any) => {
|
||||
documents.starredIds.set(event.documentId, true);
|
||||
});
|
||||
|
||||
32
app/hooks/useActionContext.ts
Normal file
32
app/hooks/useActionContext.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -1,24 +1,21 @@
|
||||
import { useRegisterActions } from "kbar";
|
||||
import { flattenDeep } from "lodash";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { actionToKBar } from "~/actions";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { Action } from "~/types";
|
||||
import useActionContext from "./useActionContext";
|
||||
|
||||
/**
|
||||
* Hook to add actions to the command bar while the hook is inside a mounted
|
||||
* component.
|
||||
*
|
||||
* @param actions actions to make available
|
||||
*/
|
||||
export default function useCommandBarActions(actions: Action[]) {
|
||||
const stores = useStores();
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const context = {
|
||||
t,
|
||||
const context = useActionContext({
|
||||
isCommandBar: true,
|
||||
isContextMenu: false,
|
||||
activeCollectionId: stores.ui.activeCollectionId,
|
||||
activeDocumentId: stores.ui.activeDocumentId,
|
||||
location,
|
||||
stores,
|
||||
};
|
||||
});
|
||||
|
||||
const registerable = flattenDeep(
|
||||
actions.map((action) => actionToKBar(action, context))
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
EditIcon,
|
||||
PinIcon,
|
||||
StarredIcon,
|
||||
UnstarredIcon,
|
||||
DuplicateIcon,
|
||||
@@ -39,6 +38,12 @@ import Template from "~/components/ContextMenu/Template";
|
||||
import Flex from "~/components/Flex";
|
||||
import Modal from "~/components/Modal";
|
||||
import Toggle from "~/components/Toggle";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import {
|
||||
pinDocument,
|
||||
pinDocumentToHome,
|
||||
} from "~/actions/definitions/documents";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
@@ -72,7 +77,6 @@ function DocumentMenu({
|
||||
modal = true,
|
||||
showToggleEmbeds,
|
||||
showDisplayOptions,
|
||||
showPin,
|
||||
label,
|
||||
onOpen,
|
||||
onClose,
|
||||
@@ -87,6 +91,11 @@ function DocumentMenu({
|
||||
unstable_flip: true,
|
||||
});
|
||||
const history = useHistory();
|
||||
const context = useActionContext({
|
||||
isContextMenu: true,
|
||||
activeDocumentId: document.id,
|
||||
activeCollectionId: document.collectionId,
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
const [renderModals, setRenderModals] = React.useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = React.useState(false);
|
||||
@@ -305,20 +314,6 @@ function DocumentMenu({
|
||||
...restoreItems,
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: t("Unpin"),
|
||||
onClick: document.unpin,
|
||||
visible: !!(showPin && document.pinned && can.unpin),
|
||||
icon: <PinIcon />,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: t("Pin to collection"),
|
||||
onClick: document.pin,
|
||||
visible: !!(showPin && !document.pinned && can.pin),
|
||||
icon: <PinIcon />,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: t("Unstar"),
|
||||
@@ -333,6 +328,8 @@ function DocumentMenu({
|
||||
visible: !document.isStarred && !!can.star,
|
||||
icon: <StarredIcon />,
|
||||
},
|
||||
actionToMenuItem(pinDocumentToHome, context),
|
||||
actionToMenuItem(pinDocument, context),
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { addDays, differenceInDays } from "date-fns";
|
||||
import invariant from "invariant";
|
||||
import { floor } from "lodash";
|
||||
import { action, computed, observable } from "mobx";
|
||||
import parseTitle from "@shared/utils/parseTitle";
|
||||
@@ -72,8 +71,6 @@ export default class Document extends BaseModel {
|
||||
|
||||
updatedBy: User;
|
||||
|
||||
pinned: boolean;
|
||||
|
||||
publishedAt: string | undefined;
|
||||
|
||||
archivedAt: string;
|
||||
@@ -240,31 +237,23 @@ export default class Document extends BaseModel {
|
||||
};
|
||||
|
||||
@action
|
||||
pin = async () => {
|
||||
this.pinned = true;
|
||||
|
||||
try {
|
||||
const res = await this.store.pin(this);
|
||||
invariant(res && res.data, "Data should be available");
|
||||
this.updateFromJson(res.data);
|
||||
} catch (err) {
|
||||
this.pinned = false;
|
||||
throw err;
|
||||
}
|
||||
pin = async (collectionId?: string) => {
|
||||
await this.store.rootStore.pins.create({
|
||||
documentId: this.id,
|
||||
...(collectionId ? { collectionId } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
@action
|
||||
unpin = async () => {
|
||||
this.pinned = false;
|
||||
unpin = async (collectionId?: string) => {
|
||||
const pin = this.store.rootStore.pins.orderedData.find(
|
||||
(pin) =>
|
||||
pin.documentId === this.id &&
|
||||
(pin.collectionId === collectionId ||
|
||||
(!collectionId && !pin.collectionId))
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await this.store.unpin(this);
|
||||
invariant(res && res.data, "Data should be available");
|
||||
this.updateFromJson(res.data);
|
||||
} catch (err) {
|
||||
this.pinned = true;
|
||||
throw err;
|
||||
}
|
||||
await pin?.delete();
|
||||
};
|
||||
|
||||
@action
|
||||
@@ -387,6 +376,21 @@ export default class Document extends BaseModel {
|
||||
return result;
|
||||
};
|
||||
|
||||
@computed
|
||||
get pinned(): boolean {
|
||||
return !!this.store.rootStore.pins.orderedData.find(
|
||||
(pin) =>
|
||||
pin.documentId === this.id && pin.collectionId === this.collectionId
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
get pinnedToHome(): boolean {
|
||||
return !!this.store.rootStore.pins.orderedData.find(
|
||||
(pin) => pin.documentId === this.id && !pin.collectionId
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
get isActive(): boolean {
|
||||
return !this.isDeleted && !this.isTemplate && !this.isArchived;
|
||||
|
||||
18
app/models/Pin.ts
Normal file
18
app/models/Pin.ts
Normal 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;
|
||||
@@ -5,7 +5,6 @@ import Collection from "~/scenes/Collection";
|
||||
import DocumentNew from "~/scenes/DocumentNew";
|
||||
import Drafts from "~/scenes/Drafts";
|
||||
import Error404 from "~/scenes/Error404";
|
||||
import Home from "~/scenes/Home";
|
||||
import Search from "~/scenes/Search";
|
||||
import Templates from "~/scenes/Templates";
|
||||
import Trash from "~/scenes/Trash";
|
||||
@@ -30,6 +29,13 @@ const Document = React.lazy(
|
||||
"~/scenes/Document"
|
||||
)
|
||||
);
|
||||
const Home = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "home" */
|
||||
"~/scenes/Home"
|
||||
)
|
||||
);
|
||||
|
||||
const NotFound = () => <Search notFound />;
|
||||
|
||||
|
||||
@@ -1,78 +1,50 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { NewDocumentIcon, PlusIcon, PinIcon, MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Dropzone from "react-dropzone";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useParams,
|
||||
Redirect,
|
||||
Link,
|
||||
Switch,
|
||||
Route,
|
||||
useHistory,
|
||||
useRouteMatch,
|
||||
} from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import Collection from "~/models/Collection";
|
||||
import CollectionPermissions from "~/scenes/CollectionPermissions";
|
||||
import Search from "~/scenes/Search";
|
||||
import { Action, Separator } from "~/components/Actions";
|
||||
import Badge from "~/components/Badge";
|
||||
import Button from "~/components/Button";
|
||||
import CenteredContent from "~/components/CenteredContent";
|
||||
import CollectionDescription from "~/components/CollectionDescription";
|
||||
import CollectionIcon from "~/components/CollectionIcon";
|
||||
import DocumentList from "~/components/DocumentList";
|
||||
import Flex from "~/components/Flex";
|
||||
import Heading from "~/components/Heading";
|
||||
import HelpText from "~/components/HelpText";
|
||||
import InputSearchPage from "~/components/InputSearchPage";
|
||||
import PlaceholderList from "~/components/List/Placeholder";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import Modal from "~/components/Modal";
|
||||
import PaginatedDocumentList from "~/components/PaginatedDocumentList";
|
||||
import PinnedDocuments from "~/components/PinnedDocuments";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import Scene from "~/components/Scene";
|
||||
import Subheading from "~/components/Subheading";
|
||||
import Tab from "~/components/Tab";
|
||||
import Tabs from "~/components/Tabs";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { editCollection } from "~/actions/definitions/collections";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCommandBarActions from "~/hooks/useCommandBarActions";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useImportDocument from "~/hooks/useImportDocument";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import CollectionMenu from "~/menus/CollectionMenu";
|
||||
import {
|
||||
newDocumentPath,
|
||||
collectionUrl,
|
||||
updateCollectionUrl,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { collectionUrl, updateCollectionUrl } from "~/utils/routeHelpers";
|
||||
import Actions from "./Collection/Actions";
|
||||
import DropToImport from "./Collection/DropToImport";
|
||||
import Empty from "./Collection/Empty";
|
||||
|
||||
function CollectionScene() {
|
||||
const params = useParams<{ id?: string }>();
|
||||
const history = useHistory();
|
||||
const match = useRouteMatch();
|
||||
const { t } = useTranslation();
|
||||
const { documents, policies, collections, ui } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const team = useCurrentTeam();
|
||||
const { documents, pins, policies, collections, ui } = useStores();
|
||||
const [isFetching, setFetching] = React.useState(false);
|
||||
const [error, setError] = React.useState<Error | undefined>();
|
||||
const [
|
||||
permissionsModalOpen,
|
||||
handlePermissionsModalOpen,
|
||||
handlePermissionsModalClose,
|
||||
] = useBoolean();
|
||||
|
||||
const id = params.id || "";
|
||||
const collection: Collection | null | undefined =
|
||||
collections.getByUrl(id) || collections.get(id);
|
||||
const can = policies.abilities(collection?.id || "");
|
||||
const canUser = policies.abilities(team.id);
|
||||
const { handleFiles, isImporting } = useImportDocument(collection?.id || "");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (collection) {
|
||||
@@ -94,11 +66,11 @@ function CollectionScene() {
|
||||
setError(undefined);
|
||||
|
||||
if (collection) {
|
||||
documents.fetchPinned({
|
||||
pins.fetchPage({
|
||||
collectionId: collection.id,
|
||||
});
|
||||
}
|
||||
}, [documents, collection]);
|
||||
}, [pins, collection]);
|
||||
|
||||
React.useEffect(() => {
|
||||
async function load() {
|
||||
@@ -117,27 +89,13 @@ function CollectionScene() {
|
||||
|
||||
load();
|
||||
}, [collections, isFetching, collection, error, id, can]);
|
||||
useCommandBarActions([editCollection]);
|
||||
|
||||
const handleRejection = React.useCallback(() => {
|
||||
showToast(
|
||||
t("Document not supported – try Markdown, Plain text, HTML, or Word"),
|
||||
{
|
||||
type: "error",
|
||||
}
|
||||
);
|
||||
}, [t, showToast]);
|
||||
useCommandBarActions([editCollection]);
|
||||
|
||||
if (!collection && error) {
|
||||
return <Search notFound />;
|
||||
}
|
||||
|
||||
const pinnedDocuments = collection
|
||||
? documents.pinnedInCollection(collection.id)
|
||||
: [];
|
||||
const collectionName = collection ? collection.name : "";
|
||||
const hasPinnedDocuments = !!pinnedDocuments.length;
|
||||
|
||||
return collection ? (
|
||||
<Scene
|
||||
centered={false}
|
||||
@@ -149,246 +107,127 @@ function CollectionScene() {
|
||||
{collection.name}
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Action>
|
||||
<InputSearchPage
|
||||
source="collection"
|
||||
placeholder={`${t("Search in collection")}…`}
|
||||
label={`${t("Search in collection")}…`}
|
||||
collectionId={collection.id}
|
||||
/>
|
||||
</Action>
|
||||
{can.update && (
|
||||
actions={<Actions collection={collection} />}
|
||||
>
|
||||
<DropToImport
|
||||
accept={documents.importFileTypes.join(", ")}
|
||||
disabled={!can.update}
|
||||
collectionId={collection.id}
|
||||
>
|
||||
<CenteredContent withStickyHeader>
|
||||
{collection.isEmpty ? (
|
||||
<Empty collection={collection} />
|
||||
) : (
|
||||
<>
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip={t("New document")}
|
||||
shortcut="n"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
as={Link}
|
||||
to={collection ? newDocumentPath(collection.id) : ""}
|
||||
disabled={!collection}
|
||||
icon={<PlusIcon />}
|
||||
<Heading>
|
||||
<CollectionIcon collection={collection} size={40} expanded />{" "}
|
||||
{collection.name}{" "}
|
||||
{!collection.permission && (
|
||||
<Tooltip
|
||||
tooltip={t(
|
||||
"This collection is only visible to those given access"
|
||||
)}
|
||||
placement="bottom"
|
||||
>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
<Separator />
|
||||
<Badge>{t("Private")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Heading>
|
||||
<CollectionDescription collection={collection} />
|
||||
|
||||
<PinnedDocuments
|
||||
pins={pins.inCollection(collection.id)}
|
||||
canUpdate={can.update}
|
||||
/>
|
||||
|
||||
<Tabs>
|
||||
<Tab to={collectionUrl(collection.url)} exact>
|
||||
{t("Documents")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.url, "updated")} exact>
|
||||
{t("Recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.url, "published")} exact>
|
||||
{t("Recently published")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.url, "old")} exact>
|
||||
{t("Least recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.url, "alphabetical")} exact>
|
||||
{t("A–Z")}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<Switch>
|
||||
<Route path={collectionUrl(collection.url, "alphabetical")}>
|
||||
<PaginatedDocumentList
|
||||
key="alphabetical"
|
||||
documents={documents.alphabeticalInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchAlphabetical}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.url, "old")}>
|
||||
<PaginatedDocumentList
|
||||
key="old"
|
||||
documents={documents.leastRecentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchLeastRecentlyUpdated}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.url, "recent")}>
|
||||
<Redirect to={collectionUrl(collection.url, "published")} />
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.url, "published")}>
|
||||
<PaginatedDocumentList
|
||||
key="published"
|
||||
documents={documents.recentlyPublishedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchRecentlyPublished}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
showPublished
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.url, "updated")}>
|
||||
<PaginatedDocumentList
|
||||
key="updated"
|
||||
documents={documents.recentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchRecentlyUpdated}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.url)} exact>
|
||||
<PaginatedDocumentList
|
||||
documents={documents.rootInCollection(collection.id)}
|
||||
fetch={documents.fetchPage}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: null,
|
||||
sort: collection.sort.field,
|
||||
direction: "ASC",
|
||||
}}
|
||||
showParentDocuments
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
</>
|
||||
)}
|
||||
<Action>
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
placement="bottom-end"
|
||||
label={(props) => (
|
||||
<Button
|
||||
icon={<MoreIcon />}
|
||||
{...props}
|
||||
borderOnHover
|
||||
neutral
|
||||
small
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Action>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Dropzone
|
||||
accept={documents.importFileTypes.join(", ")}
|
||||
onDropAccepted={handleFiles}
|
||||
onDropRejected={handleRejection}
|
||||
disabled={!can.update}
|
||||
noClick
|
||||
multiple
|
||||
>
|
||||
{({ getRootProps, getInputProps, isDragActive }) => (
|
||||
<DropzoneContainer
|
||||
{...getRootProps()}
|
||||
isDragActive={isDragActive}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{isImporting && <LoadingIndicator />}
|
||||
|
||||
<CenteredContent withStickyHeader>
|
||||
{collection.isEmpty ? (
|
||||
<Centered column>
|
||||
<HelpText>
|
||||
<Trans
|
||||
defaults="<em>{{ collectionName }}</em> doesn’t 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>
|
||||
)}
|
||||
|
||||
<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("A–Z")}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<Switch>
|
||||
<Route path={collectionUrl(collection.url, "alphabetical")}>
|
||||
<PaginatedDocumentList
|
||||
key="alphabetical"
|
||||
documents={documents.alphabeticalInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchAlphabetical}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.url, "old")}>
|
||||
<PaginatedDocumentList
|
||||
key="old"
|
||||
documents={documents.leastRecentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchLeastRecentlyUpdated}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.url, "recent")}>
|
||||
<Redirect
|
||||
to={collectionUrl(collection.url, "published")}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.url, "published")}>
|
||||
<PaginatedDocumentList
|
||||
key="published"
|
||||
documents={documents.recentlyPublishedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchRecentlyPublished}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
showPublished
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.url, "updated")}>
|
||||
<PaginatedDocumentList
|
||||
key="updated"
|
||||
documents={documents.recentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
fetch={documents.fetchRecentlyUpdated}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
}}
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.url)} exact>
|
||||
<PaginatedDocumentList
|
||||
documents={documents.rootInCollection(collection.id)}
|
||||
fetch={documents.fetchPage}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: null,
|
||||
sort: collection.sort.field,
|
||||
direction: "ASC",
|
||||
}}
|
||||
showParentDocuments
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
</>
|
||||
)}
|
||||
<DropMessage>{t("Drop documents to import")}</DropMessage>
|
||||
</CenteredContent>
|
||||
</DropzoneContainer>
|
||||
)}
|
||||
</Dropzone>
|
||||
</CenteredContent>
|
||||
</DropToImport>
|
||||
</Scene>
|
||||
) : (
|
||||
<CenteredContent>
|
||||
@@ -400,61 +239,4 @@ function CollectionScene() {
|
||||
);
|
||||
}
|
||||
|
||||
const DropMessage = styled(HelpText)`
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
const DropzoneContainer = styled.div<{ isDragActive?: boolean }>`
|
||||
outline-color: transparent !important;
|
||||
min-height: calc(100% - 56px);
|
||||
position: relative;
|
||||
|
||||
${({ isDragActive, theme }) =>
|
||||
isDragActive &&
|
||||
css`
|
||||
&:after {
|
||||
display: block;
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
left: 24px;
|
||||
background: ${theme.background};
|
||||
border-radius: 8px;
|
||||
border: 1px dashed ${theme.divider};
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
${DropMessage} {
|
||||
opacity: 1;
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const Centered = styled(Flex)`
|
||||
text-align: center;
|
||||
margin: 40vh auto 0;
|
||||
max-width: 380px;
|
||||
transform: translateY(-50%);
|
||||
`;
|
||||
|
||||
const TinyPinIcon = styled(PinIcon)`
|
||||
position: relative;
|
||||
top: 4px;
|
||||
opacity: 0.8;
|
||||
`;
|
||||
|
||||
const Empty = styled(Flex)`
|
||||
justify-content: center;
|
||||
margin: 10px 0;
|
||||
`;
|
||||
|
||||
export default observer(CollectionScene);
|
||||
|
||||
75
app/scenes/Collection/Actions.tsx
Normal file
75
app/scenes/Collection/Actions.tsx
Normal 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);
|
||||
98
app/scenes/Collection/DropToImport.tsx
Normal file
98
app/scenes/Collection/DropToImport.tsx
Normal 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);
|
||||
89
app/scenes/Collection/Empty.tsx
Normal file
89
app/scenes/Collection/Empty.tsx
Normal 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> doesn’t 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>
|
||||
)}
|
||||
|
||||
<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);
|
||||
@@ -9,19 +9,28 @@ import Heading from "~/components/Heading";
|
||||
import InputSearchPage from "~/components/InputSearchPage";
|
||||
import LanguagePrompt from "~/components/LanguagePrompt";
|
||||
import PaginatedDocumentList from "~/components/PaginatedDocumentList";
|
||||
import PinnedDocuments from "~/components/PinnedDocuments";
|
||||
import Scene from "~/components/Scene";
|
||||
import Tab from "~/components/Tab";
|
||||
import Tabs from "~/components/Tabs";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import NewDocumentMenu from "~/menus/NewDocumentMenu";
|
||||
|
||||
function Home() {
|
||||
const { documents, ui } = useStores();
|
||||
const { documents, pins, policies, ui } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const userId = user?.id;
|
||||
const { t } = useTranslation();
|
||||
|
||||
React.useEffect(() => {
|
||||
pins.fetchPage();
|
||||
}, [pins]);
|
||||
|
||||
const canManageTeam = policies.abilities(team.id).manage;
|
||||
|
||||
return (
|
||||
<Scene
|
||||
icon={<HomeIcon color="currentColor" />}
|
||||
@@ -39,6 +48,7 @@ function Home() {
|
||||
>
|
||||
{!ui.languagePromptDismissed && <LanguagePrompt />}
|
||||
<Heading>{t("Home")}</Heading>
|
||||
<PinnedDocuments pins={pins.home} canUpdate={canManageTeam} />
|
||||
<Tabs>
|
||||
<Tab to="/home" exact>
|
||||
{t("Recently viewed")}
|
||||
|
||||
@@ -18,9 +18,10 @@ import {
|
||||
} from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
|
||||
type FetchParams = PaginationParams & { collectionId: string };
|
||||
|
||||
type FetchPageParams = PaginationParams & { template?: boolean };
|
||||
type FetchPageParams = PaginationParams & {
|
||||
template?: boolean;
|
||||
collectionId?: string;
|
||||
};
|
||||
|
||||
export type SearchParams = {
|
||||
offset?: number;
|
||||
@@ -127,13 +128,6 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
);
|
||||
}
|
||||
|
||||
pinnedInCollection(collectionId: string): Document[] {
|
||||
return filter(
|
||||
this.recentlyUpdatedInCollection(collectionId),
|
||||
(document) => document.pinned
|
||||
);
|
||||
}
|
||||
|
||||
publishedInCollection(collectionId: string): Document[] {
|
||||
return filter(
|
||||
this.all,
|
||||
@@ -296,7 +290,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
fetchNamedPage = async (
|
||||
request = "list",
|
||||
options: FetchPageParams | undefined
|
||||
): Promise<Document[] | undefined> => {
|
||||
): Promise<Document[]> => {
|
||||
this.isFetching = true;
|
||||
|
||||
try {
|
||||
@@ -377,11 +371,6 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
return this.fetchNamedPage("drafts", options);
|
||||
};
|
||||
|
||||
@action
|
||||
fetchPinned = (options?: FetchParams): Promise<any> => {
|
||||
return this.fetchNamedPage("pinned", options);
|
||||
};
|
||||
|
||||
@action
|
||||
fetchOwned = (options?: PaginationParams): Promise<any> => {
|
||||
return this.fetchNamedPage("list", options);
|
||||
@@ -732,18 +721,6 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
if (collection) collection.refresh();
|
||||
};
|
||||
|
||||
pin = (document: Document) => {
|
||||
return client.post("/documents.pin", {
|
||||
id: document.id,
|
||||
});
|
||||
};
|
||||
|
||||
unpin = (document: Document) => {
|
||||
return client.post("/documents.unpin", {
|
||||
id: document.id,
|
||||
});
|
||||
};
|
||||
|
||||
star = async (document: Document) => {
|
||||
this.starredIds.set(document.id, true);
|
||||
|
||||
|
||||
57
app/stores/PinsStore.ts
Normal file
57
app/stores/PinsStore.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import GroupsStore from "./GroupsStore";
|
||||
import IntegrationsStore from "./IntegrationsStore";
|
||||
import MembershipsStore from "./MembershipsStore";
|
||||
import NotificationSettingsStore from "./NotificationSettingsStore";
|
||||
import PinsStore from "./PinsStore";
|
||||
import PoliciesStore from "./PoliciesStore";
|
||||
import RevisionsStore from "./RevisionsStore";
|
||||
import SearchesStore from "./SearchesStore";
|
||||
@@ -35,6 +36,7 @@ export default class RootStore {
|
||||
memberships: MembershipsStore;
|
||||
notificationSettings: NotificationSettingsStore;
|
||||
presence: DocumentPresenceStore;
|
||||
pins: PinsStore;
|
||||
policies: PoliciesStore;
|
||||
revisions: RevisionsStore;
|
||||
searches: SearchesStore;
|
||||
@@ -59,6 +61,7 @@ export default class RootStore {
|
||||
this.groupMemberships = new GroupMembershipsStore(this);
|
||||
this.integrations = new IntegrationsStore(this);
|
||||
this.memberships = new MembershipsStore(this);
|
||||
this.pins = new PinsStore(this);
|
||||
this.notificationSettings = new NotificationSettingsStore(this);
|
||||
this.presence = new DocumentPresenceStore();
|
||||
this.revisions = new RevisionsStore(this);
|
||||
@@ -84,6 +87,7 @@ export default class RootStore {
|
||||
this.memberships.clear();
|
||||
this.notificationSettings.clear();
|
||||
this.presence.clear();
|
||||
this.pins.clear();
|
||||
this.policies.clear();
|
||||
this.revisions.clear();
|
||||
this.searches.clear();
|
||||
|
||||
@@ -68,8 +68,10 @@ export type MenuItem =
|
||||
export type ActionContext = {
|
||||
isContextMenu: boolean;
|
||||
isCommandBar: boolean;
|
||||
activeCollectionId: string | null | undefined;
|
||||
activeDocumentId: string | null | undefined;
|
||||
activeCollectionId: string | undefined;
|
||||
activeDocumentId: string | undefined;
|
||||
currentUserId: string | undefined;
|
||||
currentTeamId: string | undefined;
|
||||
location: Location;
|
||||
stores: RootStore;
|
||||
event?: Event;
|
||||
|
||||
2
app/typings/index.d.ts
vendored
2
app/typings/index.d.ts
vendored
@@ -4,6 +4,8 @@ declare module "boundless-arrow-key-navigation";
|
||||
|
||||
declare module "string-replace-to-array";
|
||||
|
||||
declare module "sequelize-encrypted";
|
||||
|
||||
declare module "styled-components-breakpoint";
|
||||
|
||||
declare module "formidable/lib/file";
|
||||
|
||||
1
app/typings/styled-components.d.ts
vendored
1
app/typings/styled-components.d.ts
vendored
@@ -80,6 +80,7 @@ declare module "styled-components" {
|
||||
white: string;
|
||||
white10: string;
|
||||
white50: string;
|
||||
white75: string;
|
||||
black: string;
|
||||
black05: string;
|
||||
black10: string;
|
||||
|
||||
@@ -47,6 +47,9 @@
|
||||
"@babel/preset-react": "^7.16.0",
|
||||
"@bull-board/api": "^3.5.0",
|
||||
"@bull-board/koa": "^3.5.0",
|
||||
"@dnd-kit/core": "^4.0.3",
|
||||
"@dnd-kit/modifiers": "^4.0.0",
|
||||
"@dnd-kit/sortable": "^5.1.0",
|
||||
"@hocuspocus/provider": "^1.0.0-alpha.21",
|
||||
"@hocuspocus/server": "^1.0.0-alpha.78",
|
||||
"@outlinewiki/koa-passport": "^4.1.4",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { Document, Attachment, Collection, User, Event } from "@server/models";
|
||||
import { Document, Attachment, Collection, Pin, Event } from "@server/models";
|
||||
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
|
||||
import { sequelize } from "../sequelize";
|
||||
import pinDestroyer from "./pinDestroyer";
|
||||
|
||||
async function copyAttachments(
|
||||
document: Document,
|
||||
@@ -51,36 +52,30 @@ export default async function documentMover({
|
||||
}: {
|
||||
// @ts-expect-error ts-migrate(2749) FIXME: 'User' refers to a value, but is being used as a t... Remove this comment to see the full error message
|
||||
user: User;
|
||||
document: Document;
|
||||
document: any;
|
||||
collectionId: string;
|
||||
parentDocumentId?: string | null;
|
||||
index?: number;
|
||||
ip: string;
|
||||
}) {
|
||||
let transaction: Transaction | undefined;
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type 'Do... Remove this comment to see the full error message
|
||||
const collectionChanged = collectionId !== document.collectionId;
|
||||
const previousCollectionId = document.collectionId;
|
||||
const result = {
|
||||
collections: [],
|
||||
documents: [],
|
||||
collectionChanged,
|
||||
};
|
||||
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'template' does not exist on type 'Docume... Remove this comment to see the full error message
|
||||
if (document.template) {
|
||||
if (!collectionChanged) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type 'Do... Remove this comment to see the full error message
|
||||
document.collectionId = collectionId;
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'parentDocumentId' does not exist on type... Remove this comment to see the full error message
|
||||
document.parentDocumentId = null;
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'lastModifiedById' does not exist on type... Remove this comment to see the full error message
|
||||
document.lastModifiedById = user.id;
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'updatedBy' does not exist on type 'Docum... Remove this comment to see the full error message
|
||||
document.updatedBy = user;
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'save' does not exist on type 'Document'.
|
||||
await document.save();
|
||||
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'Document' is not assignable to p... Remove this comment to see the full error message
|
||||
result.documents.push(document);
|
||||
@@ -89,7 +84,6 @@ export default async function documentMover({
|
||||
transaction = await sequelize.transaction();
|
||||
|
||||
// remove from original collection
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type 'Do... Remove this comment to see the full error message
|
||||
const collection = await Collection.findByPk(document.collectionId, {
|
||||
transaction,
|
||||
paranoid: false,
|
||||
@@ -107,9 +101,7 @@ export default async function documentMover({
|
||||
// We need to compensate for this when reordering
|
||||
const toIndex =
|
||||
index !== undefined &&
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'parentDocumentId' does not exist on type... Remove this comment to see the full error message
|
||||
document.parentDocumentId === parentDocumentId &&
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type 'Do... Remove this comment to see the full error message
|
||||
document.collectionId === collectionId &&
|
||||
fromIndex < index
|
||||
? index - 1
|
||||
@@ -121,21 +113,17 @@ export default async function documentMover({
|
||||
await collection.save({
|
||||
transaction,
|
||||
});
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'text' does not exist on type 'Document'.
|
||||
document.text = await copyAttachments(document, {
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
// add to new collection (may be the same)
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type 'Do... Remove this comment to see the full error message
|
||||
document.collectionId = collectionId;
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'parentDocumentId' does not exist on type... Remove this comment to see the full error message
|
||||
document.parentDocumentId = parentDocumentId;
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'lastModifiedById' does not exist on type... Remove this comment to see the full error message
|
||||
document.lastModifiedById = user.id;
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'updatedBy' does not exist on type 'Docum... Remove this comment to see the full error message
|
||||
document.updatedBy = user;
|
||||
|
||||
// @ts-expect-error ts-migrate(2749) FIXME: 'Collection' refers to a value, but is being used ... Remove this comment to see the full error message
|
||||
const newCollection: Collection = collectionChanged
|
||||
? await Collection.scope({
|
||||
@@ -180,15 +168,27 @@ export default async function documentMover({
|
||||
);
|
||||
};
|
||||
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'Document'.
|
||||
await loopChildren(document.id);
|
||||
|
||||
const pin = await Pin.findOne({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
collectionId: previousCollectionId,
|
||||
},
|
||||
});
|
||||
|
||||
if (pin) {
|
||||
await pinDestroyer({
|
||||
user,
|
||||
pin,
|
||||
ip,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'save' does not exist on type 'Document'.
|
||||
await document.save({
|
||||
transaction,
|
||||
});
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collection' does not exist on type 'Docu... Remove this comment to see the full error message
|
||||
document.collection = newCollection;
|
||||
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'Document' is not assignable to p... Remove this comment to see the full error message
|
||||
result.documents.push(document);
|
||||
@@ -208,10 +208,8 @@ export default async function documentMover({
|
||||
await Event.create({
|
||||
name: "documents.move",
|
||||
actorId: user.id,
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'id' does not exist on type 'Document'.
|
||||
documentId: document.id,
|
||||
collectionId,
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'teamId' does not exist on type 'Document... Remove this comment to see the full error message
|
||||
teamId: document.teamId,
|
||||
data: {
|
||||
title: document.title,
|
||||
@@ -222,6 +220,7 @@ export default async function documentMover({
|
||||
},
|
||||
ip,
|
||||
});
|
||||
|
||||
// we need to send all updated models back to the client
|
||||
return result;
|
||||
}
|
||||
|
||||
55
server/commands/pinCreator.test.ts
Normal file
55
server/commands/pinCreator.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
97
server/commands/pinCreator.ts
Normal file
97
server/commands/pinCreator.ts
Normal 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;
|
||||
}
|
||||
38
server/commands/pinDestroyer.test.ts
Normal file
38
server/commands/pinDestroyer.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
54
server/commands/pinDestroyer.ts
Normal file
54
server/commands/pinDestroyer.ts
Normal 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;
|
||||
}
|
||||
53
server/commands/pinUpdater.ts
Normal file
53
server/commands/pinUpdater.ts
Normal 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;
|
||||
}
|
||||
98
server/migrations/20211221031430-create-pins.js
Normal file
98
server/migrations/20211221031430-create-pins.js
Normal 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");
|
||||
},
|
||||
};
|
||||
@@ -142,6 +142,7 @@ Document.associate = (models) => {
|
||||
as: "updatedBy",
|
||||
foreignKey: "lastModifiedById",
|
||||
});
|
||||
/** Deprecated – use Pins relationship instead */
|
||||
Document.belongsTo(models.User, {
|
||||
as: "pinnedBy",
|
||||
foreignKey: "pinnedById",
|
||||
@@ -180,8 +181,7 @@ Document.associate = (models) => {
|
||||
},
|
||||
},
|
||||
});
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'userId' implicitly has an 'any' type.
|
||||
Document.addScope("withCollection", (userId, paranoid = true) => {
|
||||
Document.addScope("withCollection", (userId: string, paranoid = true) => {
|
||||
if (userId) {
|
||||
return {
|
||||
include: [
|
||||
@@ -219,8 +219,7 @@ Document.associate = (models) => {
|
||||
},
|
||||
],
|
||||
});
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'userId' implicitly has an 'any' type.
|
||||
Document.addScope("withViews", (userId) => {
|
||||
Document.addScope("withViews", (userId: string) => {
|
||||
if (!userId) return {};
|
||||
return {
|
||||
include: [
|
||||
@@ -236,8 +235,7 @@ Document.associate = (models) => {
|
||||
],
|
||||
};
|
||||
});
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'userId' implicitly has an 'any' type.
|
||||
Document.addScope("withStarred", (userId) => ({
|
||||
Document.addScope("withStarred", (userId: string) => ({
|
||||
include: [
|
||||
{
|
||||
model: models.Star,
|
||||
@@ -250,20 +248,40 @@ Document.associate = (models) => {
|
||||
},
|
||||
],
|
||||
}));
|
||||
Document.defaultScopeWithUser = (userId: string) => {
|
||||
const starredScope = {
|
||||
method: ["withStarred", userId],
|
||||
};
|
||||
const collectionScope = {
|
||||
method: ["withCollection", userId],
|
||||
};
|
||||
const viewScope = {
|
||||
method: ["withViews", userId],
|
||||
};
|
||||
return Document.scope(
|
||||
"defaultScope",
|
||||
starredScope,
|
||||
collectionScope,
|
||||
viewScope
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'id' implicitly has an 'any' type.
|
||||
Document.findByPk = async function (id, options = {}) {
|
||||
Document.findByPk = async function (
|
||||
id: string,
|
||||
options: {
|
||||
userId?: string;
|
||||
paranoid?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
// allow default preloading of collection membership if `userId` is passed in find options
|
||||
// almost every endpoint needs the collection membership to determine policy permissions.
|
||||
const scope = this.scope(
|
||||
"withUnpublished",
|
||||
{
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'userId' does not exist on type '{}'.
|
||||
method: ["withCollection", options.userId, options.paranoid],
|
||||
},
|
||||
{
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'userId' does not exist on type '{}'.
|
||||
method: ["withViews", options.userId],
|
||||
}
|
||||
);
|
||||
@@ -275,10 +293,13 @@ Document.findByPk = async function (id, options = {}) {
|
||||
},
|
||||
...options,
|
||||
});
|
||||
} else if (id.match(SLUG_URL_REGEX)) {
|
||||
}
|
||||
|
||||
const match = id.match(SLUG_URL_REGEX);
|
||||
if (match) {
|
||||
return scope.findOne({
|
||||
where: {
|
||||
urlId: id.match(SLUG_URL_REGEX)[1],
|
||||
urlId: match[1],
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
@@ -71,8 +71,6 @@ Event.ACTIVITY_EVENTS = [
|
||||
"documents.publish",
|
||||
"documents.archive",
|
||||
"documents.unarchive",
|
||||
"documents.pin",
|
||||
"documents.unpin",
|
||||
"documents.move",
|
||||
"documents.delete",
|
||||
"documents.permanent_delete",
|
||||
@@ -99,8 +97,6 @@ Event.AUDIT_EVENTS = [
|
||||
"documents.update",
|
||||
"documents.archive",
|
||||
"documents.unarchive",
|
||||
"documents.pin",
|
||||
"documents.unpin",
|
||||
"documents.move",
|
||||
"documents.delete",
|
||||
"documents.permanent_delete",
|
||||
@@ -108,6 +104,9 @@ Event.AUDIT_EVENTS = [
|
||||
"groups.create",
|
||||
"groups.update",
|
||||
"groups.delete",
|
||||
"pins.create",
|
||||
"pins.update",
|
||||
"pins.delete",
|
||||
"revisions.create",
|
||||
"shares.create",
|
||||
"shares.update",
|
||||
|
||||
50
server/models/Pin.ts
Normal file
50
server/models/Pin.ts
Normal 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;
|
||||
@@ -14,6 +14,7 @@ import Integration from "./Integration";
|
||||
import IntegrationAuthentication from "./IntegrationAuthentication";
|
||||
import Notification from "./Notification";
|
||||
import NotificationSetting from "./NotificationSetting";
|
||||
import Pin from "./Pin";
|
||||
import Revision from "./Revision";
|
||||
import SearchQuery from "./SearchQuery";
|
||||
import Share from "./Share";
|
||||
@@ -39,6 +40,7 @@ const models = {
|
||||
IntegrationAuthentication,
|
||||
Notification,
|
||||
NotificationSetting,
|
||||
Pin,
|
||||
Revision,
|
||||
SearchQuery,
|
||||
Share,
|
||||
@@ -73,6 +75,7 @@ export {
|
||||
IntegrationAuthentication,
|
||||
Notification,
|
||||
NotificationSetting,
|
||||
Pin,
|
||||
Revision,
|
||||
SearchQuery,
|
||||
Share,
|
||||
|
||||
@@ -90,6 +90,15 @@ allow(User, ["pin", "unpin"], Document, (user, document) => {
|
||||
return user.teamId === document.teamId;
|
||||
});
|
||||
|
||||
allow(User, ["pinToHome"], Document, (user, document) => {
|
||||
if (document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
if (document.template) return false;
|
||||
if (!document.publishedAt) return false;
|
||||
|
||||
return user.teamId === document.teamId && user.isAdmin;
|
||||
});
|
||||
|
||||
allow(User, "delete", Document, (user, document) => {
|
||||
if (user.isViewer) return false;
|
||||
if (document.deletedAt) return false;
|
||||
|
||||
@@ -14,6 +14,7 @@ import "./collection";
|
||||
import "./document";
|
||||
import "./integration";
|
||||
import "./notificationSetting";
|
||||
import "./pins";
|
||||
import "./searchQuery";
|
||||
import "./share";
|
||||
import "./user";
|
||||
|
||||
9
server/policies/pins.ts
Normal file
9
server/policies/pins.ts
Normal 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;
|
||||
});
|
||||
@@ -26,7 +26,7 @@ async function replaceImageAttachments(text: string) {
|
||||
|
||||
export default async function present(
|
||||
document: any,
|
||||
options: Options | null | undefined
|
||||
options: Options | null | undefined = {}
|
||||
) {
|
||||
options = {
|
||||
isPublic: false,
|
||||
@@ -58,7 +58,6 @@ export default async function present(
|
||||
starred: document.starred ? !!document.starred.length : undefined,
|
||||
revision: document.revisionCount,
|
||||
fullWidth: document.fullWidth,
|
||||
pinned: undefined,
|
||||
collectionId: undefined,
|
||||
parentDocumentId: undefined,
|
||||
lastViewedAt: undefined,
|
||||
@@ -69,8 +68,6 @@ export default async function present(
|
||||
}
|
||||
|
||||
if (!options.isPublic) {
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'boolean' is not assignable to type 'undefine... Remove this comment to see the full error message
|
||||
data.pinned = !!document.pinnedById;
|
||||
data.collectionId = document.collectionId;
|
||||
data.parentDocumentId = document.parentDocumentId;
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type 'UserPresentation | null | undefined' is not ... Remove this comment to see the full error message
|
||||
|
||||
@@ -10,6 +10,7 @@ import presentGroupMembership from "./groupMembership";
|
||||
import presentIntegration from "./integration";
|
||||
import presentMembership from "./membership";
|
||||
import presentNotificationSetting from "./notificationSetting";
|
||||
import presentPin from "./pin";
|
||||
import presentPolicies from "./policy";
|
||||
import presentRevision from "./revision";
|
||||
import presentSearchQuery from "./searchQuery";
|
||||
@@ -37,6 +38,7 @@ export {
|
||||
presentMembership,
|
||||
presentNotificationSetting,
|
||||
presentSlackAttachment,
|
||||
presentPin,
|
||||
presentPolicies,
|
||||
presentGroupMembership,
|
||||
presentCollectionGroupMembership,
|
||||
|
||||
10
server/presenters/pin.ts
Normal file
10
server/presenters/pin.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
Group,
|
||||
CollectionGroup,
|
||||
GroupUser,
|
||||
Pin,
|
||||
} from "@server/models";
|
||||
import { presentPin } from "@server/presenters";
|
||||
import { Op } from "@server/sequelize";
|
||||
import { Event } from "../../types";
|
||||
|
||||
@@ -81,8 +83,6 @@ export default class WebsocketsProcessor {
|
||||
});
|
||||
}
|
||||
|
||||
case "documents.pin":
|
||||
case "documents.unpin":
|
||||
case "documents.update": {
|
||||
const document = await Document.findByPk(event.documentId, {
|
||||
paranoid: false,
|
||||
@@ -334,6 +334,30 @@ export default class WebsocketsProcessor {
|
||||
.emit("fileOperations.update", event.data);
|
||||
}
|
||||
|
||||
case "pins.create":
|
||||
case "pins.update": {
|
||||
const pin = await Pin.findByPk(event.modelId);
|
||||
return socketio
|
||||
.to(
|
||||
pin.collectionId
|
||||
? `collection-${pin.collectionId}`
|
||||
: `team-${pin.teamId}`
|
||||
)
|
||||
.emit(event.name, presentPin(pin));
|
||||
}
|
||||
|
||||
case "pins.delete": {
|
||||
return socketio
|
||||
.to(
|
||||
event.collectionId
|
||||
? `collection-${event.collectionId}`
|
||||
: `team-${event.teamId}`
|
||||
)
|
||||
.emit(event.name, {
|
||||
modelId: event.modelId,
|
||||
});
|
||||
}
|
||||
|
||||
case "groups.create":
|
||||
case "groups.update": {
|
||||
const group = await Group.findByPk(event.modelId, {
|
||||
|
||||
@@ -26,15 +26,6 @@ Object {
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#documents.pin should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#documents.restore should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
@@ -71,15 +62,6 @@ Object {
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#documents.unpin should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#documents.unstar should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
|
||||
@@ -57,26 +57,24 @@ router.post("collections.create", auth(), async (ctx) => {
|
||||
|
||||
const user = ctx.state.user;
|
||||
authorize(user, "createCollection", user.team);
|
||||
const collections = await Collection.findAll({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
deletedAt: null,
|
||||
},
|
||||
attributes: ["id", "index", "updatedAt"],
|
||||
limit: 1,
|
||||
order: [
|
||||
// using LC_COLLATE:"C" because we need byte order to drive the sorting
|
||||
sequelize.literal('"collection"."index" collate "C"'),
|
||||
["updatedAt", "DESC"],
|
||||
],
|
||||
});
|
||||
|
||||
if (index) {
|
||||
assertIndexCharacters(
|
||||
index,
|
||||
"Index characters must be between x20 to x7E ASCII"
|
||||
);
|
||||
assertIndexCharacters(index);
|
||||
} else {
|
||||
const collections = await Collection.findAll({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
deletedAt: null,
|
||||
},
|
||||
attributes: ["id", "index", "updatedAt"],
|
||||
limit: 1,
|
||||
order: [
|
||||
// using LC_COLLATE:"C" because we need byte order to drive the sorting
|
||||
sequelize.literal('"collection"."index" collate "C"'),
|
||||
["updatedAt", "DESC"],
|
||||
],
|
||||
});
|
||||
|
||||
index = fractionalIndex(
|
||||
null,
|
||||
collections.length ? collections[0].index : null
|
||||
@@ -648,10 +646,7 @@ router.post("collections.move", auth(), async (ctx) => {
|
||||
const id = ctx.body.id;
|
||||
let index = ctx.body.index;
|
||||
assertPresent(index, "index is required");
|
||||
assertIndexCharacters(
|
||||
index,
|
||||
"Index characters must be between x20 to x7E ASCII"
|
||||
);
|
||||
assertIndexCharacters(index);
|
||||
assertUuid(id, "id must be a uuid");
|
||||
const user = ctx.state.user;
|
||||
const collection = await Collection.findByPk(id);
|
||||
|
||||
@@ -843,68 +843,7 @@ describe("#documents.list", () => {
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
describe("#documents.pinned", () => {
|
||||
it("should return pinned documents", async () => {
|
||||
const { user, document } = await seed();
|
||||
document.pinnedById = user.id;
|
||||
await document.save();
|
||||
const res = await server.post("/api/documents.pinned", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
collectionId: document.collectionId,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].id).toEqual(document.id);
|
||||
});
|
||||
|
||||
it("should return pinned documents in private collections member of", async () => {
|
||||
const { user, collection, document } = await seed();
|
||||
collection.permission = null;
|
||||
await collection.save();
|
||||
document.pinnedById = user.id;
|
||||
await document.save();
|
||||
await CollectionUser.create({
|
||||
collectionId: collection.id,
|
||||
userId: user.id,
|
||||
createdById: user.id,
|
||||
permission: "read_write",
|
||||
});
|
||||
const res = await server.post("/api/documents.pinned", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
collectionId: document.collectionId,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].id).toEqual(document.id);
|
||||
});
|
||||
|
||||
it("should not return pinned documents in private collections not a member of", async () => {
|
||||
const collection = await buildCollection({
|
||||
permission: null,
|
||||
});
|
||||
const user = await buildUser({
|
||||
teamId: collection.teamId,
|
||||
});
|
||||
const res = await server.post("/api/documents.pinned", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
collectionId: collection.id,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/documents.pinned");
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
});
|
||||
describe("#documents.drafts", () => {
|
||||
it("should return unpublished documents", async () => {
|
||||
const { user, document } = await seed();
|
||||
@@ -1534,39 +1473,7 @@ describe("#documents.starred", () => {
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
describe("#documents.pin", () => {
|
||||
it("should pin the document", async () => {
|
||||
const { user, document } = await seed();
|
||||
const res = await server.post("/api/documents.pin", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.pinned).toEqual(true);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/documents.pin");
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const { document } = await seed();
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/documents.pin", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
describe("#documents.move", () => {
|
||||
it("should move the document", async () => {
|
||||
const { user, document } = await seed();
|
||||
@@ -1807,41 +1714,7 @@ describe("#documents.restore", () => {
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
describe("#documents.unpin", () => {
|
||||
it("should unpin the document", async () => {
|
||||
const { user, document } = await seed();
|
||||
document.pinnedBy = user;
|
||||
await document.save();
|
||||
const res = await server.post("/api/documents.unpin", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.pinned).toEqual(false);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/documents.unpin");
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const { document } = await seed();
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/documents.unpin", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
describe("#documents.star", () => {
|
||||
it("should star the document", async () => {
|
||||
const { user, document } = await seed();
|
||||
|
||||
@@ -142,22 +142,8 @@ router.post("documents.list", auth(), pagination(), async (ctx) => {
|
||||
}
|
||||
|
||||
assertSort(sort, Document);
|
||||
// add the users starred state to the response by default
|
||||
const starredScope = {
|
||||
method: ["withStarred", user.id],
|
||||
};
|
||||
const collectionScope = {
|
||||
method: ["withCollection", user.id],
|
||||
};
|
||||
const viewScope = {
|
||||
method: ["withViews", user.id],
|
||||
};
|
||||
const documents = await Document.scope(
|
||||
"defaultScope",
|
||||
starredScope,
|
||||
collectionScope,
|
||||
viewScope
|
||||
).findAll({
|
||||
|
||||
const documents = await Document.defaultScopeWithUser(user.id).findAll({
|
||||
where,
|
||||
order: [[sort, direction]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
@@ -185,57 +171,6 @@ router.post("documents.list", auth(), pagination(), async (ctx) => {
|
||||
};
|
||||
});
|
||||
|
||||
router.post("documents.pinned", auth(), pagination(), async (ctx) => {
|
||||
const { collectionId, sort = "updatedAt" } = ctx.body;
|
||||
let direction = ctx.body.direction;
|
||||
if (direction !== "ASC") direction = "DESC";
|
||||
|
||||
assertUuid(collectionId, "collectionId is required");
|
||||
assertSort(sort, Document);
|
||||
|
||||
const user = ctx.state.user;
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collectionId);
|
||||
authorize(user, "read", collection);
|
||||
const starredScope = {
|
||||
method: ["withStarred", user.id],
|
||||
};
|
||||
const collectionScope = {
|
||||
method: ["withCollection", user.id],
|
||||
};
|
||||
const viewScope = {
|
||||
method: ["withViews", user.id],
|
||||
};
|
||||
const documents = await Document.scope(
|
||||
"defaultScope",
|
||||
starredScope,
|
||||
collectionScope,
|
||||
viewScope
|
||||
).findAll({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
collectionId,
|
||||
pinnedById: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
},
|
||||
order: [[sort, direction]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
});
|
||||
const data = await Promise.all(
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'document' implicitly has an 'any' type.
|
||||
documents.map((document) => presentDocument(document))
|
||||
);
|
||||
const policies = presentPolicies(user, documents);
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data,
|
||||
policies,
|
||||
};
|
||||
});
|
||||
|
||||
router.post("documents.archived", auth(), pagination(), async (ctx) => {
|
||||
const { sort = "updatedAt" } = ctx.body;
|
||||
|
||||
@@ -807,7 +742,6 @@ router.post("documents.restore", auth(), async (ctx) => {
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
|
||||
data: await presentDocument(document),
|
||||
policies: presentPolicies(user, [document]),
|
||||
};
|
||||
@@ -916,7 +850,6 @@ router.post("documents.search", auth(), pagination(), async (ctx) => {
|
||||
const data = await Promise.all(
|
||||
// @ts-expect-error ts-migrate(7006) FIXME: Parameter 'result' implicitly has an 'any' type.
|
||||
results.map(async (result) => {
|
||||
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
|
||||
const document = await presentDocument(result.document);
|
||||
return { ...result, document };
|
||||
})
|
||||
@@ -942,62 +875,6 @@ router.post("documents.search", auth(), pagination(), async (ctx) => {
|
||||
};
|
||||
});
|
||||
|
||||
router.post("documents.pin", auth(), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
assertPresent(id, "id is required");
|
||||
const user = ctx.state.user;
|
||||
const document = await Document.findByPk(id, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "pin", document);
|
||||
document.pinnedById = user.id;
|
||||
await document.save();
|
||||
await Event.create({
|
||||
name: "documents.pin",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
data: {
|
||||
title: document.title,
|
||||
},
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
ctx.body = {
|
||||
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
|
||||
data: await presentDocument(document),
|
||||
policies: presentPolicies(user, [document]),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("documents.unpin", auth(), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
assertPresent(id, "id is required");
|
||||
const user = ctx.state.user;
|
||||
const document = await Document.findByPk(id, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "unpin", document);
|
||||
document.pinnedById = null;
|
||||
await document.save();
|
||||
await Event.create({
|
||||
name: "documents.unpin",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
data: {
|
||||
title: document.title,
|
||||
},
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
ctx.body = {
|
||||
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
|
||||
data: await presentDocument(document),
|
||||
policies: presentPolicies(user, [document]),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("documents.star", auth(), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
assertPresent(id, "id is required");
|
||||
@@ -1095,7 +972,6 @@ router.post("documents.templatize", auth(), async (ctx) => {
|
||||
userId: user.id,
|
||||
});
|
||||
ctx.body = {
|
||||
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
|
||||
data: await presentDocument(document),
|
||||
policies: presentPolicies(user, [document]),
|
||||
};
|
||||
@@ -1218,7 +1094,6 @@ router.post("documents.update", auth(), async (ctx) => {
|
||||
document.updatedBy = user;
|
||||
document.collection = collection;
|
||||
ctx.body = {
|
||||
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
|
||||
data: await presentDocument(document),
|
||||
policies: presentPolicies(user, [document]),
|
||||
};
|
||||
@@ -1271,7 +1146,6 @@ router.post("documents.move", auth(), async (ctx) => {
|
||||
ctx.body = {
|
||||
data: {
|
||||
documents: await Promise.all(
|
||||
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
|
||||
documents.map((document) => presentDocument(document))
|
||||
),
|
||||
collections: await Promise.all(
|
||||
@@ -1303,7 +1177,6 @@ router.post("documents.archive", auth(), async (ctx) => {
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
ctx.body = {
|
||||
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
|
||||
data: await presentDocument(document),
|
||||
policies: presentPolicies(user, [document]),
|
||||
};
|
||||
@@ -1388,7 +1261,6 @@ router.post("documents.unpublish", auth(), async (ctx) => {
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
ctx.body = {
|
||||
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
|
||||
data: await presentDocument(document),
|
||||
policies: presentPolicies(user, [document]),
|
||||
};
|
||||
@@ -1461,7 +1333,6 @@ router.post("documents.import", auth(), async (ctx) => {
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collection' does not exist on type 'Docu... Remove this comment to see the full error message
|
||||
document.collection = collection;
|
||||
return (ctx.body = {
|
||||
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
|
||||
data: await presentDocument(document),
|
||||
policies: presentPolicies(user, [document]),
|
||||
});
|
||||
@@ -1537,7 +1408,6 @@ router.post("documents.create", auth(), async (ctx) => {
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collection' does not exist on type 'Docu... Remove this comment to see the full error message
|
||||
document.collection = collection;
|
||||
return (ctx.body = {
|
||||
// @ts-expect-error ts-migrate(2554) FIXME: Expected 2 arguments, but got 1.
|
||||
data: await presentDocument(document),
|
||||
policies: presentPolicies(user, [document]),
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import integrations from "./integrations";
|
||||
import apiWrapper from "./middlewares/apiWrapper";
|
||||
import editor from "./middlewares/editor";
|
||||
import notificationSettings from "./notificationSettings";
|
||||
import pins from "./pins";
|
||||
import revisions from "./revisions";
|
||||
import searches from "./searches";
|
||||
import shares from "./shares";
|
||||
@@ -50,6 +51,7 @@ router.use("/", events.routes());
|
||||
router.use("/", users.routes());
|
||||
router.use("/", collections.routes());
|
||||
router.use("/", documents.routes());
|
||||
router.use("/", pins.routes());
|
||||
router.use("/", revisions.routes());
|
||||
router.use("/", views.routes());
|
||||
router.use("/", hooks.routes());
|
||||
|
||||
156
server/routes/api/pins.ts
Normal file
156
server/routes/api/pins.ts
Normal 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;
|
||||
@@ -1,5 +1,4 @@
|
||||
import Sequelize from "sequelize";
|
||||
// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'sequ... Remove this comment to see the full error message
|
||||
import EncryptedField from "sequelize-encrypted";
|
||||
import Logger from "./logging/logger";
|
||||
|
||||
|
||||
@@ -40,8 +40,6 @@ export type DocumentEvent =
|
||||
| "documents.publish"
|
||||
| "documents.delete"
|
||||
| "documents.permanent_delete"
|
||||
| "documents.pin"
|
||||
| "documents.unpin"
|
||||
| "documents.archive"
|
||||
| "documents.unarchive"
|
||||
| "documents.restore"
|
||||
@@ -240,9 +238,19 @@ export type TeamEvent = {
|
||||
ip: string;
|
||||
};
|
||||
|
||||
export type PinEvent = {
|
||||
name: "pins.create" | "pins.update" | "pins.delete";
|
||||
teamId: string;
|
||||
modelId: string;
|
||||
collectionId?: string;
|
||||
actorId: string;
|
||||
ip: string;
|
||||
};
|
||||
|
||||
export type Event =
|
||||
| UserEvent
|
||||
| DocumentEvent
|
||||
| PinEvent
|
||||
| CollectionEvent
|
||||
| CollectionImportEvent
|
||||
| CollectionExportAllEvent
|
||||
|
||||
@@ -83,7 +83,10 @@ export const assertValueInArray = (
|
||||
}
|
||||
};
|
||||
|
||||
export const assertIndexCharacters = (value: string, message?: string) => {
|
||||
export const assertIndexCharacters = (
|
||||
value: string,
|
||||
message = "index must be between x20 to x7E ASCII"
|
||||
) => {
|
||||
if (!validateIndexCharacters(value)) {
|
||||
throw ValidationError(message);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
"Duplicate": "Duplicate",
|
||||
"Duplicate document": "Duplicate document",
|
||||
"Document duplicated": "Document duplicated",
|
||||
"Pin to collection": "Pin to collection",
|
||||
"Pinned to collection": "Pinned to collection",
|
||||
"Pin to home": "Pin to home",
|
||||
"Pinned to team home": "Pinned to team home",
|
||||
"Print": "Print",
|
||||
"Print document": "Print document",
|
||||
"Import document": "Import document",
|
||||
@@ -58,6 +62,7 @@
|
||||
"Edits you make will sync once you’re online": "Edits you make will sync once you’re online",
|
||||
"Submenu": "Submenu",
|
||||
"Deleted Collection": "Deleted Collection",
|
||||
"Unpin": "Unpin",
|
||||
"History": "History",
|
||||
"Oh weird, there's nothing here": "Oh weird, there's nothing here",
|
||||
"New": "New",
|
||||
@@ -234,8 +239,6 @@
|
||||
"Document options": "Document options",
|
||||
"Restore": "Restore",
|
||||
"Choose a collection": "Choose a collection",
|
||||
"Unpin": "Unpin",
|
||||
"Pin to collection": "Pin to collection",
|
||||
"Unpublish": "Unpublish",
|
||||
"Permanently delete": "Permanently delete",
|
||||
"Move": "Move",
|
||||
@@ -281,19 +284,18 @@
|
||||
"Name your token something that will help you to remember it's use in the future, for example \"local development\", \"production\", or \"continuous integration\".": "Name your token something that will help you to remember it's use in the future, for example \"local development\", \"production\", or \"continuous integration\".",
|
||||
"Documents": "Documents",
|
||||
"The document archive is empty at the moment.": "The document archive is empty at the moment.",
|
||||
"Search in collection": "Search in collection",
|
||||
"<em>{{ collectionName }}</em> doesn’t contain any\n documents yet.": "<em>{{ collectionName }}</em> doesn’t contain any\n documents yet.",
|
||||
"Get started by creating a new one!": "Get started by creating a new one!",
|
||||
"Create a document": "Create a document",
|
||||
"Manage permissions": "Manage permissions",
|
||||
"This collection is only visible to those given access": "This collection is only visible to those given access",
|
||||
"Private": "Private",
|
||||
"Pinned": "Pinned",
|
||||
"Recently updated": "Recently updated",
|
||||
"Recently published": "Recently published",
|
||||
"Least recently updated": "Least recently updated",
|
||||
"A–Z": "A–Z",
|
||||
"Search in collection": "Search in collection",
|
||||
"Drop documents to import": "Drop documents to import",
|
||||
"<em>{{ collectionName }}</em> doesn’t contain any\n documents yet.": "<em>{{ collectionName }}</em> doesn’t contain any\n documents yet.",
|
||||
"Get started by creating a new one!": "Get started by creating a new one!",
|
||||
"Create a document": "Create a document",
|
||||
"Manage permissions": "Manage permissions",
|
||||
"Are you sure about that? Deleting the <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",
|
||||
"I’m sure – Delete": "I’m sure – Delete",
|
||||
|
||||
@@ -16,6 +16,7 @@ const colors = {
|
||||
white: "#FFF",
|
||||
white10: "rgba(255, 255, 255, 0.1)",
|
||||
white50: "rgba(255, 255, 255, 0.5)",
|
||||
white75: "rgba(255, 255, 255, 0.75)",
|
||||
black: "#000",
|
||||
black05: "rgba(0, 0, 0, 0.05)",
|
||||
black10: "rgba(0, 0, 0, 0.1)",
|
||||
|
||||
41
yarn.lock
41
yarn.lock
@@ -1133,6 +1133,45 @@
|
||||
enabled "2.0.x"
|
||||
kuler "^2.0.0"
|
||||
|
||||
"@dnd-kit/accessibility@^3.0.0":
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.0.0.tgz#b56e3750414fd907b7d6972b3116aa8f96d07fde"
|
||||
integrity sha512-QwaQ1IJHQHMMuAGOOYHQSx7h7vMZPfO97aDts8t5N/MY7n2QTDSnW+kF7uRQ1tVBkr6vJ+BqHWG5dlgGvwVjow==
|
||||
dependencies:
|
||||
tslib "^2.0.0"
|
||||
|
||||
"@dnd-kit/core@^4.0.3":
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@dnd-kit/core/-/core-4.0.3.tgz#49abe3c9b481b6e07909df1781e88b20f3dd25b0"
|
||||
integrity sha512-uT1uHZxKx3iEkupmLfknMIvbykMJSetoXXmra6sGGvtWy+OMKrWm3axH2c90+JC/q6qaeKs2znd3Qs8GLnCa5Q==
|
||||
dependencies:
|
||||
"@dnd-kit/accessibility" "^3.0.0"
|
||||
"@dnd-kit/utilities" "^3.0.1"
|
||||
tslib "^2.0.0"
|
||||
|
||||
"@dnd-kit/modifiers@^4.0.0":
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@dnd-kit/modifiers/-/modifiers-4.0.0.tgz#d1577b806b2319f14a1a0a155f270e672cfca636"
|
||||
integrity sha512-4OkNTamneH9u3YMJqG6yJ6cwFoEd/4yY9BF39TgmDh9vyMK2MoPZFVAV0vOEm193ZYsPczq3Af5tJFtJhR9jJQ==
|
||||
dependencies:
|
||||
"@dnd-kit/utilities" "^3.0.0"
|
||||
tslib "^2.0.0"
|
||||
|
||||
"@dnd-kit/sortable@^5.1.0":
|
||||
version "5.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@dnd-kit/sortable/-/sortable-5.1.0.tgz#f30ec12c95ca5aa90e2e4d9ef3dbe16b3eb26d69"
|
||||
integrity sha512-CPyiUHbTrSYzhddfgdeoX0ERg/dEyVKIWx9+4O6uqpoppo84SXCBHVFiFBRVpQ9wtpsXs7prtUAnAUTcvFQTZg==
|
||||
dependencies:
|
||||
"@dnd-kit/utilities" "^3.0.0"
|
||||
tslib "^2.0.0"
|
||||
|
||||
"@dnd-kit/utilities@^3.0.0", "@dnd-kit/utilities@^3.0.1":
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-3.0.2.tgz#24fd796491a85c2904e9c97f1fdb42005df645f2"
|
||||
integrity sha512-J4WpZXKbLJzBkuALqsIy5KmQr6PQk86ixoPKoixzjWj1+XGE5KdA2vga9Vf43EB/Ewpng+E5SmXVLfTs7ukbhw==
|
||||
dependencies:
|
||||
tslib "^2.0.0"
|
||||
|
||||
"@emotion/is-prop-valid@^0.8.2", "@emotion/is-prop-valid@^0.8.8":
|
||||
version "0.8.8"
|
||||
resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a"
|
||||
@@ -14562,7 +14601,7 @@ tslib@^1.0.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3:
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
|
||||
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
|
||||
|
||||
tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0:
|
||||
tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01"
|
||||
integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==
|
||||
|
||||
Reference in New Issue
Block a user