From 0726445135cc08c184e86ce505e4090234045eb9 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 3 Feb 2024 16:09:58 -0500 Subject: [PATCH] feat: Add pending state in document share user picker --- app/components/Sharing/MemberListItem.tsx | 3 +- app/components/Sharing/OtherAccess.tsx | 176 ++++++++++----------- app/components/Sharing/SharePopover.tsx | 88 ++++++++--- app/components/Sharing/UserSuggestions.tsx | 123 +++++++++++--- shared/i18n/locales/en_US/translation.json | 4 +- 5 files changed, 258 insertions(+), 136 deletions(-) diff --git a/app/components/Sharing/MemberListItem.tsx b/app/components/Sharing/MemberListItem.tsx index 4d02f98e1..9f875284f 100644 --- a/app/components/Sharing/MemberListItem.tsx +++ b/app/components/Sharing/MemberListItem.tsx @@ -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; } `; diff --git a/app/components/Sharing/OtherAccess.tsx b/app/components/Sharing/OtherAccess.tsx index c5163f18f..f8e6195cf 100644 --- a/app/components/Sharing/OtherAccess.tsx +++ b/app/components/Sharing/OtherAccess.tsx @@ -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 ? ( - - - - } - title={t("All members")} - subtitle={t("Everyone in the workspace")} - actions={ - - {collection?.permission === CollectionPermission.ReadWrite - ? t("Can edit") - : t("Can view")} - - } - /> - ) : usersInCollection ? ( - - - - } - title={collection.name} - subtitle={t("Everyone in the collection")} - actions={{t("Can view")}} - /> - ) : ( - } - title={user.name} - subtitle={t("You have full access")} - actions={{t("Can edit")}} - /> - )} - {children} - - ) : document.isDraft ? ( - <> - } - title={document.createdBy.name} - actions={ - - {t("Can edit")} - - } - /> - {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 ? ( - + } - title={t("Other people")} - subtitle={t("Other workspace members may have access")} + title={t("All members")} + subtitle={t("Everyone in the workspace")} actions={ - + + {collection?.permission === CollectionPermission.ReadWrite + ? t("Can edit") + : t("Can view")} + } /> - - )} - - ); - } -); + ) : usersInCollection ? ( + + + + } + title={collection.name} + subtitle={t("Everyone in the collection")} + actions={{t("Can view")}} + /> + ) : ( + } + title={user.name} + subtitle={t("You have full access")} + actions={{t("Can edit")}} + /> + )} + {children} + + ) : document.isDraft ? ( + <> + } + title={document.createdBy.name} + actions={ + + {t("Can edit")} + + } + /> + {children} + + ) : ( + <> + {children} + + + + } + title={t("Other people")} + subtitle={t("Other workspace members may have access")} + actions={ + + } + /> + + )} + + ); +}); const AccessTooltip = ({ children, diff --git a/app/components/Sharing/SharePopover.tsx b/app/components/Sharing/SharePopover.tsx index c55441e4d..0b01a3f31 100644 --- a/app/components/Sharing/SharePopover.tsx +++ b/app/components/Sharing/SharePopover.tsx @@ -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(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>(); const linkButtonRef = React.useRef(null); const [invitedInSession, setInvitedInSession] = React.useState([]); + const [pendingIds, setPendingIds] = React.useState([]); 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 ? ( - - {t("Done")} + const rightButton = picker ? ( + pendingIds.length ? ( + + {t("Invite")} ) : null ) : ( @@ -216,7 +260,7 @@ function SharePopover({ margin={0} flex > - {doneButton} + {rightButton} ) : ( @@ -232,7 +276,7 @@ function SharePopover({ onClick={showPicker} style={{ padding: "6px 0" }} /> - {doneButton} + {rightButton} ))} @@ -242,7 +286,9 @@ function SharePopover({ )} diff --git a/app/components/Sharing/UserSuggestions.tsx b/app/components/Sharing/UserSuggestions.tsx index 710bc0755..3143dcd94 100644 --- a/app/components/Sharing/UserSuggestions.tsx +++ b/app/components/Sharing/UserSuggestions.tsx @@ -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; - }) => { + ({ 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: ( + + ), + }; + } + + const isEmpty = suggestions.length === 0 && query; + + return ( <> + {pending.map((suggestion) => ( + removePendingId(suggestion.id)} + actions={ + <> + + + + } + /> + ))} + {pending.length > 0 && (suggestions.length > 0 || isEmpty) && ( + + )} {suggestions.map((suggestion) => ( onInvite(suggestion)} - title={suggestion.name} - subtitle={suggestion.isViewer ? t("Viewer") : t("Editor")} - image={ - - } + onClick={() => addPendingId(suggestion.id)} actions={} /> ))} + {isEmpty && {t("No matches")}} - ) : ( - {t("No matches")} ); } ); + +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; +`; diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 99e93e1e2..9aef516b8 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -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",