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:
428
app/scenes/DocumentPublish.tsx
Normal file
428
app/scenes/DocumentPublish.tsx
Normal 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("Couldn’t 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);
|
||||
Reference in New Issue
Block a user