Move invite dialog to centered design (#6740)

* wip

* Update invite dialog
This commit is contained in:
Tom Moor
2024-03-29 20:26:18 -06:00
committed by GitHub
parent 09c82bdf40
commit ceb7ae1514
5 changed files with 157 additions and 209 deletions

View File

@@ -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: <Invite onSubmit={stores.dialogs.closeAllModals} />,
});
},

View File

@@ -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<InputSelectRef>) => {
const wrappedLabel = <LabelText>{label}</LabelText>;
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<InputSelectRef>) => {
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<InputSelectRef>) => {
}
}, [select.visible, selectedValueIndex]);
function labelForOption(opt: Option) {
return (
<>
{opt.label}
{opt.description && (
<>
&nbsp;
<Text as="span" type="tertiary" size="small">
{opt.description}
</Text>
</>
)}
</>
);
}
const option = getOptionFromValue(options, select.selectedValue);
return (
<>
<Wrapper short={short}>
@@ -174,28 +196,30 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
))}
<Select {...select} disabled={disabled} {...rest} ref={buttonRef}>
{(props) => (
{(buttonProps) => (
<StyledButton
neutral
disclosure
className={className}
icon={icon}
$nude={nude}
{...props}
{...buttonProps}
>
{getOptionFromValue(options, select.selectedValue)?.label || (
{option ? (
labelForOption(option)
) : (
<Placeholder>Select a {ariaLabel.toLowerCase()}</Placeholder>
)}
</StyledButton>
)}
</Select>
<SelectPopover {...select} {...popover} aria-label={ariaLabel}>
{(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 (
<Positioner {...props}>
<Positioner {...popoverProps}>
<Background
dir="auto"
ref={contentRef}
@@ -215,20 +239,19 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
}
>
{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 (
<StyledSelectOption
{...select}
value={option.value}
key={option.value}
value={opt.value}
key={opt.value}
ref={isSelected ? selectedRef : undefined}
>
<Icon />
&nbsp;
{option.label}
{labelForOption(opt)}
</StyledSelectOption>
);
})

View File

@@ -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>(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<HTMLInputElement>) => {
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 ? (
<span>
<Trans>Invited members will receive access to</Trans>{" "}
<Trans>Invited {{ roleName }} will receive access to</Trans>{" "}
<Tooltip
content={
<>
@@ -149,177 +121,138 @@ function Invite({ onSubmit }: Props) {
</>
}
>
<Def>
<strong>
<Trans>{{ collectionCount }} collections</Trans>
</Def>
</strong>
</Tooltip>
.
</span>
) : 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 (
<form onSubmit={handleSubmit}>
{team.guestSignin ? (
<Text as="p" type="secondary">
<Trans
defaults="Invite members or guests to join your workspace. They can sign in with {{signinMethods}} or use their email address."
values={{
signinMethods: team.signinMethods,
}}
/>{" "}
{collectionAccessNote}
</Text>
) : (
<Text as="p" type="secondary">
<Trans
defaults="Invite members to join your workspace. They will need to sign in with {{signinMethods}}."
values={{
signinMethods: team.signinMethods,
}}
/>{" "}
{can.update && (
<Trans>
As an admin you can also{" "}
<Link to="/settings/security">enable email sign-in</Link>.
</Trans>
)}{" "}
{collectionAccessNote}
</Text>
)}
{team.subdomain && (
<CopyBlock>
<Flex align="flex-end" gap={8}>
<Input
type="text"
value={team.url}
label={t("Want a link to share directly with your team?")}
readOnly
flex
/>
<CopyToClipboard text={team.url} onCopy={handleCopy}>
<Button
type="button"
icon={<CopyIcon />}
style={{
marginBottom: "16px",
}}
neutral
/>
</CopyToClipboard>
</Flex>
</CopyBlock>
)}
<ResizingHeightContainer>
{invites.map((invite, index) => (
<Flex key={index} gap={8}>
<Input
type="email"
name="email"
label={t("Email")}
labelHidden={index !== 0}
onKeyDown={handleKeyDown}
onChange={(ev) => handleChange(ev, index)}
placeholder={`example@${predictedDomain}`}
value={invite.email}
required={index === 0}
autoFocus
flex
/>
<Input
type="text"
name="name"
label={t("Name")}
labelHidden={index !== 0}
onKeyDown={handleKeyDown}
onChange={(ev) => handleChange(ev, index)}
value={invite.name}
required={!!invite.email}
flex
/>
<InputSelect
label={t("Role")}
ariaLabel={t("Role")}
options={options}
onChange={(role: UserRole) => handleRoleChange(role, index)}
value={invite.role}
labelHidden={index !== 0}
short
/>
{index !== 0 && (
<Remove>
<Tooltip content={t("Remove invite")} placement="top">
<NudeButton onClick={(ev) => handleRemove(ev, index)}>
<CloseIcon />
</NudeButton>
</Tooltip>
</Remove>
)}
</Flex>
))}
</ResizingHeightContainer>
<Flex justify="space-between">
{invites.length <= UserValidation.maxInvitesPerRequest ? (
<Button type="button" onClick={handleAdd} neutral>
{t("Add another")}
</Button>
<Flex gap={8} column>
{team.guestSignin ? (
<Text as="p" type="secondary">
<Trans
defaults="Invite people to join your workspace. They can sign in with {{signinMethods}} or use their email address."
values={{
signinMethods: team.signinMethods,
}}
/>{" "}
{collectionAccessNote}
</Text>
) : (
<span />
<Text as="p" type="secondary">
<Trans
defaults="Invite members to join your workspace. They will need to sign in with {{signinMethods}}."
values={{
signinMethods: team.signinMethods,
}}
/>{" "}
{collectionAccessNote}
{can.update && (
<Trans>
As an admin you can also{" "}
<Link to="/settings/security">enable email sign-in</Link>.
</Trans>
)}
</Text>
)}
<Flex gap={12} column>
<InputSelect
label={t("Invite as")}
ariaLabel={t("Role")}
options={options}
onChange={(r) => setRole(r as UserRole)}
value={role}
/>
<Button
type="submit"
disabled={isSaving}
data-on="click"
data-event-category="invite"
data-event-action="sendInvites"
>
{isSaving ? `${t("Inviting")}` : t("Send Invites")}
</Button>
<ResizingHeightContainer style={{ minHeight: 72, marginBottom: 8 }}>
{invites.map((invite, index) => (
<Flex key={index} gap={8}>
<StyledInput
type="email"
name="email"
label={t("Email")}
labelHidden={index !== 0}
onKeyDown={handleKeyDown}
onChange={(ev) => handleChange(ev, index)}
placeholder={`example@${predictedDomain}`}
value={invite.email}
required={index === 0}
autoFocus
flex
/>
<StyledInput
type="text"
name="name"
label={t("Name")}
labelHidden={index !== 0}
onKeyDown={handleKeyDown}
onChange={(ev) => handleChange(ev, index)}
value={invite.name}
required={!!invite.email}
flex
/>
</Flex>
))}
</ResizingHeightContainer>
</Flex>
<Flex justify="space-between">
{invites.length <= UserValidation.maxInvitesPerRequest ? (
<Button
type="button"
onClick={handleAdd}
icon={<PlusIcon />}
neutral
>
{t("Add another")}
</Button>
) : null}
<Button
type="submit"
disabled={isSaving}
data-on="click"
data-event-category="invite"
data-event-action="sendInvites"
>
{isSaving ? `${t("Inviting")}` : t("Send Invites")}
</Button>
</Flex>
</Flex>
<br />
</form>
);
}
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);

View File

@@ -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={<PlusIcon />}
>
{t("Invite people")}
@@ -195,15 +194,6 @@ function Members() {
totalPages={totalPages}
defaultSortDirection="ASC"
/>
{can.inviteUser && (
<Modal
title={t("Invite people")}
onRequestClose={handleInviteModalClose}
isOpen={inviteModalOpen}
>
<Invite onSubmit={handleInviteModalClose} />
</Modal>
)}
</Scene>
);
}

View File

@@ -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</2>.": "As an admin you can also <2>enable email sign-in</2>.",
"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",