feat: Add pending state in document share user picker

This commit is contained in:
Tom Moor
2024-02-03 16:09:58 -05:00
parent 02711c29e3
commit 0726445135
5 changed files with 258 additions and 136 deletions

View File

@@ -12,6 +12,7 @@ import Avatar from "~/components/Avatar";
import { AvatarSize } from "~/components/Avatar/Avatar";
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
import ListItem from "~/components/List/Item";
import { hover } from "~/styles";
import { EmptySelectValue, Permission } from "~/types";
type Props = {
@@ -133,7 +134,7 @@ export const StyledListItem = styled(ListItem).attrs({
padding: 6px 16px;
border-radius: 8px;
&:hover ${InviteIcon} {
&: ${hover} ${InviteIcon} {
opacity: 1;
}
`;

View File

@@ -18,104 +18,102 @@ import Squircle from "../Squircle";
import Tooltip from "../Tooltip";
import { StyledListItem } from "./MemberListItem";
export const OtherAccess = observer(
({
document,
children,
}: {
document: Document;
children: React.ReactNode;
}) => {
const { t } = useTranslation();
const theme = useTheme();
const collection = document.collection;
const usersInCollection = useUsersInCollection(collection);
const user = useCurrentUser();
type Props = {
/** The document being shared. */
document: Document;
children: React.ReactNode;
};
return (
<>
{collection ? (
<>
{collection.permission ? (
<StyledListItem
image={
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<UserIcon color={theme.accentText} size={16} />
</Squircle>
}
title={t("All members")}
subtitle={t("Everyone in the workspace")}
actions={
<AccessTooltip>
{collection?.permission === CollectionPermission.ReadWrite
? t("Can edit")
: t("Can view")}
</AccessTooltip>
}
/>
) : usersInCollection ? (
<StyledListItem
image={
<Squircle color={collection.color} size={AvatarSize.Medium}>
<CollectionIcon
collection={collection}
color={theme.white}
size={16}
/>
</Squircle>
}
title={collection.name}
subtitle={t("Everyone in the collection")}
actions={<AccessTooltip>{t("Can view")}</AccessTooltip>}
/>
) : (
<StyledListItem
image={<Avatar model={user} showBorder={false} />}
title={user.name}
subtitle={t("You have full access")}
actions={<AccessTooltip>{t("Can edit")}</AccessTooltip>}
/>
)}
{children}
</>
) : document.isDraft ? (
<>
<StyledListItem
image={<Avatar model={document.createdBy} showBorder={false} />}
title={document.createdBy.name}
actions={
<AccessTooltip tooltip={t("Created the document")}>
{t("Can edit")}
</AccessTooltip>
}
/>
{children}
</>
) : (
<>
{children}
export const OtherAccess = observer(({ document, children }: Props) => {
const { t } = useTranslation();
const theme = useTheme();
const collection = document.collection;
const usersInCollection = useUsersInCollection(collection);
const user = useCurrentUser();
return (
<>
{collection ? (
<>
{collection.permission ? (
<StyledListItem
image={
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<MoreIcon color={theme.accentText} size={16} />
<UserIcon color={theme.accentText} size={16} />
</Squircle>
}
title={t("Other people")}
subtitle={t("Other workspace members may have access")}
title={t("All members")}
subtitle={t("Everyone in the workspace")}
actions={
<AccessTooltip
tooltip={t(
"This document may be shared with more workspace members through a parent document or collection you do not have access to"
)}
/>
<AccessTooltip>
{collection?.permission === CollectionPermission.ReadWrite
? t("Can edit")
: t("Can view")}
</AccessTooltip>
}
/>
</>
)}
</>
);
}
);
) : usersInCollection ? (
<StyledListItem
image={
<Squircle color={collection.color} size={AvatarSize.Medium}>
<CollectionIcon
collection={collection}
color={theme.white}
size={16}
/>
</Squircle>
}
title={collection.name}
subtitle={t("Everyone in the collection")}
actions={<AccessTooltip>{t("Can view")}</AccessTooltip>}
/>
) : (
<StyledListItem
image={<Avatar model={user} showBorder={false} />}
title={user.name}
subtitle={t("You have full access")}
actions={<AccessTooltip>{t("Can edit")}</AccessTooltip>}
/>
)}
{children}
</>
) : document.isDraft ? (
<>
<StyledListItem
image={<Avatar model={document.createdBy} showBorder={false} />}
title={document.createdBy.name}
actions={
<AccessTooltip tooltip={t("Created the document")}>
{t("Can edit")}
</AccessTooltip>
}
/>
{children}
</>
) : (
<>
{children}
<StyledListItem
image={
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<MoreIcon color={theme.accentText} size={16} />
</Squircle>
}
title={t("Other people")}
subtitle={t("Other workspace members may have access")}
actions={
<AccessTooltip
tooltip={t(
"This document may be shared with more workspace members through a parent document or collection you do not have access to"
)}
/>
}
/>
</>
)}
</>
);
});
const AccessTooltip = ({
children,

View File

@@ -9,9 +9,11 @@ import styled from "styled-components";
import { s } from "@shared/styles";
import Document from "~/models/Document";
import Share from "~/models/Share";
import User from "~/models/User";
import CopyToClipboard from "~/components/CopyToClipboard";
import Flex from "~/components/Flex";
import { createAction } from "~/actions";
import { UserSection } from "~/actions/sections";
import useActionContext from "~/hooks/useActionContext";
import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useKeyDown from "~/hooks/useKeyDown";
@@ -76,13 +78,14 @@ function SharePopover({
const { t } = useTranslation();
const can = usePolicy(document);
const inputRef = React.useRef<HTMLInputElement>(null);
const { userMemberships } = useStores();
const { users, userMemberships } = useStores();
const isMobile = useMobile();
const [query, setQuery] = React.useState("");
const [picker, showPicker, hidePicker] = useBoolean();
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
const linkButtonRef = React.useRef<HTMLButtonElement>(null);
const [invitedInSession, setInvitedInSession] = React.useState<string[]>([]);
const [pendingIds, setPendingIds] = React.useState<string[]>([]);
const collectionSharingDisabled = document.collection?.sharing === false;
useKeyDown(
@@ -112,6 +115,7 @@ function SharePopover({
// Hide the picker when the popover is closed
React.useEffect(() => {
if (visible) {
setPendingIds([]);
hidePicker();
}
}, [hidePicker, visible]);
@@ -137,18 +141,44 @@ function SharePopover({
};
}, [onRequestClose, t]);
const handleInvite = React.useCallback(
async (user: User) => {
setInvitedInSession((prev) => [...prev, user.id]);
await userMemberships.create({
documentId: document.id,
userId: user.id,
});
toast.message(
t("{{ userName }} was invited to the document", { userName: user.name })
);
},
[t, userMemberships, document.id]
const context = useActionContext();
const inviteAction = React.useMemo(
() =>
createAction({
name: t("Invite"),
section: UserSection,
perform: async () => {
await Promise.all(
pendingIds.map((userId) =>
userMemberships.create({
documentId: document.id,
userId,
})
)
);
if (pendingIds.length === 1) {
const user = users.get(pendingIds[0]);
toast.message(
t("{{ userName }} was invited to the document", {
userName: user!.name,
})
);
} else {
toast.message(
t("{{ count }} people invited to the document", {
count: pendingIds.length,
})
);
}
setInvitedInSession((prev) => [...prev, ...pendingIds]);
setPendingIds([]);
hidePicker();
},
}),
[document.id, hidePicker, pendingIds, t, users, userMemberships]
);
const handleQuery = React.useCallback(
@@ -166,6 +196,20 @@ function SharePopover({
}
}, [picker, showPicker]);
const handleAddPendingId = React.useCallback(
(id: string) => {
setPendingIds((prev) => [...prev, id]);
},
[setPendingIds]
);
const handleRemovePendingId = React.useCallback(
(id: string) => {
setPendingIds((prev) => prev.filter((i) => i !== id));
},
[setPendingIds]
);
const backButton = (
<>
{picker && (
@@ -176,10 +220,10 @@ function SharePopover({
</>
);
const doneButton = picker ? (
invitedInSession.length ? (
<ButtonSmall onClick={hidePicker} key="done" neutral>
{t("Done")}
const rightButton = picker ? (
pendingIds.length ? (
<ButtonSmall action={inviteAction} context={context} key="invite">
{t("Invite")}
</ButtonSmall>
) : null
) : (
@@ -216,7 +260,7 @@ function SharePopover({
margin={0}
flex
>
{doneButton}
{rightButton}
</Input>
</Flex>
) : (
@@ -232,7 +276,7 @@ function SharePopover({
onClick={showPicker}
style={{ padding: "6px 0" }}
/>
{doneButton}
{rightButton}
</AnimatePresence>
</HeaderInput>
))}
@@ -242,7 +286,9 @@ function SharePopover({
<UserSuggestions
document={document}
query={query}
onInvite={handleInvite}
pendingIds={pendingIds}
addPendingId={handleAddPendingId}
removePendingId={handleRemovePendingId}
/>
</div>
)}

View File

@@ -1,26 +1,35 @@
import { observer } from "mobx-react";
import { CheckmarkIcon, CloseIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import Document from "~/models/Document";
import User from "~/models/User";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import useThrottledCallback from "~/hooks/useThrottledCallback";
import { hover } from "~/styles";
import Avatar from "../Avatar";
import { AvatarSize } from "../Avatar/Avatar";
import Empty from "../Empty";
import { InviteIcon, StyledListItem } from "./MemberListItem";
type Props = {
/** The document being shared. */
document: Document;
/** The search query to filter users by. */
query: string;
/** A list of pending user ids that have not yet been invited. */
pendingIds: string[];
/** Callback to add a user to the pending list. */
addPendingId: (id: string) => void;
/** Callback to remove a user from the pending list. */
removePendingId: (id: string) => void;
};
export const UserSuggestions = observer(
({
document,
query,
onInvite,
}: {
document: Document;
query: string;
onInvite: (user: User) => Promise<void>;
}) => {
({ document, query, pendingIds, addPendingId, removePendingId }: Props) => {
const { users } = useStores();
const { t } = useTranslation();
const user = useCurrentUser();
@@ -34,8 +43,22 @@ export const UserSuggestions = observer(
() =>
users
.notInDocument(document.id, query)
.filter((u) => u.id !== user.id && !u.isSuspended),
[users, users.orderedData, document.id, document.members, user.id, query]
.filter((u) => u.id !== user.id && !u.isSuspended)
.filter((u) => !pendingIds.includes(u.id)),
[
users,
users.orderedData,
document.id,
document.members,
user.id,
pendingIds,
query,
]
);
const pending = React.useMemo(
() => pendingIds.map((id) => users.get(id)).filter(Boolean) as User[],
[users, pendingIds]
);
React.useEffect(() => {
@@ -44,27 +67,79 @@ export const UserSuggestions = observer(
}
}, [query, fetchUsersByQuery]);
return suggestions.length ? (
function getListItemProps(suggestion: User) {
return {
title: suggestion.name,
subtitle: suggestion.email
? suggestion.email
: suggestion.isViewer
? t("Viewer")
: t("Editor"),
image: (
<Avatar
model={suggestion}
size={AvatarSize.Medium}
showBorder={false}
/>
),
};
}
const isEmpty = suggestions.length === 0 && query;
return (
<>
{pending.map((suggestion) => (
<PendingListItem
{...getListItemProps(suggestion)}
key={suggestion.id}
onClick={() => removePendingId(suggestion.id)}
actions={
<>
<InvitedIcon />
<RemoveIcon />
</>
}
/>
))}
{pending.length > 0 && (suggestions.length > 0 || isEmpty) && (
<Separator />
)}
{suggestions.map((suggestion) => (
<StyledListItem
{...getListItemProps(suggestion)}
key={suggestion.id}
onClick={() => onInvite(suggestion)}
title={suggestion.name}
subtitle={suggestion.isViewer ? t("Viewer") : t("Editor")}
image={
<Avatar
model={suggestion}
size={AvatarSize.Medium}
showBorder={false}
/>
}
onClick={() => addPendingId(suggestion.id)}
actions={<InviteIcon />}
/>
))}
{isEmpty && <Empty style={{ marginTop: 22 }}>{t("No matches")}</Empty>}
</>
) : (
<Empty style={{ marginTop: 22 }}>{t("No matches")}</Empty>
);
}
);
const InvitedIcon = styled(CheckmarkIcon)`
color: ${s("accent")};
`;
const RemoveIcon = styled(CloseIcon)`
display: none;
`;
const PendingListItem = styled(StyledListItem)`
&: ${hover} {
${InvitedIcon} {
display: none;
}
${RemoveIcon} {
display: block;
}
}
`;
const Separator = styled.div`
border-top: 1px dashed ${s("divider")};
margin: 12px 0;
`;

View File

@@ -276,8 +276,10 @@
"Allow anyone with the link to access": "Allow anyone with the link to access",
"Publish to internet": "Publish to internet",
"Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future": "Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future",
"Invite": "Invite",
"{{ userName }} was invited to the document": "{{ userName }} was invited to the document",
"Done": "Done",
"{{ count }} people invited to the document": "{{ count }} people invited to the document",
"{{ count }} people invited to the document_plural": "{{ count }} people invited to the document",
"Invite by name": "Invite by name",
"No matches": "No matches",
"Logo": "Logo",