Ability to choose publish location for a document (#4582)

* feat: initial base structure

* feat: utils for constructing and flattening collection tree

* feat: basic demo to display tree-like structure with virtualization

* feat: make it searchable

* feat: row component

* fix: handle row selection

* fix: scroll jitter

* fix: popover max-height to eliminate extra scroll

* fix: position scrollbar correctly

* fix: do not sort to maintain correct tree-like view

* feat: footer

* fix: scroll to selected item

* fix: deselect item

* fix: display selected location in footer

* fix: deselect item if any upon search trigger

* fix: create draft without collection

* fix: pass down collectionId to all the nodes

* feat: publish document under selected location

* fix: move the doc post publish in case it is supposed to be a nested doc

* fix: wrap text for selected location

* fix: footer background in dark mode and unused css

* fix: popover height in small devices

* fix: no need to spread

* refactor: remove outline

* refactor: border-radius is common

* refactor: remove active and focus

* fix: do not shrink spacer

* fix: scroll list padding with correctly adjusted scrolling

* refactor: use constants

* fix: use padding in favor of spacer

* refactor: border attrs not needed

* refactor: control title padding and icon size centrally

* fix: rename param

* fix: import path

* fix: refactor styles, avoid magic numbers

* fix: type err

* feat: make rows collapsible

* fix: fully expanded without disclosure upon search

* fix: use modal in place of popover

* fix: collapse descendants

* fix: rename PublishPopover to PublishModal

* fix: adjust collapse icon as part of tree view

* fix: enable keyboard navigation

* not sure why collapse and expand shortcuts are not working

* fix: row expansion and search box focus and blur

* fix: remove css hover, handle it via active prop

* fix: discard tree like view for search results

* fix: minor tweaks

* refactor: no need to pass onPublish

* refactor: remove unnecessary attrs from search component

* fix: publish button text

* fix: reset intial scroll offset to 0 on search

* fix: remove search highlights

* fix: clean up search component

* refactor: search and row collapse

* refactor: PublishLocation

* fix: show emoji or star icon if present

* fix: shift focus only from top item

* fix: leading emoji

* fix: baseline text

* fix: make path tertiary

* fix: do not show path for collections

* fix: path text color upon selection

* fix: deleted collection case

* fix: no results found

* fix: space around slash

* Refinement, some small refactors

* fix: Publish shortcut, use Button action

* Allow new document creation from command menu without active collection

* fix: duplicate

* fix: Unneccessary truncation

* fix: Scroll on expand/collapse
Remove wraparound

* fix: tsc

* fix: Horizontal overflow on PublishLocation
Remove pointless moveTo method

* fix: Missing translation

* Remove method indirection
Show expanded collection icon in tree when expanded

* Shrink font size a point

* Remove feature flag

* fix: Path color contrast in light mode
Remove unused expanded/show attributes

* shrink -> collapse, fix expanded disclosure without items after searching

* Mobile styles

* fix: scroll just into view

Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
Apoorv Mishra
2023-01-24 07:08:24 +05:30
committed by GitHub
parent da4a0189dc
commit 6b286d82b8
25 changed files with 834 additions and 121 deletions

View File

@@ -0,0 +1,428 @@
import FuzzySearch from "fuzzy-search";
import { includes, difference, concat, filter } from "lodash";
import { observer } from "mobx-react";
import { StarredIcon, DocumentIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } 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 Document from "~/models/Document";
import Button from "~/components/Button";
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 useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { isModKey } from "~/utils/keyboard";
import { flattenTree, descendants } from "~/utils/tree";
type Props = {
/** Document to publish */
document: Document;
};
function DocumentPublish({ document }: Props) {
const isMobile = useMobile();
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 theme = useTheme();
const [items, setItems] = React.useState<any>(
flattenTree(collections.tree.root).slice(1)
);
const [activeItem, setActiveItem] = React.useState<number>(0);
const [expandedItems, setExpandedItems] = React.useState<string[]>([]);
const inputSearchRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>(
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 () => {
if (!selectedLocation) {
showToast(t("Select a location to publish"), {
type: "info",
});
return;
}
try {
const {
collectionId,
type,
id: parentDocumentId,
} = selectedLocation.data;
// Also move it under if selected path corresponds to another doc
if (type === "document") {
await document.move(collectionId, parentDocumentId);
}
document.collectionId = collectionId;
await document.save({ publish: true });
showToast(t("Document published"), {
type: "success",
});
dialogs.closeAllModals();
} catch (err) {
showToast(t("Couldnt publish the document, try again?"), {
type: "error",
});
}
};
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 (
<FlexContainer column tabIndex={-1} onKeyDown={handleKeyDown}>
<Search
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}>
{selectedLocation ? (
<SelectedLocation type="secondary">
<Trans
defaults="Publish in <em>{{ location }}</em>"
values={{
location: selectedLocation.data.title,
}}
components={{
em: <strong />,
}}
/>
</SelectedLocation>
) : (
<SelectedLocation type="tertiary">
{t("Select a location to publish")}
</SelectedLocation>
)}
<Button disabled={!selectedLocation} onClick={publish}>
{t("Publish")}
</Button>
</Footer>
</FlexContainer>
);
}
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)`
margin-left: -24px;
margin-right: -24px;
margin-bottom: -24px;
outline: none;
`;
const Results = styled.div`
height: 65vh;
${breakpoint("tablet")`
height: 40vh;
`}
`;
const Footer = styled(Flex)`
height: 64px;
border-top: 1px solid ${(props) => props.theme.horizontalRule};
padding-left: 24px;
padding-right: 24px;
`;
const SelectedLocation = styled(Text)`
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 0;
`;
export default observer(DocumentPublish);