feat: Collection admins (#5273

* Split permissions for reading documents from updating collection

* fix: Admins should have collection read permission, tests

* tsc

* Add admin option to permission selector

* Combine publish and create permissions, update -> createDocuments where appropriate

* Plural -> singular

* wip

* Quick version of collection structure loading, will revisit

* Remove documentIds method

* stash

* fixing tests to account for admin creation

* Add self-hosted migration

* fix: Allow groups to have admin permission

* Prefetch collection documents

* fix: Document explorer (move/publish) not working with async documents

* fix: Cannot re-parent document to collection by drag and drop

* fix: Cannot drag to import into collection item without admin permission

* Remove unused isEditor getter
This commit is contained in:
Tom Moor
2023-04-30 09:38:47 -04:00
committed by GitHub
parent 2942e9c78e
commit d8b4fef554
44 changed files with 799 additions and 535 deletions

View File

@@ -61,7 +61,7 @@ const AuthenticatedLayout: React.FC = ({ children }) => {
return;
}
const { activeCollectionId } = ui;
if (!activeCollectionId || !can.update) {
if (!activeCollectionId || !can.createDocument) {
return;
}
history.push(newDocumentPath(activeCollectionId));

View File

@@ -48,7 +48,6 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
const [initialScrollOffset, setInitialScrollOffset] = React.useState<number>(
0
);
const [nodes, setNodes] = React.useState<NavigationNode[]>([]);
const [activeNode, setActiveNode] = React.useState<number>(0);
const [expandedNodes, setExpandedNodes] = React.useState<string[]>([]);
const [itemRefs, setItemRefs] = React.useState<
@@ -79,19 +78,6 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
setActiveNode(0);
}, [searchTerm]);
React.useEffect(() => {
let results;
if (searchTerm) {
results = searchIndex.search(searchTerm);
} else {
results = items.filter((item) => item.type === "collection");
}
setInitialScrollOffset(0);
setNodes(results);
}, [searchTerm, items, searchIndex]);
React.useEffect(() => {
setItemRefs((itemRefs) =>
map(
@@ -105,6 +91,22 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
onSelect(selectedNode);
}, [selectedNode, onSelect]);
function getNodes() {
function includeDescendants(item: NavigationNode): NavigationNode[] {
return expandedNodes.includes(item.id)
? [item, ...descendants(item, 1).flatMap(includeDescendants)]
: [item];
}
return searchTerm
? searchIndex.search(searchTerm)
: items
.filter((item) => item.type === "collection")
.flatMap(includeDescendants);
}
const nodes = getNodes();
const scrollNodeIntoView = React.useCallback(
(node: number) => {
if (itemRefs[node] && itemRefs[node].current) {
@@ -130,7 +132,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
scrollOffset: number;
};
const itemsHeight = itemCount * itemSize;
return itemsHeight < height ? 0 : scrollOffset;
return itemsHeight < Number(height) ? 0 : scrollOffset;
}
return 0;
};
@@ -145,7 +147,6 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
const newNodes = filter(nodes, (node) => !includes(descendantIds, node.id));
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
setInitialScrollOffset(scrollOffset);
setNodes(newNodes);
};
const expand = (node: number) => {
@@ -156,9 +157,18 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
newNodes.splice(node + 1, 0, ...descendants(nodes[node], 1));
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
setInitialScrollOffset(scrollOffset);
setNodes(newNodes);
};
React.useEffect(() => {
collections.orderedData
.filter(
(collection) => expandedNodes.includes(collection.id) || searchTerm
)
.forEach((collection) => {
collection.fetchDocuments();
});
}, [collections, expandedNodes, searchTerm]);
const isSelected = (node: number) => {
if (!selectedNode) {
return false;
@@ -169,7 +179,8 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
return selectedNodeId === nodeId;
};
const hasChildren = (node: number) => nodes[node].children.length > 0;
const hasChildren = (node: number) =>
nodes[node].children.length > 0 || nodes[node].type === "collection";
const toggleCollapse = (node: number) => {
if (!hasChildren(node)) {
@@ -219,7 +230,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
} else if (doc?.isStarred) {
icon = <StarredIcon color={theme.yellow} />;
} else {
icon = <DocumentIcon />;
icon = <DocumentIcon color={theme.textSecondary} />;
}
path = ancestors(node)
@@ -281,12 +292,14 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
switch (ev.key) {
case "ArrowDown": {
ev.preventDefault();
ev.stopPropagation();
setActiveNode(next());
scrollNodeIntoView(next());
break;
}
case "ArrowUp": {
ev.preventDefault();
ev.stopPropagation();
if (activeNode === 0) {
focusSearchInput();
} else {

View File

@@ -0,0 +1,21 @@
import { observer } from "mobx-react";
import * as React from "react";
import Collection from "~/models/Collection";
type Props = {
enabled: boolean;
collection: Collection;
children: React.ReactNode;
};
function DocumentsLoader({ collection, enabled, children }: Props) {
React.useEffect(() => {
if (enabled) {
void collection.fetchDocuments();
}
}, [collection, enabled]);
return <>{children}</>;
}
export default observer(DocumentsLoader);

View File

@@ -44,7 +44,7 @@ const CollectionLink: React.FC<Props> = ({
const { dialogs, documents, collections } = useStores();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [isEditing, setIsEditing] = React.useState(false);
const canUpdate = usePolicy(collection).update;
const can = usePolicy(collection);
const { t } = useTranslation();
const history = useHistory();
const inStarredSection = useStarredContext();
@@ -105,7 +105,7 @@ const CollectionLink: React.FC<Props> = ({
}
}
},
canDrop: () => canUpdate,
canDrop: () => can.createDocument,
collect: (monitor) => ({
isOver: !!monitor.isOver({
shallow: true,
@@ -118,6 +118,10 @@ const CollectionLink: React.FC<Props> = ({
setIsEditing(isEditing);
}, []);
const handleMouseEnter = React.useCallback(() => {
void collection.fetchDocuments();
}, [collection]);
const context = useActionContext({
activeCollectionId: collection.id,
inStarredSection,
@@ -134,6 +138,7 @@ const CollectionLink: React.FC<Props> = ({
}}
expanded={expanded}
onDisclosureClick={onDisclosureClick}
onMouseEnter={handleMouseEnter}
icon={
<CollectionIcon collection={collection} expanded={expanded} />
}
@@ -147,7 +152,7 @@ const CollectionLink: React.FC<Props> = ({
title={collection.name}
onSubmit={handleTitleChange}
onEditing={handleTitleEditing}
canUpdate={canUpdate}
canUpdate={can.update}
/>
}
exact={false}

View File

@@ -2,8 +2,12 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import DelayedMount from "~/components/DelayedMount";
import DocumentsLoader from "~/components/DocumentsLoader";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
@@ -11,6 +15,7 @@ import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor";
import EmptyCollectionPlaceholder from "./EmptyCollectionPlaceholder";
import Folder from "./Folder";
import PlaceholderCollections from "./PlaceholderCollections";
import { DragObject } from "./SidebarLink";
import useCollectionDocuments from "./useCollectionDocuments";
@@ -63,28 +68,42 @@ function CollectionLinkChildren({
return (
<Folder expanded={expanded}>
{isDraggingAnyDocument && can.update && manualSort && (
{isDraggingAnyDocument && can.createDocument && manualSort && (
<DropCursor
isActiveDrop={isOverReorder}
innerRef={dropToReorder}
position="top"
/>
)}
{childDocuments.map((node, index) => (
<DocumentLink
key={node.id}
node={node}
collection={collection}
activeDocument={documents.active}
prefetchDocument={prefetchDocument}
isDraft={node.isDraft}
depth={2}
index={index}
/>
))}
{childDocuments.length === 0 && <EmptyCollectionPlaceholder />}
<DocumentsLoader collection={collection} enabled={expanded}>
{!childDocuments && (
<ResizingHeightContainer hideOverflow>
<DelayedMount>
<Loading />
</DelayedMount>
</ResizingHeightContainer>
)}
{childDocuments?.map((node, index) => (
<DocumentLink
key={node.id}
node={node}
collection={collection}
activeDocument={documents.active}
prefetchDocument={prefetchDocument}
isDraft={node.isDraft}
depth={2}
index={index}
/>
))}
{childDocuments?.length === 0 && <EmptyCollectionPlaceholder />}
</DocumentsLoader>
</Folder>
);
}
const Loading = styled(PlaceholderCollections)`
margin-left: 44px;
min-height: 90px;
`;
export default observer(CollectionLinkChildren);

View File

@@ -26,10 +26,14 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
collectionId,
documentId
);
const targetId = collectionId || documentId;
invariant(targetId, "Must provide either collectionId or documentId");
invariant(
collectionId || documentId,
"Must provide either collectionId or documentId"
);
const canCollection = usePolicy(collectionId);
const canDocument = usePolicy(documentId);
const can = usePolicy(targetId);
const handleRejection = React.useCallback(() => {
showToast(
t("Document not supported try Markdown, Plain text, HTML, or Word"),
@@ -39,7 +43,11 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
);
}, [t, showToast]);
if (disabled || !can.update) {
if (
disabled ||
(collectionId && !canCollection.createDocument) ||
(documentId && !canDocument.createChildDocument)
) {
return children;
}

View File

@@ -2,9 +2,9 @@ import * as React from "react";
import styled from "styled-components";
import PlaceholderText from "~/components/PlaceholderText";
function PlaceholderCollections() {
function PlaceholderCollections(props: React.HTMLAttributes<HTMLDivElement>) {
return (
<Wrapper>
<Wrapper {...props}>
<PlaceholderText />
<PlaceholderText delay={0.2} />
<PlaceholderText delay={0.4} />

View File

@@ -8,8 +8,8 @@ export default function useCollectionDocuments(
activeDocument: Document | undefined
) {
return React.useMemo(() => {
if (!collection) {
return [];
if (!collection?.sortedDocuments) {
return undefined;
}
const insertDraftDocument =

View File

@@ -196,7 +196,7 @@ class WebsocketProvider extends React.Component<Props> {
}
try {
await collections.fetch(collectionId, {
await collection?.fetchDocuments({
force: true,
});
} catch (err) {
@@ -293,6 +293,10 @@ class WebsocketProvider extends React.Component<Props> {
collections.add(event);
});
this.socket.on("collections.update", (event: PartialWithId<Collection>) => {
collections.add(event);
});
this.socket.on(
"collections.delete",
action((event: WebsocketEntityDeletedEvent) => {