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

@@ -29,6 +29,7 @@ import { getEventFiles } from "@shared/utils/files";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentMove from "~/scenes/DocumentMove";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import DocumentPublish from "~/scenes/DocumentPublish";
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
import { createAction } from "~/actions";
import { DocumentSection } from "~/actions/sections";
@@ -71,11 +72,9 @@ export const createDocument = createAction({
section: DocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
visible: ({ currentTeamId, stores }) =>
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
perform: ({ activeCollectionId, inStarredSection }) =>
activeCollectionId &&
history.push(newDocumentPath(activeCollectionId), {
starred: inStarredSection,
}),
@@ -143,20 +142,30 @@ export const publishDocument = createAction({
!!document?.isDraft && stores.policies.abilities(activeDocumentId).update
);
},
perform: ({ activeDocumentId, stores, t }) => {
perform: async ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
if (document?.publishedAt) {
return;
}
document?.save({
if (document?.collectionId) {
await document.save({
publish: true,
});
stores.toasts.showToast(t("Document published"), {
type: "success",
});
} else if (document) {
stores.dialogs.openModal({
title: t("Publish document"),
isCentered: true,
content: <DocumentPublish document={document} />,
});
}
},
});

View File

@@ -23,31 +23,44 @@ const ActionButton = React.forwardRef(
{ action, context, tooltip, hideOnActionDisabled, ...rest }: Props,
ref: React.Ref<HTMLButtonElement>
) => {
const [executing, setExecuting] = React.useState(false);
const disabled = rest.disabled;
if (!context || !action) {
return <button {...rest} ref={ref} />;
}
if (action?.visible && !action.visible(context) && hideOnActionDisabled) {
const actionContext = { ...context, isButton: true };
if (
action?.visible &&
!action.visible(actionContext) &&
hideOnActionDisabled
) {
return null;
}
const label =
typeof action.name === "function" ? action.name(context) : action.name;
typeof action.name === "function"
? action.name(actionContext)
: action.name;
const button = (
<button
{...rest}
aria-label={label}
disabled={disabled}
disabled={disabled || executing}
ref={ref}
onClick={
action?.perform && context
action?.perform && actionContext
? (ev) => {
ev.preventDefault();
ev.stopPropagation();
action.perform?.(context);
const response = action.perform?.(actionContext);
if (response?.finally) {
setExecuting(true);
response.finally(() => setExecuting(false));
}
}
: rest.onClick
}

View File

@@ -164,6 +164,7 @@ export type Props<T> = ActionButtonProps & {
as?: T;
to?: LocationDescriptor;
borderOnHover?: boolean;
hideIcon?: boolean;
href?: string;
"data-on"?: string;
"data-event-category"?: string;
@@ -184,12 +185,13 @@ const Button = <T extends React.ElementType = "button">(
icon,
iconColor,
borderOnHover,
hideIcon,
fullwidth,
danger,
...rest
} = props;
const hasText = children !== undefined || value !== undefined;
const ic = action?.icon ?? icon;
const ic = hideIcon ? undefined : action?.icon ?? icon;
const hasIcon = ic !== undefined;
return (

View File

@@ -58,7 +58,7 @@ const DocumentBreadcrumb: React.FC<Props> = ({
const category = useCategory(document);
const collection = collections.get(document.collectionId);
let collectionNode: MenuInternalLink;
let collectionNode: MenuInternalLink | undefined;
if (collection) {
collectionNode = {
@@ -67,7 +67,7 @@ const DocumentBreadcrumb: React.FC<Props> = ({
icon: <CollectionIcon collection={collection} expanded />,
to: collectionUrl(collection.url),
};
} else {
} else if (document.collectionId && !collection) {
collectionNode = {
type: "route",
title: t("Deleted Collection"),
@@ -89,7 +89,9 @@ const DocumentBreadcrumb: React.FC<Props> = ({
output.push(category);
}
if (collectionNode) {
output.push(collectionNode);
}
path.forEach((node: NavigationNode) => {
output.push({

View File

@@ -25,6 +25,7 @@ const Span = styled.span<{ $size: number }>`
align-items: center;
justify-content: center;
text-align: center;
flex-shrink: 0;
width: ${(props) => props.$size}px;
height: ${(props) => props.$size}px;
text-indent: -0.15em;

View File

@@ -254,7 +254,7 @@ const Small = styled.div`
margin: auto auto;
width: 30vw;
min-width: 350px;
max-width: 450px;
max-width: 500px;
z-index: ${depths.modal};
display: flex;
justify-content: center;

View File

@@ -0,0 +1,170 @@
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 breakpoint from "styled-components-breakpoint";
import Flex from "~/components/Flex";
import Disclosure from "~/components/Sidebar/components/Disclosure";
import Text from "~/components/Text";
import { ancestors } from "~/utils/tree";
type Props = {
location: any;
selected: boolean;
active: boolean;
style: React.CSSProperties;
isSearchResult: boolean;
expanded: boolean;
icon?: React.ReactNode;
onDisclosureClick: (ev: React.MouseEvent) => void;
onPointerMove: (ev: React.MouseEvent) => void;
onClick: (ev: React.MouseEvent) => void;
};
function PublishLocation({
location,
selected,
active,
style,
isSearchResult,
expanded,
onDisclosureClick,
onPointerMove,
onClick,
icon,
}: Props) {
const { t } = useTranslation();
const OFFSET = 12;
const ICON_SIZE = 24;
const hasChildren = location.children.length > 0;
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(
(node: HTMLSpanElement | null) => {
if (active && node) {
scrollIntoView(node, {
scrollMode: "if-needed",
behavior: "auto",
block: "nearest",
});
}
},
[active]
);
return (
<Row
ref={ref}
selected={selected}
active={active}
onClick={onClick}
style={style}
onPointerMove={onPointerMove}
role="option"
>
{!isSearchResult && (
<Spacer width={width}>
{hasChildren && (
<StyledDisclosure
expanded={expanded}
onClick={onDisclosureClick}
tabIndex={-1}
/>
)}
</Spacer>
)}
{icon}
<Title>{location.data.title || t("Untitled")}</Title>
{isSearchResult && !isCollection && (
<Path $selected={selected} size="xsmall">
{path(location)}
</Path>
)}
</Row>
);
}
const Title = styled(Text)`
white-space: nowrap;
overflow: hidden;
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};
`;
const StyledDisclosure = styled(Disclosure)`
position: relative;
left: auto;
margin-top: 2px;
`;
const Spacer = styled(Flex)<{ width: number }>`
flex-direction: row-reverse;
flex-shrink: 0;
width: ${(props) => props.width}px;
`;
const Row = styled.span<{
active: boolean;
selected: boolean;
style: React.CSSProperties;
}>`
display: flex;
user-select: none;
overflow: hidden;
font-size: 16px;
width: ${(props) => props.style.width};
color: ${(props) => props.theme.text};
cursor: var(--pointer);
padding: 12px;
border-radius: 6px;
background: ${(props) =>
!props.selected && props.active && props.theme.listItemHoverBackground};
svg {
flex-shrink: 0;
}
&:focus {
outline: none;
}
${(props) =>
props.selected &&
`
background: ${props.theme.primary};
color: ${props.theme.white};
svg {
fill: ${props.theme.white};
}
`}
${breakpoint("tablet")`
padding: 4px;
font-size: 15px;
`}
`;
export default observer(PublishLocation);

View File

@@ -57,7 +57,7 @@ export default function useKeyDown(
return () => {
callbacks = callbacks.filter((cb) => cb.callback !== handler);
};
}, []);
}, [fn, predicate, options]);
}
window.addEventListener("keydown", (event) => {

View File

@@ -2,76 +2,34 @@ import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import styled from "styled-components";
import Collection from "~/models/Collection";
import { Link } from "react-router-dom";
import Button from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import Header from "~/components/ContextMenu/Header";
import Template from "~/components/ContextMenu/Template";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import Tooltip from "~/components/Tooltip";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
import { newDocumentPath } from "~/utils/routeHelpers";
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => {
return <CollectionIcon collection={collection} />;
};
function NewDocumentMenu() {
const menu = useMenuState({
modal: true,
});
const { t } = useTranslation();
const team = useCurrentTeam();
const { collections, policies } = useStores();
const can = usePolicy(team);
const items = React.useMemo(
() =>
collections.orderedData.reduce<MenuItem[]>((filtered, collection) => {
const can = policies.abilities(collection.id);
if (can.update) {
filtered.push({
type: "route",
to: newDocumentPath(collection.id),
title: <CollectionName>{collection.name}</CollectionName>,
icon: <ColorCollectionIcon collection={collection} />,
});
}
return filtered;
}, []),
[collections.orderedData, policies]
);
if (!can.createDocument) {
return null;
}
return (
<>
<MenuButton {...menu}>
{(props) => (
<Button icon={<PlusIcon />} disabled={items.length === 0} {...props}>
{`${t("New doc")}`}
<Tooltip
tooltip={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
>
<Button as={Link} to={newDocumentPath()} icon={<PlusIcon />}>
{t("New doc")}
</Button>
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("New document")}>
<Header>{t("Choose a collection")}</Header>
<Template {...menu} items={items} />
</ContextMenu>
</>
</Tooltip>
);
}
const CollectionName = styled.div`
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
`;
export default observer(NewDocumentMenu);

View File

@@ -100,6 +100,7 @@ function AuthenticatedRoutes() {
<Route exact path="/collection/:id/new" component={DocumentNew} />
<Route exact path="/collection/:id/:tab" component={Collection} />
<Route exact path="/collection/:id" component={Collection} />
<Route exact path="/doc/new" component={DocumentNew} />
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
<Route
exact

View File

@@ -21,6 +21,7 @@ import RootStore from "~/stores/RootStore";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import DocumentMove from "~/scenes/DocumentMove";
import DocumentPublish from "~/scenes/DocumentPublish";
import Branding from "~/components/Branding";
import ConnectionStatus from "~/components/ConnectionStatus";
import ErrorBoundary from "~/components/ErrorBoundary";
@@ -268,14 +269,23 @@ class DocumentScene extends React.Component<Props> {
onPublish = (ev: React.MouseEvent | KeyboardEvent) => {
ev.preventDefault();
const { document } = this.props;
const { document, dialogs, t } = this.props;
if (document.publishedAt) {
return;
}
if (document?.collectionId) {
this.onSave({
publish: true,
done: true,
});
} else {
dialogs.openModal({
title: t("Publish document"),
isCentered: true,
content: <DocumentPublish document={document} />,
});
}
};
onToggleTableOfContents = (ev: KeyboardEvent) => {

View File

@@ -20,6 +20,7 @@ import Collaborators from "~/components/Collaborators";
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
import Header from "~/components/Header";
import Tooltip from "~/components/Tooltip";
import { publishDocument } from "~/actions/definitions/documents";
import { restoreRevision } from "~/actions/definitions/revisions";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
@@ -94,13 +95,6 @@ function DocumentHeader({
});
}, [onSave]);
const handlePublish = React.useCallback(() => {
onSave({
done: true,
publish: true,
});
}, [onSave]);
const context = useActionContext({
activeDocumentId: document?.id,
});
@@ -312,23 +306,17 @@ function DocumentHeader({
</Tooltip>
</Action>
)}
{can.update && isDraft && !isRevision && (
<Action>
<Tooltip
tooltip={t("Publish")}
shortcut={`${metaDisplay}+shift+p`}
delay={500}
placement="bottom"
>
<Button
onClick={handlePublish}
action={publishDocument}
context={context}
disabled={publishingIsDisabled}
hideOnActionDisabled
hideIcon
>
{isPublishing ? `${t("Publishing")}` : t("Publish")}
{document.collectionId ? t("Publish") : `${t("Publish")}`}
</Button>
</Tooltip>
</Action>
)}
{!isEditing && (
<>
{!isDeleted && <Separator />}

View File

@@ -23,11 +23,14 @@ function DocumentNew() {
useEffect(() => {
async function createDocument() {
const params = queryString.parse(location.search);
let collection;
try {
const collection = await collections.fetch(id);
if (id) {
collection = await collections.fetch(id);
}
const document = await documents.create({
collectionId: collection.id,
collectionId: collection?.id,
parentDocumentId: params.parentDocumentId?.toString(),
templateId: params.templateId?.toString(),
template: params.template === "true" ? true : false,

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);

View File

@@ -1,7 +1,8 @@
import invariant from "invariant";
import { concat, find, last } from "lodash";
import { concat, find, last, isEmpty } from "lodash";
import { computed, action } from "mobx";
import { CollectionPermission, FileOperationFormat } from "@shared/types";
import parseTitle from "@shared/utils/parseTitle";
import Collection from "~/models/Collection";
import { NavigationNode } from "~/types";
import { client } from "~/utils/ApiClient";
@@ -99,6 +100,76 @@ 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.data.forEach((collection) => {
root.children.push(
subtree({
data: {
...collection,
type: DocumentPathItemType.Collection,
},
children: collection.documents || [],
parent: root,
depth: root.depth + 1,
}).root
);
});
}
return { root };
}
@action
import = async (attachmentId: string, format?: string) => {
await client.post("/collections.import", {

View File

@@ -99,7 +99,7 @@ export type Action = {
placeholder?: ((context: ActionContext) => string) | string;
selected?: (context: ActionContext) => boolean;
visible?: (context: ActionContext) => boolean;
perform?: (context: ActionContext) => void;
perform?: (context: ActionContext) => Promise<any> | any;
children?: ((context: ActionContext) => Action[]) | Action[];
};

View File

@@ -97,14 +97,16 @@ export function updateDocumentUrl(oldUrl: string, document: Document): string {
}
export function newDocumentPath(
collectionId: string,
collectionId?: string,
params: {
parentDocumentId?: string;
templateId?: string;
template?: boolean;
} = {}
): string {
return `/collection/${collectionId}/new?${queryString.stringify(params)}`;
return collectionId
? `/collection/${collectionId}/new?${queryString.stringify(params)}`
: `/doc/new`;
}
export function searchPath(

32
app/utils/tree.ts Normal file
View File

@@ -0,0 +1,32 @@
import { flatten } from "lodash";
export const flattenTree = (root: any) => {
const flattened: any[] = [];
if (!root) {
return flattened;
}
flattened.push(root);
root.children.forEach((child: any) => {
flattened.push(flattenTree(child));
});
return flatten(flattened);
};
export const ancestors = (node: any) => {
const ancestors: any[] = [];
while (node.parent !== null) {
ancestors.unshift(node);
node = node.parent;
}
return ancestors;
};
export const descendants = (node: any, depth = 0) => {
const allDescendants = flattenTree(node).slice(1);
return depth === 0
? allDescendants
: allDescendants.filter((d) => d.depth <= node.depth + depth);
};

View File

@@ -7,7 +7,7 @@ type Props = {
title: string;
text: string;
publish?: boolean;
collectionId?: string;
collectionId?: string | null;
parentDocumentId?: string | null;
importId?: string;
templateDocument?: Document | null;

View File

@@ -22,7 +22,7 @@ type Props = {
/** Whether the document should be published to the collection */
publish?: boolean;
/** The ID of the collection to publish the document to */
collectionId?: string;
collectionId?: string | null;
/** The IP address of the user creating the document */
ip: string;
/** The database transaction to run within */

View File

@@ -2238,6 +2238,19 @@ describe("#documents.create", () => {
expect(body.message).toEqual("collectionId: Invalid uuid");
});
it("should succeed if collectionId is null", async () => {
const { user } = await seed();
const res = await server.post("/api/documents.create", {
body: {
token: user.getJwtToken(),
collectionId: null,
title: "new document",
text: "hello",
},
});
expect(res.status).toEqual(200);
});
it("should fail for invalid parentDocumentId", async () => {
const { user, collection } = await seed();
const res = await server.post("/api/documents.create", {

View File

@@ -197,7 +197,7 @@ export const DocumentsUpdateSchema = BaseSchema.extend({
templateId: z.string().uuid().nullish(),
/** Doc collection Id */
collectionId: z.string().uuid().optional(),
collectionId: z.string().uuid().nullish(),
/** Boolean to denote if text should be appended */
append: z.boolean().optional(),
@@ -279,7 +279,7 @@ export const DocumentsCreateSchema = BaseSchema.extend({
publish: z.boolean().optional(),
/** Create Doc under this collection */
collectionId: z.string().uuid().optional(),
collectionId: z.string().uuid().nullish(),
/** Create Doc under this parent */
parentDocumentId: z.string().uuid().nullish(),

View File

@@ -15,6 +15,7 @@
"New document": "New document",
"Publish": "Publish",
"Document published": "Document published",
"Publish document": "Publish document",
"Unpublish": "Unpublish",
"Document unpublished": "Document unpublished",
"Subscribe": "Subscribe",
@@ -198,13 +199,13 @@
"Click to retry": "Click to retry",
"Back": "Back",
"Documents": "Documents",
"Untitled": "Untitled",
"Results": "Results",
"No results for {{query}}": "No results for {{query}}",
"Logo": "Logo",
"Move document": "Move document",
"You can't reorder documents in an alphabetically sorted collection": "You can't reorder documents in an alphabetically sorted collection",
"Collections": "Collections",
"Untitled": "Untitled",
"New nested document": "New nested document",
"Document not supported try Markdown, Plain text, HTML, or Word": "Document not supported try Markdown, Plain text, HTML, or Word",
"Empty": "Empty",
@@ -443,7 +444,6 @@
"Done Editing": "Done Editing",
"New from template": "New from template",
"Restore version": "Restore version",
"Publishing": "Publishing",
"No history yet": "No history yet",
"Stats": "Stats",
"{{ count }} minute read": "{{ count }} minute read",
@@ -512,6 +512,10 @@
"Couldnt create the document, try again?": "Couldnt create the document, try again?",
"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.",
"Select a location to publish": "Select a location to publish",
"Couldnt publish the document, try again?": "Couldnt publish the document, try again?",
"No results found": "No results found",
"Publish in <em>{{ location }}</em>": "Publish in <em>{{ location }}</em>",
"view and edit access": "view and edit access",
"view only access": "view only access",
"no access": "no access",

View File

@@ -17,8 +17,14 @@ export default function parseTitle(text = "") {
const startsWithEmoji = firstEmoji && title.startsWith(`${firstEmoji} `);
const emoji = startsWithEmoji ? firstEmoji : undefined;
// title with first leading emoji stripped
const strippedTitle = startsWithEmoji
? title.replace(firstEmoji, "").trim()
: title;
return {
title,
emoji,
strippedTitle,
};
}

View File

@@ -158,7 +158,7 @@
dependencies:
"@babel/types" "^7.16.0"
"@babel/helper-function-name@^7.16.0", "@babel/helper-function-name@^7.18.9", "@babel/helper-function-name@^7.19.0":
"@babel/helper-function-name@^7.16.0", "@babel/helper-function-name@^7.19.0":
version "7.19.0"
resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz#941574ed5390682e872e52d3f38ce9d1bef4648c"
integrity sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==
@@ -173,7 +173,7 @@
dependencies:
"@babel/types" "^7.18.6"
"@babel/helper-member-expression-to-functions@^7.18.9", "@babel/helper-member-expression-to-functions@^7.20.7":
"@babel/helper-member-expression-to-functions@^7.20.7":
version "7.20.7"
resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.20.7.tgz#a6f26e919582275a93c3aa6594756d71b0bb7f05"
integrity sha512-9J0CxJLq315fEdi4s7xK5TQaNYjZw+nDVpVqr1axNGKzdrdwYBD5b4uKv3n75aABG0rCCTK8Im8Ww7eYfMrZgw==
@@ -298,7 +298,7 @@
chalk "^2.0.0"
js-tokens "^4.0.0"
"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.18.10", "@babel/parser@^7.18.11", "@babel/parser@^7.20.7", "@babel/parser@^7.7.0":
"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.18.10", "@babel/parser@^7.20.7", "@babel/parser@^7.7.0":
version "7.20.7"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.20.7.tgz#66fe23b3c8569220817d5feb8b9dcdc95bb4f71b"
integrity sha512-T3Z9oHybU+0vZlY9CiDSJQTD5ZapcW18ZctFMi0MOAl/4BjFF4ul7NVSARLdbGO5vDqy9eQiGTV0LtKfvCYvcg==