diff --git a/app/actions/definitions/users.tsx b/app/actions/definitions/users.tsx index df6082d6b..95c4f301f 100644 --- a/app/actions/definitions/users.tsx +++ b/app/actions/definitions/users.tsx @@ -16,8 +16,7 @@ export const inviteUser = createAction({ stores.policies.abilities(stores.auth.team?.id || "").inviteUser, perform: ({ t }) => { stores.dialogs.openModal({ - title: t("Invite people"), - fullscreen: true, + title: t("Invite to workspace"), content: , }); }, diff --git a/app/components/InputSelect.tsx b/app/components/InputSelect.tsx index dfb29304f..26980f650 100644 --- a/app/components/InputSelect.tsx +++ b/app/components/InputSelect.tsx @@ -28,6 +28,7 @@ import { LabelText } from "./Input"; export type Option = { label: string | JSX.Element; value: string; + description?: string; }; export type Props = { @@ -112,7 +113,7 @@ const InputSelect = (props: Props, ref: React.RefObject) => { const wrappedLabel = {label}; const selectedValueIndex = options.findIndex( - (option) => option.value === select.selectedValue + (opt) => opt.value === select.selectedValue ); // Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can @@ -120,6 +121,9 @@ const InputSelect = (props: Props, ref: React.RefObject) => { useOnClickOutside( contentRef, (event) => { + if (buttonRef.current?.contains(event.target as Node)) { + return; + } if (select.visible) { event.stopPropagation(); event.preventDefault(); @@ -163,6 +167,24 @@ const InputSelect = (props: Props, ref: React.RefObject) => { } }, [select.visible, selectedValueIndex]); + function labelForOption(opt: Option) { + return ( + <> + {opt.label} + {opt.description && ( + <> +   + + – {opt.description} + + + )} + + ); + } + + const option = getOptionFromValue(options, select.selectedValue); + return ( <> @@ -174,28 +196,30 @@ const InputSelect = (props: Props, ref: React.RefObject) => { ))} - {(props: InnerProps) => { - const topAnchor = props.style?.top === "0"; - const rightAnchor = props.placement === "bottom-end"; + {(popoverProps: InnerProps) => { + const topAnchor = popoverProps.style?.top === "0"; + const rightAnchor = popoverProps.placement === "bottom-end"; return ( - + ) => { } > {select.visible - ? options.map((option) => { - const isSelected = - select.selectedValue === option.value; + ? options.map((opt) => { + const isSelected = select.selectedValue === opt.value; const Icon = isSelected ? CheckmarkIcon : Spacer; return (   - {option.label} + {labelForOption(opt)} ); }) diff --git a/app/scenes/Invite.tsx b/app/scenes/Invite.tsx index 84de52ea4..aec77fd18 100644 --- a/app/scenes/Invite.tsx +++ b/app/scenes/Invite.tsx @@ -1,19 +1,17 @@ import { observer } from "mobx-react"; -import { CloseIcon, CopyIcon } from "outline-icons"; +import { PlusIcon } from "outline-icons"; +import pluralize from "pluralize"; import * as React from "react"; import { useTranslation, Trans } from "react-i18next"; import { Link } from "react-router-dom"; import { toast } from "sonner"; import styled from "styled-components"; -import { s } from "@shared/styles"; import { UserRole } from "@shared/types"; import { UserValidation } from "@shared/validations"; import Button from "~/components/Button"; -import CopyToClipboard from "~/components/CopyToClipboard"; import Flex from "~/components/Flex"; import Input from "~/components/Input"; import InputSelect from "~/components/InputSelect"; -import NudeButton from "~/components/NudeButton"; import { ResizingHeightContainer } from "~/components/ResizingHeightContainer"; import Text from "~/components/Text"; import Tooltip from "~/components/Tooltip"; @@ -29,7 +27,6 @@ type Props = { type InviteRequest = { email: string; name: string; - role: UserRole; }; function Invite({ onSubmit }: Props) { @@ -38,7 +35,6 @@ function Invite({ onSubmit }: Props) { { email: "", name: "", - role: UserRole.Member, }, ]); const { users, collections } = useStores(); @@ -47,6 +43,7 @@ function Invite({ onSubmit }: Props) { const { t } = useTranslation(); const predictedDomain = user.email.split("@")[1]; const can = usePolicy(team); + const [role, setRole] = React.useState(UserRole.Member); const handleSubmit = React.useCallback( async (ev: React.SyntheticEvent) => { @@ -54,7 +51,9 @@ function Invite({ onSubmit }: Props) { setIsSaving(true); try { - const data = await users.invite(invites.filter((i) => i.email)); + const data = await users.invite( + invites.filter((i) => i.email).map((memo) => ({ ...memo, role })) + ); onSubmit(); if (data.sent.length > 0) { @@ -68,7 +67,7 @@ function Invite({ onSubmit }: Props) { setIsSaving(false); } }, - [onSubmit, invites, t, users] + [onSubmit, invites, role, t, users] ); const handleChange = React.useCallback((ev, index) => { @@ -93,39 +92,11 @@ function Invite({ onSubmit }: Props) { newInvites.push({ email: "", name: "", - role: invites[invites.length - 1].role, }); return newInvites; }); }, [invites, t]); - const handleRemove = React.useCallback( - (ev: React.SyntheticEvent, index: number) => { - ev.preventDefault(); - setInvites((prevInvites) => { - const newInvites = [...prevInvites]; - newInvites.splice(index, 1); - return newInvites; - }); - }, - [] - ); - - const handleCopy = React.useCallback(() => { - toast.success(t("Share link copied")); - }, [t]); - - const handleRoleChange = React.useCallback( - (role: UserRole, index: number) => { - setInvites((prevInvites) => { - const newInvites = [...prevInvites]; - newInvites[index]["role"] = role; - return newInvites; - }); - }, - [] - ); - const handleKeyDown = React.useCallback( (ev: React.KeyboardEvent) => { if (ev.key === "Enter") { @@ -136,10 +107,11 @@ function Invite({ onSubmit }: Props) { [handleAdd] ); + const roleName = pluralize(role); const collectionCount = collections.nonPrivate.length; const collectionAccessNote = collectionCount ? ( - Invited members will receive access to{" "} + Invited {{ roleName }} will receive access to{" "} @@ -149,177 +121,138 @@ function Invite({ onSubmit }: Props) { } > - + {{ collectionCount }} collections - + . ) : undefined; const options = React.useMemo(() => { - const options = [ + const memo = [ { label: t("Editor"), + description: t("Can create, edit, and delete documents"), value: "member", }, { label: t("Viewer"), + description: t("Can view documents and comment"), value: "viewer", }, ]; if (user.isAdmin) { - options.push({ + memo.push({ label: t("Admin"), + description: t("Can manage all workspace settings"), value: "admin", }); } - return options; + return memo; }, [t, user]); return (
- {team.guestSignin ? ( - - {" "} - {collectionAccessNote} - - ) : ( - - {" "} - {can.update && ( - - As an admin you can also{" "} - enable email sign-in. - - )}{" "} - {collectionAccessNote} - - )} - {team.subdomain && ( - - - - - + + {team.guestSignin ? ( + + {" "} + {collectionAccessNote} + ) : ( - + + {" "} + {collectionAccessNote} + {can.update && ( + + As an admin you can also{" "} + enable email sign-in. + + )} + )} + + setRole(r as UserRole)} + value={role} + /> - + + {invites.map((invite, index) => ( + + handleChange(ev, index)} + placeholder={`example@${predictedDomain}`} + value={invite.email} + required={index === 0} + autoFocus + flex + /> + handleChange(ev, index)} + value={invite.name} + required={!!invite.email} + flex + /> + + ))} + + + + + {invites.length <= UserValidation.maxInvitesPerRequest ? ( + + ) : null} + + -
); } -const CopyBlock = styled("div")` - margin: 2em 0; - font-size: 14px; - background: ${s("secondaryBackground")}; - border-radius: 8px; - padding: 16px 16px 8px; -`; - -const Remove = styled("div")` - color: ${s("textTertiary")}; - margin-top: 4px; - margin-right: -32px; -`; - -const Def = styled("span")` - text-decoration: underline dotted; +const StyledInput = styled(Input)` + margin-bottom: -4px; `; export default observer(Invite); diff --git a/app/scenes/Settings/Members.tsx b/app/scenes/Settings/Members.tsx index 044ec6f6b..2d8e4aac3 100644 --- a/app/scenes/Settings/Members.tsx +++ b/app/scenes/Settings/Members.tsx @@ -7,17 +7,16 @@ import { useHistory, useLocation } from "react-router-dom"; import styled from "styled-components"; import { PAGINATION_SYMBOL } from "~/stores/base/Store"; import User from "~/models/User"; -import Invite from "~/scenes/Invite"; import { Action } from "~/components/Actions"; import Button from "~/components/Button"; import Flex from "~/components/Flex"; import Heading from "~/components/Heading"; import InputSearch from "~/components/InputSearch"; -import Modal from "~/components/Modal"; import Scene from "~/components/Scene"; import Text from "~/components/Text"; +import { inviteUser } from "~/actions/definitions/users"; import env from "~/env"; -import useBoolean from "~/hooks/useBoolean"; +import useActionContext from "~/hooks/useActionContext"; import useCurrentTeam from "~/hooks/useCurrentTeam"; import usePolicy from "~/hooks/usePolicy"; import useQuery from "~/hooks/useQuery"; @@ -28,9 +27,8 @@ import UserStatusFilter from "./components/UserStatusFilter"; function Members() { const location = useLocation(); const history = useHistory(); - const [inviteModalOpen, handleInviteModalOpen, handleInviteModalClose] = - useBoolean(); const team = useCurrentTeam(); + const context = useActionContext(); const { users } = useStores(); const { t } = useTranslation(); const params = useQuery(); @@ -155,7 +153,8 @@ function Members() { data-on="click" data-event-category="invite" data-event-action="peoplePage" - onClick={handleInviteModalOpen} + action={inviteUser} + context={context} icon={} > {t("Invite people")}… @@ -195,15 +194,6 @@ function Members() { totalPages={totalPages} defaultSortDirection="ASC" /> - {can.inviteUser && ( - - - - )} ); } diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 9ef1b0ff8..66b7df51d 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -102,6 +102,7 @@ "New workspace": "New workspace", "Create a workspace": "Create a workspace", "Invite people": "Invite people", + "Invite to workspace": "Invite to workspace", "Delete user": "Delete user", "Collection": "Collection", "Debug": "Debug", @@ -656,15 +657,17 @@ "We sent out your invites!": "We sent out your invites!", "Those email addresses are already invited": "Those email addresses are already invited", "Sorry, you can only send {{MAX_INVITES}} invites at a time": "Sorry, you can only send {{MAX_INVITES}} invites at a time", - "Invited members will receive access to": "Invited members will receive access to", + "Invited {{roleName}} will receive access to": "Invited {{roleName}} will receive access to", "{{collectionCount}} collections": "{{collectionCount}} collections", - "Invite members or guests to join your workspace. They can sign in with {{signinMethods}} or use their email address.": "Invite members or guests to join your workspace. They can sign in with {{signinMethods}} or use their email address.", + "Can create, edit, and delete documents": "Can create, edit, and delete documents", + "Can view documents and comment": "Can view documents and comment", + "Can manage all workspace settings": "Can manage all workspace settings", + "Invite people to join your workspace. They can sign in with {{signinMethods}} or use their email address.": "Invite people to join your workspace. They can sign in with {{signinMethods}} or use their email address.", "Invite members to join your workspace. They will need to sign in with {{signinMethods}}.": "Invite members to join your workspace. They will need to sign in with {{signinMethods}}.", "As an admin you can also <2>enable email sign-in.": "As an admin you can also <2>enable email sign-in.", - "Want a link to share directly with your team?": "Want a link to share directly with your team?", - "Email": "Email", + "Invite as": "Invite as", "Role": "Role", - "Remove invite": "Remove invite", + "Email": "Email", "Add another": "Add another", "Inviting": "Inviting", "Send Invites": "Send Invites",