feat: Add pending state in document share user picker
This commit is contained in:
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user