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:
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
21
app/components/DocumentsLoader.tsx
Normal file
21
app/components/DocumentsLoader.tsx
Normal 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);
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -8,8 +8,8 @@ export default function useCollectionDocuments(
|
||||
activeDocument: Document | undefined
|
||||
) {
|
||||
return React.useMemo(() => {
|
||||
if (!collection) {
|
||||
return [];
|
||||
if (!collection?.sortedDocuments) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const insertDraftDocument =
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -73,9 +73,11 @@ export default function useCollectionTrees(): NavigationNode[] {
|
||||
return addParent(addCollectionId(addDepth(addType(collectionNode))));
|
||||
};
|
||||
|
||||
const key = collections.orderedData.map((o) => o.documents?.length).join("-");
|
||||
const collectionTrees = React.useMemo(
|
||||
() => collections.orderedData.map(getCollectionTree),
|
||||
[collections.orderedData]
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[collections.orderedData, key]
|
||||
);
|
||||
|
||||
return collectionTrees;
|
||||
|
||||
@@ -200,14 +200,14 @@ function CollectionMenu({
|
||||
{
|
||||
type: "button",
|
||||
title: t("New document"),
|
||||
visible: can.update,
|
||||
visible: can.createDocument,
|
||||
onClick: handleNewDocument,
|
||||
icon: <NewDocumentIcon />,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: t("Import document"),
|
||||
visible: can.update,
|
||||
visible: can.createDocument,
|
||||
onClick: handleImportDocument,
|
||||
icon: <ImportIcon />,
|
||||
},
|
||||
@@ -261,6 +261,7 @@ function CollectionMenu({
|
||||
collection,
|
||||
can.unstar,
|
||||
can.star,
|
||||
can.createDocument,
|
||||
can.update,
|
||||
can.delete,
|
||||
handleStar,
|
||||
|
||||
@@ -132,7 +132,7 @@ function DocumentMenu({
|
||||
...collections.orderedData.reduce<MenuItem[]>((filtered, collection) => {
|
||||
const can = policies.abilities(collection.id);
|
||||
|
||||
if (can.update) {
|
||||
if (can.createDocument) {
|
||||
filtered.push({
|
||||
type: "button",
|
||||
onClick: (ev) =>
|
||||
|
||||
@@ -60,7 +60,6 @@ export default abstract class BaseModel {
|
||||
};
|
||||
|
||||
updateFromJson = (data: any) => {
|
||||
// const isNew = !data.id && !this.id && this.isNew;
|
||||
set(this, { ...data, isNew: false });
|
||||
this.persistedAttributes = this.toAPI();
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { trim } from "lodash";
|
||||
import { action, computed, observable } from "mobx";
|
||||
import { action, computed, observable, runInAction } from "mobx";
|
||||
import {
|
||||
CollectionPermission,
|
||||
FileOperationFormat,
|
||||
@@ -18,6 +18,8 @@ export default class Collection extends ParanoidModel {
|
||||
@observable
|
||||
isSaving: boolean;
|
||||
|
||||
isFetching = false;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
id: string;
|
||||
@@ -57,32 +59,34 @@ export default class Collection extends ParanoidModel {
|
||||
direction: "asc" | "desc";
|
||||
};
|
||||
|
||||
documents: NavigationNode[];
|
||||
@observable
|
||||
documents?: NavigationNode[];
|
||||
|
||||
url: string;
|
||||
|
||||
urlId: string;
|
||||
|
||||
@computed
|
||||
get isEmpty(): boolean {
|
||||
get isEmpty(): boolean | undefined {
|
||||
if (!this.documents) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
this.documents.length === 0 &&
|
||||
this.store.rootStore.documents.inCollection(this.id).length === 0
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
get documentIds(): string[] {
|
||||
const results: string[] = [];
|
||||
|
||||
const travelNodes = (nodes: NavigationNode[]) =>
|
||||
nodes.forEach((node) => {
|
||||
results.push(node.id);
|
||||
travelNodes(node.children);
|
||||
});
|
||||
|
||||
travelNodes(this.documents);
|
||||
return results;
|
||||
/**
|
||||
* Convenience method to return if a collection is considered private.
|
||||
* This means that a membership is required to view it rather than just being
|
||||
* a workspace member.
|
||||
*
|
||||
* @returns boolean
|
||||
*/
|
||||
get isPrivate(): boolean {
|
||||
return !this.permission;
|
||||
}
|
||||
|
||||
@computed
|
||||
@@ -98,10 +102,35 @@ export default class Collection extends ParanoidModel {
|
||||
}
|
||||
|
||||
@computed
|
||||
get sortedDocuments() {
|
||||
get sortedDocuments(): NavigationNode[] | undefined {
|
||||
if (!this.documents) {
|
||||
return undefined;
|
||||
}
|
||||
return sortNavigationNodes(this.documents, this.sort);
|
||||
}
|
||||
|
||||
fetchDocuments = async (options?: { force: boolean }) => {
|
||||
if (this.isFetching) {
|
||||
return;
|
||||
}
|
||||
if (this.documents && options?.force !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.isFetching = true;
|
||||
const { data } = await client.post("/collections.documents", {
|
||||
id: this.id,
|
||||
});
|
||||
|
||||
runInAction("Collection#fetchDocuments", () => {
|
||||
this.documents = data;
|
||||
});
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the document identified by the given id in the collection in memory.
|
||||
* Does not update the document in the database.
|
||||
@@ -110,6 +139,10 @@ export default class Collection extends ParanoidModel {
|
||||
*/
|
||||
@action
|
||||
updateDocument(document: Pick<Document, "id" | "title" | "url">) {
|
||||
if (!this.documents) {
|
||||
throw new Error("Collection documents not loaded");
|
||||
}
|
||||
|
||||
const travelNodes = (nodes: NavigationNode[]) =>
|
||||
nodes.forEach((node) => {
|
||||
if (node.id === document.id) {
|
||||
@@ -131,6 +164,10 @@ export default class Collection extends ParanoidModel {
|
||||
*/
|
||||
@action
|
||||
removeDocument(documentId: string) {
|
||||
if (!this.documents) {
|
||||
throw new Error("Collection documents not loaded");
|
||||
}
|
||||
|
||||
this.documents = this.documents.filter(function f(node): boolean {
|
||||
if (node.id === documentId) {
|
||||
return false;
|
||||
@@ -163,7 +200,7 @@ export default class Collection extends ParanoidModel {
|
||||
});
|
||||
};
|
||||
|
||||
if (this.documents) {
|
||||
if (this.sortedDocuments) {
|
||||
travelNodes(this.sortedDocuments);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed } from "mobx";
|
||||
import { observable } from "mobx";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import BaseModel from "./BaseModel";
|
||||
|
||||
@@ -9,12 +9,8 @@ class CollectionGroupMembership extends BaseModel {
|
||||
|
||||
collectionId: string;
|
||||
|
||||
@observable
|
||||
permission: CollectionPermission;
|
||||
|
||||
@computed
|
||||
get isEditor(): boolean {
|
||||
return this.permission === CollectionPermission.ReadWrite;
|
||||
}
|
||||
}
|
||||
|
||||
export default CollectionGroupMembership;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed } from "mobx";
|
||||
import { observable } from "mobx";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import BaseModel from "./BaseModel";
|
||||
|
||||
@@ -9,12 +9,8 @@ class Membership extends BaseModel {
|
||||
|
||||
collectionId: string;
|
||||
|
||||
@observable
|
||||
permission: CollectionPermission;
|
||||
|
||||
@computed
|
||||
get isEditor(): boolean {
|
||||
return this.permission === CollectionPermission.ReadWrite;
|
||||
}
|
||||
}
|
||||
|
||||
export default Membership;
|
||||
|
||||
@@ -148,7 +148,7 @@ function CollectionScene() {
|
||||
>
|
||||
<DropToImport
|
||||
accept={documents.importFileTypes.join(", ")}
|
||||
disabled={!can.update}
|
||||
disabled={!can.createDocument}
|
||||
collectionId={collection.id}
|
||||
>
|
||||
<CenteredContent withStickyHeader>
|
||||
@@ -159,7 +159,7 @@ function CollectionScene() {
|
||||
<HeadingWithIcon $isStarred={collection.isStarred}>
|
||||
<HeadingIcon collection={collection} size={40} expanded />
|
||||
{collection.name}
|
||||
{!collection.permission && (
|
||||
{collection.isPrivate && (
|
||||
<Tooltip
|
||||
tooltip={t(
|
||||
"This collection is only visible to those given access"
|
||||
|
||||
@@ -37,9 +37,11 @@ function EmptyCollection({ collection }: Props) {
|
||||
}}
|
||||
/>
|
||||
<br />
|
||||
{can.update && <Trans>Get started by creating a new one!</Trans>}
|
||||
{can.createDocument && (
|
||||
<Trans>Get started by creating a new one!</Trans>
|
||||
)}
|
||||
</Text>
|
||||
{can.update && (
|
||||
{can.createDocument && (
|
||||
<Empty>
|
||||
<Link to={newDocumentPath(collection.id)}>
|
||||
<Button icon={<NewDocumentIcon />} neutral>
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import CollectionGroupMembership from "~/models/CollectionGroupMembership";
|
||||
import Group from "~/models/Group";
|
||||
import GroupListItem from "~/components/GroupListItem";
|
||||
import InputSelect, { Props as SelectProps } from "~/components/InputSelect";
|
||||
import CollectionGroupMemberMenu from "~/menus/CollectionGroupMemberMenu";
|
||||
import InputMemberPermissionSelect from "./InputMemberPermissionSelect";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
@@ -21,57 +18,27 @@ const CollectionGroupMemberListItem = ({
|
||||
collectionGroupMembership,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<GroupListItem
|
||||
group={group}
|
||||
showAvatar
|
||||
renderActions={({ openMembersModal }) => (
|
||||
<>
|
||||
<Select
|
||||
label={t("Permissions")}
|
||||
options={[
|
||||
{
|
||||
label: t("View only"),
|
||||
value: CollectionPermission.Read,
|
||||
},
|
||||
{
|
||||
label: t("View and edit"),
|
||||
value: CollectionPermission.ReadWrite,
|
||||
},
|
||||
]}
|
||||
value={
|
||||
collectionGroupMembership
|
||||
? collectionGroupMembership.permission
|
||||
: undefined
|
||||
}
|
||||
onChange={onUpdate}
|
||||
ariaLabel={t("Permissions")}
|
||||
labelHidden
|
||||
nude
|
||||
/>
|
||||
<CollectionGroupMemberMenu
|
||||
onMembers={openMembersModal}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Select = styled(InputSelect)`
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
color: ${s("textSecondary")};
|
||||
|
||||
select {
|
||||
margin: 0;
|
||||
}
|
||||
` as React.ComponentType<SelectProps>;
|
||||
}: Props) => (
|
||||
<GroupListItem
|
||||
group={group}
|
||||
showAvatar
|
||||
renderActions={({ openMembersModal }) => (
|
||||
<>
|
||||
<InputMemberPermissionSelect
|
||||
value={
|
||||
collectionGroupMembership
|
||||
? collectionGroupMembership.permission
|
||||
: undefined
|
||||
}
|
||||
onChange={onUpdate}
|
||||
/>
|
||||
<CollectionGroupMemberMenu
|
||||
onMembers={openMembersModal}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
export default CollectionGroupMemberListItem;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import InputSelect, { Props as SelectProps } from "~/components/InputSelect";
|
||||
|
||||
export default function InputMemberPermissionSelect(
|
||||
props: Partial<SelectProps>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Select
|
||||
label={t("Permissions")}
|
||||
options={[
|
||||
{
|
||||
label: t("View only"),
|
||||
value: CollectionPermission.Read,
|
||||
},
|
||||
{
|
||||
label: t("View and edit"),
|
||||
value: CollectionPermission.ReadWrite,
|
||||
},
|
||||
{
|
||||
label: t("Admin"),
|
||||
value: CollectionPermission.Admin,
|
||||
},
|
||||
]}
|
||||
ariaLabel={t("Permissions")}
|
||||
labelHidden
|
||||
nude
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Select = styled(InputSelect)`
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
color: ${s("textSecondary")};
|
||||
|
||||
select {
|
||||
margin: 0;
|
||||
}
|
||||
` as React.ComponentType<SelectProps>;
|
||||
@@ -1,8 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import Membership from "~/models/Membership";
|
||||
import User from "~/models/User";
|
||||
@@ -10,10 +8,10 @@ import Avatar from "~/components/Avatar";
|
||||
import Badge from "~/components/Badge";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import InputSelect, { Props as SelectProps } from "~/components/InputSelect";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Time from "~/components/Time";
|
||||
import MemberMenu from "~/menus/MemberMenu";
|
||||
import InputMemberPermissionSelect from "./InputMemberPermissionSelect";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
@@ -54,24 +52,10 @@ const MemberListItem = ({
|
||||
actions={
|
||||
<Flex align="center" gap={8}>
|
||||
{onUpdate && (
|
||||
<Select
|
||||
label={t("Permissions")}
|
||||
options={[
|
||||
{
|
||||
label: t("View only"),
|
||||
value: CollectionPermission.Read,
|
||||
},
|
||||
{
|
||||
label: t("View and edit"),
|
||||
value: CollectionPermission.ReadWrite,
|
||||
},
|
||||
]}
|
||||
<InputMemberPermissionSelect
|
||||
value={membership ? membership.permission : undefined}
|
||||
onChange={onUpdate}
|
||||
disabled={!canEdit}
|
||||
ariaLabel={t("Permissions")}
|
||||
labelHidden
|
||||
nude
|
||||
/>
|
||||
)}
|
||||
{canEdit && (
|
||||
@@ -90,16 +74,4 @@ const MemberListItem = ({
|
||||
);
|
||||
};
|
||||
|
||||
const Select = styled(InputSelect)`
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
color: ${s("textSecondary")};
|
||||
|
||||
select {
|
||||
margin: 0;
|
||||
}
|
||||
` as React.ComponentType<SelectProps>;
|
||||
|
||||
export default observer(MemberListItem);
|
||||
|
||||
@@ -211,7 +211,7 @@ function CollectionPermissions({ collectionId }: Props) {
|
||||
value={collection.permission || ""}
|
||||
/>
|
||||
<PermissionExplainer size="small">
|
||||
{!collection.permission && (
|
||||
{collection.isPrivate && (
|
||||
<Trans
|
||||
defaults="The <em>{{ collectionName }}</em> collection is private. Workspace members have no access to it by default."
|
||||
values={{
|
||||
|
||||
@@ -28,13 +28,15 @@ function DocumentMove({ document }: Props) {
|
||||
null
|
||||
);
|
||||
|
||||
const moveOptions = React.useMemo(() => {
|
||||
// filter out the document itself and also its parent doc if any
|
||||
const items = React.useMemo(() => {
|
||||
// Filter out the document itself and its existing parent doc, if any.
|
||||
const nodes = flatten(collectionTrees.map(flattenTree)).filter(
|
||||
(node) => node.id !== document.id && node.id !== document.parentDocumentId
|
||||
);
|
||||
|
||||
// If the document we're moving is a template, only show collections as
|
||||
// move targets.
|
||||
if (document.isTemplate) {
|
||||
// only show collections with children stripped off to prevent node expansion
|
||||
return nodes
|
||||
.filter((node) => node.type === "collection")
|
||||
.map((node) => ({ ...node, children: [] }));
|
||||
@@ -80,11 +82,7 @@ function DocumentMove({ document }: Props) {
|
||||
|
||||
return (
|
||||
<FlexContainer column>
|
||||
<DocumentExplorer
|
||||
items={moveOptions}
|
||||
onSubmit={move}
|
||||
onSelect={selectPath}
|
||||
/>
|
||||
<DocumentExplorer items={items} onSubmit={move} onSelect={selectPath} />
|
||||
<Footer justify="space-between" align="center" gap={8}>
|
||||
<StyledText type="secondary">
|
||||
{selectedPath ? (
|
||||
|
||||
@@ -34,8 +34,14 @@ export default class CollectionsStore extends BaseStore<Collection> {
|
||||
super(rootStore, Collection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currently active collection, or undefined if not in the context
|
||||
* of a collection.
|
||||
*
|
||||
* @returns The active Collection or undefined
|
||||
*/
|
||||
@computed
|
||||
get active(): Collection | null | undefined {
|
||||
get active(): Collection | undefined {
|
||||
return this.rootStore.ui.activeCollectionId
|
||||
? this.data.get(this.rootStore.ui.activeCollectionId)
|
||||
: undefined;
|
||||
@@ -92,7 +98,10 @@ export default class CollectionsStore extends BaseStore<Collection> {
|
||||
url,
|
||||
};
|
||||
results.push([node]);
|
||||
travelDocuments(collection.documents, id, [node]);
|
||||
|
||||
if (collection.documents) {
|
||||
travelDocuments(collection.documents, id, [node]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -132,13 +141,9 @@ export default class CollectionsStore extends BaseStore<Collection> {
|
||||
// remove all locally cached policies for documents in the collection as they
|
||||
// are now invalid
|
||||
if (params.sharing !== undefined) {
|
||||
const collection = this.get(params.id);
|
||||
|
||||
if (collection) {
|
||||
collection.documentIds.forEach((id) => {
|
||||
this.rootStore.policies.remove(id);
|
||||
});
|
||||
}
|
||||
this.rootStore.documents.inCollection(params.id).forEach((document) => {
|
||||
this.rootStore.policies.remove(document.id);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -139,7 +139,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
rootInCollection(collectionId: string): Document[] {
|
||||
const collection = this.rootStore.collections.get(collectionId);
|
||||
|
||||
if (!collection) {
|
||||
if (!collection || !collection.sortedDocuments) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user