Move invite dialog to centered design (#6740)
* wip * Update invite dialog
This commit is contained in:
@@ -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} />,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
<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 />
|
||||
|
||||
{option.label}
|
||||
{labelForOption(opt)}
|
||||
</StyledSelectOption>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user