Update collection permissions UI (#6917)

This commit is contained in:
Tom Moor
2024-05-16 19:45:09 -04:00
committed by GitHub
parent 728c68be58
commit cae013837b
34 changed files with 1088 additions and 287 deletions

View File

@@ -18,6 +18,7 @@ import Switch from "~/components/Switch";
import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
export interface FormData {
name: string;
@@ -138,16 +139,18 @@ export const CollectionForm = observer(function CollectionForm_({
/>
)}
{team.sharing && !collection && (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
{...register("sharing")}
/>
)}
{team.sharing &&
(!collection ||
FeatureFlags.isEnabled(Feature.newCollectionSharing)) && (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
{...register("sharing")}
/>
)}
<Flex justify="flex-end">
<Button

View File

@@ -3,21 +3,30 @@ import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import InputSelect, { Props as SelectProps } from "~/components/InputSelect";
import { Permission } from "~/types";
import { EmptySelectValue, Permission } from "~/types";
export default function InputMemberPermissionSelect(
props: Partial<SelectProps> & { permissions: Permission[] }
) {
const { value, onChange, ...rest } = props;
const { t } = useTranslation();
const handleChange = React.useCallback(
(value) => {
onChange?.(value === EmptySelectValue ? null : value);
},
[onChange]
);
return (
<Select
label={t("Permissions")}
options={props.permissions}
ariaLabel={t("Permissions")}
onChange={handleChange}
value={value || EmptySelectValue}
labelHidden
nude
{...props}
{...rest}
/>
);
}

View File

@@ -23,12 +23,14 @@ import {
Placement,
} from "./ContextMenu";
import { MenuAnchorCSS } from "./ContextMenu/MenuItem";
import Separator from "./ContextMenu/Separator";
import { LabelText } from "./Input";
export type Option = {
label: string | JSX.Element;
value: string;
description?: string;
divider?: boolean;
};
export type Props = {
@@ -47,6 +49,7 @@ export type Props = {
/** @deprecated Removing soon, do not use. */
note?: React.ReactNode;
onChange?: (value: string | null) => void;
style?: React.CSSProperties;
};
export interface InputSelectRef {
@@ -247,16 +250,19 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
const isSelected = select.selectedValue === opt.value;
const Icon = isSelected ? CheckmarkIcon : Spacer;
return (
<StyledSelectOption
{...select}
value={opt.value}
key={opt.value}
ref={isSelected ? selectedRef : undefined}
>
<Icon />
&nbsp;
{labelForOption(opt)}
</StyledSelectOption>
<>
{opt.divider && <Separator />}
<StyledSelectOption
{...select}
value={opt.value}
key={opt.value}
ref={isSelected ? selectedRef : undefined}
>
<Icon />
&nbsp;
{labelForOption(opt)}
</StyledSelectOption>
</>
);
})
: null}

View File

@@ -0,0 +1,176 @@
import { observer } from "mobx-react";
import { GroupIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { CollectionPermission } from "@shared/types";
import Collection from "~/models/Collection";
import Avatar, { AvatarSize } from "~/components/Avatar/Avatar";
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { EmptySelectValue, Permission } from "~/types";
import { ListItem } from "../components/ListItem";
type Props = {
/** Collection to which team members are supposed to be invited */
collection: Collection;
/** Children to be rendered before the list of members */
children?: React.ReactNode;
/** List of users and groups that have been invited during the current editing session */
invitedInSession: string[];
};
function CollectionMemberList({ collection, invitedInSession }: Props) {
const { memberships, collectionGroupMemberships } = useStores();
const can = usePolicy(collection);
const { t } = useTranslation();
const theme = useTheme();
const collectionId = collection.id;
const { request: fetchMemberships } = useRequest(
React.useCallback(
() => memberships.fetchAll({ id: collectionId }),
[memberships, collectionId]
)
);
const { request: fetchGroupMemberships } = useRequest(
React.useCallback(
() => collectionGroupMemberships.fetchAll({ id: collectionId }),
[collectionGroupMemberships, collectionId]
)
);
React.useEffect(() => {
void fetchMemberships();
void fetchGroupMemberships();
}, [fetchMemberships, fetchGroupMemberships]);
const permissions = React.useMemo(
() =>
[
{
label: t("Admin"),
value: CollectionPermission.Admin,
},
{
label: t("Can edit"),
value: CollectionPermission.ReadWrite,
},
{
label: t("View only"),
value: CollectionPermission.Read,
},
{
divider: true,
label: t("Remove"),
value: EmptySelectValue,
},
] as Permission[],
[t]
);
return (
<>
{collectionGroupMemberships
.inCollection(collection.id)
.sort((a, b) =>
(
(invitedInSession.includes(a.group.id) ? "_" : "") + a.group.name
).localeCompare(b.group.name)
)
.map((membership) => (
<ListItem
key={membership.id}
image={
<Squircle color={theme.text} size={AvatarSize.Medium}>
<GroupIcon color={theme.background} size={16} />
</Squircle>
}
title={membership.group.name}
subtitle={t("{{ count }} member", {
count: membership.group.memberCount,
})}
actions={
<div style={{ marginRight: -8 }}>
<InputMemberPermissionSelect
style={{ margin: 0 }}
permissions={permissions}
onChange={async (permission: CollectionPermission) => {
if (permission) {
await collectionGroupMemberships.create({
collectionId: collection.id,
groupId: membership.groupId,
permission,
});
} else {
await collectionGroupMemberships.delete({
collectionId: collection.id,
groupId: membership.groupId,
});
}
}}
disabled={!can.update}
value={membership.permission}
labelHidden
nude
/>
</div>
}
/>
))}
{memberships
.inCollection(collection.id)
.sort((a, b) =>
(
(invitedInSession.includes(a.user.id) ? "_" : "") + a.user.name
).localeCompare(b.user.name)
)
.map((membership) => (
<ListItem
key={membership.id}
image={
<Avatar
model={membership.user}
size={AvatarSize.Medium}
showBorder={false}
/>
}
title={membership.user.name}
subtitle={membership.user.email}
actions={
<div style={{ marginRight: -8 }}>
<InputMemberPermissionSelect
style={{ margin: 0 }}
permissions={permissions}
onChange={async (permission: CollectionPermission) => {
if (permission) {
await memberships.create({
collectionId: collection.id,
userId: membership.userId,
permission,
});
} else {
await memberships.delete({
collectionId: collection.id,
userId: membership.userId,
});
}
}}
disabled={!can.update}
value={membership.permission}
labelHidden
nude
/>
</div>
}
/>
))}
</>
);
}
export default observer(CollectionMemberList);

View File

@@ -0,0 +1,340 @@
import { isEmail } from "class-validator";
import { m } from "framer-motion";
import { observer } from "mobx-react";
import { BackIcon, LinkIcon, UserIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { CollectionPermission, UserRole } from "@shared/types";
import Collection from "~/models/Collection";
import Group from "~/models/Group";
import Share from "~/models/Share";
import User from "~/models/User";
import Avatar, { AvatarSize } from "~/components/Avatar/Avatar";
import ButtonSmall from "~/components/ButtonSmall";
import CopyToClipboard from "~/components/CopyToClipboard";
import InputSelectPermission from "~/components/InputSelectPermission";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
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";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { collectionPath, urlify } from "~/utils/routeHelpers";
import { Wrapper, presence } from "../components";
import { ListItem } from "../components/ListItem";
import { SearchInput } from "../components/SearchInput";
import { Suggestions } from "../components/Suggestions";
import CollectionMemberList from "./CollectionMemberList";
type Props = {
collection: Collection;
/** The existing share model, if any. */
share: Share | null | undefined;
/** Callback fired when the popover requests to be closed. */
onRequestClose: () => void;
/** Whether the popover is visible. */
visible: boolean;
};
function SharePopover({ collection, visible, onRequestClose }: Props) {
const theme = useTheme();
const team = useCurrentTeam();
const { collectionGroupMemberships, users, groups, memberships } =
useStores();
const { t } = useTranslation();
const can = usePolicy(collection);
const [query, setQuery] = React.useState("");
const [picker, showPicker, hidePicker] = useBoolean();
const [hasRendered, setHasRendered] = React.useState(visible);
const [pendingIds, setPendingIds] = React.useState<string[]>([]);
const [invitedInSession, setInvitedInSession] = React.useState<string[]>([]);
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
const context = useActionContext();
useKeyDown(
"Escape",
(ev) => {
ev.preventDefault();
ev.stopImmediatePropagation();
if (picker) {
hidePicker();
} else {
onRequestClose();
}
},
{
allowInInput: true,
}
);
// Clear the query when picker is closed
React.useEffect(() => {
if (!picker) {
setQuery("");
}
}, [picker]);
React.useEffect(() => {
if (visible) {
setHasRendered(true);
}
}, [visible]);
const handleCopied = React.useCallback(() => {
onRequestClose();
timeout.current = setTimeout(() => {
toast.message(t("Link copied to clipboard"));
}, 100);
return () => {
if (timeout.current) {
clearTimeout(timeout.current);
}
};
}, [onRequestClose, t]);
const handleQuery = React.useCallback(
(event) => {
showPicker();
setQuery(event.target.value);
},
[showPicker, setQuery]
);
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 inviteAction = React.useMemo(
() =>
createAction({
name: t("Invite"),
section: UserSection,
perform: async () => {
const invited = await Promise.all(
pendingIds.map(async (idOrEmail) => {
let user, group;
// convert email to user
if (isEmail(idOrEmail)) {
const response = await users.invite([
{
email: idOrEmail,
name: idOrEmail,
role: team.defaultUserRole,
},
]);
user = response.users[0];
} else {
user = users.get(idOrEmail);
group = groups.get(idOrEmail);
}
if (user) {
await memberships.create({
collectionId: collection.id,
userId: user.id,
permission:
user?.role === UserRole.Viewer ||
user?.role === UserRole.Guest
? CollectionPermission.Read
: CollectionPermission.ReadWrite,
});
return user;
}
if (group) {
await collectionGroupMemberships.create({
collectionId: collection.id,
groupId: group.id,
permission:
user?.role === UserRole.Viewer ||
user?.role === UserRole.Guest
? CollectionPermission.Read
: CollectionPermission.ReadWrite,
});
return group;
}
})
);
const invitedUsers = invited.filter((item) => item instanceof User);
const invitedGroups = invited.filter((item) => item instanceof Group);
// Special case for the common action of adding a single user.
if (invitedUsers.length === 1 && invited.length === 1) {
const user = invitedUsers[0];
toast.message(
t("{{ userName }} was added to the collection", {
userName: user.name,
}),
{
icon: <Avatar model={user} size={AvatarSize.Toast} />,
}
);
} else if (invitedGroups.length === 1 && invited.length === 1) {
const group = invitedGroups[0];
toast.success(
t("{{ userName }} was added to the collection", {
userName: group.name,
})
);
} else if (invitedGroups.length === 0) {
toast.success(
t("{{ count }} people added to the collection", {
count: invitedUsers.length,
})
);
} else {
toast.success(
t(
"{{ count }} people and {{ count2 }} groups added to the collection",
{
count: invitedUsers.length,
count2: invitedGroups.length,
}
)
);
}
setInvitedInSession((prev) => [...prev, ...pendingIds]);
setPendingIds([]);
hidePicker();
},
}),
[
collection.id,
hidePicker,
memberships,
pendingIds,
t,
team.defaultUserRole,
users,
]
);
if (!hasRendered) {
return null;
}
const backButton = (
<>
{picker && (
<NudeButton
key="back"
as={m.button}
{...presence}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
hidePicker();
}}
>
<BackIcon />
</NudeButton>
)}
</>
);
const rightButton = picker ? (
pendingIds.length ? (
<ButtonSmall action={inviteAction} context={context} key="invite">
{t("Add")}
</ButtonSmall>
) : null
) : (
<Tooltip
content={t("Copy link")}
delay={500}
placement="top"
key="copy-link"
>
<CopyToClipboard
text={urlify(collectionPath(collection.path))}
onCopy={handleCopied}
>
<NudeButton type="button">
<LinkIcon size={20} />
</NudeButton>
</CopyToClipboard>
</Tooltip>
);
return (
<Wrapper>
{can.update && (
<SearchInput
onChange={handleQuery}
onClick={showPicker}
query={query}
back={backButton}
action={rightButton}
/>
)}
{picker && (
<div>
<Suggestions
query={query}
collection={collection}
pendingIds={pendingIds}
addPendingId={handleAddPendingId}
removePendingId={handleRemovePendingId}
/>
</div>
)}
<div style={{ display: picker ? "none" : "block" }}>
<ListItem
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={
<div style={{ marginRight: -8 }}>
<InputSelectPermission
style={{ margin: 0 }}
onChange={(permission) => {
void collection.save({ permission });
}}
disabled={!can.update}
value={collection?.permission}
labelHidden
nude
/>
</div>
}
/>
<CollectionMemberList
collection={collection}
invitedInSession={invitedInSession}
/>
</div>
</Wrapper>
);
}
export default observer(SharePopover);

View File

@@ -1,7 +1,7 @@
import { t } from "i18next";
import orderBy from "lodash/orderBy";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import { Pagination } from "@shared/constants";
@@ -13,29 +13,23 @@ import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { homePath } from "~/utils/routeHelpers";
import MemberListItem from "./MemberListItem";
import MemberListItem from "./DocumentMemberListItem";
type Props = {
/** Document to which team members are supposed to be invited */
document: Document;
/** Children to be rendered before the list of members */
children?: React.ReactNode;
/** List of users that have been invited to the document during the current editing session */
/** List of users that have been invited during the current editing session */
invitedInSession: string[];
};
function DocumentMembersList({ document, invitedInSession }: Props) {
const { users, userMemberships } = useStores();
const { userMemberships } = useStores();
const user = useCurrentUser();
const history = useHistory();
const can = usePolicy(document);
const { loading: loadingTeamMembers, request: fetchTeamMembers } = useRequest(
React.useCallback(
() => users.fetchPage({ limit: Pagination.defaultLimit }),
[users]
)
);
const { t } = useTranslation();
const { loading: loadingDocumentMembers, request: fetchDocumentMembers } =
useRequest(
@@ -50,9 +44,8 @@ function DocumentMembersList({ document, invitedInSession }: Props) {
);
React.useEffect(() => {
void fetchTeamMembers();
void fetchDocumentMembers();
}, [fetchTeamMembers, fetchDocumentMembers]);
}, [fetchDocumentMembers]);
const handleRemoveUser = React.useCallback(
async (item) => {
@@ -112,7 +105,7 @@ function DocumentMembersList({ document, invitedInSession }: Props) {
[document.members, invitedInSession]
);
if (loadingTeamMembers || loadingDocumentMembers) {
if (loadingDocumentMembers) {
return <LoadingIndicator />;
}

View File

@@ -1,5 +1,4 @@
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
@@ -11,9 +10,8 @@ import UserMembership from "~/models/UserMembership";
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";
import { ListItem } from "../components/ListItem";
type Props = {
user: User;
@@ -24,7 +22,7 @@ type Props = {
onUpdate?: (permission: DocumentPermission) => void;
};
const MemberListItem = ({
const DocumentMemberListItem = ({
user,
membership,
onRemove,
@@ -54,7 +52,7 @@ const MemberListItem = ({
value: DocumentPermission.ReadWrite,
},
{
label: t("No access"),
label: t("Remove"),
value: EmptySelectValue,
},
];
@@ -69,7 +67,7 @@ const MemberListItem = ({
const MaybeLink = membership?.source ? StyledLink : React.Fragment;
return (
<StyledListItem
<ListItem
title={user.name}
image={
<Avatar model={user} size={AvatarSize.Medium} showBorder={false} />
@@ -122,26 +120,9 @@ const MemberListItem = ({
);
};
export const InviteIcon = styled(PlusIcon)`
opacity: 0;
`;
export const StyledListItem = styled(ListItem).attrs({
small: true,
border: false,
})`
margin: 0 -16px;
padding: 6px 16px;
border-radius: 8px;
&: ${hover} ${InviteIcon} {
opacity: 1;
}
`;
const StyledLink = styled(Link)`
color: ${s("textTertiary")};
text-decoration: underline;
`;
export default observer(MemberListItem);
export default observer(DocumentMemberListItem);

View File

@@ -12,11 +12,11 @@ import Text from "~/components/Text";
import useCurrentUser from "~/hooks/useCurrentUser";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import Avatar from "../Avatar";
import { AvatarSize } from "../Avatar/Avatar";
import CollectionIcon from "../Icons/CollectionIcon";
import Tooltip from "../Tooltip";
import { StyledListItem } from "./MemberListItem";
import Avatar from "../../Avatar";
import { AvatarSize } from "../../Avatar/Avatar";
import CollectionIcon from "../../Icons/CollectionIcon";
import Tooltip from "../../Tooltip";
import { ListItem } from "../components/ListItem";
type Props = {
/** The document being shared. */
@@ -36,7 +36,7 @@ export const OtherAccess = observer(({ document, children }: Props) => {
{collection ? (
<>
{collection.permission ? (
<StyledListItem
<ListItem
image={
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<UserIcon color={theme.accentText} size={16} />
@@ -53,7 +53,7 @@ export const OtherAccess = observer(({ document, children }: Props) => {
}
/>
) : usersInCollection ? (
<StyledListItem
<ListItem
image={
<Squircle color={collection.color} size={AvatarSize.Medium}>
<CollectionIcon
@@ -68,7 +68,7 @@ export const OtherAccess = observer(({ document, children }: Props) => {
actions={<AccessTooltip>{t("Can view")}</AccessTooltip>}
/>
) : (
<StyledListItem
<ListItem
image={<Avatar model={user} showBorder={false} />}
title={user.name}
subtitle={t("You have full access")}
@@ -79,7 +79,7 @@ export const OtherAccess = observer(({ document, children }: Props) => {
</>
) : document.isDraft ? (
<>
<StyledListItem
<ListItem
image={<Avatar model={document.createdBy} showBorder={false} />}
title={document.createdBy?.name}
actions={
@@ -93,7 +93,7 @@ export const OtherAccess = observer(({ document, children }: Props) => {
) : (
<>
{children}
<StyledListItem
<ListItem
image={
<Squircle color={theme.accent} size={AvatarSize.Medium}>
<MoreIcon color={theme.accentText} size={16} />

View File

@@ -18,13 +18,13 @@ import Switch from "~/components/Switch";
import env from "~/env";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { AvatarSize } from "../Avatar/Avatar";
import CopyToClipboard from "../CopyToClipboard";
import NudeButton from "../NudeButton";
import { ResizingHeightContainer } from "../ResizingHeightContainer";
import Text from "../Text";
import Tooltip from "../Tooltip";
import { StyledListItem } from "./MemberListItem";
import { AvatarSize } from "../../Avatar/Avatar";
import CopyToClipboard from "../../CopyToClipboard";
import NudeButton from "../../NudeButton";
import { ResizingHeightContainer } from "../../ResizingHeightContainer";
import Text from "../../Text";
import Tooltip from "../../Tooltip";
import { ListItem } from "../components/ListItem";
type Props = {
/** The document to share. */
@@ -122,7 +122,7 @@ function PublicAccess({ document, share, sharedParent }: Props) {
return (
<Wrapper>
<StyledListItem
<ListItem
title={t("Web")}
subtitle={
<>

View File

@@ -1,37 +1,34 @@
import { isEmail } from "class-validator";
import { AnimatePresence, m } from "framer-motion";
import { m } from "framer-motion";
import { observer } from "mobx-react";
import { BackIcon, LinkIcon } from "outline-icons";
import { darken } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { s } from "@shared/styles";
import { DocumentPermission, UserRole } from "@shared/types";
import Document from "~/models/Document";
import Share from "~/models/Share";
import Avatar from "~/components/Avatar";
import { AvatarSize } from "~/components/Avatar/Avatar";
import ButtonSmall from "~/components/ButtonSmall";
import CopyToClipboard from "~/components/CopyToClipboard";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
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";
import useMobile from "~/hooks/useMobile";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { hover } from "~/styles";
import { documentPath, urlify } from "~/utils/routeHelpers";
import ButtonSmall from "../ButtonSmall";
import Input, { NativeInput } from "../Input";
import NudeButton from "../NudeButton";
import Tooltip from "../Tooltip";
import { Separator, Wrapper, presence } from "../components";
import { SearchInput } from "../components/SearchInput";
import { Suggestions } from "../components/Suggestions";
import DocumentMembersList from "./DocumentMemberList";
import { OtherAccess } from "./OtherAccess";
import PublicAccess from "./PublicAccess";
import { UserSuggestions } from "./UserSuggestions";
type Props = {
/** The document to share. */
@@ -46,29 +43,6 @@ type Props = {
visible: boolean;
};
const presence = {
initial: {
opacity: 0,
width: 0,
marginRight: 0,
},
animate: {
opacity: 1,
width: "auto",
marginRight: 8,
transition: {
type: "spring",
duration: 0.2,
bounce: 0,
},
},
exit: {
opacity: 0,
width: 0,
marginRight: 0,
},
};
function SharePopover({
document,
share,
@@ -79,13 +53,13 @@ function SharePopover({
const team = useCurrentTeam();
const { t } = useTranslation();
const can = usePolicy(document);
const inputRef = React.useRef<HTMLInputElement>(null);
const linkButtonRef = React.useRef<HTMLButtonElement>(null);
const context = useActionContext();
const [hasRendered, setHasRendered] = React.useState(visible);
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;
@@ -111,6 +85,7 @@ function SharePopover({
React.useEffect(() => {
if (visible) {
void document.share();
setHasRendered(true);
}
}, [document, hidePicker, visible]);
@@ -143,8 +118,6 @@ function SharePopover({
};
}, [onRequestClose, t]);
const context = useActionContext();
const inviteAction = React.useMemo(
() =>
createAction({
@@ -188,13 +161,17 @@ function SharePopover({
);
if (usersInvited.length === 1) {
const user = usersInvited[0];
toast.message(
t("{{ userName }} was invited to the document", {
userName: usersInvited[0].name,
})
userName: user.name,
}),
{
icon: <Avatar model={user} size={AvatarSize.Toast} />,
}
);
} else {
toast.message(
toast.success(
t("{{ count }} people invited to the document", {
count: pendingIds.length,
})
@@ -225,13 +202,6 @@ function SharePopover({
[showPicker, setQuery]
);
const focusInput = React.useCallback(() => {
if (!picker) {
inputRef.current?.focus();
showPicker();
}
}, [picker, showPicker]);
const handleAddPendingId = React.useCallback(
(id: string) => {
setPendingIds((prev) => [...prev, id]);
@@ -246,10 +216,23 @@ function SharePopover({
[setPendingIds]
);
if (!hasRendered) {
return null;
}
const backButton = (
<>
{picker && (
<NudeButton key="back" as={m.button} {...presence} onClick={hidePicker}>
<NudeButton
key="back"
as={m.button}
{...presence}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
hidePicker();
}}
>
<BackIcon />
</NudeButton>
)}
@@ -282,44 +265,19 @@ function SharePopover({
return (
<Wrapper>
{can.manageUsers &&
(isMobile ? (
<Flex align="center" style={{ marginBottom: 12 }} auto>
{backButton}
<Input
key="input"
placeholder={`${t("Invite")}`}
value={query}
onChange={handleQuery}
onClick={showPicker}
autoFocus
margin={0}
flex
>
{rightButton}
</Input>
</Flex>
) : (
<HeaderInput align="center" onClick={focusInput}>
<AnimatePresence initial={false}>
{backButton}
<NativeInput
key="input"
ref={inputRef}
placeholder={`${t("Invite")}`}
value={query}
onChange={handleQuery}
onClick={showPicker}
style={{ padding: "6px 0" }}
/>
{rightButton}
</AnimatePresence>
</HeaderInput>
))}
{can.manageUsers && (
<SearchInput
onChange={handleQuery}
onClick={showPicker}
query={query}
back={backButton}
action={rightButton}
/>
)}
{picker && (
<div>
<UserSuggestions
<Suggestions
document={document}
query={query}
pendingIds={pendingIds}
@@ -353,42 +311,4 @@ function SharePopover({
);
}
// TODO: Temp until Button/NudeButton styles are normalized
const Wrapper = styled.div`
${NudeButton}:${hover},
${NudeButton}[aria-expanded="true"] {
background: ${(props) => darken(0.05, props.theme.buttonNeutralBackground)};
}
`;
const Separator = styled.div`
border-top: 1px dashed ${s("divider")};
margin: 12px 0;
`;
const HeaderInput = styled(Flex)`
position: sticky;
z-index: 1;
top: 0;
background: ${s("menuBackground")};
color: ${s("textTertiary")};
border-bottom: 1px solid ${s("inputBorder")};
padding: 0 24px 12px;
margin-top: 0;
margin-left: -24px;
margin-right: -24px;
margin-bottom: 12px;
cursor: text;
&:before {
content: "";
position: absolute;
left: 0;
right: 0;
top: -20px;
height: 20px;
background: ${s("menuBackground")};
}
`;
export default observer(SharePopover);

View File

@@ -0,0 +1,21 @@
import { PlusIcon } from "outline-icons";
import styled from "styled-components";
import BaseListItem from "~/components/List/Item";
import { hover } from "~/styles";
export const InviteIcon = styled(PlusIcon)`
opacity: 0;
`;
export const ListItem = styled(BaseListItem).attrs({
small: true,
border: false,
})`
margin: 0 -16px;
padding: 6px 16px;
border-radius: 8px;
&: ${hover} ${InviteIcon} {
opacity: 1;
}
`;

View File

@@ -0,0 +1,63 @@
import { AnimatePresence } from "framer-motion";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Flex from "~/components/Flex";
import useMobile from "~/hooks/useMobile";
import Input, { NativeInput } from "../../Input";
import { HeaderInput } from "../components";
type Props = {
query: string;
onChange: React.ChangeEventHandler;
onClick: React.MouseEventHandler;
back: React.ReactNode;
action: React.ReactNode;
};
export function SearchInput({ onChange, onClick, query, back, action }: Props) {
const { t } = useTranslation();
const inputRef = React.useRef<HTMLInputElement>(null);
const isMobile = useMobile();
const focusInput = React.useCallback(
(event) => {
inputRef.current?.focus();
onClick(event);
},
[onClick]
);
return isMobile ? (
<Flex align="center" style={{ marginBottom: 12 }} auto>
{back}
<Input
key="input"
placeholder={`${t("Add or invite")}`}
value={query}
onChange={onChange}
onClick={onClick}
autoFocus
margin={0}
flex
>
{action}
</Input>
</Flex>
) : (
<HeaderInput align="center" onClick={focusInput}>
<AnimatePresence initial={false}>
{back}
<NativeInput
key="input"
ref={inputRef}
placeholder={`${t("Add or invite")}`}
value={query}
onChange={onChange}
onClick={onClick}
style={{ padding: "6px 0" }}
/>
{action}
</AnimatePresence>
</HeaderInput>
);
}

View File

@@ -1,21 +1,24 @@
import { isEmail } from "class-validator";
import { observer } from "mobx-react";
import { CheckmarkIcon, CloseIcon } from "outline-icons";
import { CheckmarkIcon, CloseIcon, GroupIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import styled, { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { s } from "@shared/styles";
import { stringToColor } from "@shared/utils/color";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import Group from "~/models/Group";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
import { AvatarSize, IAvatar } from "~/components/Avatar/Avatar";
import Empty from "~/components/Empty";
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, IAvatar } from "../Avatar/Avatar";
import Empty from "../Empty";
import { InviteIcon, StyledListItem } from "./MemberListItem";
import { InviteIcon, ListItem } from "./ListItem";
type Suggestion = IAvatar & {
id: string;
@@ -23,7 +26,9 @@ type Suggestion = IAvatar & {
type Props = {
/** The document being shared. */
document: Document;
document?: Document;
/** The collection being shared. */
collection?: Collection;
/** The search query to filter users by. */
query: string;
/** A list of pending user ids that have not yet been invited. */
@@ -32,18 +37,32 @@ type Props = {
addPendingId: (id: string) => void;
/** Callback to remove a user from the pending list. */
removePendingId: (id: string) => void;
/** Show group suggestions. */
showGroups?: boolean;
};
export const UserSuggestions = observer(
({ document, query, pendingIds, addPendingId, removePendingId }: Props) => {
const { users } = useStores();
export const Suggestions = observer(
({
document,
collection,
query,
pendingIds,
addPendingId,
removePendingId,
showGroups,
}: Props) => {
const { users, groups } = useStores();
const { t } = useTranslation();
const user = useCurrentUser();
const theme = useTheme();
const fetchUsersByQuery = useThrottledCallback(
(params) => users.fetchPage({ query: params.query }),
250
);
const fetchUsersByQuery = useThrottledCallback((params) => {
void users.fetchPage({ query: params.query });
if (showGroups) {
void groups.fetchPage({ query: params.query });
}
}, 250);
const getSuggestionForEmail = React.useCallback(
(email: string) => ({
@@ -58,21 +77,30 @@ export const UserSuggestions = observer(
);
const suggestions = React.useMemo(() => {
const filtered: Suggestion[] = users
.notInDocument(document.id, query)
.filter((u) => u.id !== user.id && !u.isSuspended);
const filtered: Suggestion[] = (
document
? users.notInDocument(document.id, query)
: collection
? users.notInCollection(collection.id, query)
: users.orderedData
).filter((u) => u.id !== user.id && !u.isSuspended);
if (isEmail(query)) {
filtered.push(getSuggestionForEmail(query));
}
if (collection?.id) {
return [...groups.notInCollection(collection.id, query), ...filtered];
}
return filtered;
}, [
getSuggestionForEmail,
users,
users.orderedData,
document.id,
document.members,
document?.id,
document?.members,
collection?.id,
user.id,
query,
t,
@@ -82,19 +110,32 @@ export const UserSuggestions = observer(
() =>
pendingIds
.map((id) =>
isEmail(id) ? getSuggestionForEmail(id) : users.get(id)
isEmail(id)
? getSuggestionForEmail(id)
: users.get(id) ?? groups.get(id)
)
.filter(Boolean) as User[],
[users, getSuggestionForEmail, pendingIds]
);
React.useEffect(() => {
if (query) {
void fetchUsersByQuery(query);
}
void fetchUsersByQuery(query);
}, [query, fetchUsersByQuery]);
function getListItemProps(suggestion: User) {
function getListItemProps(suggestion: User | Group) {
if (suggestion instanceof Group) {
return {
title: suggestion.name,
subtitle: t("{{ count }} member", {
count: suggestion.memberCount,
}),
image: (
<Squircle color={theme.text} size={AvatarSize.Medium}>
<GroupIcon color={theme.background} size={16} />
</Squircle>
),
};
}
return {
title: suggestion.name,
subtitle: suggestion.email
@@ -135,7 +176,7 @@ export const UserSuggestions = observer(
{pending.length > 0 &&
(suggestionsWithPending.length > 0 || isEmpty) && <Separator />}
{suggestionsWithPending.map((suggestion) => (
<StyledListItem
<ListItem
{...getListItemProps(suggestion as User)}
key={suggestion.id}
onClick={() => addPendingId(suggestion.id)}
@@ -156,7 +197,7 @@ const RemoveIcon = styled(CloseIcon)`
display: none;
`;
const PendingListItem = styled(StyledListItem)`
const PendingListItem = styled(ListItem)`
&: ${hover} {
${InvitedIcon} {
display: none;

View File

@@ -0,0 +1,67 @@
import { darken } from "polished";
import styled from "styled-components";
import Flex from "@shared/components/Flex";
import { s } from "@shared/styles";
import NudeButton from "~/components/NudeButton";
import { hover } from "~/styles";
// TODO: Temp until Button/NudeButton styles are normalized
export const Wrapper = styled.div`
${NudeButton}:${hover},
${NudeButton}[aria-expanded="true"] {
background: ${(props) => darken(0.05, props.theme.buttonNeutralBackground)};
}
`;
export const Separator = styled.div`
border-top: 1px dashed ${s("divider")};
margin: 12px 0;
`;
export const HeaderInput = styled(Flex)`
position: sticky;
z-index: 1;
top: 0;
background: ${s("menuBackground")};
color: ${s("textTertiary")};
border-bottom: 1px solid ${s("inputBorder")};
padding: 0 24px 12px;
margin-top: 0;
margin-left: -24px;
margin-right: -24px;
margin-bottom: 12px;
cursor: text;
&:before {
content: "";
position: absolute;
left: 0;
right: 0;
top: -20px;
height: 20px;
background: ${s("menuBackground")};
}
`;
export const presence = {
initial: {
opacity: 0,
width: 0,
marginRight: 0,
},
animate: {
opacity: 1,
width: "auto",
marginRight: 8,
transition: {
type: "spring",
duration: 0.2,
bounce: 0,
},
},
exit: {
opacity: 0,
width: 0,
marginRight: 0,
},
};