Move collection description into dedicated tab (#8326)

* Move collection description into dedicated tab

* Dynamic default

* refactor
This commit is contained in:
Tom Moor
2025-02-03 19:58:35 -05:00
committed by GitHub
parent 144d83e68c
commit c717e8e3eb
8 changed files with 256 additions and 344 deletions

View File

@@ -1,6 +1,5 @@
import debounce from "lodash/debounce";
import { observer } from "mobx-react";
import { transparentize } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -8,23 +7,14 @@ import styled from "styled-components";
import { richExtensions } from "@shared/editor/nodes";
import { s } from "@shared/styles";
import Collection from "~/models/Collection";
import Arrow from "~/components/Arrow";
import ButtonLink from "~/components/ButtonLink";
import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator";
import NudeButton from "~/components/NudeButton";
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
import { withUIExtensions } from "~/editor/extensions";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import Text from "./Text";
const extensions = [
...richExtensions,
BlockMenuExtension,
EmojiMenuExtension,
HoverPreviewsExtension,
];
const extensions = withUIExtensions(richExtensions);
type Props = {
collection: Collection;
@@ -33,33 +23,8 @@ type Props = {
function CollectionDescription({ collection }: Props) {
const { collections } = useStores();
const { t } = useTranslation();
const [isExpanded, setExpanded] = React.useState(false);
const [isEditing, setEditing] = React.useState(false);
const [isDirty, setDirty] = React.useState(false);
const can = usePolicy(collection);
const handleStartEditing = React.useCallback(() => {
setEditing(true);
}, []);
const handleStopEditing = React.useCallback(() => {
setEditing(false);
}, []);
const handleClickDisclosure = React.useCallback(
(event) => {
event.preventDefault();
if (isExpanded && document.activeElement) {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'blur' does not exist on type 'Element'.
document.activeElement.blur();
}
setExpanded(!isExpanded);
},
[isExpanded]
);
const handleSave = React.useMemo(
() =>
debounce(async (getValue) => {
@@ -67,7 +32,6 @@ function CollectionDescription({ collection }: Props) {
await collection.save({
data: getValue(false),
});
setDirty(false);
} catch (err) {
toast.error(t("Sorry, an error occurred saving the collection"));
throw err;
@@ -76,162 +40,44 @@ function CollectionDescription({ collection }: Props) {
[collection, t]
);
const handleChange = React.useCallback(
async (getValue) => {
setDirty(true);
await handleSave(getValue);
},
[handleSave]
const childRef = React.useRef<HTMLDivElement>(null);
const childOffsetHeight = childRef.current?.offsetHeight || 0;
const editorStyle = React.useMemo(
() => ({
padding: "0 32px",
margin: "0 -32px",
paddingBottom: `calc(50vh - ${childOffsetHeight}px)`,
}),
[childOffsetHeight]
);
React.useEffect(() => {
setEditing(false);
}, [collection.id]);
const placeholder = `${t("Add a description")}`;
const key = isEditing || isDirty ? "draft" : collection.updatedAt;
return (
<MaxHeight data-editing={isEditing} data-expanded={isExpanded}>
<Input data-editing={isEditing} data-expanded={isExpanded}>
<span onClick={can.update ? handleStartEditing : undefined}>
{collections.isSaving && <LoadingIndicator />}
{collection.hasDescription || isEditing || isDirty ? (
<React.Suspense
fallback={
<Placeholder
onClick={() => {
//
}}
>
Loading
</Placeholder>
}
>
<Editor
key={key}
defaultValue={collection.data}
onChange={handleChange}
placeholder={placeholder}
readOnly={!isEditing}
autoFocus={isEditing}
onBlur={handleStopEditing}
extensions={extensions}
maxLength={1000}
embedsDisabled
canUpdate
/>
</React.Suspense>
) : (
can.update && (
<Placeholder
onClick={() => {
//
}}
>
{placeholder}
</Placeholder>
)
)}
</span>
</Input>
{!isEditing && (
<Disclosure
onClick={handleClickDisclosure}
aria-label={isExpanded ? t("Collapse") : t("Expand")}
size={30}
>
<Arrow />
</Disclosure>
<>
{collections.isSaving && <LoadingIndicator />}
{(collection.hasDescription || can.update) && (
<React.Suspense fallback={<Placeholder>Loading</Placeholder>}>
<Editor
defaultValue={collection.data}
onChange={handleSave}
placeholder={`${t("Add a description")}`}
extensions={extensions}
maxLength={1000}
canUpdate={can.update}
readOnly={!can.update}
editorStyle={editorStyle}
embedsDisabled
/>
<div ref={childRef} />
</React.Suspense>
)}
</MaxHeight>
</>
);
}
const Disclosure = styled(NudeButton)`
opacity: 0;
color: ${s("divider")};
position: absolute;
top: calc(25vh - 50px);
left: 50%;
z-index: 1;
transform: rotate(-90deg) translateX(-50%);
transition: opacity 100ms ease-in-out;
&:focus,
&:hover {
opacity: 1;
}
&:active {
color: ${s("sidebarText")};
}
`;
const Placeholder = styled(ButtonLink)`
const Placeholder = styled(Text)`
color: ${s("placeholder")};
cursor: text;
min-height: 27px;
`;
const MaxHeight = styled.div`
position: relative;
max-height: 25vh;
overflow: hidden;
margin: 8px -8px -8px;
padding: 8px;
&[data-editing="true"],
&[data-expanded="true"] {
max-height: initial;
overflow: initial;
${Disclosure} {
top: initial;
bottom: 0;
transform: rotate(90deg) translateX(-50%);
}
}
&:hover ${Disclosure} {
opacity: 1;
}
`;
const Input = styled.div`
margin: -8px;
padding: 8px;
border-radius: 8px;
&:after {
content: "";
position: absolute;
top: calc(25vh - 50px);
left: 0;
right: 0;
height: 50px;
pointer-events: none;
background: linear-gradient(
180deg,
${(props) => transparentize(1, props.theme.background)} 0%,
${s("background")} 100%
);
}
&[data-editing="true"],
&[data-expanded="true"] {
&:after {
background: transparent;
}
}
&[data-editing="true"] {
background: ${s("backgroundSecondary")};
}
.block-menu-trigger,
.heading-anchor {
display: none !important;
}
`;
export default observer(CollectionDescription);

View File

@@ -157,6 +157,9 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
if (item.attrs.type === MentionType.Document) {
return;
}
if (!documentId) {
return;
}
// Check if the mentioned user has access to the document
const res = await client.post("/documents.users", {
id: documentId,

View File

@@ -0,0 +1,30 @@
import Extension from "@shared/editor/lib/Extension";
import Mark from "@shared/editor/marks/Mark";
import Node from "@shared/editor/nodes/Node";
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
import ClipboardTextSerializer from "~/editor/extensions/ClipboardTextSerializer";
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
import FindAndReplaceExtension from "~/editor/extensions/FindAndReplace";
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
import Keys from "~/editor/extensions/Keys";
import MentionMenuExtension from "~/editor/extensions/MentionMenu";
import PasteHandler from "~/editor/extensions/PasteHandler";
import PreventTab from "~/editor/extensions/PreventTab";
import SmartText from "~/editor/extensions/SmartText";
type Nodes = (typeof Node | typeof Mark | typeof Extension)[];
export const withUIExtensions = (nodes: Nodes) => [
...nodes,
SmartText,
PasteHandler,
ClipboardTextSerializer,
BlockMenuExtension,
EmojiMenuExtension,
MentionMenuExtension,
FindAndReplaceExtension,
HoverPreviewsExtension,
// Order these default key handlers last
PreventTab,
Keys,
];

View File

@@ -72,8 +72,7 @@ function AuthenticatedRoutes() {
<Redirect exact from="/templates" to={settingsPath("templates")} />
<Redirect exact from="/collections/*" to="/collection/*" />
<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="/collection/:id/:tab?" component={Collection} />
<Route exact path="/doc/new" component={DocumentNew} />
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
<Route

View File

@@ -3,12 +3,12 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import {
useParams,
Redirect,
Switch,
Route,
useHistory,
useRouteMatch,
useLocation,
Redirect,
} from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -30,13 +30,13 @@ import PaginatedDocumentList from "~/components/PaginatedDocumentList";
import PinnedDocuments from "~/components/PinnedDocuments";
import PlaceholderText from "~/components/PlaceholderText";
import Scene from "~/components/Scene";
import Subheading from "~/components/Subheading";
import Tab from "~/components/Tab";
import Tabs from "~/components/Tabs";
import { editCollection } from "~/actions/definitions/collections";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import { useLastVisitedPath } from "~/hooks/useLastVisitedPath";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePersistedState from "~/hooks/usePersistedState";
import { usePinnedDocuments } from "~/hooks/usePinnedDocuments";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -50,7 +50,16 @@ import ShareButton from "./components/ShareButton";
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
function CollectionScene() {
enum CollectionPath {
Overview = "overview",
Recent = "recent",
Updated = "updated",
Published = "published",
Old = "old",
Alphabetical = "alphabetical",
}
const CollectionScene = observer(function _CollectionScene() {
const params = useParams<{ id?: string }>();
const history = useHistory();
const match = useRouteMatch();
@@ -68,11 +77,19 @@ function CollectionScene() {
collections.getByUrl(id) || collections.get(id);
const can = usePolicy(collection);
const { pins, count } = usePinnedDocuments(id, collection?.id);
const [collectionTab, setCollectionTab] = usePersistedState<CollectionPath>(
`collection-tab:${collection?.id}`,
collection?.hasDescription
? CollectionPath.Overview
: CollectionPath.Recent,
{
listen: false,
}
);
const handleIconChange = React.useCallback(
async (icon: string | null, color: string | null) => {
await collection?.save({ icon, color });
},
(icon: string | null, color: string | null) =>
collection?.save({ icon, color }),
[collection]
);
@@ -122,6 +139,8 @@ function CollectionScene() {
return <Search notFound />;
}
const hasOverview = can.update || collection?.hasDescription;
const fallbackIcon = collection ? (
<Icon
value={collection.icon ?? "collection"}
@@ -130,11 +149,17 @@ function CollectionScene() {
/>
) : null;
const tabProps = (path: CollectionPath) => ({
exact: true,
onClick: () => setCollectionTab(path),
to: {
pathname: collectionPath(collection!.path, path),
state: { sidebarContext },
},
});
return collection ? (
<Scene
// Forced mount prevents animation of pinned documents when navigating
// _between_ collections, speeds up perceived performance.
key={collection.id}
centered={false}
textTitle={collection.name}
left={
@@ -198,135 +223,156 @@ function CollectionScene() {
canUpdate={can.update}
placeholderCount={count}
/>
<CollectionDescription collection={collection} />
<Documents>
{!collection.isArchived && (
<Tabs>
<Tab
<Tabs>
{hasOverview && (
<Tab {...tabProps(CollectionPath.Overview)}>
{t("Overview")}
</Tab>
)}
<Tab {...tabProps(CollectionPath.Recent)}>{t("Documents")}</Tab>
{!collection.isArchived && (
<>
<Tab {...tabProps(CollectionPath.Updated)}>
{t("Recently updated")}
</Tab>
<Tab {...tabProps(CollectionPath.Published)}>
{t("Recently published")}
</Tab>
<Tab {...tabProps(CollectionPath.Old)}>
{t("Least recently updated")}
</Tab>
<Tab {...tabProps(CollectionPath.Alphabetical)}>
{t("AZ")}
</Tab>
</>
)}
</Tabs>
<Switch>
<Route path={collectionPath(collection.path)} exact>
<Redirect
to={{
pathname: collectionPath(collection.path),
pathname: collectionPath(collection!.path, collectionTab),
state: { sidebarContext },
}}
exact
>
{t("Documents")}
</Tab>
<Tab
to={{
pathname: collectionPath(collection.path, "updated"),
state: { sidebarContext },
}}
exact
>
{t("Recently updated")}
</Tab>
<Tab
to={{
pathname: collectionPath(collection.path, "published"),
state: { sidebarContext },
}}
exact
>
{t("Recently published")}
</Tab>
<Tab
to={{
pathname: collectionPath(collection.path, "old"),
state: { sidebarContext },
}}
exact
>
{t("Least recently updated")}
</Tab>
<Tab
to={{
pathname: collectionPath(collection.path, "alphabetical"),
state: { sidebarContext },
}}
exact
>
{t("AZ")}
</Tab>
</Tabs>
)}
{collection.isEmpty ? (
<Empty collection={collection} />
) : !collection.isArchived ? (
<Switch>
<Route path={collectionPath(collection.path, "alphabetical")}>
<PaginatedDocumentList
key="alphabetical"
documents={documents.alphabeticalInCollection(
collection.id
/>
</Route>
<Route
path={collectionPath(collection.path, CollectionPath.Overview)}
>
{hasOverview ? (
<CollectionDescription collection={collection} />
) : (
<Redirect
to={{
pathname: collectionPath(
collection.path,
CollectionPath.Recent
),
state: { sidebarContext },
}}
/>
)}
</Route>
{collection.isEmpty ? (
<Empty collection={collection} />
) : !collection.isArchived ? (
<>
<Route
path={collectionPath(
collection.path,
CollectionPath.Alphabetical
)}
fetch={documents.fetchAlphabetical}
options={{
collectionId: collection.id,
}}
/>
</Route>
<Route path={collectionPath(collection.path, "old")}>
<PaginatedDocumentList
key="old"
documents={documents.leastRecentlyUpdatedInCollection(
collection.id
>
<PaginatedDocumentList
key="alphabetical"
documents={documents.alphabeticalInCollection(
collection.id
)}
fetch={documents.fetchAlphabetical}
options={{
collectionId: collection.id,
}}
/>
</Route>
<Route
path={collectionPath(collection.path, CollectionPath.Old)}
>
<PaginatedDocumentList
key="old"
documents={documents.leastRecentlyUpdatedInCollection(
collection.id
)}
fetch={documents.fetchLeastRecentlyUpdated}
options={{
collectionId: collection.id,
}}
/>
</Route>
<Route
path={collectionPath(
collection.path,
CollectionPath.Published
)}
fetch={documents.fetchLeastRecentlyUpdated}
options={{
collectionId: collection.id,
}}
/>
</Route>
<Route path={collectionPath(collection.path, "recent")}>
<Redirect to={collectionPath(collection.path, "published")} />
</Route>
<Route path={collectionPath(collection.path, "published")}>
<PaginatedDocumentList
key="published"
documents={documents.recentlyPublishedInCollection(
collection.id
>
<PaginatedDocumentList
key="published"
documents={documents.recentlyPublishedInCollection(
collection.id
)}
fetch={documents.fetchRecentlyPublished}
options={{
collectionId: collection.id,
}}
showPublished
/>
</Route>
<Route
path={collectionPath(
collection.path,
CollectionPath.Updated
)}
fetch={documents.fetchRecentlyPublished}
options={{
collectionId: collection.id,
}}
showPublished
/>
</Route>
<Route path={collectionPath(collection.path, "updated")}>
<PaginatedDocumentList
key="updated"
documents={documents.recentlyUpdatedInCollection(
collection.id
>
<PaginatedDocumentList
key="updated"
documents={documents.recentlyUpdatedInCollection(
collection.id
)}
fetch={documents.fetchRecentlyUpdated}
options={{
collectionId: collection.id,
}}
/>
</Route>
<Route
path={collectionPath(
collection.path,
CollectionPath.Recent
)}
fetch={documents.fetchRecentlyUpdated}
options={{
collectionId: collection.id,
}}
/>
</Route>
<Route path={collectionPath(collection.path)} exact>
<PaginatedDocumentList
documents={documents.rootInCollection(collection.id)}
fetch={documents.fetchPage}
options={{
collectionId: collection.id,
parentDocumentId: null,
sort: collection.sort.field,
direction: collection.sort.direction,
}}
showParentDocuments
/>
</Route>
</Switch>
) : (
<Switch>
<Route path={collectionPath(collection.path)} exact>
exact
>
<PaginatedDocumentList
documents={documents.rootInCollection(collection.id)}
fetch={documents.fetchPage}
options={{
collectionId: collection.id,
parentDocumentId: null,
sort: collection.sort.field,
direction: collection.sort.direction,
}}
showParentDocuments
/>
</Route>
</>
) : (
<Route
path={collectionPath(collection.path, CollectionPath.Recent)}
exact
>
<PaginatedDocumentList
documents={documents.archivedInCollection(collection.id)}
fetch={documents.fetchPage}
heading={<Subheading sticky>{t("Documents")}</Subheading>}
options={{
collectionId: collection.id,
parentDocumentId: null,
@@ -337,8 +383,8 @@ function CollectionScene() {
showParentDocuments
/>
</Route>
</Switch>
)}
)}
</Switch>
</Documents>
</CenteredContent>
</DropToImport>
@@ -351,7 +397,15 @@ function CollectionScene() {
<PlaceholderList count={5} />
</CenteredContent>
);
}
});
const KeyedCollection = () => {
const params = useParams<{ id?: string }>();
// Forced mount prevents animation of pinned documents when navigating
// _between_ collections, speeds up perceived performance.
return <CollectionScene key={params.id} />;
};
const Documents = styled.div`
position: relative;
@@ -369,4 +423,4 @@ const CollectionHeading = styled(Heading)`
`}
`;
export default observer(CollectionScene);
export default KeyedCollection;

View File

@@ -13,16 +13,7 @@ import { RefHandle } from "~/components/ContentEditable";
import { useDocumentContext } from "~/components/DocumentContext";
import Editor, { Props as EditorProps } from "~/components/Editor";
import Flex from "~/components/Flex";
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
import ClipboardTextSerializer from "~/editor/extensions/ClipboardTextSerializer";
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
import FindAndReplaceExtension from "~/editor/extensions/FindAndReplace";
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
import Keys from "~/editor/extensions/Keys";
import MentionMenuExtension from "~/editor/extensions/MentionMenu";
import PasteHandler from "~/editor/extensions/PasteHandler";
import PreventTab from "~/editor/extensions/PreventTab";
import SmartText from "~/editor/extensions/SmartText";
import { withUIExtensions } from "~/editor/extensions";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import useFocusedComment from "~/hooks/useFocusedComment";
@@ -40,20 +31,7 @@ import MultiplayerEditor from "./AsyncMultiplayerEditor";
import DocumentMeta from "./DocumentMeta";
import DocumentTitle from "./DocumentTitle";
const extensions = [
...withComments(richExtensions),
SmartText,
PasteHandler,
ClipboardTextSerializer,
BlockMenuExtension,
EmojiMenuExtension,
MentionMenuExtension,
FindAndReplaceExtension,
HoverPreviewsExtension,
// Order these default key handlers last
PreventTab,
Keys,
];
const extensions = withUIExtensions(withComments(richExtensions));
type Props = Omit<EditorProps, "editorStyle"> & {
onChangeTitle: (title: string) => void;

View File

@@ -107,6 +107,7 @@ export const richExtensions: Nodes = [
TemplatePlaceholder,
Math,
MathBlock,
Mention,
// Container type nodes should be last so that key handlers are registered for content inside
// the container nodes first.
...listExtensions,
@@ -116,4 +117,4 @@ export const richExtensions: Nodes = [
/**
* Add commenting and mentions to a set of nodes
*/
export const withComments = (nodes: Nodes) => [...nodes, Mention, Comment];
export const withComments = (nodes: Nodes) => [...nodes, Comment];

View File

@@ -167,8 +167,6 @@
"Also, <em>{{collectionName}}</em> is being used as the start view deleting it will reset the start view to the Home page.": "Also, <em>{{collectionName}}</em> is being used as the start view deleting it will reset the start view to the Home page.",
"Sorry, an error occurred saving the collection": "Sorry, an error occurred saving the collection",
"Add a description": "Add a description",
"Collapse": "Collapse",
"Expand": "Expand",
"Type a command or search": "Type a command or search",
"Choose a template": "Choose a template",
"Are you sure you want to permanently delete this entire comment thread?": "Are you sure you want to permanently delete this entire comment thread?",
@@ -371,6 +369,8 @@
"You can't reorder documents in an alphabetically sorted collection": "You can't reorder documents in an alphabetically sorted collection",
"Empty": "Empty",
"Collections": "Collections",
"Collapse": "Collapse",
"Expand": "Expand",
"Document not supported try Markdown, Plain text, HTML, or Word": "Document not supported try Markdown, Plain text, HTML, or Word",
"Go back": "Go back",
"Go forward": "Go forward",
@@ -594,6 +594,7 @@
"{{ groupsCount }} groups with access_plural": "{{ groupsCount }} groups with access",
"Archived by {{userName}}": "Archived by {{userName}}",
"Share": "Share",
"Overview": "Overview",
"Recently updated": "Recently updated",
"Recently published": "Recently published",
"Least recently updated": "Least recently updated",