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

@@ -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);
}
output.push(collectionNode);
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);