feat: Updated collection header (#4101)

* Return total results from collection membership endpoints

* Display membership preview on collections

* fix permissions

* Revert unneccessary changes
This commit is contained in:
Tom Moor
2022-09-11 14:54:57 +02:00
committed by GitHub
parent 3aa7f34a73
commit 0fd576cdd5
19 changed files with 267 additions and 165 deletions

View File

@@ -1,6 +1,7 @@
import {
CollectionIcon,
EditIcon,
PadlockIcon,
PlusIcon,
StarredIcon,
UnstarredIcon,
@@ -10,6 +11,7 @@ import stores from "~/stores";
import Collection from "~/models/Collection";
import CollectionEdit from "~/scenes/CollectionEdit";
import CollectionNew from "~/scenes/CollectionNew";
import CollectionPermissions from "~/scenes/CollectionPermissions";
import DynamicCollectionIcon from "~/components/CollectionIcon";
import { createAction } from "~/actions";
import { CollectionSection } from "~/actions/sections";
@@ -56,7 +58,8 @@ export const createCollection = createAction({
});
export const editCollection = createAction({
name: ({ t }) => t("Edit collection"),
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Edit")}` : t("Edit collection"),
section: CollectionSection,
icon: <EditIcon />,
visible: ({ stores, activeCollectionId }) =>
@@ -79,6 +82,26 @@ export const editCollection = createAction({
},
});
export const editCollectionPermissions = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Permissions")}` : t("Collection permissions"),
section: CollectionSection,
icon: <PadlockIcon />,
visible: ({ stores, activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId }) => {
if (!activeCollectionId) {
return;
}
stores.dialogs.openModal({
title: t("Collection permissions"),
content: <CollectionPermissions collectionId={activeCollectionId} />,
});
},
});
export const starCollection = createAction({
name: ({ t }) => t("Star"),
section: CollectionSection,

View File

@@ -9,7 +9,7 @@ type Props = {
users: User[];
size?: number;
overflow?: number;
onClick?: React.MouseEventHandler<HTMLDivElement>;
limit?: number;
renderAvatar?: (user: User) => React.ReactNode;
};
@@ -17,6 +17,7 @@ function Facepile({
users,
overflow = 0,
size = 32,
limit = 8,
renderAvatar = DefaultAvatar,
...rest
}: Props) {
@@ -24,10 +25,13 @@ function Facepile({
<Avatars {...rest}>
{overflow > 0 && (
<More size={size}>
<span>+{overflow}</span>
<span>
{users.length ? "+" : ""}
{overflow}
</span>
</More>
)}
{users.map((user) => (
{users.slice(0, limit).map((user) => (
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
))}
</Avatars>

View File

@@ -13,6 +13,7 @@ import Flex from "~/components/Flex";
import ListItem from "~/components/List/Item";
import Modal from "~/components/Modal";
import withStores from "~/components/withStores";
import NudeButton from "./NudeButton";
type Props = RootStore & {
group: Group;
@@ -63,11 +64,13 @@ class GroupListItem extends React.Component<Props> {
actions={
<Flex align="center" gap={8}>
{showFacepile && (
<Facepile
<NudeButton
width="auto"
height="auto"
onClick={this.handleMembersModalOpen}
users={users}
overflow={overflow}
/>
>
<Facepile users={users} overflow={overflow} />
</NudeButton>
)}
{renderActions({
openMembersModal: this.handleMembersModalOpen,

View File

@@ -15,19 +15,19 @@ import useStores from "~/hooks/useStores";
import { supportsPassiveListener } from "~/utils/browser";
type Props = {
breadcrumb?: React.ReactNode;
left?: React.ReactNode;
title: React.ReactNode;
actions?: React.ReactNode;
hasSidebar?: boolean;
};
function Header({ breadcrumb, title, actions, hasSidebar }: Props) {
function Header({ left, title, actions, hasSidebar }: Props) {
const { ui } = useStores();
const isMobile = useMobile();
const hasMobileSidebar = hasSidebar && isMobile;
const passThrough = !actions && !breadcrumb && !title;
const passThrough = !actions && !left && !title;
const [isScrolled, setScrolled] = React.useState(false);
const handleScroll = React.useMemo(
@@ -51,7 +51,7 @@ function Header({ breadcrumb, title, actions, hasSidebar }: Props) {
return (
<Wrapper align="center" shrink={false} $passThrough={passThrough}>
{breadcrumb || hasMobileSidebar ? (
{left || hasMobileSidebar ? (
<Breadcrumbs>
{hasMobileSidebar && (
<MobileMenuButton
@@ -61,7 +61,7 @@ function Header({ breadcrumb, title, actions, hasSidebar }: Props) {
neutral
/>
)}
{breadcrumb}
{left}
</Breadcrumbs>
) : null}

View File

@@ -173,7 +173,7 @@ class Input extends React.Component<Props> {
{type === "textarea" ? (
<RealTextarea
ref={this.props.innerRef}
onBlur={this.props.onBlur}
onBlur={this.handleBlur}
onFocus={this.handleFocus}
hasIcon={!!icon}
{...rest}
@@ -181,7 +181,7 @@ class Input extends React.Component<Props> {
) : (
<RealInput
ref={this.props.innerRef}
onBlur={this.props.onBlur}
onBlur={this.handleBlur}
onFocus={this.handleFocus}
hasIcon={!!icon}
type={type}

View File

@@ -4,8 +4,8 @@ import ActionButton, {
} from "~/components/ActionButton";
type Props = ActionButtonProps & {
width?: number;
height?: number;
width?: number | string;
height?: number | string;
size?: number;
type?: "button" | "submit" | "reset";
};
@@ -13,8 +13,14 @@ type Props = ActionButtonProps & {
const NudeButton = styled(ActionButton).attrs((props: Props) => ({
type: "type" in props ? props.type : "button",
}))<Props>`
width: ${(props) => props.width || props.size || 24}px;
height: ${(props) => props.height || props.size || 24}px;
width: ${(props) =>
typeof props.width === "string"
? props.width
: `${props.width || props.size || 24}px`};
height: ${(props) =>
typeof props.height === "string"
? props.height
: `${props.width || props.size || 24}px`};
background: none;
border-radius: 4px;
display: inline-block;

View File

@@ -8,7 +8,7 @@ type Props = {
icon?: React.ReactNode;
title?: React.ReactNode;
textTitle?: string;
breadcrumb?: React.ReactNode;
left?: React.ReactNode;
actions?: React.ReactNode;
centered?: boolean;
};
@@ -18,7 +18,7 @@ const Scene: React.FC<Props> = ({
icon,
textTitle,
actions,
breadcrumb,
left,
children,
centered,
}) => {
@@ -37,7 +37,7 @@ const Scene: React.FC<Props> = ({
)
}
actions={actions}
breadcrumb={breadcrumb}
left={left}
/>
{centered !== false ? (
<CenteredContent withStickyHeader>{children}</CenteredContent>

View File

@@ -1,11 +1,9 @@
import { observer } from "mobx-react";
import {
NewDocumentIcon,
EditIcon,
TrashIcon,
ImportIcon,
ExportIcon,
PadlockIcon,
AlphabeticalSortIcon,
ManualSortIcon,
UnstarredIcon,
@@ -18,13 +16,17 @@ import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import { getEventFiles } from "@shared/utils/files";
import Collection from "~/models/Collection";
import CollectionEdit from "~/scenes/CollectionEdit";
import CollectionExport from "~/scenes/CollectionExport";
import CollectionPermissions from "~/scenes/CollectionPermissions";
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
import ContextMenu, { Placement } from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import { actionToMenuItem } from "~/actions";
import {
editCollection,
editCollectionPermissions,
} from "~/actions/definitions/collections";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -60,25 +62,6 @@ function CollectionMenu({
const history = useHistory();
const file = React.useRef<HTMLInputElement>(null);
const handlePermissions = React.useCallback(() => {
dialogs.openModal({
title: t("Collection permissions"),
content: <CollectionPermissions collection={collection} />,
});
}, [collection, dialogs, t]);
const handleEdit = React.useCallback(() => {
dialogs.openModal({
title: t("Edit collection"),
content: (
<CollectionEdit
collectionId={collection.id}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [collection.id, dialogs, t]);
const handleExport = React.useCallback(() => {
dialogs.openModal({
title: t("Export collection"),
@@ -186,6 +169,11 @@ function CollectionMenu({
[collection]
);
const context = useActionContext({
isContextMenu: true,
activeCollectionId: collection.id,
});
const alphabeticalSort = collection.sort.field === "title";
const can = usePolicy(collection);
const canUserInTeam = usePolicy(team);
@@ -225,6 +213,8 @@ function CollectionMenu({
{
type: "separator",
},
actionToMenuItem(editCollection, context),
actionToMenuItem(editCollectionPermissions, context),
{
type: "submenu",
title: t("Sort in sidebar"),
@@ -249,20 +239,6 @@ function CollectionMenu({
},
],
},
{
type: "button",
title: `${t("Edit")}`,
visible: can.update,
onClick: handleEdit,
icon: <EditIcon />,
},
{
type: "button",
title: `${t("Permissions")}`,
visible: can.update,
onClick: handlePermissions,
icon: <PadlockIcon />,
},
{
type: "button",
title: `${t("Export")}`,
@@ -293,9 +269,8 @@ function CollectionMenu({
handleStar,
handleNewDocument,
handleImportDocument,
context,
alphabeticalSort,
handleEdit,
handlePermissions,
canUserInTeam.createExport,
handleExport,
handleDelete,

View File

@@ -18,6 +18,7 @@ import CenteredContent from "~/components/CenteredContent";
import CollectionDescription from "~/components/CollectionDescription";
import CollectionIcon from "~/components/CollectionIcon";
import Heading from "~/components/Heading";
import InputSearchPage from "~/components/InputSearchPage";
import PlaceholderList from "~/components/List/Placeholder";
import PaginatedDocumentList from "~/components/PaginatedDocumentList";
import PinnedDocuments from "~/components/PinnedDocuments";
@@ -35,6 +36,7 @@ import { collectionUrl, updateCollectionUrl } from "~/utils/routeHelpers";
import Actions from "./Collection/Actions";
import DropToImport from "./Collection/DropToImport";
import Empty from "./Collection/Empty";
import MembershipPreview from "./Collection/MembershipPreview";
function CollectionScene() {
const params = useParams<{ id?: string }>();
@@ -112,13 +114,28 @@ function CollectionScene() {
key={collection.id}
centered={false}
textTitle={collection.name}
left={
collection.isEmpty ? undefined : (
<InputSearchPage
source="collection"
placeholder={`${t("Search in collection")}`}
label={t("Search in collection")}
collectionId={collection.id}
/>
)
}
title={
<>
<CollectionIcon collection={collection} expanded />
&nbsp;{collection.name}
</>
}
actions={<Actions collection={collection} />}
actions={
<>
<MembershipPreview collection={collection} />
<Actions collection={collection} />
</>
}
>
<DropToImport
accept={documents.importFileTypes.join(", ")}

View File

@@ -6,7 +6,6 @@ import { Link } from "react-router-dom";
import Collection from "~/models/Collection";
import { Action, Separator } from "~/components/Actions";
import Button from "~/components/Button";
import InputSearchPage from "~/components/InputSearchPage";
import Tooltip from "~/components/Tooltip";
import usePolicy from "~/hooks/usePolicy";
import CollectionMenu from "~/menus/CollectionMenu";
@@ -22,38 +21,26 @@ function Actions({ collection }: Props) {
return (
<>
{!collection.isEmpty && (
{can.update && (
<>
<Action>
<InputSearchPage
source="collection"
placeholder={`${t("Search in collection")}`}
label={`${t("Search in collection")}`}
collectionId={collection.id}
/>
<Tooltip
tooltip={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
>
<Button
as={Link}
to={collection ? newDocumentPath(collection.id) : ""}
disabled={!collection}
icon={<PlusIcon />}
>
{t("New doc")}
</Button>
</Tooltip>
</Action>
{can.update && (
<>
<Action>
<Tooltip
tooltip={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
>
<Button
as={Link}
to={collection ? newDocumentPath(collection.id) : ""}
disabled={!collection}
icon={<PlusIcon />}
>
{t("New doc")}
</Button>
</Tooltip>
</Action>
<Separator />
</>
)}
<Separator />
</>
)}
<Action>

View File

@@ -5,12 +5,11 @@ import { Trans, useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import Collection from "~/models/Collection";
import CollectionPermissions from "~/scenes/CollectionPermissions";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Modal from "~/components/Modal";
import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean";
import { editCollectionPermissions } from "~/actions/definitions/collections";
import useActionContext from "~/hooks/useActionContext";
import usePolicy from "~/hooks/usePolicy";
import { newDocumentPath } from "~/utils/routeHelpers";
@@ -21,13 +20,10 @@ type Props = {
function EmptyCollection({ collection }: Props) {
const { t } = useTranslation();
const can = usePolicy(collection);
const context = useActionContext();
const collectionName = collection ? collection.name : "";
const [
permissionsModalOpen,
handlePermissionsModalOpen,
handlePermissionsModalClose,
] = useBoolean();
console.log({ context });
return (
<Centered column>
@@ -48,23 +44,20 @@ function EmptyCollection({ collection }: Props) {
{can.update && (
<Empty>
<Link to={newDocumentPath(collection.id)}>
<Button icon={<NewDocumentIcon color="currentColor" />}>
<Button icon={<NewDocumentIcon color="currentColor" />} neutral>
{t("Create a document")}
</Button>
</Link>
&nbsp;&nbsp;
<Button onClick={handlePermissionsModalOpen} neutral>
<Button
action={editCollectionPermissions}
context={context}
hideOnActionDisabled
neutral
>
{t("Manage permissions")}
</Button>
</Empty>
)}
<Modal
title={t("Collection permissions")}
onRequestClose={handlePermissionsModalClose}
isOpen={permissionsModalOpen}
>
<CollectionPermissions collection={collection} />
</Modal>
</Centered>
);
}
@@ -79,6 +72,7 @@ const Centered = styled(Flex)`
const Empty = styled(Flex)`
justify-content: center;
margin: 10px 0;
gap: 8px;
`;
export default observer(EmptyCollection);

View File

@@ -0,0 +1,81 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { PAGINATION_SYMBOL } from "~/stores/BaseStore";
import Collection from "~/models/Collection";
import Facepile from "~/components/Facepile";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import { editCollectionPermissions } from "~/actions/definitions/collections";
import useActionContext from "~/hooks/useActionContext";
import useStores from "~/hooks/useStores";
type Props = {
collection: Collection;
};
const MembershipPreview = ({ collection }: Props) => {
const [isLoading, setIsLoading] = React.useState(false);
const [totalMemberships, setTotalMemberships] = React.useState(0);
const { t } = useTranslation();
const { memberships, collectionGroupMemberships, users } = useStores();
const collectionUsers = users.inCollection(collection.id);
const context = useActionContext();
React.useEffect(() => {
const fetchData = async () => {
if (collection.permission) {
return;
}
setIsLoading(true);
try {
const options = {
id: collection.id,
limit: 8,
};
const [users, groups] = await Promise.all([
memberships.fetchPage(options),
collectionGroupMemberships.fetchPage(options),
]);
setTotalMemberships(
users[PAGINATION_SYMBOL].total + groups[PAGINATION_SYMBOL].total
);
} finally {
setIsLoading(false);
}
};
fetchData();
}, [
collection.permission,
collection.id,
collectionGroupMemberships,
memberships,
]);
if (isLoading || collection.permission) {
return null;
}
const overflow = totalMemberships - collectionUsers.length;
return (
<NudeButton
context={context}
action={editCollectionPermissions}
tooltip={{
tooltip: t("Users and groups with access"),
delay: 250,
}}
width="auto"
height="auto"
>
<Fade>
<Facepile users={collectionUsers} overflow={overflow} />
</Fade>
</NudeButton>
);
};
export default observer(MembershipPreview);

View File

@@ -1,10 +1,10 @@
import invariant from "invariant";
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import styled from "styled-components";
import { CollectionPermission } from "@shared/types";
import Collection from "~/models/Collection";
import Group from "~/models/Group";
import User from "~/models/User";
import Button from "~/components/Button";
@@ -26,13 +26,14 @@ import CollectionGroupMemberListItem from "./components/CollectionGroupMemberLis
import MemberListItem from "./components/MemberListItem";
type Props = {
collection: Collection;
collectionId: string;
};
function CollectionPermissions({ collection }: Props) {
function CollectionPermissions({ collectionId }: Props) {
const { t } = useTranslation();
const user = useCurrentUser();
const {
collections,
memberships,
collectionGroupMemberships,
users,
@@ -40,6 +41,8 @@ function CollectionPermissions({ collection }: Props) {
auth,
} = useStores();
const { showToast } = useToasts();
const collection = collections.get(collectionId);
invariant(collection, "Collection not found");
const [
addGroupModalOpen,

View File

@@ -174,7 +174,7 @@ function DocumentHeader({
<Header
title={document.title}
hasSidebar={!!sharedTree}
breadcrumb={
left={
isMobile ? (
<TableOfContentsMenu headings={headings} />
) : (
@@ -201,7 +201,7 @@ function DocumentHeader({
<>
<Header
hasSidebar
breadcrumb={
left={
isMobile ? (
<TableOfContentsMenu headings={headings} />
) : (

View File

@@ -36,15 +36,13 @@ function Home() {
<Scene
icon={<HomeIcon color="currentColor" />}
title={t("Home")}
left={
<InputSearchPage source="dashboard" label={t("Search documents")} />
}
actions={
<>
<Action>
<InputSearchPage source="dashboard" label={t("Search documents")} />
</Action>
<Action>
<NewDocumentMenu />
</Action>
</>
<Action>
<NewDocumentMenu />
</Action>
}
>
{!ui.languagePromptDismissed && <LanguagePrompt />}

View File

@@ -4,7 +4,7 @@ import { CollectionPermission } from "@shared/types";
import CollectionGroupMembership from "~/models/CollectionGroupMembership";
import { PaginationParams } from "~/types";
import { client } from "~/utils/ApiClient";
import BaseStore, { RPCAction } from "./BaseStore";
import BaseStore, { PAGINATION_SYMBOL, RPCAction } from "./BaseStore";
import RootStore from "./RootStore";
export default class CollectionGroupMembershipsStore extends BaseStore<
@@ -26,13 +26,15 @@ export default class CollectionGroupMembershipsStore extends BaseStore<
const res = await client.post(`/collections.group_memberships`, params);
invariant(res?.data, "Data not available");
let models: CollectionGroupMembership[] = [];
let response: CollectionGroupMembership[] = [];
runInAction(`CollectionGroupMembershipsStore#fetchPage`, () => {
res.data.groups.forEach(this.rootStore.groups.add);
models = res.data.collectionGroupMemberships.map(this.add);
response = res.data.collectionGroupMemberships.map(this.add);
this.isLoaded = true;
});
return models;
response[PAGINATION_SYMBOL] = res.pagination;
return response;
} finally {
this.isFetching = false;
}

View File

@@ -4,7 +4,7 @@ import { CollectionPermission } from "@shared/types";
import Membership from "~/models/Membership";
import { PaginationParams } from "~/types";
import { client } from "~/utils/ApiClient";
import BaseStore, { RPCAction } from "./BaseStore";
import BaseStore, { PAGINATION_SYMBOL, RPCAction } from "./BaseStore";
import RootStore from "./RootStore";
export default class MembershipsStore extends BaseStore<Membership> {
@@ -24,13 +24,14 @@ export default class MembershipsStore extends BaseStore<Membership> {
const res = await client.post(`/collections.memberships`, params);
invariant(res?.data, "Data not available");
let models: Membership[] = [];
let response: Membership[] = [];
runInAction(`MembershipsStore#fetchPage`, () => {
res.data.users.forEach(this.rootStore.users.add);
models = res.data.memberships.map(this.add);
response = res.data.memberships.map(this.add);
this.isLoaded = true;
});
return models;
response[PAGINATION_SYMBOL] = res.pagination;
return response;
} finally {
this.isFetching = false;
}