Move tree implementation out of collections store (#4763)
* refactor: attaching emoji in tree node is unnecessary * refactor: pass depth and hasChildren as separate props * refactor: move tree impl into a separate hook * refactor: separate out as DocumentExplorer for reuse * fix: separate search and node * fix: review comments * fix: tsc
This commit is contained in:
@@ -3,11 +3,12 @@ import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
import type { NavigationNode } from "@shared/types";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import Breadcrumb from "~/components/Breadcrumb";
|
import Breadcrumb from "~/components/Breadcrumb";
|
||||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import { MenuInternalLink, NavigationNode } from "~/types";
|
import { MenuInternalLink } from "~/types";
|
||||||
import { collectionUrl } from "~/utils/routeHelpers";
|
import { collectionUrl } from "~/utils/routeHelpers";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|||||||
375
app/components/DocumentExplorer.tsx
Normal file
375
app/components/DocumentExplorer.tsx
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
import FuzzySearch from "fuzzy-search";
|
||||||
|
import { includes, difference, concat, filter, flatten } from "lodash";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { StarredIcon, DocumentIcon } from "outline-icons";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import AutoSizer from "react-virtualized-auto-sizer";
|
||||||
|
import { FixedSizeList as List } from "react-window";
|
||||||
|
import styled, { useTheme } from "styled-components";
|
||||||
|
import breakpoint from "styled-components-breakpoint";
|
||||||
|
import { NavigationNode } from "@shared/types";
|
||||||
|
import parseTitle from "@shared/utils/parseTitle";
|
||||||
|
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
||||||
|
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
||||||
|
import Flex from "~/components/Flex";
|
||||||
|
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||||
|
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
||||||
|
import { Outline } from "~/components/Input";
|
||||||
|
import InputSearch from "~/components/InputSearch";
|
||||||
|
import Text from "~/components/Text";
|
||||||
|
import useCollectionTrees from "~/hooks/useCollectionTrees";
|
||||||
|
import useMobile from "~/hooks/useMobile";
|
||||||
|
import useStores from "~/hooks/useStores";
|
||||||
|
import { isModKey } from "~/utils/keyboard";
|
||||||
|
import { flattenTree, ancestors, descendants } from "~/utils/tree";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** Action taken upon submission of selected item, could be publish, move etc. */
|
||||||
|
onSubmit: () => void;
|
||||||
|
|
||||||
|
/** A side-effect of item selection */
|
||||||
|
onSelect: (item: NavigationNode | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function DocumentExplorer({ onSubmit, onSelect }: Props) {
|
||||||
|
const isMobile = useMobile();
|
||||||
|
const { collections, documents } = useStores();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const theme = useTheme();
|
||||||
|
const collectionTrees = useCollectionTrees();
|
||||||
|
|
||||||
|
const [searchTerm, setSearchTerm] = React.useState<string>();
|
||||||
|
const [selectedNode, selectNode] = React.useState<NavigationNode | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [initialScrollOffset, setInitialScrollOffset] = React.useState<number>(
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const [nodes, setNodes] = React.useState<NavigationNode[]>([]);
|
||||||
|
const [activeNode, setActiveNode] = React.useState<number>(0);
|
||||||
|
const [expandedNodes, setExpandedNodes] = React.useState<string[]>([]);
|
||||||
|
|
||||||
|
const inputSearchRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const listRef = React.useRef<List<NavigationNode[]>>(null);
|
||||||
|
|
||||||
|
const VERTICAL_PADDING = 6;
|
||||||
|
const HORIZONTAL_PADDING = 24;
|
||||||
|
|
||||||
|
const allNodes = React.useMemo(
|
||||||
|
() => flatten(collectionTrees.map(flattenTree)),
|
||||||
|
[collectionTrees]
|
||||||
|
);
|
||||||
|
|
||||||
|
const searchIndex = React.useMemo(() => {
|
||||||
|
return new FuzzySearch(allNodes, ["title"], {
|
||||||
|
caseSensitive: false,
|
||||||
|
});
|
||||||
|
}, [allNodes]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (searchTerm) {
|
||||||
|
selectNode(null);
|
||||||
|
setExpandedNodes([]);
|
||||||
|
}
|
||||||
|
setActiveNode(0);
|
||||||
|
}, [searchTerm]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let results;
|
||||||
|
|
||||||
|
if (searchTerm) {
|
||||||
|
results = searchIndex.search(searchTerm);
|
||||||
|
} else {
|
||||||
|
results = allNodes.filter((r) => r.type === "collection");
|
||||||
|
}
|
||||||
|
|
||||||
|
setInitialScrollOffset(0);
|
||||||
|
setNodes(results);
|
||||||
|
}, [searchTerm, allNodes, searchIndex]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
onSelect(selectedNode);
|
||||||
|
}, [selectedNode, onSelect]);
|
||||||
|
|
||||||
|
const handleSearch = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSearchTerm(ev.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isExpanded = (node: number) => {
|
||||||
|
return includes(expandedNodes, nodes[node].id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateInitialScrollOffset = (itemCount: number) => {
|
||||||
|
if (listRef.current) {
|
||||||
|
const { height, itemSize } = listRef.current.props;
|
||||||
|
const { scrollOffset } = listRef.current.state as {
|
||||||
|
scrollOffset: number;
|
||||||
|
};
|
||||||
|
const itemsHeight = itemCount * itemSize;
|
||||||
|
return itemsHeight < height ? 0 : scrollOffset;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collapse = (node: number) => {
|
||||||
|
const descendantIds = descendants(nodes[node]).map((des) => des.id);
|
||||||
|
setExpandedNodes(
|
||||||
|
difference(expandedNodes, [...descendantIds, nodes[node].id])
|
||||||
|
);
|
||||||
|
|
||||||
|
// remove children
|
||||||
|
const newNodes = filter(nodes, (node) => !includes(descendantIds, node.id));
|
||||||
|
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
|
||||||
|
setInitialScrollOffset(scrollOffset);
|
||||||
|
setNodes(newNodes);
|
||||||
|
};
|
||||||
|
|
||||||
|
const expand = (node: number) => {
|
||||||
|
setExpandedNodes(concat(expandedNodes, nodes[node].id));
|
||||||
|
|
||||||
|
// add children
|
||||||
|
const newNodes = nodes.slice();
|
||||||
|
newNodes.splice(node + 1, 0, ...descendants(nodes[node], 1));
|
||||||
|
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
|
||||||
|
setInitialScrollOffset(scrollOffset);
|
||||||
|
setNodes(newNodes);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSelected = (node: number) => {
|
||||||
|
if (!selectedNode) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const selectedNodeId = selectedNode.id;
|
||||||
|
const nodeId = nodes[node].id;
|
||||||
|
|
||||||
|
return selectedNodeId === nodeId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCollapse = (node: number) => {
|
||||||
|
if (isExpanded(node)) {
|
||||||
|
collapse(node);
|
||||||
|
} else {
|
||||||
|
expand(node);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelect = (node: number) => {
|
||||||
|
if (isSelected(node)) {
|
||||||
|
selectNode(null);
|
||||||
|
} else {
|
||||||
|
selectNode(nodes[node]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ListItem = ({
|
||||||
|
index,
|
||||||
|
data,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
index: number;
|
||||||
|
data: NavigationNode[];
|
||||||
|
style: React.CSSProperties;
|
||||||
|
}) => {
|
||||||
|
const node = data[index];
|
||||||
|
const isCollection = node.type === "collection";
|
||||||
|
let icon, title, path;
|
||||||
|
|
||||||
|
if (isCollection) {
|
||||||
|
const col = collections.get(node.collectionId as string);
|
||||||
|
icon = col && (
|
||||||
|
<CollectionIcon collection={col} expanded={isExpanded(index)} />
|
||||||
|
);
|
||||||
|
title = node.title;
|
||||||
|
} else {
|
||||||
|
const doc = documents.get(node.id);
|
||||||
|
const { strippedTitle, emoji } = parseTitle(node.title);
|
||||||
|
title = strippedTitle;
|
||||||
|
|
||||||
|
if (emoji) {
|
||||||
|
icon = <EmojiIcon emoji={emoji} />;
|
||||||
|
} else if (doc?.isStarred) {
|
||||||
|
icon = <StarredIcon color={theme.yellow} />;
|
||||||
|
} else {
|
||||||
|
icon = <DocumentIcon />;
|
||||||
|
}
|
||||||
|
|
||||||
|
path = ancestors(node)
|
||||||
|
.map((a) => parseTitle(a.title).strippedTitle)
|
||||||
|
.join(" / ");
|
||||||
|
}
|
||||||
|
|
||||||
|
return searchTerm ? (
|
||||||
|
<DocumentExplorerSearchResult
|
||||||
|
selected={isSelected(index)}
|
||||||
|
active={activeNode === index}
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
top: (style.top as number) + VERTICAL_PADDING,
|
||||||
|
left: (style.left as number) + HORIZONTAL_PADDING,
|
||||||
|
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
||||||
|
}}
|
||||||
|
onPointerMove={() => setActiveNode(index)}
|
||||||
|
onClick={() => toggleSelect(index)}
|
||||||
|
icon={icon}
|
||||||
|
title={title}
|
||||||
|
path={path}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DocumentExplorerNode
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
top: (style.top as number) + VERTICAL_PADDING,
|
||||||
|
left: (style.left as number) + HORIZONTAL_PADDING,
|
||||||
|
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
||||||
|
}}
|
||||||
|
onPointerMove={() => setActiveNode(index)}
|
||||||
|
onClick={() => toggleSelect(index)}
|
||||||
|
onDisclosureClick={(ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
toggleCollapse(index);
|
||||||
|
}}
|
||||||
|
selected={isSelected(index)}
|
||||||
|
active={activeNode === index}
|
||||||
|
expanded={isExpanded(index)}
|
||||||
|
icon={icon}
|
||||||
|
title={title}
|
||||||
|
depth={node.depth as number}
|
||||||
|
hasChildren={node.children.length > 0}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const focusSearchInput = () => {
|
||||||
|
inputSearchRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = () => {
|
||||||
|
return Math.min(activeNode + 1, nodes.length - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const prev = () => {
|
||||||
|
return Math.max(activeNode - 1, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
switch (ev.key) {
|
||||||
|
case "ArrowDown": {
|
||||||
|
ev.preventDefault();
|
||||||
|
setActiveNode(next());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "ArrowUp": {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (activeNode === 0) {
|
||||||
|
focusSearchInput();
|
||||||
|
} else {
|
||||||
|
setActiveNode(prev());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "ArrowLeft": {
|
||||||
|
if (!searchTerm && isExpanded(activeNode)) {
|
||||||
|
toggleCollapse(activeNode);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "ArrowRight": {
|
||||||
|
if (!searchTerm) {
|
||||||
|
toggleCollapse(activeNode);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "Enter": {
|
||||||
|
if (isModKey(ev)) {
|
||||||
|
onSubmit();
|
||||||
|
} else {
|
||||||
|
toggleSelect(activeNode);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const innerElementType = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ style, ...rest }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container tabIndex={-1} onKeyDown={handleKeyDown}>
|
||||||
|
<ListSearch
|
||||||
|
ref={inputSearchRef}
|
||||||
|
onChange={handleSearch}
|
||||||
|
placeholder={`${t("Search collections & documents")}…`}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<ListContainer>
|
||||||
|
{nodes.length ? (
|
||||||
|
<AutoSizer>
|
||||||
|
{({ width, height }: { width: number; height: number }) => (
|
||||||
|
<Flex role="listbox" column>
|
||||||
|
<List
|
||||||
|
ref={listRef}
|
||||||
|
key={nodes.length}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
itemData={nodes}
|
||||||
|
itemCount={nodes.length}
|
||||||
|
itemSize={isMobile ? 48 : 32}
|
||||||
|
innerElementType={innerElementType}
|
||||||
|
initialScrollOffset={initialScrollOffset}
|
||||||
|
itemKey={(index, results) => results[index].id}
|
||||||
|
>
|
||||||
|
{ListItem}
|
||||||
|
</List>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</AutoSizer>
|
||||||
|
) : (
|
||||||
|
<FlexContainer>
|
||||||
|
<Text type="secondary">{t("No results found")}.</Text>
|
||||||
|
</FlexContainer>
|
||||||
|
)}
|
||||||
|
</ListContainer>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Container = styled.div``;
|
||||||
|
|
||||||
|
const FlexContainer = styled(Flex)`
|
||||||
|
height: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ListSearch = styled(InputSearch)`
|
||||||
|
${Outline} {
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding-left: 24px;
|
||||||
|
padding-right: 24px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ListContainer = styled.div`
|
||||||
|
height: 65vh;
|
||||||
|
|
||||||
|
${breakpoint("tablet")`
|
||||||
|
height: 40vh;
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default observer(DocumentExplorer);
|
||||||
@@ -7,49 +7,40 @@ import breakpoint from "styled-components-breakpoint";
|
|||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import Disclosure from "~/components/Sidebar/components/Disclosure";
|
import Disclosure from "~/components/Sidebar/components/Disclosure";
|
||||||
import Text from "~/components/Text";
|
import Text from "~/components/Text";
|
||||||
import { ancestors } from "~/utils/tree";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
location: any;
|
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
style: React.CSSProperties;
|
style: React.CSSProperties;
|
||||||
isSearchResult: boolean;
|
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
depth: number;
|
||||||
|
hasChildren: boolean;
|
||||||
|
|
||||||
onDisclosureClick: (ev: React.MouseEvent) => void;
|
onDisclosureClick: (ev: React.MouseEvent) => void;
|
||||||
onPointerMove: (ev: React.MouseEvent) => void;
|
onPointerMove: (ev: React.MouseEvent) => void;
|
||||||
onClick: (ev: React.MouseEvent) => void;
|
onClick: (ev: React.MouseEvent) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function PublishLocation({
|
function DocumentExplorerNode({
|
||||||
location,
|
|
||||||
selected,
|
selected,
|
||||||
active,
|
active,
|
||||||
style,
|
style,
|
||||||
isSearchResult,
|
|
||||||
expanded,
|
expanded,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
depth,
|
||||||
|
hasChildren,
|
||||||
onDisclosureClick,
|
onDisclosureClick,
|
||||||
onPointerMove,
|
onPointerMove,
|
||||||
onClick,
|
onClick,
|
||||||
icon,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const OFFSET = 12;
|
const OFFSET = 12;
|
||||||
const ICON_SIZE = 24;
|
const ICON_SIZE = 24;
|
||||||
|
|
||||||
const hasChildren = location.children.length > 0;
|
const width = depth ? depth * ICON_SIZE + OFFSET : ICON_SIZE;
|
||||||
const isCollection = location.data.type === "collection";
|
|
||||||
|
|
||||||
const width = location.depth
|
|
||||||
? location.depth * ICON_SIZE + OFFSET
|
|
||||||
: ICON_SIZE;
|
|
||||||
|
|
||||||
const path = (location: any) =>
|
|
||||||
ancestors(location)
|
|
||||||
.map((a) => a.data.title)
|
|
||||||
.join(" / ");
|
|
||||||
|
|
||||||
const ref = React.useCallback(
|
const ref = React.useCallback(
|
||||||
(node: HTMLSpanElement | null) => {
|
(node: HTMLSpanElement | null) => {
|
||||||
@@ -65,7 +56,7 @@ function PublishLocation({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row
|
<Node
|
||||||
ref={ref}
|
ref={ref}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
active={active}
|
active={active}
|
||||||
@@ -74,45 +65,29 @@ function PublishLocation({
|
|||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
role="option"
|
role="option"
|
||||||
>
|
>
|
||||||
{!isSearchResult && (
|
<Spacer width={width}>
|
||||||
<Spacer width={width}>
|
{hasChildren && (
|
||||||
{hasChildren && (
|
<StyledDisclosure
|
||||||
<StyledDisclosure
|
expanded={expanded}
|
||||||
expanded={expanded}
|
onClick={onDisclosureClick}
|
||||||
onClick={onDisclosureClick}
|
tabIndex={-1}
|
||||||
tabIndex={-1}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
</Spacer>
|
||||||
</Spacer>
|
|
||||||
)}
|
|
||||||
{icon}
|
{icon}
|
||||||
<Title>{location.data.title || t("Untitled")}</Title>
|
<Title>{title || t("Untitled")}</Title>
|
||||||
{isSearchResult && !isCollection && (
|
</Node>
|
||||||
<Path $selected={selected} size="xsmall">
|
|
||||||
{path(location)}
|
|
||||||
</Path>
|
|
||||||
)}
|
|
||||||
</Row>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const Title = styled(Text)`
|
const Title = styled(Text)`
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
margin: 0 4px 0 4px;
|
margin: 0 4px 0 4px;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Path = styled(Text)<{ $selected: boolean }>`
|
|
||||||
padding-top: 3px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
margin: 0 4px 0 8px;
|
|
||||||
color: ${(props) =>
|
|
||||||
props.$selected ? props.theme.white50 : props.theme.textTertiary};
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledDisclosure = styled(Disclosure)`
|
const StyledDisclosure = styled(Disclosure)`
|
||||||
position: relative;
|
position: relative;
|
||||||
left: auto;
|
left: auto;
|
||||||
@@ -125,7 +100,7 @@ const Spacer = styled(Flex)<{ width: number }>`
|
|||||||
width: ${(props) => props.width}px;
|
width: ${(props) => props.width}px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Row = styled.span<{
|
export const Node = styled.span<{
|
||||||
active: boolean;
|
active: boolean;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
style: React.CSSProperties;
|
style: React.CSSProperties;
|
||||||
@@ -167,4 +142,4 @@ const Row = styled.span<{
|
|||||||
`}
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default observer(PublishLocation);
|
export default observer(DocumentExplorerNode);
|
||||||
85
app/components/DocumentExplorerSearchResult.tsx
Normal file
85
app/components/DocumentExplorerSearchResult.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { observer } from "mobx-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import { Node as SearchResult } from "~/components/DocumentExplorerNode";
|
||||||
|
import Flex from "~/components/Flex";
|
||||||
|
import Text from "~/components/Text";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
selected: boolean;
|
||||||
|
active: boolean;
|
||||||
|
style: React.CSSProperties;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
path?: string;
|
||||||
|
|
||||||
|
onPointerMove: (ev: React.MouseEvent) => void;
|
||||||
|
onClick: (ev: React.MouseEvent) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function DocumentExplorerSearchResult({
|
||||||
|
selected,
|
||||||
|
active,
|
||||||
|
style,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
path,
|
||||||
|
onPointerMove,
|
||||||
|
onClick,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const ref = React.useCallback(
|
||||||
|
(node: HTMLSpanElement | null) => {
|
||||||
|
if (active && node) {
|
||||||
|
scrollIntoView(node, {
|
||||||
|
scrollMode: "if-needed",
|
||||||
|
behavior: "auto",
|
||||||
|
block: "nearest",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[active]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SearchResult
|
||||||
|
ref={ref}
|
||||||
|
selected={selected}
|
||||||
|
active={active}
|
||||||
|
onClick={onClick}
|
||||||
|
style={style}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
role="option"
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<Flex>
|
||||||
|
<Title>{title || t("Untitled")}</Title>
|
||||||
|
<Path $selected={selected} size="xsmall">
|
||||||
|
{path}
|
||||||
|
</Path>
|
||||||
|
</Flex>
|
||||||
|
</SearchResult>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Title = styled(Text)`
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin: 0 4px 0 4px;
|
||||||
|
color: inherit;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Path = styled(Text)<{ $selected: boolean }>`
|
||||||
|
padding-top: 3px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin: 0 4px 0 8px;
|
||||||
|
color: ${(props) =>
|
||||||
|
props.$selected ? props.theme.white50 : props.theme.textTertiary};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default observer(DocumentExplorerSearchResult);
|
||||||
@@ -2,11 +2,11 @@ import { observer } from "mobx-react";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
import { NavigationNode } from "@shared/types";
|
||||||
import Team from "~/models/Team";
|
import Team from "~/models/Team";
|
||||||
import Scrollable from "~/components/Scrollable";
|
import Scrollable from "~/components/Scrollable";
|
||||||
import SearchPopover from "~/components/SearchPopover";
|
import SearchPopover from "~/components/SearchPopover";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import { NavigationNode } from "~/types";
|
|
||||||
import history from "~/utils/history";
|
import history from "~/utils/history";
|
||||||
import { homePath, sharedDocumentPath } from "~/utils/routeHelpers";
|
import { homePath, sharedDocumentPath } from "~/utils/routeHelpers";
|
||||||
import TeamLogo from "../TeamLogo";
|
import TeamLogo from "../TeamLogo";
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as React from "react";
|
|||||||
import { useDrop } from "react-dnd";
|
import { useDrop } from "react-dnd";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useHistory } from "react-router-dom";
|
import { useHistory } from "react-router-dom";
|
||||||
|
import { NavigationNode } from "@shared/types";
|
||||||
import Collection from "~/models/Collection";
|
import Collection from "~/models/Collection";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import DocumentReparent from "~/scenes/DocumentReparent";
|
import DocumentReparent from "~/scenes/DocumentReparent";
|
||||||
@@ -17,7 +18,6 @@ import useBoolean from "~/hooks/useBoolean";
|
|||||||
import usePolicy from "~/hooks/usePolicy";
|
import usePolicy from "~/hooks/usePolicy";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import CollectionMenu from "~/menus/CollectionMenu";
|
import CollectionMenu from "~/menus/CollectionMenu";
|
||||||
import { NavigationNode } from "~/types";
|
|
||||||
import DropToImport from "./DropToImport";
|
import DropToImport from "./DropToImport";
|
||||||
import EditableTitle from "./EditableTitle";
|
import EditableTitle from "./EditableTitle";
|
||||||
import Relative from "./Relative";
|
import Relative from "./Relative";
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useDrag, useDrop } from "react-dnd";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
import { NavigationNode } from "@shared/types";
|
||||||
import { sortNavigationNodes } from "@shared/utils/collections";
|
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||||
import { DocumentValidation } from "@shared/validations";
|
import { DocumentValidation } from "@shared/validations";
|
||||||
import Collection from "~/models/Collection";
|
import Collection from "~/models/Collection";
|
||||||
@@ -18,7 +19,6 @@ import usePolicy from "~/hooks/usePolicy";
|
|||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import useToasts from "~/hooks/useToasts";
|
import useToasts from "~/hooks/useToasts";
|
||||||
import DocumentMenu from "~/menus/DocumentMenu";
|
import DocumentMenu from "~/menus/DocumentMenu";
|
||||||
import { NavigationNode } from "~/types";
|
|
||||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||||
import DropCursor from "./DropCursor";
|
import DropCursor from "./DropCursor";
|
||||||
import DropToImport from "./DropToImport";
|
import DropToImport from "./DropToImport";
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
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 { useTranslation } from "react-i18next";
|
||||||
|
import { NavigationNode } from "@shared/types";
|
||||||
import Collection from "~/models/Collection";
|
import Collection from "~/models/Collection";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import { NavigationNode } from "~/types";
|
|
||||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||||
import Disclosure from "./Disclosure";
|
import Disclosure from "./Disclosure";
|
||||||
import SidebarLink from "./SidebarLink";
|
import SidebarLink from "./SidebarLink";
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { LocationDescriptor } from "history";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import styled, { useTheme, css } from "styled-components";
|
import styled, { useTheme, css } from "styled-components";
|
||||||
import breakpoint from "styled-components-breakpoint";
|
import breakpoint from "styled-components-breakpoint";
|
||||||
|
import { NavigationNode } from "@shared/types";
|
||||||
import EventBoundary from "~/components/EventBoundary";
|
import EventBoundary from "~/components/EventBoundary";
|
||||||
import NudeButton from "~/components/NudeButton";
|
import NudeButton from "~/components/NudeButton";
|
||||||
import { undraggableOnDesktop } from "~/styles";
|
import { undraggableOnDesktop } from "~/styles";
|
||||||
import { NavigationNode } from "~/types";
|
|
||||||
import Disclosure from "./Disclosure";
|
import Disclosure from "./Disclosure";
|
||||||
import NavLink, { Props as NavLinkProps } from "./NavLink";
|
import NavLink, { Props as NavLinkProps } from "./NavLink";
|
||||||
|
|
||||||
|
|||||||
82
app/hooks/useCollectionTrees.ts
Normal file
82
app/hooks/useCollectionTrees.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { NavigationNode, NavigationNodeType } from "@shared/types";
|
||||||
|
import Collection from "~/models/Collection";
|
||||||
|
import useStores from "~/hooks/useStores";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook that modifies the document structure
|
||||||
|
* of all collections present in store. Adds extra attributes
|
||||||
|
* like type, depth and parent to each of the nodes in document
|
||||||
|
* structure.
|
||||||
|
*
|
||||||
|
* @return {NavigationNode[]} collectionTrees root collection nodes of modified trees
|
||||||
|
*/
|
||||||
|
export default function useCollectionTrees(): NavigationNode[] {
|
||||||
|
const { collections } = useStores();
|
||||||
|
|
||||||
|
const getCollectionTree = (collection: Collection): NavigationNode => {
|
||||||
|
const addType = (node: NavigationNode): NavigationNode => {
|
||||||
|
if (node.children.length > 0) {
|
||||||
|
node.children = node.children.map(addType);
|
||||||
|
}
|
||||||
|
|
||||||
|
node.type = node.type ? node.type : NavigationNodeType.Document;
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addParent = (
|
||||||
|
node: NavigationNode,
|
||||||
|
parent: NavigationNode | null = null
|
||||||
|
): NavigationNode => {
|
||||||
|
if (node.children.length > 0) {
|
||||||
|
node.children = node.children.map((child) => addParent(child, node));
|
||||||
|
}
|
||||||
|
|
||||||
|
node.parent = parent;
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addDepth = (node: NavigationNode, depth = 0): NavigationNode => {
|
||||||
|
if (node.children.length > 0) {
|
||||||
|
node.children = node.children.map((child) =>
|
||||||
|
addDepth(child, depth + 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
node.depth = depth;
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCollectionId = (
|
||||||
|
node: NavigationNode,
|
||||||
|
collectionId = collection.id
|
||||||
|
): NavigationNode => {
|
||||||
|
if (node.children.length > 0) {
|
||||||
|
node.children = node.children.map((child) =>
|
||||||
|
addCollectionId(child, collectionId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
node.collectionId = collectionId;
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectionNode: NavigationNode = {
|
||||||
|
id: collection.id,
|
||||||
|
title: collection.name,
|
||||||
|
url: collection.url,
|
||||||
|
type: NavigationNodeType.Collection,
|
||||||
|
children: collection.documents || [],
|
||||||
|
parent: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return addParent(addCollectionId(addDepth(addType(collectionNode))));
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectionTrees = React.useMemo(
|
||||||
|
() => collections.orderedData.map(getCollectionTree),
|
||||||
|
[collections.orderedData]
|
||||||
|
);
|
||||||
|
|
||||||
|
return collectionTrees;
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { trim } from "lodash";
|
import { trim } from "lodash";
|
||||||
import { action, computed, observable } from "mobx";
|
import { action, computed, observable } from "mobx";
|
||||||
import { CollectionPermission, FileOperationFormat } from "@shared/types";
|
import {
|
||||||
|
CollectionPermission,
|
||||||
|
FileOperationFormat,
|
||||||
|
NavigationNode,
|
||||||
|
} from "@shared/types";
|
||||||
import { sortNavigationNodes } from "@shared/utils/collections";
|
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||||
import CollectionsStore from "~/stores/CollectionsStore";
|
import CollectionsStore from "~/stores/CollectionsStore";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import ParanoidModel from "~/models/ParanoidModel";
|
import ParanoidModel from "~/models/ParanoidModel";
|
||||||
import { NavigationNode } from "~/types";
|
|
||||||
import { client } from "~/utils/ApiClient";
|
import { client } from "~/utils/ApiClient";
|
||||||
import Field from "./decorators/Field";
|
import Field from "./decorators/Field";
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { addDays, differenceInDays } from "date-fns";
|
|||||||
import { floor } from "lodash";
|
import { floor } from "lodash";
|
||||||
import { action, autorun, computed, observable, set } from "mobx";
|
import { action, autorun, computed, observable, set } from "mobx";
|
||||||
import { ExportContentType } from "@shared/types";
|
import { ExportContentType } from "@shared/types";
|
||||||
|
import type { NavigationNode } from "@shared/types";
|
||||||
import Storage from "@shared/utils/Storage";
|
import Storage from "@shared/utils/Storage";
|
||||||
import parseTitle from "@shared/utils/parseTitle";
|
import parseTitle from "@shared/utils/parseTitle";
|
||||||
import { isRTL } from "@shared/utils/rtl";
|
import { isRTL } from "@shared/utils/rtl";
|
||||||
import DocumentsStore from "~/stores/DocumentsStore";
|
import DocumentsStore from "~/stores/DocumentsStore";
|
||||||
import User from "~/models/User";
|
import User from "~/models/User";
|
||||||
import type { NavigationNode } from "~/types";
|
|
||||||
import { client } from "~/utils/ApiClient";
|
import { client } from "~/utils/ApiClient";
|
||||||
import ParanoidModel from "./ParanoidModel";
|
import ParanoidModel from "./ParanoidModel";
|
||||||
import View from "./View";
|
import View from "./View";
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { RouteComponentProps, useLocation, Redirect } from "react-router-dom";
|
import { RouteComponentProps, useLocation, Redirect } from "react-router-dom";
|
||||||
import styled, { useTheme } from "styled-components";
|
import styled, { useTheme } from "styled-components";
|
||||||
import { setCookie } from "tiny-cookie";
|
import { setCookie } from "tiny-cookie";
|
||||||
|
import { NavigationNode } from "@shared/types";
|
||||||
import DocumentModel from "~/models/Document";
|
import DocumentModel from "~/models/Document";
|
||||||
import Team from "~/models/Team";
|
import Team from "~/models/Team";
|
||||||
import Error404 from "~/scenes/Error404";
|
import Error404 from "~/scenes/Error404";
|
||||||
@@ -16,7 +17,6 @@ import Text from "~/components/Text";
|
|||||||
import env from "~/env";
|
import env from "~/env";
|
||||||
import usePolicy from "~/hooks/usePolicy";
|
import usePolicy from "~/hooks/usePolicy";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import { NavigationNode } from "~/types";
|
|
||||||
import { AuthorizationError, OfflineError } from "~/utils/errors";
|
import { AuthorizationError, OfflineError } from "~/utils/errors";
|
||||||
import isCloudHosted from "~/utils/isCloudHosted";
|
import isCloudHosted from "~/utils/isCloudHosted";
|
||||||
import { changeLanguage, detectLanguage } from "~/utils/language";
|
import { changeLanguage, detectLanguage } from "~/utils/language";
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useLocation, RouteComponentProps, StaticContext } from "react-router";
|
import { useLocation, RouteComponentProps, StaticContext } from "react-router";
|
||||||
|
import { NavigationNode } from "@shared/types";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import Revision from "~/models/Revision";
|
import Revision from "~/models/Revision";
|
||||||
import Error404 from "~/scenes/Error404";
|
import Error404 from "~/scenes/Error404";
|
||||||
import ErrorOffline from "~/scenes/ErrorOffline";
|
import ErrorOffline from "~/scenes/ErrorOffline";
|
||||||
import usePolicy from "~/hooks/usePolicy";
|
import usePolicy from "~/hooks/usePolicy";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import { NavigationNode } from "~/types";
|
|
||||||
import Logger from "~/utils/Logger";
|
import Logger from "~/utils/Logger";
|
||||||
import { NotFoundError, OfflineError } from "~/utils/errors";
|
import { NotFoundError, OfflineError } from "~/utils/errors";
|
||||||
import history from "~/utils/history";
|
import history from "~/utils/history";
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import breakpoint from "styled-components-breakpoint";
|
import breakpoint from "styled-components-breakpoint";
|
||||||
import { Heading } from "@shared/editor/lib/getHeadings";
|
import { Heading } from "@shared/editor/lib/getHeadings";
|
||||||
|
import { NavigationNode } from "@shared/types";
|
||||||
import { parseDomain } from "@shared/utils/domains";
|
import { parseDomain } from "@shared/utils/domains";
|
||||||
import getTasks from "@shared/utils/getTasks";
|
import getTasks from "@shared/utils/getTasks";
|
||||||
import RootStore from "~/stores/RootStore";
|
import RootStore from "~/stores/RootStore";
|
||||||
@@ -33,7 +34,6 @@ import PlaceholderDocument from "~/components/PlaceholderDocument";
|
|||||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||||
import withStores from "~/components/withStores";
|
import withStores from "~/components/withStores";
|
||||||
import type { Editor as TEditor } from "~/editor";
|
import type { Editor as TEditor } from "~/editor";
|
||||||
import { NavigationNode } from "~/types";
|
|
||||||
import { client } from "~/utils/ApiClient";
|
import { client } from "~/utils/ApiClient";
|
||||||
import { replaceTitleVariables } from "~/utils/date";
|
import { replaceTitleVariables } from "~/utils/date";
|
||||||
import { emojiToUrl } from "~/utils/emoji";
|
import { emojiToUrl } from "~/utils/emoji";
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import * as React from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
import { NavigationNode } from "@shared/types";
|
||||||
import { Theme } from "~/stores/UiStore";
|
import { Theme } from "~/stores/UiStore";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import { Action, Separator } from "~/components/Actions";
|
import { Action, Separator } from "~/components/Actions";
|
||||||
@@ -30,7 +31,6 @@ import DocumentMenu from "~/menus/DocumentMenu";
|
|||||||
import NewChildDocumentMenu from "~/menus/NewChildDocumentMenu";
|
import NewChildDocumentMenu from "~/menus/NewChildDocumentMenu";
|
||||||
import TableOfContentsMenu from "~/menus/TableOfContentsMenu";
|
import TableOfContentsMenu from "~/menus/TableOfContentsMenu";
|
||||||
import TemplatesMenu from "~/menus/TemplatesMenu";
|
import TemplatesMenu from "~/menus/TemplatesMenu";
|
||||||
import { NavigationNode } from "~/types";
|
|
||||||
import { metaDisplay } from "~/utils/keyboard";
|
import { metaDisplay } from "~/utils/keyboard";
|
||||||
import { newDocumentPath, editDocumentUrl } from "~/utils/routeHelpers";
|
import { newDocumentPath, editDocumentUrl } from "~/utils/routeHelpers";
|
||||||
import ObservingBanner from "./ObservingBanner";
|
import ObservingBanner from "./ObservingBanner";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { NavigationNode } from "@shared/types";
|
||||||
import Breadcrumb from "~/components/Breadcrumb";
|
import Breadcrumb from "~/components/Breadcrumb";
|
||||||
import { MenuInternalLink, NavigationNode } from "~/types";
|
import { MenuInternalLink } from "~/types";
|
||||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
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 { useTranslation } from "react-i18next";
|
||||||
|
import { NavigationNode } from "@shared/types";
|
||||||
import Subheading from "~/components/Subheading";
|
import Subheading from "~/components/Subheading";
|
||||||
import { NavigationNode } from "~/types";
|
|
||||||
import ReferenceListItem from "./ReferenceListItem";
|
import ReferenceListItem from "./ReferenceListItem";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { DocumentIcon } from "outline-icons";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
import { NavigationNode } from "@shared/types";
|
||||||
import parseTitle from "@shared/utils/parseTitle";
|
import parseTitle from "@shared/utils/parseTitle";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
||||||
import { hover } from "~/styles";
|
import { hover } from "~/styles";
|
||||||
import { NavigationNode } from "~/types";
|
|
||||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|||||||
@@ -1,27 +1,15 @@
|
|||||||
import FuzzySearch from "fuzzy-search";
|
|
||||||
import { includes, difference, concat, filter } from "lodash";
|
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { StarredIcon, DocumentIcon } from "outline-icons";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation, Trans } from "react-i18next";
|
import { useTranslation, Trans } from "react-i18next";
|
||||||
import AutoSizer from "react-virtualized-auto-sizer";
|
import styled from "styled-components";
|
||||||
import { FixedSizeList as List } from "react-window";
|
import { NavigationNode } from "@shared/types";
|
||||||
import styled, { useTheme } from "styled-components";
|
|
||||||
import breakpoint from "styled-components-breakpoint";
|
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
|
import DocumentExplorer from "~/components/DocumentExplorer";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
|
||||||
import EmojiIcon from "~/components/Icons/EmojiIcon";
|
|
||||||
import { Outline } from "~/components/Input";
|
|
||||||
import InputSearch from "~/components/InputSearch";
|
|
||||||
import PublishLocation from "~/components/PublishLocation";
|
|
||||||
import Text from "~/components/Text";
|
import Text from "~/components/Text";
|
||||||
import useMobile from "~/hooks/useMobile";
|
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import useToasts from "~/hooks/useToasts";
|
import useToasts from "~/hooks/useToasts";
|
||||||
import { isModKey } from "~/utils/keyboard";
|
|
||||||
import { flattenTree, descendants } from "~/utils/tree";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/** Document to publish */
|
/** Document to publish */
|
||||||
@@ -29,144 +17,16 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function DocumentPublish({ document }: Props) {
|
function DocumentPublish({ document }: Props) {
|
||||||
const isMobile = useMobile();
|
const { dialogs } = useStores();
|
||||||
const [searchTerm, setSearchTerm] = React.useState<string>();
|
|
||||||
const [selectedLocation, setLocation] = React.useState<any>();
|
|
||||||
const [initialScrollOffset, setInitialScrollOffset] = React.useState<number>(
|
|
||||||
0
|
|
||||||
);
|
|
||||||
const { collections, documents } = useStores();
|
|
||||||
const { showToast } = useToasts();
|
const { showToast } = useToasts();
|
||||||
const theme = useTheme();
|
const { t } = useTranslation();
|
||||||
const [items, setItems] = React.useState<any>(
|
|
||||||
flattenTree(collections.tree.root).slice(1)
|
const [selectedPath, selectPath] = React.useState<NavigationNode | null>(
|
||||||
);
|
|
||||||
const [activeItem, setActiveItem] = React.useState<number>(0);
|
|
||||||
const [expandedItems, setExpandedItems] = React.useState<string[]>([]);
|
|
||||||
const inputSearchRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>(
|
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
const { t } = useTranslation();
|
|
||||||
const { dialogs } = useStores();
|
|
||||||
const listRef = React.useRef<List<HTMLDivElement>>(null);
|
|
||||||
const VERTICAL_PADDING = 6;
|
|
||||||
const HORIZONTAL_PADDING = 24;
|
|
||||||
|
|
||||||
const nextItem = () => {
|
|
||||||
return Math.min(activeItem + 1, items.length - 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const prevItem = () => {
|
|
||||||
return Math.max(activeItem - 1, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const searchIndex = React.useMemo(() => {
|
|
||||||
const data = flattenTree(collections.tree.root).slice(1);
|
|
||||||
|
|
||||||
return new FuzzySearch(data, ["data.title"], {
|
|
||||||
caseSensitive: false,
|
|
||||||
});
|
|
||||||
}, [collections.tree]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (searchTerm) {
|
|
||||||
setLocation(null);
|
|
||||||
setExpandedItems([]);
|
|
||||||
}
|
|
||||||
setActiveItem(0);
|
|
||||||
}, [searchTerm]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
let results = flattenTree(collections.tree.root).slice(1);
|
|
||||||
|
|
||||||
if (collections.isLoaded) {
|
|
||||||
if (searchTerm) {
|
|
||||||
results = searchIndex.search(searchTerm);
|
|
||||||
} else {
|
|
||||||
results = results.filter((r) => r.data.type === "collection");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setInitialScrollOffset(0);
|
|
||||||
setItems(results);
|
|
||||||
}, [document, collections, searchTerm, searchIndex]);
|
|
||||||
|
|
||||||
const handleSearch = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setSearchTerm(ev.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isExpanded = (index: number) => {
|
|
||||||
const item = items[index];
|
|
||||||
return includes(expandedItems, item.data.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const calculateInitialScrollOffset = (itemCount: number) => {
|
|
||||||
if (listRef.current) {
|
|
||||||
const { height, itemSize } = listRef.current.props;
|
|
||||||
const { scrollOffset } = listRef.current.state as {
|
|
||||||
scrollOffset: number;
|
|
||||||
};
|
|
||||||
const itemsHeight = itemCount * itemSize;
|
|
||||||
return itemsHeight < height ? 0 : scrollOffset;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const collapse = (item: number) => {
|
|
||||||
const descendantIds = descendants(items[item]).map((des) => des.data.id);
|
|
||||||
setExpandedItems(
|
|
||||||
difference(expandedItems, [...descendantIds, items[item].data.id])
|
|
||||||
);
|
|
||||||
|
|
||||||
// remove children
|
|
||||||
const newItems = filter(
|
|
||||||
items,
|
|
||||||
(item: any) => !includes(descendantIds, item.data.id)
|
|
||||||
);
|
|
||||||
const scrollOffset = calculateInitialScrollOffset(newItems.length);
|
|
||||||
setInitialScrollOffset(scrollOffset);
|
|
||||||
setItems(newItems);
|
|
||||||
};
|
|
||||||
|
|
||||||
const expand = (item: number) => {
|
|
||||||
setExpandedItems(concat(expandedItems, items[item].data.id));
|
|
||||||
|
|
||||||
// add children
|
|
||||||
const newItems = items.slice();
|
|
||||||
newItems.splice(item + 1, 0, ...descendants(items[item], 1));
|
|
||||||
const scrollOffset = calculateInitialScrollOffset(newItems.length);
|
|
||||||
setInitialScrollOffset(scrollOffset);
|
|
||||||
setItems(newItems);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSelected = (item: number) => {
|
|
||||||
if (!selectedLocation) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const selectedItemId = selectedLocation.data.id;
|
|
||||||
const itemId = items[item].data.id;
|
|
||||||
|
|
||||||
return selectedItemId === itemId;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleCollapse = (item: number) => {
|
|
||||||
if (isExpanded(item)) {
|
|
||||||
collapse(item);
|
|
||||||
} else {
|
|
||||||
expand(item);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleSelect = (item: number) => {
|
|
||||||
if (isSelected(item)) {
|
|
||||||
setLocation(null);
|
|
||||||
} else {
|
|
||||||
setLocation(items[item]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const publish = async () => {
|
const publish = async () => {
|
||||||
if (!selectedLocation) {
|
if (!selectedPath) {
|
||||||
showToast(t("Select a location to publish"), {
|
showToast(t("Select a location to publish"), {
|
||||||
type: "info",
|
type: "info",
|
||||||
});
|
});
|
||||||
@@ -174,11 +34,9 @@ function DocumentPublish({ document }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const {
|
const { type, id: parentDocumentId } = selectedPath;
|
||||||
collectionId,
|
|
||||||
type,
|
const collectionId = selectedPath.collectionId as string;
|
||||||
id: parentDocumentId,
|
|
||||||
} = selectedLocation.data;
|
|
||||||
|
|
||||||
// Also move it under if selected path corresponds to another doc
|
// Also move it under if selected path corresponds to another doc
|
||||||
if (type === "document") {
|
if (type === "document") {
|
||||||
@@ -200,176 +58,26 @@ function DocumentPublish({ document }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const row = ({
|
|
||||||
index,
|
|
||||||
data,
|
|
||||||
style,
|
|
||||||
}: {
|
|
||||||
index: number;
|
|
||||||
data: any[];
|
|
||||||
style: React.CSSProperties;
|
|
||||||
}) => {
|
|
||||||
const result = data[index];
|
|
||||||
const isCollection = result.data.type === "collection";
|
|
||||||
let icon;
|
|
||||||
|
|
||||||
if (isCollection) {
|
|
||||||
const col = collections.get(result.data.collectionId);
|
|
||||||
icon = col && (
|
|
||||||
<CollectionIcon collection={col} expanded={isExpanded(index)} />
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const doc = documents.get(result.data.id);
|
|
||||||
const { emoji } = result.data;
|
|
||||||
if (emoji) {
|
|
||||||
icon = <EmojiIcon emoji={emoji} />;
|
|
||||||
} else if (doc?.isStarred) {
|
|
||||||
icon = <StarredIcon color={theme.yellow} />;
|
|
||||||
} else {
|
|
||||||
icon = <DocumentIcon />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PublishLocation
|
|
||||||
style={{
|
|
||||||
...style,
|
|
||||||
top: (style.top as number) + VERTICAL_PADDING,
|
|
||||||
left: (style.left as number) + HORIZONTAL_PADDING,
|
|
||||||
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
|
|
||||||
}}
|
|
||||||
onPointerMove={() => setActiveItem(index)}
|
|
||||||
onClick={() => toggleSelect(index)}
|
|
||||||
onDisclosureClick={(ev) => {
|
|
||||||
ev.stopPropagation();
|
|
||||||
toggleCollapse(index);
|
|
||||||
}}
|
|
||||||
location={result}
|
|
||||||
selected={isSelected(index)}
|
|
||||||
active={activeItem === index}
|
|
||||||
expanded={isExpanded(index)}
|
|
||||||
icon={icon}
|
|
||||||
isSearchResult={!!searchTerm}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!document || !collections.isLoaded) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const focusSearchInput = () => {
|
|
||||||
inputSearchRef.current?.focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>) => {
|
|
||||||
switch (ev.key) {
|
|
||||||
case "ArrowDown": {
|
|
||||||
ev.preventDefault();
|
|
||||||
setActiveItem(nextItem());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "ArrowUp": {
|
|
||||||
ev.preventDefault();
|
|
||||||
if (activeItem === 0) {
|
|
||||||
focusSearchInput();
|
|
||||||
} else {
|
|
||||||
setActiveItem(prevItem());
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "ArrowLeft": {
|
|
||||||
if (!searchTerm && isExpanded(activeItem)) {
|
|
||||||
toggleCollapse(activeItem);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "ArrowRight": {
|
|
||||||
if (!searchTerm) {
|
|
||||||
toggleCollapse(activeItem);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "Enter": {
|
|
||||||
if (isModKey(ev)) {
|
|
||||||
publish();
|
|
||||||
} else {
|
|
||||||
toggleSelect(activeItem);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const innerElementType = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
|
||||||
>(({ style, ...rest }, ref) => (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
style={{
|
|
||||||
...style,
|
|
||||||
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
|
|
||||||
}}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlexContainer column tabIndex={-1} onKeyDown={handleKeyDown}>
|
<FlexContainer column>
|
||||||
<Search
|
<DocumentExplorer onSubmit={publish} onSelect={selectPath} />
|
||||||
ref={inputSearchRef}
|
|
||||||
onChange={handleSearch}
|
|
||||||
placeholder={`${t("Search collections & documents")}…`}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
{items.length ? (
|
|
||||||
<Results>
|
|
||||||
<AutoSizer>
|
|
||||||
{({ width, height }: { width: number; height: number }) => (
|
|
||||||
<Flex role="listbox" column>
|
|
||||||
<List
|
|
||||||
ref={listRef}
|
|
||||||
key={items.length}
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
itemData={items}
|
|
||||||
itemCount={items.length}
|
|
||||||
itemSize={isMobile ? 48 : 32}
|
|
||||||
innerElementType={innerElementType}
|
|
||||||
initialScrollOffset={initialScrollOffset}
|
|
||||||
itemKey={(index, results: any) => results[index].data.id}
|
|
||||||
>
|
|
||||||
{row}
|
|
||||||
</List>
|
|
||||||
</Flex>
|
|
||||||
)}
|
|
||||||
</AutoSizer>
|
|
||||||
</Results>
|
|
||||||
) : (
|
|
||||||
<NoResults>
|
|
||||||
<Text type="secondary">{t("No results found")}.</Text>
|
|
||||||
</NoResults>
|
|
||||||
)}
|
|
||||||
<Footer justify="space-between" align="center" gap={8}>
|
<Footer justify="space-between" align="center" gap={8}>
|
||||||
{selectedLocation ? (
|
<StyledText type="secondary">
|
||||||
<SelectedLocation type="secondary">
|
{selectedPath ? (
|
||||||
<Trans
|
<Trans
|
||||||
defaults="Publish in <em>{{ location }}</em>"
|
defaults="Publish in <em>{{ location }}</em>"
|
||||||
values={{
|
values={{
|
||||||
location: selectedLocation.data.title,
|
location: selectedPath.title,
|
||||||
}}
|
}}
|
||||||
components={{
|
components={{
|
||||||
em: <strong />,
|
em: <strong />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SelectedLocation>
|
) : (
|
||||||
) : (
|
t("Select a location to publish")
|
||||||
<SelectedLocation type="tertiary">
|
)}
|
||||||
{t("Select a location to publish")}
|
</StyledText>
|
||||||
</SelectedLocation>
|
<Button disabled={!selectedPath} onClick={publish}>
|
||||||
)}
|
|
||||||
<Button disabled={!selectedLocation} onClick={publish}>
|
|
||||||
{t("Publish")}
|
{t("Publish")}
|
||||||
</Button>
|
</Button>
|
||||||
</Footer>
|
</Footer>
|
||||||
@@ -377,25 +85,6 @@ function DocumentPublish({ document }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const NoResults = styled(Flex)`
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 65vh;
|
|
||||||
|
|
||||||
${breakpoint("tablet")`
|
|
||||||
height: 40vh;
|
|
||||||
`}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Search = styled(InputSearch)`
|
|
||||||
${Outline} {
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
|
||||||
margin-bottom: 4px;
|
|
||||||
padding-left: 24px;
|
|
||||||
padding-right: 24px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const FlexContainer = styled(Flex)`
|
const FlexContainer = styled(Flex)`
|
||||||
margin-left: -24px;
|
margin-left: -24px;
|
||||||
margin-right: -24px;
|
margin-right: -24px;
|
||||||
@@ -403,14 +92,6 @@ const FlexContainer = styled(Flex)`
|
|||||||
outline: none;
|
outline: none;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Results = styled.div`
|
|
||||||
height: 65vh;
|
|
||||||
|
|
||||||
${breakpoint("tablet")`
|
|
||||||
height: 40vh;
|
|
||||||
`}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Footer = styled(Flex)`
|
const Footer = styled(Flex)`
|
||||||
height: 64px;
|
height: 64px;
|
||||||
border-top: 1px solid ${(props) => props.theme.horizontalRule};
|
border-top: 1px solid ${(props) => props.theme.horizontalRule};
|
||||||
@@ -418,7 +99,7 @@ const Footer = styled(Flex)`
|
|||||||
padding-right: 24px;
|
padding-right: 24px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const SelectedLocation = styled(Text)`
|
const StyledText = styled(Text)`
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|||||||
@@ -2,14 +2,13 @@ import { observer } from "mobx-react";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { CollectionPermission } from "@shared/types";
|
import { CollectionPermission, NavigationNode } from "@shared/types";
|
||||||
import Collection from "~/models/Collection";
|
import Collection from "~/models/Collection";
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import Text from "~/components/Text";
|
import Text from "~/components/Text";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import useToasts from "~/hooks/useToasts";
|
import useToasts from "~/hooks/useToasts";
|
||||||
import { NavigationNode } from "~/types";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item:
|
item:
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import invariant from "invariant";
|
import invariant from "invariant";
|
||||||
import { concat, find, last, isEmpty } from "lodash";
|
import { concat, find, last } from "lodash";
|
||||||
import { computed, action } from "mobx";
|
import { computed, action } from "mobx";
|
||||||
import { CollectionPermission, FileOperationFormat } from "@shared/types";
|
import {
|
||||||
import parseTitle from "@shared/utils/parseTitle";
|
CollectionPermission,
|
||||||
|
FileOperationFormat,
|
||||||
|
NavigationNode,
|
||||||
|
} from "@shared/types";
|
||||||
import Collection from "~/models/Collection";
|
import Collection from "~/models/Collection";
|
||||||
import { NavigationNode } from "~/types";
|
|
||||||
import { client } from "~/utils/ApiClient";
|
import { client } from "~/utils/ApiClient";
|
||||||
import { AuthorizationError, NotFoundError } from "~/utils/errors";
|
import { AuthorizationError, NotFoundError } from "~/utils/errors";
|
||||||
import BaseStore from "./BaseStore";
|
import BaseStore from "./BaseStore";
|
||||||
@@ -100,76 +102,6 @@ export default class CollectionsStore extends BaseStore<Collection> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@computed
|
|
||||||
get tree() {
|
|
||||||
const subtree = (node: any) => {
|
|
||||||
const isDocument = node.data.type === DocumentPathItemType.Document;
|
|
||||||
if (isDocument) {
|
|
||||||
const { strippedTitle, emoji } = parseTitle(node.data.title);
|
|
||||||
node.data.title = strippedTitle;
|
|
||||||
if (emoji) {
|
|
||||||
node.data.emoji = emoji;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const root: any = {
|
|
||||||
data: {
|
|
||||||
id: node.data.id,
|
|
||||||
title: node.data.name || node.data.title,
|
|
||||||
type: node.data.type,
|
|
||||||
collectionId:
|
|
||||||
node.data.type === DocumentPathItemType.Collection
|
|
||||||
? node.data.id
|
|
||||||
: node.data.collectionId,
|
|
||||||
emoji: node.data.emoji,
|
|
||||||
},
|
|
||||||
children: [],
|
|
||||||
parent: node.parent,
|
|
||||||
depth: node.depth,
|
|
||||||
};
|
|
||||||
!isEmpty(node.children) &&
|
|
||||||
node.children.forEach((child: any) => {
|
|
||||||
root.children.push(
|
|
||||||
subtree({
|
|
||||||
data: {
|
|
||||||
...child,
|
|
||||||
type: DocumentPathItemType.Document,
|
|
||||||
collectionId: root.data.collectionId,
|
|
||||||
},
|
|
||||||
parent: root,
|
|
||||||
children: child.children || [],
|
|
||||||
depth: root.depth + 1,
|
|
||||||
}).root
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return { root };
|
|
||||||
};
|
|
||||||
|
|
||||||
const root: any = {
|
|
||||||
data: null,
|
|
||||||
parent: null,
|
|
||||||
children: [],
|
|
||||||
depth: -1,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.isLoaded) {
|
|
||||||
this.orderedData.forEach((collection) => {
|
|
||||||
root.children.push(
|
|
||||||
subtree({
|
|
||||||
data: {
|
|
||||||
...collection,
|
|
||||||
type: DocumentPathItemType.Collection,
|
|
||||||
},
|
|
||||||
children: collection.documents || [],
|
|
||||||
parent: root,
|
|
||||||
depth: root.depth + 1,
|
|
||||||
}).root
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { root };
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
@action
|
||||||
import = async (attachmentId: string, format?: string) => {
|
import = async (attachmentId: string, format?: string) => {
|
||||||
await client.post("/collections.import", {
|
await client.post("/collections.import", {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import path from "path";
|
|||||||
import invariant from "invariant";
|
import invariant from "invariant";
|
||||||
import { find, orderBy, filter, compact, omitBy } from "lodash";
|
import { find, orderBy, filter, compact, omitBy } from "lodash";
|
||||||
import { observable, action, computed, runInAction } from "mobx";
|
import { observable, action, computed, runInAction } from "mobx";
|
||||||
import { DateFilter } from "@shared/types";
|
import { DateFilter, NavigationNode } from "@shared/types";
|
||||||
import { subtractDate } from "@shared/utils/date";
|
import { subtractDate } from "@shared/utils/date";
|
||||||
import { bytesToHumanReadable } from "@shared/utils/files";
|
import { bytesToHumanReadable } from "@shared/utils/files";
|
||||||
import naturalSort from "@shared/utils/naturalSort";
|
import naturalSort from "@shared/utils/naturalSort";
|
||||||
@@ -12,12 +12,7 @@ import RootStore from "~/stores/RootStore";
|
|||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import Team from "~/models/Team";
|
import Team from "~/models/Team";
|
||||||
import env from "~/env";
|
import env from "~/env";
|
||||||
import {
|
import { FetchOptions, PaginationParams, SearchResult } from "~/types";
|
||||||
FetchOptions,
|
|
||||||
PaginationParams,
|
|
||||||
SearchResult,
|
|
||||||
NavigationNode,
|
|
||||||
} from "~/types";
|
|
||||||
import { client } from "~/utils/ApiClient";
|
import { client } from "~/utils/ApiClient";
|
||||||
|
|
||||||
type FetchPageParams = PaginationParams & {
|
type FetchPageParams = PaginationParams & {
|
||||||
|
|||||||
@@ -140,14 +140,6 @@ export type FetchOptions = {
|
|||||||
force?: boolean;
|
force?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NavigationNode = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
children: NavigationNode[];
|
|
||||||
isDraft?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CollectionSort = {
|
export type CollectionSort = {
|
||||||
field: string;
|
field: string;
|
||||||
direction: "asc" | "desc";
|
direction: "asc" | "desc";
|
||||||
|
|||||||
@@ -1,32 +1,34 @@
|
|||||||
import { flatten } from "lodash";
|
import { NavigationNode } from "@shared/types";
|
||||||
|
|
||||||
export const flattenTree = (root: any) => {
|
export const flattenTree = (root: NavigationNode) => {
|
||||||
const flattened: any[] = [];
|
const flattened: NavigationNode[] = [];
|
||||||
if (!root) {
|
if (!root) {
|
||||||
return flattened;
|
return flattened;
|
||||||
}
|
}
|
||||||
|
|
||||||
flattened.push(root);
|
flattened.push(root);
|
||||||
|
|
||||||
root.children.forEach((child: any) => {
|
root.children.forEach((child) => {
|
||||||
flattened.push(flattenTree(child));
|
flattened.push(...flattenTree(child));
|
||||||
});
|
});
|
||||||
|
|
||||||
return flatten(flattened);
|
return flattened;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ancestors = (node: any) => {
|
export const ancestors = (node: NavigationNode) => {
|
||||||
const ancestors: any[] = [];
|
const ancestors: NavigationNode[] = [];
|
||||||
while (node.parent !== null) {
|
while (node.parent !== null) {
|
||||||
ancestors.unshift(node);
|
ancestors.unshift(node);
|
||||||
node = node.parent;
|
node = node.parent as NavigationNode;
|
||||||
}
|
}
|
||||||
return ancestors;
|
return ancestors;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const descendants = (node: any, depth = 0) => {
|
export const descendants = (node: NavigationNode, depth = 0) => {
|
||||||
const allDescendants = flattenTree(node).slice(1);
|
const allDescendants = flattenTree(node).slice(1);
|
||||||
return depth === 0
|
return depth === 0
|
||||||
? allDescendants
|
? allDescendants
|
||||||
: allDescendants.filter((d) => d.depth <= node.depth + depth);
|
: allDescendants.filter(
|
||||||
|
(d) => (d.depth as number) <= (node.depth as number) + depth
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ import {
|
|||||||
Length as SimpleLength,
|
Length as SimpleLength,
|
||||||
} from "sequelize-typescript";
|
} from "sequelize-typescript";
|
||||||
import isUUID from "validator/lib/isUUID";
|
import isUUID from "validator/lib/isUUID";
|
||||||
import { CollectionPermission } from "@shared/types";
|
import { CollectionPermission, NavigationNode } from "@shared/types";
|
||||||
import { sortNavigationNodes } from "@shared/utils/collections";
|
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||||
import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers";
|
import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers";
|
||||||
import { CollectionValidation } from "@shared/validations";
|
import { CollectionValidation } from "@shared/validations";
|
||||||
import slugify from "@server/utils/slugify";
|
import slugify from "@server/utils/slugify";
|
||||||
import type { NavigationNode, CollectionSort } from "~/types";
|
import type { CollectionSort } from "~/types";
|
||||||
import CollectionGroup from "./CollectionGroup";
|
import CollectionGroup from "./CollectionGroup";
|
||||||
import CollectionUser from "./CollectionUser";
|
import CollectionUser from "./CollectionUser";
|
||||||
import Document from "./Document";
|
import Document from "./Document";
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ import {
|
|||||||
} from "sequelize-typescript";
|
} from "sequelize-typescript";
|
||||||
import MarkdownSerializer from "slate-md-serializer";
|
import MarkdownSerializer from "slate-md-serializer";
|
||||||
import isUUID from "validator/lib/isUUID";
|
import isUUID from "validator/lib/isUUID";
|
||||||
|
import type { NavigationNode } from "@shared/types";
|
||||||
import getTasks from "@shared/utils/getTasks";
|
import getTasks from "@shared/utils/getTasks";
|
||||||
import parseTitle from "@shared/utils/parseTitle";
|
import parseTitle from "@shared/utils/parseTitle";
|
||||||
import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers";
|
import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers";
|
||||||
import { DocumentValidation } from "@shared/validations";
|
import { DocumentValidation } from "@shared/validations";
|
||||||
import slugify from "@server/utils/slugify";
|
import slugify from "@server/utils/slugify";
|
||||||
import type { NavigationNode } from "~/types";
|
|
||||||
import Backlink from "./Backlink";
|
import Backlink from "./Backlink";
|
||||||
import Collection from "./Collection";
|
import Collection from "./Collection";
|
||||||
import FileOperation from "./FileOperation";
|
import FileOperation from "./FileOperation";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
import { FileOperationFormat } from "@shared/types";
|
import { FileOperationFormat, NavigationNode } from "@shared/types";
|
||||||
import Logger from "@server/logging/Logger";
|
import Logger from "@server/logging/Logger";
|
||||||
import { Collection } from "@server/models";
|
import { Collection } from "@server/models";
|
||||||
import Attachment from "@server/models/Attachment";
|
import Attachment from "@server/models/Attachment";
|
||||||
@@ -10,7 +10,6 @@ import ZipHelper from "@server/utils/ZipHelper";
|
|||||||
import { serializeFilename } from "@server/utils/fs";
|
import { serializeFilename } from "@server/utils/fs";
|
||||||
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
|
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
|
||||||
import { getFileByKey } from "@server/utils/s3";
|
import { getFileByKey } from "@server/utils/s3";
|
||||||
import { NavigationNode } from "~/types";
|
|
||||||
import ExportTask from "./ExportTask";
|
import ExportTask from "./ExportTask";
|
||||||
|
|
||||||
export default abstract class ExportDocumentTreeTask extends ExportTask {
|
export default abstract class ExportDocumentTreeTask extends ExportTask {
|
||||||
|
|||||||
@@ -113,6 +113,9 @@
|
|||||||
"Default collection": "Default collection",
|
"Default collection": "Default collection",
|
||||||
"Deleted Collection": "Deleted Collection",
|
"Deleted Collection": "Deleted Collection",
|
||||||
"Unpin": "Unpin",
|
"Unpin": "Unpin",
|
||||||
|
"Search collections & documents": "Search collections & documents",
|
||||||
|
"No results found": "No results found",
|
||||||
|
"Untitled": "Untitled",
|
||||||
"New": "New",
|
"New": "New",
|
||||||
"Only visible to you": "Only visible to you",
|
"Only visible to you": "Only visible to you",
|
||||||
"Draft": "Draft",
|
"Draft": "Draft",
|
||||||
@@ -199,7 +202,6 @@
|
|||||||
"Click to retry": "Click to retry",
|
"Click to retry": "Click to retry",
|
||||||
"Back": "Back",
|
"Back": "Back",
|
||||||
"Documents": "Documents",
|
"Documents": "Documents",
|
||||||
"Untitled": "Untitled",
|
|
||||||
"Results": "Results",
|
"Results": "Results",
|
||||||
"No results for {{query}}": "No results for {{query}}",
|
"No results for {{query}}": "No results for {{query}}",
|
||||||
"Logo": "Logo",
|
"Logo": "Logo",
|
||||||
@@ -508,13 +510,11 @@
|
|||||||
"Document moved": "Document moved",
|
"Document moved": "Document moved",
|
||||||
"Current location": "Current location",
|
"Current location": "Current location",
|
||||||
"Choose a new location": "Choose a new location",
|
"Choose a new location": "Choose a new location",
|
||||||
"Search collections & documents": "Search collections & documents",
|
|
||||||
"Couldn’t create the document, try again?": "Couldn’t create the document, try again?",
|
"Couldn’t create the document, try again?": "Couldn’t create the document, try again?",
|
||||||
"Document permanently deleted": "Document permanently deleted",
|
"Document permanently deleted": "Document permanently deleted",
|
||||||
"Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.": "Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.",
|
"Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.": "Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.",
|
||||||
"Select a location to publish": "Select a location to publish",
|
"Select a location to publish": "Select a location to publish",
|
||||||
"Couldn’t publish the document, try again?": "Couldn’t publish the document, try again?",
|
"Couldn’t publish the document, try again?": "Couldn’t publish the document, try again?",
|
||||||
"No results found": "No results found",
|
|
||||||
"Publish in <em>{{ location }}</em>": "Publish in <em>{{ location }}</em>",
|
"Publish in <em>{{ location }}</em>": "Publish in <em>{{ location }}</em>",
|
||||||
"view and edit access": "view and edit access",
|
"view and edit access": "view and edit access",
|
||||||
"view only access": "view only access",
|
"view only access": "view only access",
|
||||||
|
|||||||
@@ -117,3 +117,20 @@ export enum TeamPreference {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type TeamPreferences = { [key in TeamPreference]?: boolean };
|
export type TeamPreferences = { [key in TeamPreference]?: boolean };
|
||||||
|
|
||||||
|
export enum NavigationNodeType {
|
||||||
|
Collection = "collection",
|
||||||
|
Document = "document",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NavigationNode = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
children: NavigationNode[];
|
||||||
|
isDraft?: boolean;
|
||||||
|
collectionId?: string;
|
||||||
|
type?: NavigationNodeType;
|
||||||
|
parent?: NavigationNode | null;
|
||||||
|
depth?: number;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NavigationNode } from "~/types";
|
import { NavigationNode } from "@shared/types";
|
||||||
import naturalSort from "./naturalSort";
|
import naturalSort from "./naturalSort";
|
||||||
|
|
||||||
type Sort = {
|
type Sort = {
|
||||||
|
|||||||
Reference in New Issue
Block a user