feat: Pin to home (#2880)

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

View File

@@ -9,6 +9,7 @@ import {
NewDocumentIcon,
ShapesIcon,
ImportIcon,
PinIcon,
} from "outline-icons";
import * as React from "react";
import DocumentTemplatize from "~/scenes/DocumentTemplatize";
@@ -16,7 +17,7 @@ import { createAction } from "~/actions";
import { DocumentSection } from "~/actions/sections";
import getDataTransferFiles from "~/utils/getDataTransferFiles";
import history from "~/utils/history";
import { newDocumentPath } from "~/utils/routeHelpers";
import { homePath, newDocumentPath } from "~/utils/routeHelpers";
export const openDocument = createAction({
name: ({ t }) => t("Open document"),
@@ -133,6 +134,72 @@ export const duplicateDocument = createAction({
},
});
/**
* Pin a document to a collection. Pinned documents will be displayed at the top
* of the collection for all collection members to see.
*/
export const pinDocument = createAction({
name: ({ t }) => t("Pin to collection"),
section: DocumentSection,
icon: <PinIcon />,
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
if (!activeDocumentId || !activeCollectionId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
!!stores.policies.abilities(activeDocumentId).pin && !document?.pinned
);
},
perform: async ({ activeDocumentId, activeCollectionId, t, stores }) => {
if (!activeDocumentId || !activeCollectionId) {
return;
}
const document = stores.documents.get(activeDocumentId);
await document?.pin(document.collectionId);
const collection = stores.collections.get(activeCollectionId);
if (!collection || !location.pathname.startsWith(collection?.url)) {
stores.toasts.showToast(t("Pinned to collection"));
}
},
});
/**
* Pin a document to team home. Pinned documents will be displayed at the top
* of the home screen for all team members to see.
*/
export const pinDocumentToHome = createAction({
name: ({ t }) => t("Pin to home"),
section: DocumentSection,
icon: <PinIcon />,
visible: ({ activeDocumentId, currentTeamId, stores }) => {
if (!currentTeamId || !activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
!!stores.policies.abilities(activeDocumentId).pinToHome &&
!document?.pinnedToHome
);
},
perform: async ({ activeDocumentId, location, t, stores }) => {
if (!activeDocumentId) return;
const document = stores.documents.get(activeDocumentId);
await document?.pin();
if (location.pathname !== homePath()) {
stores.toasts.showToast(t("Pinned to team home"));
}
},
});
export const printDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Print") : t("Print document"),
@@ -234,4 +301,6 @@ export const rootDocumentActions = [
unstarDocument,
duplicateDocument,
printDocument,
pinDocument,
pinDocumentToHome,
];

View File

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

View File

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

View File

@@ -0,0 +1,253 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { m } from "framer-motion";
import { observer } from "mobx-react";
import { CloseIcon, DocumentIcon } from "outline-icons";
import { getLuminance, transparentize } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { css } from "styled-components";
import Document from "~/models/Document";
import Pin from "~/models/Pin";
import DocumentMeta from "~/components/DocumentMeta";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import useStores from "~/hooks/useStores";
import CollectionIcon from "./CollectionIcon";
import Tooltip from "./Tooltip";
type Props = {
pin: Pin | undefined;
document: Document;
canUpdatePin?: boolean;
};
function DocumentCard(props: Props) {
const { t } = useTranslation();
const { collections } = useStores();
const { document, pin, canUpdatePin } = props;
const collection = collections.get(document.collectionId);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: props.document.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const handleUnpin = React.useCallback(() => {
pin?.delete();
}, [pin]);
return (
<Reorderable
ref={setNodeRef}
style={style}
$isDragging={isDragging}
{...attributes}
>
<AnimatePresence
initial={{ opacity: 0, scale: 0.95 }}
animate={{
opacity: 1,
scale: 1,
transition: {
type: "spring",
bounce: 0.6,
},
}}
exit={{ opacity: 0, scale: 0.95 }}
>
<DocumentLink
dir={document.dir}
style={{
background:
collection?.color && getLuminance(collection.color) < 0.6
? collection.color
: undefined,
}}
$isDragging={isDragging}
to={{
pathname: document.url,
state: {
title: document.titleWithDefault,
},
}}
>
<Content justify="space-between" column>
{collection?.icon &&
collection?.icon !== "collection" &&
!pin?.collectionId ? (
<CollectionIcon collection={collection} color="white" />
) : (
<DocumentIcon color="white" />
)}
<div>
<Heading dir={document.dir}>{document.titleWithDefault}</Heading>
<StyledDocumentMeta document={document} />
</div>
</Content>
</DocumentLink>
{canUpdatePin && (
<Actions dir={document.dir} gap={4}>
{!isDragging && pin && (
<Tooltip tooltip={t("Unpin")}>
<PinButton onClick={handleUnpin}>
<CloseIcon color="currentColor" />
</PinButton>
</Tooltip>
)}
<DragHandle $isDragging={isDragging} {...listeners}>
:::
</DragHandle>
</Actions>
)}
</AnimatePresence>
</Reorderable>
);
}
const PinButton = styled(NudeButton)`
color: ${(props) => props.theme.white75};
&:hover,
&:active {
color: ${(props) => props.theme.white};
}
`;
const Actions = styled(Flex)`
position: absolute;
top: 12px;
right: ${(props) => (props.dir === "rtl" ? "auto" : "12px")};
left: ${(props) => (props.dir === "rtl" ? "12px" : "auto")};
opacity: 0;
transition: opacity 100ms ease-in-out;
// move actions above content
z-index: 2;
`;
const DragHandle = styled.div<{ $isDragging: boolean }>`
cursor: ${(props) => (props.$isDragging ? "grabbing" : "grab")};
padding: 0 4px;
font-weight: bold;
color: ${(props) => props.theme.white75};
line-height: 1.35;
&:hover,
&:active {
color: ${(props) => props.theme.white};
}
`;
const AnimatePresence = m.div;
const Reorderable = styled.div<{ $isDragging: boolean }>`
position: relative;
user-select: none;
border-radius: 8px;
// move above other cards when dragging
z-index: ${(props) => (props.$isDragging ? 1 : "inherit")};
transform: scale(${(props) => (props.$isDragging ? "1.025" : "1")});
box-shadow: ${(props) =>
props.$isDragging ? "0 0 20px rgba(0,0,0,0.3);" : "0 0 0 rgba(0,0,0,0)"};
&:hover ${Actions} {
opacity: 1;
}
`;
const Content = styled(Flex)`
min-width: 0;
height: 100%;
// move content above ::after
position: relative;
z-index: 1;
`;
const StyledDocumentMeta = styled(DocumentMeta)`
color: ${(props) => transparentize(0.25, props.theme.white)} !important;
`;
const DocumentLink = styled(Link)<{
$menuOpen?: boolean;
$isDragging?: boolean;
}>`
position: relative;
display: block;
padding: 12px;
border-radius: 8px;
height: 160px;
background: ${(props) => props.theme.slate};
color: ${(props) => props.theme.white};
transition: transform 50ms ease-in-out;
&:after {
content: "";
display: block;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.1));
border-radius: 8px;
pointer-events: none;
}
${Actions} {
opacity: 0;
}
&:hover,
&:active,
&:focus,
&:focus-within {
${Actions} {
opacity: 1;
}
${(props) =>
!props.$isDragging &&
css`
&:after {
background: rgba(0, 0, 0, 0.1);
}
`}
}
${(props) =>
props.$menuOpen &&
css`
background: ${(props) => props.theme.listItemHoverBackground};
${Actions} {
opacity: 1;
}
`}
`;
const Heading = styled.h3`
margin-top: 0;
margin-bottom: 0.35em;
line-height: 22px;
max-height: 66px; // 3*line-height
overflow: hidden;
color: ${(props) => props.theme.white};
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
`;
export default observer(DocumentCard);

View File

@@ -26,7 +26,6 @@ const Viewed = styled.span`
`;
const Modified = styled.span<{ highlight?: boolean }>`
color: ${(props) => props.theme.textTertiary};
font-weight: ${(props) => (props.highlight ? "600" : "400")};
`;

View File

@@ -12,7 +12,6 @@ type Props = {
showParentDocuments?: boolean;
showCollection?: boolean;
showPublished?: boolean;
showPin?: boolean;
showDraft?: boolean;
showTemplate?: boolean;
};
@@ -33,7 +32,12 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
fetch={fetch}
options={options}
renderItem={(item) => (
<DocumentListItem key={item.id} document={item} {...rest} />
<DocumentListItem
key={item.id}
document={item}
showPin={!!options?.collectionId}
{...rest}
/>
)}
/>
);

View File

@@ -0,0 +1,137 @@
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import { restrictToParentElement } from "@dnd-kit/modifiers";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
rectSortingStrategy,
} from "@dnd-kit/sortable";
import fractionalIndex from "fractional-index";
import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Pin from "~/models/Pin";
import DocumentCard from "~/components/DocumentCard";
import useStores from "~/hooks/useStores";
type Props = {
/** Pins to display */
pins: Pin[];
/** Maximum number of pins to display */
limit?: number;
/** Whether the user has permission to update pins */
canUpdate?: boolean;
};
function PinnedDocuments({ limit, pins, canUpdate, ...rest }: Props) {
const { documents } = useStores();
const [items, setItems] = React.useState(pins.map((pin) => pin.documentId));
React.useEffect(() => {
setItems(pins.map((pin) => pin.documentId));
}, [pins]);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = React.useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setItems((items) => {
const activePos = items.indexOf(active.id);
const overPos = items.indexOf(over.id);
const overIndex = pins[overPos]?.index || null;
const nextIndex = pins[overPos + 1]?.index || null;
const prevIndex = pins[overPos - 1]?.index || null;
const pin = pins[activePos];
// Update the order on the backend, revert if the call fails
pin
.save({
index:
overPos === 0
? fractionalIndex(null, overIndex)
: activePos > overPos
? fractionalIndex(prevIndex, overIndex)
: fractionalIndex(overIndex, nextIndex),
})
.catch(() => setItems(items));
// Update the order in state immediately
return arrayMove(items, activePos, overPos);
});
}
},
[pins]
);
return (
<DndContext
sensors={sensors}
modifiers={[restrictToParentElement]}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={items} strategy={rectSortingStrategy}>
<List>
<AnimatePresence initial={false}>
{items.map((documentId) => {
const document = documents.get(documentId);
const pin = pins.find((pin) => pin.documentId === documentId);
return document ? (
<DocumentCard
key={documentId}
document={document}
canUpdatePin={canUpdate}
pin={pin}
{...rest}
/>
) : null;
})}
</AnimatePresence>
</List>
</SortableContext>
</DndContext>
);
}
const List = styled.div`
display: grid;
column-gap: 8px;
row-gap: 8px;
grid-template-columns: repeat(2, minmax(0, 1fr));
padding: 0;
list-style: none;
&:not(:empty) {
margin: 16px 0 32px;
}
${breakpoint("tablet")`
grid-template-columns: repeat(3, minmax(0, 1fr));
`};
${breakpoint("desktop")`
grid-template-columns: repeat(4, minmax(0, 1fr));
`};
`;
export default observer(PinnedDocuments);

View File

@@ -1,10 +1,8 @@
import invariant from "invariant";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
import { actionToMenuItem } from "~/actions";
import useStores from "~/hooks/useStores";
import useActionContext from "~/hooks/useActionContext";
import { Action } from "~/types";
import SidebarLink from "./SidebarLink";
@@ -14,18 +12,12 @@ type Props = {
};
function SidebarAction({ action, ...rest }: Props) {
const stores = useStores();
const { t } = useTranslation();
const location = useLocation();
const context = {
const context = useActionContext({
isContextMenu: false,
isCommandBar: false,
activeCollectionId: undefined,
activeDocumentId: undefined,
location,
stores,
t,
};
});
const menuItem = actionToMenuItem(action, context);
invariant(menuItem.type === "button", "passed action must be a button");

View File

@@ -71,6 +71,7 @@ class SocketProvider extends React.Component<Props> {
documents,
collections,
groups,
pins,
memberships,
policies,
presence,
@@ -260,6 +261,18 @@ class SocketProvider extends React.Component<Props> {
}
});
this.socket.on("pins.create", (event: any) => {
pins.add(event);
});
this.socket.on("pins.update", (event: any) => {
pins.add(event);
});
this.socket.on("pins.delete", (event: any) => {
pins.remove(event.modelId);
});
this.socket.on("documents.star", (event: any) => {
documents.starredIds.set(event.documentId, true);
});

View File

@@ -0,0 +1,32 @@
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
import useStores from "~/hooks/useStores";
import { ActionContext } from "~/types";
/**
* Hook to get the current action context, an object that is passed to all
* action definitions.
*
* @param overrides Overides of the default action context.
* @returns The current action context.
*/
export default function useActionContext(
overrides?: Partial<ActionContext>
): ActionContext {
const stores = useStores();
const { t } = useTranslation();
const location = useLocation();
return {
isContextMenu: false,
isCommandBar: false,
activeCollectionId: stores.ui.activeCollectionId,
activeDocumentId: stores.ui.activeDocumentId,
currentUserId: stores.auth.user?.id,
currentTeamId: stores.auth.team?.id,
...overrides,
location,
stores,
t,
};
}

View File

@@ -1,24 +1,21 @@
import { useRegisterActions } from "kbar";
import { flattenDeep } from "lodash";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { actionToKBar } from "~/actions";
import useStores from "~/hooks/useStores";
import { Action } from "~/types";
import useActionContext from "./useActionContext";
/**
* Hook to add actions to the command bar while the hook is inside a mounted
* component.
*
* @param actions actions to make available
*/
export default function useCommandBarActions(actions: Action[]) {
const stores = useStores();
const { t } = useTranslation();
const location = useLocation();
const context = {
t,
const context = useActionContext({
isCommandBar: true,
isContextMenu: false,
activeCollectionId: stores.ui.activeCollectionId,
activeDocumentId: stores.ui.activeDocumentId,
location,
stores,
};
});
const registerable = flattenDeep(
actions.map((action) => actionToKBar(action, context))

View File

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

View File

@@ -1,5 +1,4 @@
import { addDays, differenceInDays } from "date-fns";
import invariant from "invariant";
import { floor } from "lodash";
import { action, computed, observable } from "mobx";
import parseTitle from "@shared/utils/parseTitle";
@@ -72,8 +71,6 @@ export default class Document extends BaseModel {
updatedBy: User;
pinned: boolean;
publishedAt: string | undefined;
archivedAt: string;
@@ -240,31 +237,23 @@ export default class Document extends BaseModel {
};
@action
pin = async () => {
this.pinned = true;
try {
const res = await this.store.pin(this);
invariant(res && res.data, "Data should be available");
this.updateFromJson(res.data);
} catch (err) {
this.pinned = false;
throw err;
}
pin = async (collectionId?: string) => {
await this.store.rootStore.pins.create({
documentId: this.id,
...(collectionId ? { collectionId } : {}),
});
};
@action
unpin = async () => {
this.pinned = false;
unpin = async (collectionId?: string) => {
const pin = this.store.rootStore.pins.orderedData.find(
(pin) =>
pin.documentId === this.id &&
(pin.collectionId === collectionId ||
(!collectionId && !pin.collectionId))
);
try {
const res = await this.store.unpin(this);
invariant(res && res.data, "Data should be available");
this.updateFromJson(res.data);
} catch (err) {
this.pinned = true;
throw err;
}
await pin?.delete();
};
@action
@@ -387,6 +376,21 @@ export default class Document extends BaseModel {
return result;
};
@computed
get pinned(): boolean {
return !!this.store.rootStore.pins.orderedData.find(
(pin) =>
pin.documentId === this.id && pin.collectionId === this.collectionId
);
}
@computed
get pinnedToHome(): boolean {
return !!this.store.rootStore.pins.orderedData.find(
(pin) => pin.documentId === this.id && !pin.collectionId
);
}
@computed
get isActive(): boolean {
return !this.isDeleted && !this.isTemplate && !this.isArchived;

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

@@ -0,0 +1,18 @@
import { observable } from "mobx";
import BaseModel from "./BaseModel";
import Field from "./decorators/Field";
class Pin extends BaseModel {
id: string;
collectionId: string;
documentId: string;
@observable
@Field
index: string;
createdAt: string;
updatedAt: string;
}
export default Pin;

View File

@@ -5,7 +5,6 @@ import Collection from "~/scenes/Collection";
import DocumentNew from "~/scenes/DocumentNew";
import Drafts from "~/scenes/Drafts";
import Error404 from "~/scenes/Error404";
import Home from "~/scenes/Home";
import Search from "~/scenes/Search";
import Templates from "~/scenes/Templates";
import Trash from "~/scenes/Trash";
@@ -30,6 +29,13 @@ const Document = React.lazy(
"~/scenes/Document"
)
);
const Home = React.lazy(
() =>
import(
/* webpackChunkName: "home" */
"~/scenes/Home"
)
);
const NotFound = () => <Search notFound />;

View File

@@ -1,78 +1,50 @@
import { observer } from "mobx-react";
import { NewDocumentIcon, PlusIcon, PinIcon, MoreIcon } from "outline-icons";
import * as React from "react";
import Dropzone from "react-dropzone";
import { useTranslation, Trans } from "react-i18next";
import { useTranslation } from "react-i18next";
import {
useParams,
Redirect,
Link,
Switch,
Route,
useHistory,
useRouteMatch,
} from "react-router-dom";
import styled, { css } from "styled-components";
import Collection from "~/models/Collection";
import CollectionPermissions from "~/scenes/CollectionPermissions";
import Search from "~/scenes/Search";
import { Action, Separator } from "~/components/Actions";
import Badge from "~/components/Badge";
import Button from "~/components/Button";
import CenteredContent from "~/components/CenteredContent";
import CollectionDescription from "~/components/CollectionDescription";
import CollectionIcon from "~/components/CollectionIcon";
import DocumentList from "~/components/DocumentList";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
import HelpText from "~/components/HelpText";
import InputSearchPage from "~/components/InputSearchPage";
import PlaceholderList from "~/components/List/Placeholder";
import LoadingIndicator from "~/components/LoadingIndicator";
import Modal from "~/components/Modal";
import PaginatedDocumentList from "~/components/PaginatedDocumentList";
import PinnedDocuments from "~/components/PinnedDocuments";
import PlaceholderText from "~/components/PlaceholderText";
import Scene from "~/components/Scene";
import Subheading from "~/components/Subheading";
import Tab from "~/components/Tab";
import Tabs from "~/components/Tabs";
import Tooltip from "~/components/Tooltip";
import { editCollection } from "~/actions/definitions/collections";
import useBoolean from "~/hooks/useBoolean";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useImportDocument from "~/hooks/useImportDocument";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import CollectionMenu from "~/menus/CollectionMenu";
import {
newDocumentPath,
collectionUrl,
updateCollectionUrl,
} from "~/utils/routeHelpers";
import { collectionUrl, updateCollectionUrl } from "~/utils/routeHelpers";
import Actions from "./Collection/Actions";
import DropToImport from "./Collection/DropToImport";
import Empty from "./Collection/Empty";
function CollectionScene() {
const params = useParams<{ id?: string }>();
const history = useHistory();
const match = useRouteMatch();
const { t } = useTranslation();
const { documents, policies, collections, ui } = useStores();
const { showToast } = useToasts();
const team = useCurrentTeam();
const { documents, pins, policies, collections, ui } = useStores();
const [isFetching, setFetching] = React.useState(false);
const [error, setError] = React.useState<Error | undefined>();
const [
permissionsModalOpen,
handlePermissionsModalOpen,
handlePermissionsModalClose,
] = useBoolean();
const id = params.id || "";
const collection: Collection | null | undefined =
collections.getByUrl(id) || collections.get(id);
const can = policies.abilities(collection?.id || "");
const canUser = policies.abilities(team.id);
const { handleFiles, isImporting } = useImportDocument(collection?.id || "");
React.useEffect(() => {
if (collection) {
@@ -94,11 +66,11 @@ function CollectionScene() {
setError(undefined);
if (collection) {
documents.fetchPinned({
pins.fetchPage({
collectionId: collection.id,
});
}
}, [documents, collection]);
}, [pins, collection]);
React.useEffect(() => {
async function load() {
@@ -117,27 +89,13 @@ function CollectionScene() {
load();
}, [collections, isFetching, collection, error, id, can]);
useCommandBarActions([editCollection]);
const handleRejection = React.useCallback(() => {
showToast(
t("Document not supported try Markdown, Plain text, HTML, or Word"),
{
type: "error",
}
);
}, [t, showToast]);
useCommandBarActions([editCollection]);
if (!collection && error) {
return <Search notFound />;
}
const pinnedDocuments = collection
? documents.pinnedInCollection(collection.id)
: [];
const collectionName = collection ? collection.name : "";
const hasPinnedDocuments = !!pinnedDocuments.length;
return collection ? (
<Scene
centered={false}
@@ -149,246 +107,127 @@ function CollectionScene() {
{collection.name}
</>
}
actions={
<>
<Action>
<InputSearchPage
source="collection"
placeholder={`${t("Search in collection")}`}
label={`${t("Search in collection")}`}
collectionId={collection.id}
/>
</Action>
{can.update && (
actions={<Actions collection={collection} />}
>
<DropToImport
accept={documents.importFileTypes.join(", ")}
disabled={!can.update}
collectionId={collection.id}
>
<CenteredContent withStickyHeader>
{collection.isEmpty ? (
<Empty collection={collection} />
) : (
<>
<Action>
<Tooltip
tooltip={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
>
<Button
as={Link}
to={collection ? newDocumentPath(collection.id) : ""}
disabled={!collection}
icon={<PlusIcon />}
<Heading>
<CollectionIcon collection={collection} size={40} expanded />{" "}
{collection.name}{" "}
{!collection.permission && (
<Tooltip
tooltip={t(
"This collection is only visible to those given access"
)}
placement="bottom"
>
{t("New doc")}
</Button>
</Tooltip>
</Action>
<Separator />
<Badge>{t("Private")}</Badge>
</Tooltip>
)}
</Heading>
<CollectionDescription collection={collection} />
<PinnedDocuments
pins={pins.inCollection(collection.id)}
canUpdate={can.update}
/>
<Tabs>
<Tab to={collectionUrl(collection.url)} exact>
{t("Documents")}
</Tab>
<Tab to={collectionUrl(collection.url, "updated")} exact>
{t("Recently updated")}
</Tab>
<Tab to={collectionUrl(collection.url, "published")} exact>
{t("Recently published")}
</Tab>
<Tab to={collectionUrl(collection.url, "old")} exact>
{t("Least recently updated")}
</Tab>
<Tab to={collectionUrl(collection.url, "alphabetical")} exact>
{t("AZ")}
</Tab>
</Tabs>
<Switch>
<Route path={collectionUrl(collection.url, "alphabetical")}>
<PaginatedDocumentList
key="alphabetical"
documents={documents.alphabeticalInCollection(
collection.id
)}
fetch={documents.fetchAlphabetical}
options={{
collectionId: collection.id,
}}
/>
</Route>
<Route path={collectionUrl(collection.url, "old")}>
<PaginatedDocumentList
key="old"
documents={documents.leastRecentlyUpdatedInCollection(
collection.id
)}
fetch={documents.fetchLeastRecentlyUpdated}
options={{
collectionId: collection.id,
}}
/>
</Route>
<Route path={collectionUrl(collection.url, "recent")}>
<Redirect to={collectionUrl(collection.url, "published")} />
</Route>
<Route path={collectionUrl(collection.url, "published")}>
<PaginatedDocumentList
key="published"
documents={documents.recentlyPublishedInCollection(
collection.id
)}
fetch={documents.fetchRecentlyPublished}
options={{
collectionId: collection.id,
}}
showPublished
/>
</Route>
<Route path={collectionUrl(collection.url, "updated")}>
<PaginatedDocumentList
key="updated"
documents={documents.recentlyUpdatedInCollection(
collection.id
)}
fetch={documents.fetchRecentlyUpdated}
options={{
collectionId: collection.id,
}}
/>
</Route>
<Route path={collectionUrl(collection.url)} exact>
<PaginatedDocumentList
documents={documents.rootInCollection(collection.id)}
fetch={documents.fetchPage}
options={{
collectionId: collection.id,
parentDocumentId: null,
sort: collection.sort.field,
direction: "ASC",
}}
showParentDocuments
/>
</Route>
</Switch>
</>
)}
<Action>
<CollectionMenu
collection={collection}
placement="bottom-end"
label={(props) => (
<Button
icon={<MoreIcon />}
{...props}
borderOnHover
neutral
small
/>
)}
/>
</Action>
</>
}
>
<Dropzone
accept={documents.importFileTypes.join(", ")}
onDropAccepted={handleFiles}
onDropRejected={handleRejection}
disabled={!can.update}
noClick
multiple
>
{({ getRootProps, getInputProps, isDragActive }) => (
<DropzoneContainer
{...getRootProps()}
isDragActive={isDragActive}
tabIndex={-1}
>
<input {...getInputProps()} />
{isImporting && <LoadingIndicator />}
<CenteredContent withStickyHeader>
{collection.isEmpty ? (
<Centered column>
<HelpText>
<Trans
defaults="<em>{{ collectionName }}</em> doesnt contain any
documents yet."
values={{
collectionName,
}}
components={{
em: <strong />,
}}
/>
<br />
{canUser.createDocument && (
<Trans>Get started by creating a new one!</Trans>
)}
</HelpText>
<Empty>
{canUser.createDocument && (
<Link to={newDocumentPath(collection.id)}>
<Button icon={<NewDocumentIcon color="currentColor" />}>
{t("Create a document")}
</Button>
</Link>
)}
&nbsp;&nbsp;
<Button onClick={handlePermissionsModalOpen} neutral>
{t("Manage permissions")}
</Button>
</Empty>
<Modal
title={t("Collection permissions")}
onRequestClose={handlePermissionsModalClose}
isOpen={permissionsModalOpen}
>
<CollectionPermissions collection={collection} />
</Modal>
</Centered>
) : (
<>
<Heading>
<CollectionIcon
collection={collection}
size={40}
expanded
/>{" "}
{collection.name}{" "}
{!collection.permission && (
<Tooltip
tooltip={t(
"This collection is only visible to those given access"
)}
placement="bottom"
>
<Badge>{t("Private")}</Badge>
</Tooltip>
)}
</Heading>
<CollectionDescription collection={collection} />
{hasPinnedDocuments && (
<>
<Subheading sticky>
<TinyPinIcon size={18} color="currentColor" />{" "}
{t("Pinned")}
</Subheading>
<DocumentList documents={pinnedDocuments} showPin />
</>
)}
<Tabs>
<Tab to={collectionUrl(collection.url)} exact>
{t("Documents")}
</Tab>
<Tab to={collectionUrl(collection.url, "updated")} exact>
{t("Recently updated")}
</Tab>
<Tab to={collectionUrl(collection.url, "published")} exact>
{t("Recently published")}
</Tab>
<Tab to={collectionUrl(collection.url, "old")} exact>
{t("Least recently updated")}
</Tab>
<Tab
to={collectionUrl(collection.url, "alphabetical")}
exact
>
{t("AZ")}
</Tab>
</Tabs>
<Switch>
<Route path={collectionUrl(collection.url, "alphabetical")}>
<PaginatedDocumentList
key="alphabetical"
documents={documents.alphabeticalInCollection(
collection.id
)}
fetch={documents.fetchAlphabetical}
options={{
collectionId: collection.id,
}}
showPin
/>
</Route>
<Route path={collectionUrl(collection.url, "old")}>
<PaginatedDocumentList
key="old"
documents={documents.leastRecentlyUpdatedInCollection(
collection.id
)}
fetch={documents.fetchLeastRecentlyUpdated}
options={{
collectionId: collection.id,
}}
showPin
/>
</Route>
<Route path={collectionUrl(collection.url, "recent")}>
<Redirect
to={collectionUrl(collection.url, "published")}
/>
</Route>
<Route path={collectionUrl(collection.url, "published")}>
<PaginatedDocumentList
key="published"
documents={documents.recentlyPublishedInCollection(
collection.id
)}
fetch={documents.fetchRecentlyPublished}
options={{
collectionId: collection.id,
}}
showPublished
showPin
/>
</Route>
<Route path={collectionUrl(collection.url, "updated")}>
<PaginatedDocumentList
key="updated"
documents={documents.recentlyUpdatedInCollection(
collection.id
)}
fetch={documents.fetchRecentlyUpdated}
options={{
collectionId: collection.id,
}}
showPin
/>
</Route>
<Route path={collectionUrl(collection.url)} exact>
<PaginatedDocumentList
documents={documents.rootInCollection(collection.id)}
fetch={documents.fetchPage}
options={{
collectionId: collection.id,
parentDocumentId: null,
sort: collection.sort.field,
direction: "ASC",
}}
showParentDocuments
showPin
/>
</Route>
</Switch>
</>
)}
<DropMessage>{t("Drop documents to import")}</DropMessage>
</CenteredContent>
</DropzoneContainer>
)}
</Dropzone>
</CenteredContent>
</DropToImport>
</Scene>
) : (
<CenteredContent>
@@ -400,61 +239,4 @@ function CollectionScene() {
);
}
const DropMessage = styled(HelpText)`
opacity: 0;
pointer-events: none;
`;
const DropzoneContainer = styled.div<{ isDragActive?: boolean }>`
outline-color: transparent !important;
min-height: calc(100% - 56px);
position: relative;
${({ isDragActive, theme }) =>
isDragActive &&
css`
&:after {
display: block;
content: "";
position: absolute;
top: 24px;
right: 24px;
bottom: 24px;
left: 24px;
background: ${theme.background};
border-radius: 8px;
border: 1px dashed ${theme.divider};
z-index: 1;
}
${DropMessage} {
opacity: 1;
z-index: 2;
position: absolute;
text-align: center;
top: 50%;
left: 50%;
transform: translateX(-50%);
}
`}
`;
const Centered = styled(Flex)`
text-align: center;
margin: 40vh auto 0;
max-width: 380px;
transform: translateY(-50%);
`;
const TinyPinIcon = styled(PinIcon)`
position: relative;
top: 4px;
opacity: 0.8;
`;
const Empty = styled(Flex)`
justify-content: center;
margin: 10px 0;
`;
export default observer(CollectionScene);

View File

@@ -0,0 +1,75 @@
import { observer } from "mobx-react";
import { MoreIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import Collection from "~/models/Collection";
import { Action, Separator } from "~/components/Actions";
import Button from "~/components/Button";
import InputSearchPage from "~/components/InputSearchPage";
import Tooltip from "~/components/Tooltip";
import useStores from "~/hooks/useStores";
import CollectionMenu from "~/menus/CollectionMenu";
import { newDocumentPath } from "~/utils/routeHelpers";
type Props = {
collection: Collection;
};
function Actions({ collection }: Props) {
const { t } = useTranslation();
const { policies } = useStores();
const can = policies.abilities(collection.id);
return (
<>
<Action>
<InputSearchPage
source="collection"
placeholder={`${t("Search in collection")}`}
label={`${t("Search in collection")}`}
collectionId={collection.id}
/>
</Action>
{can.update && (
<>
<Action>
<Tooltip
tooltip={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
>
<Button
as={Link}
to={collection ? newDocumentPath(collection.id) : ""}
disabled={!collection}
icon={<PlusIcon />}
>
{t("New doc")}
</Button>
</Tooltip>
</Action>
<Separator />
</>
)}
<Action>
<CollectionMenu
collection={collection}
placement="bottom-end"
label={(props) => (
<Button
icon={<MoreIcon />}
{...props}
borderOnHover
neutral
small
/>
)}
/>
</Action>
</>
);
}
export default observer(Actions);

View File

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

View File

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

View File

@@ -9,19 +9,28 @@ import Heading from "~/components/Heading";
import InputSearchPage from "~/components/InputSearchPage";
import LanguagePrompt from "~/components/LanguagePrompt";
import PaginatedDocumentList from "~/components/PaginatedDocumentList";
import PinnedDocuments from "~/components/PinnedDocuments";
import Scene from "~/components/Scene";
import Tab from "~/components/Tab";
import Tabs from "~/components/Tabs";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import NewDocumentMenu from "~/menus/NewDocumentMenu";
function Home() {
const { documents, ui } = useStores();
const { documents, pins, policies, ui } = useStores();
const team = useCurrentTeam();
const user = useCurrentUser();
const userId = user?.id;
const { t } = useTranslation();
React.useEffect(() => {
pins.fetchPage();
}, [pins]);
const canManageTeam = policies.abilities(team.id).manage;
return (
<Scene
icon={<HomeIcon color="currentColor" />}
@@ -39,6 +48,7 @@ function Home() {
>
{!ui.languagePromptDismissed && <LanguagePrompt />}
<Heading>{t("Home")}</Heading>
<PinnedDocuments pins={pins.home} canUpdate={canManageTeam} />
<Tabs>
<Tab to="/home" exact>
{t("Recently viewed")}

View File

@@ -18,9 +18,10 @@ import {
} from "~/types";
import { client } from "~/utils/ApiClient";
type FetchParams = PaginationParams & { collectionId: string };
type FetchPageParams = PaginationParams & { template?: boolean };
type FetchPageParams = PaginationParams & {
template?: boolean;
collectionId?: string;
};
export type SearchParams = {
offset?: number;
@@ -127,13 +128,6 @@ export default class DocumentsStore extends BaseStore<Document> {
);
}
pinnedInCollection(collectionId: string): Document[] {
return filter(
this.recentlyUpdatedInCollection(collectionId),
(document) => document.pinned
);
}
publishedInCollection(collectionId: string): Document[] {
return filter(
this.all,
@@ -296,7 +290,7 @@ export default class DocumentsStore extends BaseStore<Document> {
fetchNamedPage = async (
request = "list",
options: FetchPageParams | undefined
): Promise<Document[] | undefined> => {
): Promise<Document[]> => {
this.isFetching = true;
try {
@@ -377,11 +371,6 @@ export default class DocumentsStore extends BaseStore<Document> {
return this.fetchNamedPage("drafts", options);
};
@action
fetchPinned = (options?: FetchParams): Promise<any> => {
return this.fetchNamedPage("pinned", options);
};
@action
fetchOwned = (options?: PaginationParams): Promise<any> => {
return this.fetchNamedPage("list", options);
@@ -732,18 +721,6 @@ export default class DocumentsStore extends BaseStore<Document> {
if (collection) collection.refresh();
};
pin = (document: Document) => {
return client.post("/documents.pin", {
id: document.id,
});
};
unpin = (document: Document) => {
return client.post("/documents.unpin", {
id: document.id,
});
};
star = async (document: Document) => {
this.starredIds.set(document.id, true);

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

@@ -0,0 +1,57 @@
import invariant from "invariant";
import { action, runInAction, computed } from "mobx";
import Pin from "~/models/Pin";
import { PaginationParams } from "~/types";
import { client } from "~/utils/ApiClient";
import BaseStore from "./BaseStore";
import RootStore from "./RootStore";
type FetchParams = PaginationParams & { collectionId?: string };
export default class PinsStore extends BaseStore<Pin> {
constructor(rootStore: RootStore) {
super(rootStore, Pin);
}
@action
fetchPage = async (params?: FetchParams | undefined): Promise<void> => {
this.isFetching = true;
try {
const res = await client.post(`/pins.list`, params);
invariant(res && res.data, "Data not available");
runInAction(`PinsStore#fetchPage`, () => {
res.data.documents.forEach(this.rootStore.documents.add);
res.data.pins.forEach(this.add);
this.addPolicies(res.policies);
this.isLoaded = true;
});
} finally {
this.isFetching = false;
}
};
inCollection = (collectionId: string) => {
return computed(() => this.orderedData)
.get()
.filter((pin) => pin.collectionId === collectionId);
};
@computed
get home() {
return this.orderedData.filter((pin) => !pin.collectionId);
}
@computed
get orderedData(): Pin[] {
const pins = Array.from(this.data.values());
return pins.sort((a, b) => {
if (a.index === b.index) {
return a.updatedAt > b.updatedAt ? -1 : 1;
}
return a.index < b.index ? -1 : 1;
});
}
}

View File

@@ -12,6 +12,7 @@ import GroupsStore from "./GroupsStore";
import IntegrationsStore from "./IntegrationsStore";
import MembershipsStore from "./MembershipsStore";
import NotificationSettingsStore from "./NotificationSettingsStore";
import PinsStore from "./PinsStore";
import PoliciesStore from "./PoliciesStore";
import RevisionsStore from "./RevisionsStore";
import SearchesStore from "./SearchesStore";
@@ -35,6 +36,7 @@ export default class RootStore {
memberships: MembershipsStore;
notificationSettings: NotificationSettingsStore;
presence: DocumentPresenceStore;
pins: PinsStore;
policies: PoliciesStore;
revisions: RevisionsStore;
searches: SearchesStore;
@@ -59,6 +61,7 @@ export default class RootStore {
this.groupMemberships = new GroupMembershipsStore(this);
this.integrations = new IntegrationsStore(this);
this.memberships = new MembershipsStore(this);
this.pins = new PinsStore(this);
this.notificationSettings = new NotificationSettingsStore(this);
this.presence = new DocumentPresenceStore();
this.revisions = new RevisionsStore(this);
@@ -84,6 +87,7 @@ export default class RootStore {
this.memberships.clear();
this.notificationSettings.clear();
this.presence.clear();
this.pins.clear();
this.policies.clear();
this.revisions.clear();
this.searches.clear();

View File

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

View File

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

View File

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