Update collection permissions UI (#6917)
This commit is contained in:
21
app/components/Sharing/components/ListItem.tsx
Normal file
21
app/components/Sharing/components/ListItem.tsx
Normal 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;
|
||||
}
|
||||
`;
|
||||
63
app/components/Sharing/components/SearchInput.tsx
Normal file
63
app/components/Sharing/components/SearchInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
215
app/components/Sharing/components/Suggestions.tsx
Normal file
215
app/components/Sharing/components/Suggestions.tsx
Normal 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;
|
||||
`;
|
||||
67
app/components/Sharing/components/index.tsx
Normal file
67
app/components/Sharing/components/index.tsx
Normal 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,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user