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:
@@ -29,6 +29,7 @@ import { getEventFiles } from "@shared/utils/files";
|
|||||||
import DocumentDelete from "~/scenes/DocumentDelete";
|
import DocumentDelete from "~/scenes/DocumentDelete";
|
||||||
import DocumentMove from "~/scenes/DocumentMove";
|
import DocumentMove from "~/scenes/DocumentMove";
|
||||||
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
|
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
|
||||||
|
import DocumentPublish from "~/scenes/DocumentPublish";
|
||||||
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
|
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
|
||||||
import { createAction } from "~/actions";
|
import { createAction } from "~/actions";
|
||||||
import { DocumentSection } from "~/actions/sections";
|
import { DocumentSection } from "~/actions/sections";
|
||||||
@@ -71,11 +72,9 @@ export const createDocument = createAction({
|
|||||||
section: DocumentSection,
|
section: DocumentSection,
|
||||||
icon: <NewDocumentIcon />,
|
icon: <NewDocumentIcon />,
|
||||||
keywords: "create",
|
keywords: "create",
|
||||||
visible: ({ activeCollectionId, stores }) =>
|
visible: ({ currentTeamId, stores }) =>
|
||||||
!!activeCollectionId &&
|
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
|
||||||
stores.policies.abilities(activeCollectionId).update,
|
|
||||||
perform: ({ activeCollectionId, inStarredSection }) =>
|
perform: ({ activeCollectionId, inStarredSection }) =>
|
||||||
activeCollectionId &&
|
|
||||||
history.push(newDocumentPath(activeCollectionId), {
|
history.push(newDocumentPath(activeCollectionId), {
|
||||||
starred: inStarredSection,
|
starred: inStarredSection,
|
||||||
}),
|
}),
|
||||||
@@ -143,20 +142,30 @@ export const publishDocument = createAction({
|
|||||||
!!document?.isDraft && stores.policies.abilities(activeDocumentId).update
|
!!document?.isDraft && stores.policies.abilities(activeDocumentId).update
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
perform: ({ activeDocumentId, stores, t }) => {
|
perform: async ({ activeDocumentId, stores, t }) => {
|
||||||
if (!activeDocumentId) {
|
if (!activeDocumentId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const document = stores.documents.get(activeDocumentId);
|
const document = stores.documents.get(activeDocumentId);
|
||||||
|
if (document?.publishedAt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
document?.save({
|
if (document?.collectionId) {
|
||||||
|
await document.save({
|
||||||
publish: true,
|
publish: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
stores.toasts.showToast(t("Document published"), {
|
stores.toasts.showToast(t("Document published"), {
|
||||||
type: "success",
|
type: "success",
|
||||||
});
|
});
|
||||||
|
} else if (document) {
|
||||||
|
stores.dialogs.openModal({
|
||||||
|
title: t("Publish document"),
|
||||||
|
isCentered: true,
|
||||||
|
content: <DocumentPublish document={document} />,
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,31 +23,44 @@ const ActionButton = React.forwardRef(
|
|||||||
{ action, context, tooltip, hideOnActionDisabled, ...rest }: Props,
|
{ action, context, tooltip, hideOnActionDisabled, ...rest }: Props,
|
||||||
ref: React.Ref<HTMLButtonElement>
|
ref: React.Ref<HTMLButtonElement>
|
||||||
) => {
|
) => {
|
||||||
|
const [executing, setExecuting] = React.useState(false);
|
||||||
const disabled = rest.disabled;
|
const disabled = rest.disabled;
|
||||||
|
|
||||||
if (!context || !action) {
|
if (!context || !action) {
|
||||||
return <button {...rest} ref={ref} />;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const label =
|
const label =
|
||||||
typeof action.name === "function" ? action.name(context) : action.name;
|
typeof action.name === "function"
|
||||||
|
? action.name(actionContext)
|
||||||
|
: action.name;
|
||||||
|
|
||||||
const button = (
|
const button = (
|
||||||
<button
|
<button
|
||||||
{...rest}
|
{...rest}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
disabled={disabled}
|
disabled={disabled || executing}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onClick={
|
onClick={
|
||||||
action?.perform && context
|
action?.perform && actionContext
|
||||||
? (ev) => {
|
? (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
action.perform?.(context);
|
const response = action.perform?.(actionContext);
|
||||||
|
if (response?.finally) {
|
||||||
|
setExecuting(true);
|
||||||
|
response.finally(() => setExecuting(false));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
: rest.onClick
|
: rest.onClick
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ export type Props<T> = ActionButtonProps & {
|
|||||||
as?: T;
|
as?: T;
|
||||||
to?: LocationDescriptor;
|
to?: LocationDescriptor;
|
||||||
borderOnHover?: boolean;
|
borderOnHover?: boolean;
|
||||||
|
hideIcon?: boolean;
|
||||||
href?: string;
|
href?: string;
|
||||||
"data-on"?: string;
|
"data-on"?: string;
|
||||||
"data-event-category"?: string;
|
"data-event-category"?: string;
|
||||||
@@ -184,12 +185,13 @@ const Button = <T extends React.ElementType = "button">(
|
|||||||
icon,
|
icon,
|
||||||
iconColor,
|
iconColor,
|
||||||
borderOnHover,
|
borderOnHover,
|
||||||
|
hideIcon,
|
||||||
fullwidth,
|
fullwidth,
|
||||||
danger,
|
danger,
|
||||||
...rest
|
...rest
|
||||||
} = props;
|
} = props;
|
||||||
const hasText = children !== undefined || value !== undefined;
|
const hasText = children !== undefined || value !== undefined;
|
||||||
const ic = action?.icon ?? icon;
|
const ic = hideIcon ? undefined : action?.icon ?? icon;
|
||||||
const hasIcon = ic !== undefined;
|
const hasIcon = ic !== undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
|||||||
const category = useCategory(document);
|
const category = useCategory(document);
|
||||||
const collection = collections.get(document.collectionId);
|
const collection = collections.get(document.collectionId);
|
||||||
|
|
||||||
let collectionNode: MenuInternalLink;
|
let collectionNode: MenuInternalLink | undefined;
|
||||||
|
|
||||||
if (collection) {
|
if (collection) {
|
||||||
collectionNode = {
|
collectionNode = {
|
||||||
@@ -67,7 +67,7 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
|||||||
icon: <CollectionIcon collection={collection} expanded />,
|
icon: <CollectionIcon collection={collection} expanded />,
|
||||||
to: collectionUrl(collection.url),
|
to: collectionUrl(collection.url),
|
||||||
};
|
};
|
||||||
} else {
|
} else if (document.collectionId && !collection) {
|
||||||
collectionNode = {
|
collectionNode = {
|
||||||
type: "route",
|
type: "route",
|
||||||
title: t("Deleted Collection"),
|
title: t("Deleted Collection"),
|
||||||
@@ -89,7 +89,9 @@ const DocumentBreadcrumb: React.FC<Props> = ({
|
|||||||
output.push(category);
|
output.push(category);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (collectionNode) {
|
||||||
output.push(collectionNode);
|
output.push(collectionNode);
|
||||||
|
}
|
||||||
|
|
||||||
path.forEach((node: NavigationNode) => {
|
path.forEach((node: NavigationNode) => {
|
||||||
output.push({
|
output.push({
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const Span = styled.span<{ $size: number }>`
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
width: ${(props) => props.$size}px;
|
width: ${(props) => props.$size}px;
|
||||||
height: ${(props) => props.$size}px;
|
height: ${(props) => props.$size}px;
|
||||||
text-indent: -0.15em;
|
text-indent: -0.15em;
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ const Small = styled.div`
|
|||||||
margin: auto auto;
|
margin: auto auto;
|
||||||
width: 30vw;
|
width: 30vw;
|
||||||
min-width: 350px;
|
min-width: 350px;
|
||||||
max-width: 450px;
|
max-width: 500px;
|
||||||
z-index: ${depths.modal};
|
z-index: ${depths.modal};
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
170
app/components/PublishLocation.tsx
Normal file
170
app/components/PublishLocation.tsx
Normal 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);
|
||||||
@@ -57,7 +57,7 @@ export default function useKeyDown(
|
|||||||
return () => {
|
return () => {
|
||||||
callbacks = callbacks.filter((cb) => cb.callback !== handler);
|
callbacks = callbacks.filter((cb) => cb.callback !== handler);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [fn, predicate, options]);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("keydown", (event) => {
|
window.addEventListener("keydown", (event) => {
|
||||||
|
|||||||
@@ -2,76 +2,34 @@ import { observer } from "mobx-react";
|
|||||||
import { PlusIcon } from "outline-icons";
|
import { PlusIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
import { Link } from "react-router-dom";
|
||||||
import styled from "styled-components";
|
|
||||||
import Collection from "~/models/Collection";
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import ContextMenu from "~/components/ContextMenu";
|
import Tooltip from "~/components/Tooltip";
|
||||||
import Header from "~/components/ContextMenu/Header";
|
|
||||||
import Template from "~/components/ContextMenu/Template";
|
|
||||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
|
||||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||||
import usePolicy from "~/hooks/usePolicy";
|
import usePolicy from "~/hooks/usePolicy";
|
||||||
import useStores from "~/hooks/useStores";
|
|
||||||
import { MenuItem } from "~/types";
|
|
||||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||||
|
|
||||||
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => {
|
|
||||||
return <CollectionIcon collection={collection} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
function NewDocumentMenu() {
|
function NewDocumentMenu() {
|
||||||
const menu = useMenuState({
|
|
||||||
modal: true,
|
|
||||||
});
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
const { collections, policies } = useStores();
|
|
||||||
const can = usePolicy(team);
|
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) {
|
if (!can.createDocument) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Tooltip
|
||||||
<MenuButton {...menu}>
|
tooltip={t("New document")}
|
||||||
{(props) => (
|
shortcut="n"
|
||||||
<Button icon={<PlusIcon />} disabled={items.length === 0} {...props}>
|
delay={500}
|
||||||
{`${t("New doc")}…`}
|
placement="bottom"
|
||||||
|
>
|
||||||
|
<Button as={Link} to={newDocumentPath()} icon={<PlusIcon />}>
|
||||||
|
{t("New doc")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</Tooltip>
|
||||||
</MenuButton>
|
|
||||||
<ContextMenu {...menu} aria-label={t("New document")}>
|
|
||||||
<Header>{t("Choose a collection")}</Header>
|
|
||||||
<Template {...menu} items={items} />
|
|
||||||
</ContextMenu>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const CollectionName = styled.div`
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default observer(NewDocumentMenu);
|
export default observer(NewDocumentMenu);
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ function AuthenticatedRoutes() {
|
|||||||
<Route exact path="/collection/:id/new" component={DocumentNew} />
|
<Route exact path="/collection/:id/new" component={DocumentNew} />
|
||||||
<Route exact path="/collection/:id/:tab" component={Collection} />
|
<Route exact path="/collection/:id/:tab" component={Collection} />
|
||||||
<Route exact path="/collection/:id" 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 path={`/d/${slug}`} component={RedirectDocument} />
|
||||||
<Route
|
<Route
|
||||||
exact
|
exact
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import RootStore from "~/stores/RootStore";
|
|||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import Revision from "~/models/Revision";
|
import Revision from "~/models/Revision";
|
||||||
import DocumentMove from "~/scenes/DocumentMove";
|
import DocumentMove from "~/scenes/DocumentMove";
|
||||||
|
import DocumentPublish from "~/scenes/DocumentPublish";
|
||||||
import Branding from "~/components/Branding";
|
import Branding from "~/components/Branding";
|
||||||
import ConnectionStatus from "~/components/ConnectionStatus";
|
import ConnectionStatus from "~/components/ConnectionStatus";
|
||||||
import ErrorBoundary from "~/components/ErrorBoundary";
|
import ErrorBoundary from "~/components/ErrorBoundary";
|
||||||
@@ -268,14 +269,23 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
|
|
||||||
onPublish = (ev: React.MouseEvent | KeyboardEvent) => {
|
onPublish = (ev: React.MouseEvent | KeyboardEvent) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
const { document } = this.props;
|
const { document, dialogs, t } = this.props;
|
||||||
if (document.publishedAt) {
|
if (document.publishedAt) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (document?.collectionId) {
|
||||||
this.onSave({
|
this.onSave({
|
||||||
publish: true,
|
publish: true,
|
||||||
done: true,
|
done: true,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
dialogs.openModal({
|
||||||
|
title: t("Publish document"),
|
||||||
|
isCentered: true,
|
||||||
|
content: <DocumentPublish document={document} />,
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onToggleTableOfContents = (ev: KeyboardEvent) => {
|
onToggleTableOfContents = (ev: KeyboardEvent) => {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import Collaborators from "~/components/Collaborators";
|
|||||||
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
||||||
import Header from "~/components/Header";
|
import Header from "~/components/Header";
|
||||||
import Tooltip from "~/components/Tooltip";
|
import Tooltip from "~/components/Tooltip";
|
||||||
|
import { publishDocument } from "~/actions/definitions/documents";
|
||||||
import { restoreRevision } from "~/actions/definitions/revisions";
|
import { restoreRevision } from "~/actions/definitions/revisions";
|
||||||
import useActionContext from "~/hooks/useActionContext";
|
import useActionContext from "~/hooks/useActionContext";
|
||||||
import useMobile from "~/hooks/useMobile";
|
import useMobile from "~/hooks/useMobile";
|
||||||
@@ -94,13 +95,6 @@ function DocumentHeader({
|
|||||||
});
|
});
|
||||||
}, [onSave]);
|
}, [onSave]);
|
||||||
|
|
||||||
const handlePublish = React.useCallback(() => {
|
|
||||||
onSave({
|
|
||||||
done: true,
|
|
||||||
publish: true,
|
|
||||||
});
|
|
||||||
}, [onSave]);
|
|
||||||
|
|
||||||
const context = useActionContext({
|
const context = useActionContext({
|
||||||
activeDocumentId: document?.id,
|
activeDocumentId: document?.id,
|
||||||
});
|
});
|
||||||
@@ -312,23 +306,17 @@ function DocumentHeader({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Action>
|
</Action>
|
||||||
)}
|
)}
|
||||||
{can.update && isDraft && !isRevision && (
|
|
||||||
<Action>
|
<Action>
|
||||||
<Tooltip
|
|
||||||
tooltip={t("Publish")}
|
|
||||||
shortcut={`${metaDisplay}+shift+p`}
|
|
||||||
delay={500}
|
|
||||||
placement="bottom"
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handlePublish}
|
action={publishDocument}
|
||||||
|
context={context}
|
||||||
disabled={publishingIsDisabled}
|
disabled={publishingIsDisabled}
|
||||||
|
hideOnActionDisabled
|
||||||
|
hideIcon
|
||||||
>
|
>
|
||||||
{isPublishing ? `${t("Publishing")}…` : t("Publish")}
|
{document.collectionId ? t("Publish") : `${t("Publish")}…`}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
|
||||||
</Action>
|
</Action>
|
||||||
)}
|
|
||||||
{!isEditing && (
|
{!isEditing && (
|
||||||
<>
|
<>
|
||||||
{!isDeleted && <Separator />}
|
{!isDeleted && <Separator />}
|
||||||
|
|||||||
@@ -23,11 +23,14 @@ function DocumentNew() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function createDocument() {
|
async function createDocument() {
|
||||||
const params = queryString.parse(location.search);
|
const params = queryString.parse(location.search);
|
||||||
|
let collection;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const collection = await collections.fetch(id);
|
if (id) {
|
||||||
|
collection = await collections.fetch(id);
|
||||||
|
}
|
||||||
const document = await documents.create({
|
const document = await documents.create({
|
||||||
collectionId: collection.id,
|
collectionId: collection?.id,
|
||||||
parentDocumentId: params.parentDocumentId?.toString(),
|
parentDocumentId: params.parentDocumentId?.toString(),
|
||||||
templateId: params.templateId?.toString(),
|
templateId: params.templateId?.toString(),
|
||||||
template: params.template === "true" ? true : false,
|
template: params.template === "true" ? true : false,
|
||||||
|
|||||||
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);
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import invariant from "invariant";
|
import invariant from "invariant";
|
||||||
import { concat, find, last } from "lodash";
|
import { concat, find, last, isEmpty } from "lodash";
|
||||||
import { computed, action } from "mobx";
|
import { computed, action } from "mobx";
|
||||||
import { CollectionPermission, FileOperationFormat } from "@shared/types";
|
import { CollectionPermission, FileOperationFormat } from "@shared/types";
|
||||||
|
import parseTitle from "@shared/utils/parseTitle";
|
||||||
import Collection from "~/models/Collection";
|
import Collection from "~/models/Collection";
|
||||||
import { NavigationNode } from "~/types";
|
import { NavigationNode } from "~/types";
|
||||||
import { client } from "~/utils/ApiClient";
|
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
|
@action
|
||||||
import = async (attachmentId: string, format?: string) => {
|
import = async (attachmentId: string, format?: string) => {
|
||||||
await client.post("/collections.import", {
|
await client.post("/collections.import", {
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export type Action = {
|
|||||||
placeholder?: ((context: ActionContext) => string) | string;
|
placeholder?: ((context: ActionContext) => string) | string;
|
||||||
selected?: (context: ActionContext) => boolean;
|
selected?: (context: ActionContext) => boolean;
|
||||||
visible?: (context: ActionContext) => boolean;
|
visible?: (context: ActionContext) => boolean;
|
||||||
perform?: (context: ActionContext) => void;
|
perform?: (context: ActionContext) => Promise<any> | any;
|
||||||
children?: ((context: ActionContext) => Action[]) | Action[];
|
children?: ((context: ActionContext) => Action[]) | Action[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -97,14 +97,16 @@ export function updateDocumentUrl(oldUrl: string, document: Document): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function newDocumentPath(
|
export function newDocumentPath(
|
||||||
collectionId: string,
|
collectionId?: string,
|
||||||
params: {
|
params: {
|
||||||
parentDocumentId?: string;
|
parentDocumentId?: string;
|
||||||
templateId?: string;
|
templateId?: string;
|
||||||
template?: boolean;
|
template?: boolean;
|
||||||
} = {}
|
} = {}
|
||||||
): string {
|
): string {
|
||||||
return `/collection/${collectionId}/new?${queryString.stringify(params)}`;
|
return collectionId
|
||||||
|
? `/collection/${collectionId}/new?${queryString.stringify(params)}`
|
||||||
|
: `/doc/new`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function searchPath(
|
export function searchPath(
|
||||||
|
|||||||
32
app/utils/tree.ts
Normal file
32
app/utils/tree.ts
Normal 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);
|
||||||
|
};
|
||||||
@@ -7,7 +7,7 @@ type Props = {
|
|||||||
title: string;
|
title: string;
|
||||||
text: string;
|
text: string;
|
||||||
publish?: boolean;
|
publish?: boolean;
|
||||||
collectionId?: string;
|
collectionId?: string | null;
|
||||||
parentDocumentId?: string | null;
|
parentDocumentId?: string | null;
|
||||||
importId?: string;
|
importId?: string;
|
||||||
templateDocument?: Document | null;
|
templateDocument?: Document | null;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type Props = {
|
|||||||
/** Whether the document should be published to the collection */
|
/** Whether the document should be published to the collection */
|
||||||
publish?: boolean;
|
publish?: boolean;
|
||||||
/** The ID of the collection to publish the document to */
|
/** The ID of the collection to publish the document to */
|
||||||
collectionId?: string;
|
collectionId?: string | null;
|
||||||
/** The IP address of the user creating the document */
|
/** The IP address of the user creating the document */
|
||||||
ip: string;
|
ip: string;
|
||||||
/** The database transaction to run within */
|
/** The database transaction to run within */
|
||||||
|
|||||||
@@ -2238,6 +2238,19 @@ describe("#documents.create", () => {
|
|||||||
expect(body.message).toEqual("collectionId: Invalid uuid");
|
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 () => {
|
it("should fail for invalid parentDocumentId", async () => {
|
||||||
const { user, collection } = await seed();
|
const { user, collection } = await seed();
|
||||||
const res = await server.post("/api/documents.create", {
|
const res = await server.post("/api/documents.create", {
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ export const DocumentsUpdateSchema = BaseSchema.extend({
|
|||||||
templateId: z.string().uuid().nullish(),
|
templateId: z.string().uuid().nullish(),
|
||||||
|
|
||||||
/** Doc collection Id */
|
/** Doc collection Id */
|
||||||
collectionId: z.string().uuid().optional(),
|
collectionId: z.string().uuid().nullish(),
|
||||||
|
|
||||||
/** Boolean to denote if text should be appended */
|
/** Boolean to denote if text should be appended */
|
||||||
append: z.boolean().optional(),
|
append: z.boolean().optional(),
|
||||||
@@ -279,7 +279,7 @@ export const DocumentsCreateSchema = BaseSchema.extend({
|
|||||||
publish: z.boolean().optional(),
|
publish: z.boolean().optional(),
|
||||||
|
|
||||||
/** Create Doc under this collection */
|
/** Create Doc under this collection */
|
||||||
collectionId: z.string().uuid().optional(),
|
collectionId: z.string().uuid().nullish(),
|
||||||
|
|
||||||
/** Create Doc under this parent */
|
/** Create Doc under this parent */
|
||||||
parentDocumentId: z.string().uuid().nullish(),
|
parentDocumentId: z.string().uuid().nullish(),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"New document": "New document",
|
"New document": "New document",
|
||||||
"Publish": "Publish",
|
"Publish": "Publish",
|
||||||
"Document published": "Document published",
|
"Document published": "Document published",
|
||||||
|
"Publish document": "Publish document",
|
||||||
"Unpublish": "Unpublish",
|
"Unpublish": "Unpublish",
|
||||||
"Document unpublished": "Document unpublished",
|
"Document unpublished": "Document unpublished",
|
||||||
"Subscribe": "Subscribe",
|
"Subscribe": "Subscribe",
|
||||||
@@ -198,13 +199,13 @@
|
|||||||
"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",
|
||||||
"Move document": "Move document",
|
"Move document": "Move document",
|
||||||
"You can't reorder documents in an alphabetically sorted collection": "You can't reorder documents in an alphabetically sorted collection",
|
"You can't reorder documents in an alphabetically sorted collection": "You can't reorder documents in an alphabetically sorted collection",
|
||||||
"Collections": "Collections",
|
"Collections": "Collections",
|
||||||
"Untitled": "Untitled",
|
|
||||||
"New nested document": "New nested document",
|
"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",
|
"Document not supported – try Markdown, Plain text, HTML, or Word": "Document not supported – try Markdown, Plain text, HTML, or Word",
|
||||||
"Empty": "Empty",
|
"Empty": "Empty",
|
||||||
@@ -443,7 +444,6 @@
|
|||||||
"Done Editing": "Done Editing",
|
"Done Editing": "Done Editing",
|
||||||
"New from template": "New from template",
|
"New from template": "New from template",
|
||||||
"Restore version": "Restore version",
|
"Restore version": "Restore version",
|
||||||
"Publishing": "Publishing",
|
|
||||||
"No history yet": "No history yet",
|
"No history yet": "No history yet",
|
||||||
"Stats": "Stats",
|
"Stats": "Stats",
|
||||||
"{{ count }} minute read": "{{ count }} minute read",
|
"{{ count }} minute read": "{{ count }} minute read",
|
||||||
@@ -512,6 +512,10 @@
|
|||||||
"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",
|
||||||
|
"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>",
|
||||||
"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",
|
||||||
"no access": "no access",
|
"no access": "no access",
|
||||||
|
|||||||
@@ -17,8 +17,14 @@ export default function parseTitle(text = "") {
|
|||||||
const startsWithEmoji = firstEmoji && title.startsWith(`${firstEmoji} `);
|
const startsWithEmoji = firstEmoji && title.startsWith(`${firstEmoji} `);
|
||||||
const emoji = startsWithEmoji ? firstEmoji : undefined;
|
const emoji = startsWithEmoji ? firstEmoji : undefined;
|
||||||
|
|
||||||
|
// title with first leading emoji stripped
|
||||||
|
const strippedTitle = startsWithEmoji
|
||||||
|
? title.replace(firstEmoji, "").trim()
|
||||||
|
: title;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
emoji,
|
emoji,
|
||||||
|
strippedTitle,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,7 +158,7 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@babel/types" "^7.16.0"
|
"@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"
|
version "7.19.0"
|
||||||
resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz#941574ed5390682e872e52d3f38ce9d1bef4648c"
|
resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz#941574ed5390682e872e52d3f38ce9d1bef4648c"
|
||||||
integrity sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==
|
integrity sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==
|
||||||
@@ -173,7 +173,7 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@babel/types" "^7.18.6"
|
"@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"
|
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"
|
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==
|
integrity sha512-9J0CxJLq315fEdi4s7xK5TQaNYjZw+nDVpVqr1axNGKzdrdwYBD5b4uKv3n75aABG0rCCTK8Im8Ww7eYfMrZgw==
|
||||||
@@ -298,7 +298,7 @@
|
|||||||
chalk "^2.0.0"
|
chalk "^2.0.0"
|
||||||
js-tokens "^4.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"
|
version "7.20.7"
|
||||||
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.20.7.tgz#66fe23b3c8569220817d5feb8b9dcdc95bb4f71b"
|
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.20.7.tgz#66fe23b3c8569220817d5feb8b9dcdc95bb4f71b"
|
||||||
integrity sha512-T3Z9oHybU+0vZlY9CiDSJQTD5ZapcW18ZctFMi0MOAl/4BjFF4ul7NVSARLdbGO5vDqy9eQiGTV0LtKfvCYvcg==
|
integrity sha512-T3Z9oHybU+0vZlY9CiDSJQTD5ZapcW18ZctFMi0MOAl/4BjFF4ul7NVSARLdbGO5vDqy9eQiGTV0LtKfvCYvcg==
|
||||||
|
|||||||
Reference in New Issue
Block a user