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