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

@@ -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

@@ -0,0 +1,215 @@
import { isEmail } from "class-validator";
import { observer } from "mobx-react";
import { CheckmarkIcon, CloseIcon, GroupIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
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 { InviteIcon, ListItem } from "./ListItem";
type Suggestion = IAvatar & {
id: string;
};
type Props = {
/** The document being shared. */
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. */
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;
/** Show group suggestions. */
showGroups?: boolean;
};
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) => {
void users.fetchPage({ query: params.query });
if (showGroups) {
void groups.fetchPage({ query: params.query });
}
}, 250);
const getSuggestionForEmail = React.useCallback(
(email: string) => ({
id: email,
name: email,
avatarUrl: "",
color: stringToColor(email),
initial: email[0].toUpperCase(),
email: t("Invite to workspace"),
}),
[t]
);
const suggestions = React.useMemo(() => {
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,
collection?.id,
user.id,
query,
t,
]);
const pending = React.useMemo(
() =>
pendingIds
.map((id) =>
isEmail(id)
? getSuggestionForEmail(id)
: users.get(id) ?? groups.get(id)
)
.filter(Boolean) as User[],
[users, getSuggestionForEmail, pendingIds]
);
React.useEffect(() => {
void fetchUsersByQuery(query);
}, [query, fetchUsersByQuery]);
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
? suggestion.email
: suggestion.isViewer
? t("Viewer")
: t("Editor"),
image: (
<Avatar
model={suggestion}
size={AvatarSize.Medium}
showBorder={false}
/>
),
};
}
const isEmpty = suggestions.length === 0;
const suggestionsWithPending = suggestions.filter(
(u) => !pendingIds.includes(u.id)
);
return (
<>
{pending.map((suggestion) => (
<PendingListItem
{...getListItemProps(suggestion)}
key={suggestion.id}
onClick={() => removePendingId(suggestion.id)}
actions={
<>
<InvitedIcon />
<RemoveIcon />
</>
}
/>
))}
{pending.length > 0 &&
(suggestionsWithPending.length > 0 || isEmpty) && <Separator />}
{suggestionsWithPending.map((suggestion) => (
<ListItem
{...getListItemProps(suggestion as User)}
key={suggestion.id}
onClick={() => addPendingId(suggestion.id)}
actions={<InviteIcon />}
/>
))}
{isEmpty && <Empty style={{ marginTop: 22 }}>{t("No matches")}</Empty>}
</>
);
}
);
const InvitedIcon = styled(CheckmarkIcon)`
color: ${s("accent")};
`;
const RemoveIcon = styled(CloseIcon)`
display: none;
`;
const PendingListItem = styled(ListItem)`
&: ${hover} {
${InvitedIcon} {
display: none;
}
${RemoveIcon} {
display: block;
}
}
`;
const Separator = styled.div`
border-top: 1px dashed ${s("divider")};
margin: 12px 0;
`;

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,
},
};