mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Move group management to table (#8212)
* convert to table * refactor edit group modal * refactor delete group modal * refactor add people modal * refactor create group modal * rebased changes * filter works * empty group message * retain group title click * fade * cleanup * pre-filtered for determining isEmpty * remove fade, unnecessary role check * StickyFilters component * createdAt column * Remove DelayedMount Add 'External ID' in menu when present --------- Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
@@ -1,89 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { GroupIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
|
||||
import { s } from "@shared/styles";
|
||||
import Group from "~/models/Group";
|
||||
import GroupMembership from "~/models/GroupMembership";
|
||||
import GroupMembers from "~/scenes/GroupMembers";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Flex from "~/components/Flex";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Modal from "~/components/Modal";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import { hover } from "~/styles";
|
||||
import NudeButton from "./NudeButton";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
membership?: GroupMembership;
|
||||
showFacepile?: boolean;
|
||||
showAvatar?: boolean;
|
||||
renderActions: (params: { openMembersModal: () => void }) => React.ReactNode;
|
||||
};
|
||||
|
||||
function GroupListItem({ group, showFacepile, renderActions }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [membersModalOpen, setMembersModalOpen, setMembersModalClosed] =
|
||||
useBoolean();
|
||||
const memberCount = group.memberCount;
|
||||
const users = group.users.slice(0, MAX_AVATAR_DISPLAY);
|
||||
const overflow = memberCount - users.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem
|
||||
image={
|
||||
<Image>
|
||||
<GroupIcon size={24} />
|
||||
</Image>
|
||||
}
|
||||
title={<Title onClick={setMembersModalOpen}>{group.name}</Title>}
|
||||
subtitle={t("{{ count }} member", { count: memberCount })}
|
||||
actions={
|
||||
<Flex align="center" gap={8}>
|
||||
{showFacepile && (
|
||||
<NudeButton
|
||||
width="auto"
|
||||
height="auto"
|
||||
onClick={setMembersModalOpen}
|
||||
>
|
||||
<Facepile users={users} overflow={overflow} />
|
||||
</NudeButton>
|
||||
)}
|
||||
{renderActions({
|
||||
openMembersModal: setMembersModalOpen,
|
||||
})}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
title={t("Group members")}
|
||||
onRequestClose={setMembersModalClosed}
|
||||
isOpen={membersModalOpen}
|
||||
>
|
||||
<GroupMembers group={group} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Image = styled(Flex)`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: ${s("backgroundSecondary")};
|
||||
border-radius: 32px;
|
||||
`;
|
||||
|
||||
const Title = styled.span`
|
||||
&: ${hover} {
|
||||
text-decoration: underline;
|
||||
cursor: var(--pointer);
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(GroupListItem);
|
||||
+47
-24
@@ -4,44 +4,57 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import Group from "~/models/Group";
|
||||
import GroupDelete from "~/scenes/GroupDelete";
|
||||
import GroupEdit from "~/scenes/GroupEdit";
|
||||
import {
|
||||
DeleteGroupDialog,
|
||||
EditGroupDialog,
|
||||
ViewGroupMembersDialog,
|
||||
} from "~/scenes/Settings/components/GroupDialogs";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import Modal from "~/components/Modal";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
onMembers: () => void;
|
||||
};
|
||||
|
||||
function GroupMenu({ group, onMembers }: Props) {
|
||||
function GroupMenu({ group }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
const [editModalOpen, setEditModalOpen] = React.useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = React.useState(false);
|
||||
const can = usePolicy(group);
|
||||
|
||||
const handleViewMembers = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Group members"),
|
||||
content: <ViewGroupMembersDialog group={group} />,
|
||||
fullscreen: true,
|
||||
});
|
||||
}, [t, group, dialogs]);
|
||||
|
||||
const handleEditGroup = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Edit group"),
|
||||
content: (
|
||||
<EditGroupDialog group={group} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [t, group, dialogs]);
|
||||
|
||||
const handleDeleteGroup = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Delete group"),
|
||||
content: (
|
||||
<DeleteGroupDialog group={group} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
}, [t, group, dialogs]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title={t("Edit group")}
|
||||
onRequestClose={() => setEditModalOpen(false)}
|
||||
isOpen={editModalOpen}
|
||||
>
|
||||
<GroupEdit group={group} onSubmit={() => setEditModalOpen(false)} />
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Delete group")}
|
||||
onRequestClose={() => setDeleteModalOpen(false)}
|
||||
isOpen={deleteModalOpen}
|
||||
>
|
||||
<GroupDelete group={group} onSubmit={() => setDeleteModalOpen(false)} />
|
||||
</Modal>
|
||||
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("Group options")}>
|
||||
<Template
|
||||
@@ -51,7 +64,7 @@ function GroupMenu({ group, onMembers }: Props) {
|
||||
type: "button",
|
||||
title: `${t("Members")}…`,
|
||||
icon: <GroupIcon />,
|
||||
onClick: onMembers,
|
||||
onClick: handleViewMembers,
|
||||
visible: !!(group && can.read),
|
||||
},
|
||||
{
|
||||
@@ -61,7 +74,7 @@ function GroupMenu({ group, onMembers }: Props) {
|
||||
type: "button",
|
||||
title: `${t("Edit")}…`,
|
||||
icon: <EditIcon />,
|
||||
onClick: () => setEditModalOpen(true),
|
||||
onClick: handleEditGroup,
|
||||
visible: !!(group && can.update),
|
||||
},
|
||||
{
|
||||
@@ -69,9 +82,19 @@ function GroupMenu({ group, onMembers }: Props) {
|
||||
title: `${t("Delete")}…`,
|
||||
icon: <TrashIcon />,
|
||||
dangerous: true,
|
||||
onClick: () => setDeleteModalOpen(true),
|
||||
onClick: handleDeleteGroup,
|
||||
visible: !!(group && can.delete),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
type: "link",
|
||||
href: "",
|
||||
title: group.externalId,
|
||||
disabled: true,
|
||||
visible: !!group.externalId,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ContextMenu>
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import Group from "~/models/Group";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
function GroupDelete({ group, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await group.delete();
|
||||
history.push(settingsPath("groups"));
|
||||
onSubmit();
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("I’m sure – Delete")}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
danger
|
||||
>
|
||||
<Trans
|
||||
defaults="Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with."
|
||||
values={{
|
||||
groupName: group.name,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(GroupDelete);
|
||||
@@ -1,73 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import Group from "~/models/Group";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
function GroupEdit({ group, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = React.useState(group.name);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
await group.save({
|
||||
name,
|
||||
});
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[group, onSubmit, name]
|
||||
);
|
||||
|
||||
const handleNameChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setName(ev.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans>
|
||||
You can edit the name of this group at any time, however doing so too
|
||||
often might confuse your team mates.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Flex>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Name")}
|
||||
onChange={handleNameChange}
|
||||
value={name}
|
||||
required
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Button type="submit" disabled={isSaving || !name}>
|
||||
{isSaving ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(GroupEdit);
|
||||
@@ -1,148 +0,0 @@
|
||||
import debounce from "lodash/debounce";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import Group from "~/models/Group";
|
||||
import User from "~/models/User";
|
||||
import Invite from "~/scenes/Invite";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import ButtonLink from "~/components/ButtonLink";
|
||||
import DelayedMount from "~/components/DelayedMount";
|
||||
import Empty from "~/components/Empty";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
import PlaceholderList from "~/components/List/Placeholder";
|
||||
import Modal from "~/components/Modal";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import Text from "~/components/Text";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import GroupMemberListItem from "./components/GroupMemberListItem";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
function AddPeopleToGroup(props: Props) {
|
||||
const { group } = props;
|
||||
|
||||
const { users, groupUsers } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(team);
|
||||
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [inviteModalOpen, handleInviteModalOpen, handleInviteModalClose] =
|
||||
useBoolean(false);
|
||||
|
||||
const { fetchPage: fetchUsers } = users;
|
||||
const debouncedFetch = React.useMemo(
|
||||
() => debounce((query) => fetchUsers({ query }), 250),
|
||||
[fetchUsers]
|
||||
);
|
||||
|
||||
const handleFilter = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedQuery = ev.target.value;
|
||||
setQuery(updatedQuery);
|
||||
void debouncedFetch(updatedQuery);
|
||||
},
|
||||
[debouncedFetch]
|
||||
);
|
||||
|
||||
const handleAddUser = async (user: User) => {
|
||||
try {
|
||||
await groupUsers.create({
|
||||
groupId: group.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
toast.success(
|
||||
t(`{{userName}} was added to the group`, {
|
||||
userName: user.name,
|
||||
}),
|
||||
{
|
||||
icon: <Avatar model={user} size={AvatarSize.Toast} />,
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(t("Could not add user"));
|
||||
}
|
||||
};
|
||||
|
||||
const { loading } = useRequest(
|
||||
React.useCallback(
|
||||
() => groupUsers.fetchAll({ id: group.id }),
|
||||
[groupUsers, group]
|
||||
),
|
||||
true
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<Text as="p" type="secondary">
|
||||
{t(
|
||||
"Add members below to give them access to the group. Need to add someone who’s not yet a member?"
|
||||
)}{" "}
|
||||
{can.inviteUser ? (
|
||||
<ButtonLink onClick={handleInviteModalOpen}>
|
||||
{t("Invite them to {{teamName}}", {
|
||||
teamName: team.name,
|
||||
})}
|
||||
</ButtonLink>
|
||||
) : (
|
||||
t("Ask an admin to invite them first")
|
||||
)}
|
||||
.
|
||||
</Text>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={`${t("Search by name")}…`}
|
||||
value={query}
|
||||
onChange={handleFilter}
|
||||
label={t("Search people")}
|
||||
labelHidden
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
{loading ? (
|
||||
<DelayedMount>
|
||||
<PlaceholderList count={5} />
|
||||
</DelayedMount>
|
||||
) : (
|
||||
<PaginatedList
|
||||
empty={
|
||||
query ? (
|
||||
<Empty>{t("No people matching your search")}</Empty>
|
||||
) : (
|
||||
<Empty>{t("No people left to add")}</Empty>
|
||||
)
|
||||
}
|
||||
items={users.notInGroup(group.id, query)}
|
||||
fetch={query ? undefined : users.fetchPage}
|
||||
renderItem={(item: User) => (
|
||||
<GroupMemberListItem
|
||||
key={item.id}
|
||||
user={item}
|
||||
onAdd={() => handleAddUser(item)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Modal
|
||||
title={t("Invite people")}
|
||||
onRequestClose={handleInviteModalClose}
|
||||
isOpen={inviteModalOpen}
|
||||
>
|
||||
<Invite onSubmit={handleInviteModalClose} />
|
||||
</Modal>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(AddPeopleToGroup);
|
||||
@@ -1,126 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import Group from "~/models/Group";
|
||||
import User from "~/models/User";
|
||||
import Button from "~/components/Button";
|
||||
import Empty from "~/components/Empty";
|
||||
import Flex from "~/components/Flex";
|
||||
import Modal from "~/components/Modal";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import Subheading from "~/components/Subheading";
|
||||
import Text from "~/components/Text";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import AddPeopleToGroup from "./AddPeopleToGroup";
|
||||
import GroupMemberListItem from "./components/GroupMemberListItem";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
};
|
||||
|
||||
function GroupMembers({ group }: Props) {
|
||||
const [addModalOpen, setAddModalOpen] = React.useState(false);
|
||||
const { users, groupUsers } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(group);
|
||||
|
||||
const handleAddModal = (state: boolean) => {
|
||||
setAddModalOpen(state);
|
||||
};
|
||||
|
||||
const handleRemoveUser = async (user: User) => {
|
||||
try {
|
||||
await groupUsers.delete({
|
||||
groupId: group.id,
|
||||
userId: user.id,
|
||||
});
|
||||
toast.success(
|
||||
t(`{{userName}} was removed from the group`, {
|
||||
userName: user.name,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(t("Could not remove user"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
{can.update ? (
|
||||
<>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Add and remove members to the <em>{{groupName}}</em> group. Members of the group will have access to any collections this group has been added to."
|
||||
values={{
|
||||
groupName: group.name,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
<span>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleAddModal(true)}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
{t("Add people")}…
|
||||
</Button>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Listing members of the <em>{{groupName}}</em> group."
|
||||
values={{
|
||||
groupName: group.name,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Subheading>
|
||||
<Trans>Members</Trans>
|
||||
</Subheading>
|
||||
<PaginatedList
|
||||
items={users.inGroup(group.id)}
|
||||
fetch={groupUsers.fetchPage}
|
||||
options={{
|
||||
id: group.id,
|
||||
}}
|
||||
empty={<Empty>{t("This group has no members.")}</Empty>}
|
||||
renderItem={(item: User) => (
|
||||
<GroupMemberListItem
|
||||
key={item.id}
|
||||
user={item}
|
||||
onRemove={can.update ? () => handleRemoveUser(item) : undefined}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{can.update && (
|
||||
<Modal
|
||||
title={t(`Add people to {{groupName}}`, {
|
||||
groupName: group.name,
|
||||
})}
|
||||
onRequestClose={() => handleAddModal(false)}
|
||||
isOpen={addModalOpen}
|
||||
>
|
||||
<AddPeopleToGroup
|
||||
group={group}
|
||||
onSubmit={() => handleAddModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(GroupMembers);
|
||||
@@ -1,53 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import User from "~/models/User";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Badge from "~/components/Badge";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Time from "~/components/Time";
|
||||
import GroupMemberMenu from "~/menus/GroupMemberMenu";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
onAdd?: () => Promise<void>;
|
||||
onRemove?: () => Promise<void>;
|
||||
};
|
||||
|
||||
const GroupMemberListItem = ({ user, onRemove, onAdd }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
title={user.name}
|
||||
subtitle={
|
||||
<>
|
||||
{user.lastActiveAt ? (
|
||||
<Trans>
|
||||
Active <Time dateTime={user.lastActiveAt} /> ago
|
||||
</Trans>
|
||||
) : (
|
||||
t("Never signed in")
|
||||
)}
|
||||
{user.isInvited && <Badge>{t("Invited")}</Badge>}
|
||||
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
|
||||
</>
|
||||
}
|
||||
image={<Avatar model={user} size={32} />}
|
||||
actions={
|
||||
<Flex align="center">
|
||||
{onRemove && <GroupMemberMenu onRemove={onRemove} />}
|
||||
{onAdd && (
|
||||
<Button onClick={onAdd} neutral>
|
||||
{t("Add")}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(GroupMemberListItem);
|
||||
@@ -1,3 +0,0 @@
|
||||
import GroupMembers from "./GroupMembers";
|
||||
|
||||
export default GroupMembers;
|
||||
@@ -1,90 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import Group from "~/models/Group";
|
||||
import GroupMembers from "~/scenes/GroupMembers";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
import Modal from "~/components/Modal";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
function GroupNew({ onSubmit }: Props) {
|
||||
const { groups } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = React.useState<string | undefined>();
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const [group, setGroup] = React.useState<Group | undefined>();
|
||||
|
||||
const handleSubmit = async (ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
setIsSaving(true);
|
||||
|
||||
const group = new Group(
|
||||
{
|
||||
name,
|
||||
},
|
||||
groups
|
||||
);
|
||||
|
||||
try {
|
||||
await group.save();
|
||||
setGroup(group);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setName(ev.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans>
|
||||
Groups are for organizing your team. They work best when centered
|
||||
around a function or a responsibility — Support or Engineering for
|
||||
example.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Flex>
|
||||
<Input
|
||||
type="text"
|
||||
label="Name"
|
||||
onChange={handleNameChange}
|
||||
value={name}
|
||||
required
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
</Flex>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans>You’ll be able to add people to the group next.</Trans>
|
||||
</Text>
|
||||
|
||||
<Button type="submit" disabled={isSaving || !name}>
|
||||
{isSaving ? `${t("Creating")}…` : t("Continue")}
|
||||
</Button>
|
||||
</form>
|
||||
<Modal
|
||||
title={t("Group members")}
|
||||
onRequestClose={onSubmit}
|
||||
isOpen={!!group}
|
||||
>
|
||||
{group && <GroupMembers group={group} />}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(GroupNew);
|
||||
+116
-37
@@ -1,31 +1,115 @@
|
||||
import { ColumnSort } from "@tanstack/react-table";
|
||||
import deburr from "lodash/deburr";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon, GroupIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import Group from "~/models/Group";
|
||||
import GroupNew from "~/scenes/GroupNew";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Button from "~/components/Button";
|
||||
import Empty from "~/components/Empty";
|
||||
import GroupListItem from "~/components/GroupListItem";
|
||||
import Heading from "~/components/Heading";
|
||||
import Modal from "~/components/Modal";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import GroupMenu from "~/menus/GroupMenu";
|
||||
import { useTableRequest } from "~/hooks/useTableRequest";
|
||||
import { CreateGroupDialog } from "./components/GroupDialogs";
|
||||
import { GroupsTable } from "./components/GroupsTable";
|
||||
import { StickyFilters } from "./components/StickyFilters";
|
||||
|
||||
function getFilteredGroups(groups: Group[], query?: string) {
|
||||
if (!query?.length) {
|
||||
return groups;
|
||||
}
|
||||
|
||||
const normalizedQuery = deburr(query.toLocaleLowerCase());
|
||||
return groups.filter((group) =>
|
||||
deburr(group.name).toLocaleLowerCase().includes(normalizedQuery)
|
||||
);
|
||||
}
|
||||
|
||||
function Groups() {
|
||||
const { t } = useTranslation();
|
||||
const { groups } = useStores();
|
||||
const { dialogs, groups } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(team);
|
||||
const [newGroupModalOpen, handleNewGroupModalOpen, handleNewGroupModalClose] =
|
||||
useBoolean();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const params = useQuery();
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
const reqParams = React.useMemo(
|
||||
() => ({
|
||||
query: params.get("query") || undefined,
|
||||
sort: params.get("sort") || "name",
|
||||
direction: (params.get("direction") || "asc").toUpperCase() as
|
||||
| "ASC"
|
||||
| "DESC",
|
||||
}),
|
||||
[params]
|
||||
);
|
||||
|
||||
const sort: ColumnSort = React.useMemo(
|
||||
() => ({
|
||||
id: reqParams.sort,
|
||||
desc: reqParams.direction === "DESC",
|
||||
}),
|
||||
[reqParams.sort, reqParams.direction]
|
||||
);
|
||||
|
||||
const { data, error, loading, next } = useTableRequest({
|
||||
data: getFilteredGroups(groups.orderedData, reqParams.query),
|
||||
sort,
|
||||
reqFn: groups.fetchPage,
|
||||
reqParams,
|
||||
});
|
||||
|
||||
const isEmpty = !loading && !groups.orderedData.length;
|
||||
|
||||
const updateQuery = React.useCallback(
|
||||
(value: string) => {
|
||||
if (value) {
|
||||
params.set("query", value);
|
||||
} else {
|
||||
params.delete("query");
|
||||
}
|
||||
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
});
|
||||
},
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
const handleSearch = React.useCallback((event) => {
|
||||
const { value } = event.target;
|
||||
setQuery(value);
|
||||
}, []);
|
||||
|
||||
const handleNewGroup = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Create a group"),
|
||||
content: <CreateGroupDialog />,
|
||||
});
|
||||
}, [t, dialogs]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (error) {
|
||||
toast.error(t("Could not load groups"));
|
||||
}
|
||||
}, [t, error]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timeout = setTimeout(() => updateQuery(query), 250);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [query, updateQuery]);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
@@ -37,7 +121,7 @@ function Groups() {
|
||||
<Action>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleNewGroupModalOpen}
|
||||
onClick={handleNewGroup}
|
||||
icon={<PlusIcon />}
|
||||
>
|
||||
{`${t("New group")}…`}
|
||||
@@ -46,6 +130,7 @@ function Groups() {
|
||||
)}
|
||||
</>
|
||||
}
|
||||
wide
|
||||
>
|
||||
<Heading>{t("Groups")}</Heading>
|
||||
<Text as="p" type="secondary">
|
||||
@@ -53,34 +138,28 @@ function Groups() {
|
||||
Groups can be used to organize and manage the people on your team.
|
||||
</Trans>
|
||||
</Text>
|
||||
<PaginatedList
|
||||
items={groups.orderedData}
|
||||
empty={<Empty>{t("No groups have been created yet")}</Empty>}
|
||||
fetch={groups.fetchPage}
|
||||
heading={
|
||||
<h2>
|
||||
<Trans>All</Trans>
|
||||
</h2>
|
||||
}
|
||||
renderItem={(item: Group) => (
|
||||
<GroupListItem
|
||||
key={item.id}
|
||||
group={item}
|
||||
renderActions={({ openMembersModal }) => (
|
||||
<GroupMenu group={item} onMembers={openMembersModal} />
|
||||
)}
|
||||
showFacepile
|
||||
{isEmpty ? (
|
||||
<Empty>{t("No groups have been created yet")}</Empty>
|
||||
) : (
|
||||
<>
|
||||
<StickyFilters>
|
||||
<InputSearch
|
||||
value={query}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</StickyFilters>
|
||||
<GroupsTable
|
||||
data={data ?? []}
|
||||
sort={sort}
|
||||
loading={loading}
|
||||
page={{
|
||||
hasNext: !!next,
|
||||
fetchNext: next,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={t("Create a group")}
|
||||
onRequestClose={handleNewGroupModalClose}
|
||||
isOpen={newGroupModalOpen}
|
||||
>
|
||||
<GroupNew onSubmit={handleNewGroupModalClose} />
|
||||
</Modal>
|
||||
</>
|
||||
)}
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,13 +6,10 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import UsersStore, { queriedUsers } from "~/stores/UsersStore";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Button from "~/components/Button";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import { HEADER_HEIGHT } from "~/components/Header";
|
||||
import Heading from "~/components/Heading";
|
||||
import InputSearch from "~/components/InputSearch";
|
||||
import Scene from "~/components/Scene";
|
||||
@@ -26,6 +23,7 @@ import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useTableRequest } from "~/hooks/useTableRequest";
|
||||
import { PeopleTable } from "./components/PeopleTable";
|
||||
import { StickyFilters } from "./components/StickyFilters";
|
||||
import UserRoleFilter from "./components/UserRoleFilter";
|
||||
import UserStatusFilter from "./components/UserStatusFilter";
|
||||
|
||||
@@ -219,14 +217,6 @@ function getFilteredUsers({
|
||||
return filteredUsers;
|
||||
}
|
||||
|
||||
const StickyFilters = styled(Flex)`
|
||||
height: 40px;
|
||||
position: sticky;
|
||||
top: ${HEADER_HEIGHT}px;
|
||||
z-index: ${depths.header};
|
||||
background: ${s("background")};
|
||||
`;
|
||||
|
||||
const LargeUserStatusFilter = styled(UserStatusFilter)`
|
||||
height: 32px;
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
import debounce from "lodash/debounce";
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import Group from "~/models/Group";
|
||||
import User from "~/models/User";
|
||||
import Invite from "~/scenes/Invite";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Badge from "~/components/Badge";
|
||||
import Button from "~/components/Button";
|
||||
import ButtonLink from "~/components/ButtonLink";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DelayedMount from "~/components/DelayedMount";
|
||||
import Empty from "~/components/Empty";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
import PlaceholderList from "~/components/List/Placeholder";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import { ListItem } from "~/components/Sharing/components/ListItem";
|
||||
import Subheading from "~/components/Subheading";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import GroupMemberMenu from "~/menus/GroupMemberMenu";
|
||||
|
||||
type Props = {
|
||||
group: Group;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export function CreateGroupDialog() {
|
||||
const { dialogs, groups } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = React.useState<string | undefined>();
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
setIsSaving(true);
|
||||
|
||||
const group = new Group(
|
||||
{
|
||||
name,
|
||||
},
|
||||
groups
|
||||
);
|
||||
|
||||
try {
|
||||
await group.save();
|
||||
dialogs.openModal({
|
||||
title: t("Group members"),
|
||||
content: <ViewGroupMembersDialog group={group} />,
|
||||
fullscreen: true,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[t, dialogs, groups, name]
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans>
|
||||
Groups are for organizing your team. They work best when centered
|
||||
around a function or a responsibility — Support or Engineering for
|
||||
example.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Flex>
|
||||
<Input
|
||||
type="text"
|
||||
label="Name"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
value={name}
|
||||
required
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
</Flex>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans>You’ll be able to add people to the group next.</Trans>
|
||||
</Text>
|
||||
|
||||
<Button type="submit" disabled={isSaving || !name}>
|
||||
{isSaving ? `${t("Creating")}…` : t("Continue")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditGroupDialog({ group, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = React.useState(group.name);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
await group.save({
|
||||
name,
|
||||
});
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[group, onSubmit, name]
|
||||
);
|
||||
|
||||
const handleNameChange = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setName(ev.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans>
|
||||
You can edit the name of this group at any time, however doing so too
|
||||
often might confuse your team mates.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Flex>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Name")}
|
||||
onChange={handleNameChange}
|
||||
value={name}
|
||||
required
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Button type="submit" disabled={isSaving || !name}>
|
||||
{isSaving ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeleteGroupDialog({ group, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await group.delete();
|
||||
onSubmit();
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("I’m sure – Delete")}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
danger
|
||||
>
|
||||
<Trans
|
||||
defaults="Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with."
|
||||
values={{
|
||||
groupName: group.name,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export const ViewGroupMembersDialog = observer(function ({
|
||||
group,
|
||||
}: Pick<Props, "group">) {
|
||||
const { dialogs, users, groupUsers } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(group);
|
||||
|
||||
const handleAddPeople = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t(`Add people to {{groupName}}`, {
|
||||
groupName: group.name,
|
||||
}),
|
||||
content: <AddPeopleToGroupDialog group={group} />,
|
||||
fullscreen: true,
|
||||
});
|
||||
}, [t, group, dialogs]);
|
||||
|
||||
const handleRemoveUser = React.useCallback(
|
||||
async (user: User) => {
|
||||
try {
|
||||
await groupUsers.delete({
|
||||
groupId: group.id,
|
||||
userId: user.id,
|
||||
});
|
||||
toast.success(
|
||||
t(`{{userName}} was removed from the group`, {
|
||||
userName: user.name,
|
||||
}),
|
||||
{
|
||||
icon: <Avatar model={user} size={AvatarSize.Toast} />,
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(t("Could not remove user"));
|
||||
}
|
||||
},
|
||||
[t, groupUsers, group.id]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
{can.update ? (
|
||||
<>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Add and remove members to the <em>{{groupName}}</em> group. Members of the group will have access to any collections this group has been added to."
|
||||
values={{
|
||||
groupName: group.name,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
{can.update && (
|
||||
<span>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddPeople}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
{t("Add people")}…
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Listing members of the <em>{{groupName}}</em> group."
|
||||
values={{
|
||||
groupName: group.name,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Subheading>
|
||||
<Trans>Members</Trans>
|
||||
</Subheading>
|
||||
<PaginatedList
|
||||
items={users.inGroup(group.id)}
|
||||
fetch={groupUsers.fetchPage}
|
||||
options={{
|
||||
id: group.id,
|
||||
}}
|
||||
empty={<Empty>{t("This group has no members.")}</Empty>}
|
||||
renderItem={(user: User) => (
|
||||
<GroupMemberListItem
|
||||
key={user.id}
|
||||
user={user}
|
||||
onRemove={can.update ? () => handleRemoveUser(user) : undefined}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
const AddPeopleToGroupDialog = observer(function ({
|
||||
group,
|
||||
}: Pick<Props, "group">) {
|
||||
const { dialogs, users, groupUsers } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(team);
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
const debouncedFetch = React.useMemo(
|
||||
() => debounce((q) => users.fetchPage({ query: q }), 250),
|
||||
[users]
|
||||
);
|
||||
|
||||
const handleFilter = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedQuery = ev.target.value;
|
||||
setQuery(updatedQuery);
|
||||
void debouncedFetch(updatedQuery);
|
||||
},
|
||||
[debouncedFetch]
|
||||
);
|
||||
|
||||
const handleAddUser = React.useCallback(
|
||||
async (user: User) => {
|
||||
try {
|
||||
await groupUsers.create({
|
||||
groupId: group.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
toast.success(
|
||||
t(`{{userName}} was added to the group`, {
|
||||
userName: user.name,
|
||||
}),
|
||||
{
|
||||
icon: <Avatar model={user} size={AvatarSize.Toast} />,
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(t("Could not add user"));
|
||||
}
|
||||
},
|
||||
[t, groupUsers, group.id]
|
||||
);
|
||||
|
||||
const handleInvitePeople = React.useCallback(() => {
|
||||
const id = uuidv4();
|
||||
dialogs.openModal({
|
||||
id,
|
||||
title: t("Invite people"),
|
||||
content: <Invite onSubmit={() => dialogs.closeModal(id)} />,
|
||||
});
|
||||
}, [t, dialogs]);
|
||||
|
||||
const { loading } = useRequest(
|
||||
React.useCallback(
|
||||
() => groupUsers.fetchAll({ id: group.id }),
|
||||
[groupUsers, group]
|
||||
),
|
||||
true
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<Text as="p" type="secondary">
|
||||
{t(
|
||||
"Add members below to give them access to the group. Need to add someone who’s not yet a member?"
|
||||
)}{" "}
|
||||
{can.inviteUser ? (
|
||||
<ButtonLink onClick={handleInvitePeople}>
|
||||
{t("Invite them to {{teamName}}", {
|
||||
teamName: team.name,
|
||||
})}
|
||||
</ButtonLink>
|
||||
) : (
|
||||
t("Ask an admin to invite them first")
|
||||
)}
|
||||
.
|
||||
</Text>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={`${t("Search by name")}…`}
|
||||
value={query}
|
||||
onChange={handleFilter}
|
||||
label={t("Search people")}
|
||||
labelHidden
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
{loading ? (
|
||||
<DelayedMount>
|
||||
<PlaceholderList count={5} />
|
||||
</DelayedMount>
|
||||
) : (
|
||||
<PaginatedList
|
||||
empty={
|
||||
query ? (
|
||||
<Empty>{t("No people matching your search")}</Empty>
|
||||
) : (
|
||||
<Empty>{t("No people left to add")}</Empty>
|
||||
)
|
||||
}
|
||||
items={users.notInGroup(group.id, query)}
|
||||
fetch={query ? undefined : users.fetchPage}
|
||||
renderItem={(item: User) => (
|
||||
<GroupMemberListItem
|
||||
key={item.id}
|
||||
user={item}
|
||||
onAdd={() => handleAddUser(item)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
type GroupMemberListItemProps = {
|
||||
user: User;
|
||||
onAdd?: () => Promise<void>;
|
||||
onRemove?: () => Promise<void>;
|
||||
};
|
||||
|
||||
const GroupMemberListItem = observer(function ({
|
||||
user,
|
||||
onRemove,
|
||||
onAdd,
|
||||
}: GroupMemberListItemProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
title={user.name}
|
||||
subtitle={
|
||||
<>
|
||||
{user.lastActiveAt ? (
|
||||
<Trans>
|
||||
Active <Time dateTime={user.lastActiveAt} /> ago
|
||||
</Trans>
|
||||
) : (
|
||||
t("Never signed in")
|
||||
)}
|
||||
{user.isInvited && <Badge>{t("Invited")}</Badge>}
|
||||
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
|
||||
</>
|
||||
}
|
||||
image={<Avatar model={user} size={32} />}
|
||||
actions={
|
||||
<Flex align="center">
|
||||
{onRemove && <GroupMemberMenu onRemove={onRemove} />}
|
||||
{onAdd && (
|
||||
<Button onClick={onAdd} neutral>
|
||||
{t("Add")}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import compact from "lodash/compact";
|
||||
import { GroupIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
|
||||
import { s } from "@shared/styles";
|
||||
import Group from "~/models/Group";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Flex from "~/components/Flex";
|
||||
import { HEADER_HEIGHT } from "~/components/Header";
|
||||
import {
|
||||
type Props as TableProps,
|
||||
SortableTable,
|
||||
} from "~/components/SortableTable";
|
||||
import { type Column as TableColumn } from "~/components/Table";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import GroupMenu from "~/menus/GroupMenu";
|
||||
import { hover } from "~/styles";
|
||||
import { ViewGroupMembersDialog } from "./GroupDialogs";
|
||||
import { FILTER_HEIGHT } from "./StickyFilters";
|
||||
|
||||
const ROW_HEIGHT = 60;
|
||||
const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
|
||||
|
||||
type Props = Omit<TableProps<Group>, "columns" | "rowHeight">;
|
||||
|
||||
export function GroupsTable(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
|
||||
const handleViewMembers = React.useCallback(
|
||||
(group: Group) => {
|
||||
dialogs.openModal({
|
||||
title: t("Group members"),
|
||||
content: <ViewGroupMembersDialog group={group} />,
|
||||
fullscreen: true,
|
||||
});
|
||||
},
|
||||
[t, dialogs]
|
||||
);
|
||||
|
||||
const columns = React.useMemo<TableColumn<Group>[]>(
|
||||
() =>
|
||||
compact<TableColumn<Group>>([
|
||||
{
|
||||
type: "data",
|
||||
id: "name",
|
||||
header: t("Name"),
|
||||
accessor: (group) => group.name,
|
||||
component: (group) => (
|
||||
<Flex align="center" gap={8}>
|
||||
<Image>
|
||||
<GroupIcon size={24} />
|
||||
</Image>
|
||||
<Flex column>
|
||||
<Title onClick={() => handleViewMembers(group)}>
|
||||
{group.name}
|
||||
</Title>
|
||||
<Text type="tertiary" size="small">
|
||||
<Trans
|
||||
defaults="{{ count }} member"
|
||||
values={{ count: group.memberCount }}
|
||||
/>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
),
|
||||
width: "2fr",
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "members",
|
||||
header: t("Members"),
|
||||
accessor: (group) => `${group.memberCount} members`,
|
||||
component: (group) => {
|
||||
const users = group.users.slice(0, MAX_AVATAR_DISPLAY);
|
||||
const overflow = group.memberCount - users.length;
|
||||
|
||||
return (
|
||||
<Flex>
|
||||
<Facepile users={users} overflow={overflow} />
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
width: "1fr",
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
type: "data",
|
||||
id: "createdAt",
|
||||
header: t("Date created"),
|
||||
accessor: (group) => group.createdAt,
|
||||
component: (group) =>
|
||||
group.createdAt ? (
|
||||
<Time dateTime={group.createdAt} addSuffix />
|
||||
) : null,
|
||||
width: "1fr",
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
id: "action",
|
||||
component: (group) => <GroupMenu group={group} />,
|
||||
width: "50px",
|
||||
},
|
||||
]),
|
||||
[t, handleViewMembers]
|
||||
);
|
||||
|
||||
return (
|
||||
<SortableTable
|
||||
columns={columns}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
stickyOffset={STICKY_OFFSET}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Image = styled(Flex)`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: ${s("backgroundSecondary")};
|
||||
border-radius: 32px;
|
||||
`;
|
||||
|
||||
const Title = styled.span`
|
||||
&: ${hover} {
|
||||
text-decoration: underline;
|
||||
cursor: var(--pointer);
|
||||
}
|
||||
`;
|
||||
@@ -15,9 +15,10 @@ import { type Column as TableColumn } from "~/components/Table";
|
||||
import Time from "~/components/Time";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import UserMenu from "~/menus/UserMenu";
|
||||
import { FILTER_HEIGHT } from "./StickyFilters";
|
||||
|
||||
const ROW_HEIGHT = 60;
|
||||
const STICKY_OFFSET = HEADER_HEIGHT + 40; // filter height
|
||||
const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
|
||||
|
||||
type Props = Omit<TableProps<User>, "columns" | "rowHeight"> & {
|
||||
canManage: boolean;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import { HEADER_HEIGHT } from "~/components/Header";
|
||||
|
||||
export const FILTER_HEIGHT = 40;
|
||||
|
||||
export const StickyFilters = styled(Flex)`
|
||||
height: ${FILTER_HEIGHT}px;
|
||||
position: sticky;
|
||||
top: ${HEADER_HEIGHT}px;
|
||||
z-index: ${depths.header};
|
||||
background: ${s("background")};
|
||||
`;
|
||||
@@ -44,12 +44,14 @@ export default class DialogsStore {
|
||||
};
|
||||
|
||||
openModal = ({
|
||||
id,
|
||||
title,
|
||||
content,
|
||||
fullscreen,
|
||||
replace,
|
||||
style,
|
||||
}: {
|
||||
id?: string;
|
||||
title: string;
|
||||
fullscreen?: boolean;
|
||||
content: React.ReactNode;
|
||||
@@ -58,13 +60,11 @@ export default class DialogsStore {
|
||||
}) => {
|
||||
setTimeout(
|
||||
action(() => {
|
||||
const id = uuidv4();
|
||||
|
||||
if (replace) {
|
||||
this.modalStack.clear();
|
||||
}
|
||||
|
||||
this.modalStack.set(id, {
|
||||
this.modalStack.set(id ?? uuidv4(), {
|
||||
title,
|
||||
content,
|
||||
fullscreen,
|
||||
|
||||
@@ -264,9 +264,6 @@
|
||||
"Including uploaded images and files in the exported data": "Including uploaded images and files in the exported data",
|
||||
"Filter": "Filter",
|
||||
"No results": "No results",
|
||||
"{{ count }} member": "{{ count }} member",
|
||||
"{{ count }} member_plural": "{{ count }} members",
|
||||
"Group members": "Group members",
|
||||
"{{authorName}} created <3></3>": "{{authorName}} created <3></3>",
|
||||
"{{authorName}} opened <3></3>": "{{authorName}} opened <3></3>",
|
||||
"Search emoji": "Search emoji",
|
||||
@@ -319,6 +316,8 @@
|
||||
"Manage": "Manage",
|
||||
"All members": "All members",
|
||||
"Everyone in the workspace": "Everyone in the workspace",
|
||||
"{{ count }} member": "{{ count }} member",
|
||||
"{{ count }} member_plural": "{{ count }} members",
|
||||
"Invite": "Invite",
|
||||
"{{ userName }} was added to the collection": "{{ userName }} was added to the collection",
|
||||
"{{ count }} people added to the collection": "{{ count }} people added to the collection",
|
||||
@@ -529,6 +528,7 @@
|
||||
"Choose a collection": "Choose a collection",
|
||||
"Enable embeds": "Enable embeds",
|
||||
"Export options": "Export options",
|
||||
"Group members": "Group members",
|
||||
"Edit group": "Edit group",
|
||||
"Delete group": "Delete group",
|
||||
"Group options": "Group options",
|
||||
@@ -697,27 +697,6 @@
|
||||
"Your account has been suspended": "Your account has been suspended",
|
||||
"Warning Sign": "Warning Sign",
|
||||
"A workspace admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.": "A workspace admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.",
|
||||
"Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.": "Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.",
|
||||
"You can edit the name of this group at any time, however doing so too often might confuse your team mates.": "You can edit the name of this group at any time, however doing so too often might confuse your team mates.",
|
||||
"{{userName}} was added to the group": "{{userName}} was added to the group",
|
||||
"Could not add user": "Could not add user",
|
||||
"Add members below to give them access to the group. Need to add someone who’s not yet a member?": "Add members below to give them access to the group. Need to add someone who’s not yet a member?",
|
||||
"Invite them to {{teamName}}": "Invite them to {{teamName}}",
|
||||
"Ask an admin to invite them first": "Ask an admin to invite them first",
|
||||
"Search by name": "Search by name",
|
||||
"Search people": "Search people",
|
||||
"No people matching your search": "No people matching your search",
|
||||
"No people left to add": "No people left to add",
|
||||
"Admin": "Admin",
|
||||
"{{userName}} was removed from the group": "{{userName}} was removed from the group",
|
||||
"Add and remove members to the <em>{{groupName}}</em> group. Members of the group will have access to any collections this group has been added to.": "Add and remove members to the <em>{{groupName}}</em> group. Members of the group will have access to any collections this group has been added to.",
|
||||
"Add people": "Add people",
|
||||
"Listing members of the <em>{{groupName}}</em> group.": "Listing members of the <em>{{groupName}}</em> group.",
|
||||
"This group has no members.": "This group has no members.",
|
||||
"Add people to {{groupName}}": "Add people to {{groupName}}",
|
||||
"Groups are for organizing your team. They work best when centered around a function or a responsibility — Support or Engineering for example.": "Groups are for organizing your team. They work best when centered around a function or a responsibility — Support or Engineering for example.",
|
||||
"You’ll be able to add people to the group next.": "You’ll be able to add people to the group next.",
|
||||
"Continue": "Continue",
|
||||
"Created by me": "Created by me",
|
||||
"Weird, this shouldn’t ever be empty": "Weird, this shouldn’t ever be empty",
|
||||
"You haven’t created any documents yet": "You haven’t created any documents yet",
|
||||
@@ -727,6 +706,7 @@
|
||||
"Sorry, you can only send {{MAX_INVITES}} invites at a time": "Sorry, you can only send {{MAX_INVITES}} invites at a time",
|
||||
"Invited {{roleName}} will receive access to": "Invited {{roleName}} will receive access to",
|
||||
"{{collectionCount}} collections": "{{collectionCount}} collections",
|
||||
"Admin": "Admin",
|
||||
"Can manage all workspace settings": "Can manage all workspace settings",
|
||||
"Can create, edit, and delete documents": "Can create, edit, and delete documents",
|
||||
"Can view and comment": "Can view and comment",
|
||||
@@ -786,6 +766,7 @@
|
||||
"The workspace could not be found": "The workspace could not be found",
|
||||
"To continue, enter your workspace’s subdomain.": "To continue, enter your workspace’s subdomain.",
|
||||
"subdomain": "subdomain",
|
||||
"Continue": "Continue",
|
||||
"The domain associated with your email address has not been allowed for this workspace.": "The domain associated with your email address has not been allowed for this workspace.",
|
||||
"Unable to sign-in. Please navigate to your workspace's custom URL, then try to sign-in again.<1></1>If you were invited to a workspace, you will find a link to it in the invite email.": "Unable to sign-in. Please navigate to your workspace's custom URL, then try to sign-in again.<1></1>If you were invited to a workspace, you will find a link to it in the invite email.",
|
||||
"Sorry, a new account cannot be created with a personal Gmail address.<1></1>Please use a Google Workspaces account instead.": "Sorry, a new account cannot be created with a personal Gmail address.<1></1>Please use a Google Workspaces account instead.",
|
||||
@@ -869,6 +850,26 @@
|
||||
"Deleting this import will also delete all collections and documents that were created from it. This cannot be undone.": "Deleting this import will also delete all collections and documents that were created from it. This cannot be undone.",
|
||||
"Check server logs for more details.": "Check server logs for more details.",
|
||||
"{{userName}} requested": "{{userName}} requested",
|
||||
"Groups are for organizing your team. They work best when centered around a function or a responsibility — Support or Engineering for example.": "Groups are for organizing your team. They work best when centered around a function or a responsibility — Support or Engineering for example.",
|
||||
"You’ll be able to add people to the group next.": "You’ll be able to add people to the group next.",
|
||||
"You can edit the name of this group at any time, however doing so too often might confuse your team mates.": "You can edit the name of this group at any time, however doing so too often might confuse your team mates.",
|
||||
"Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.": "Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.",
|
||||
"Add people to {{groupName}}": "Add people to {{groupName}}",
|
||||
"{{userName}} was removed from the group": "{{userName}} was removed from the group",
|
||||
"Add and remove members to the <em>{{groupName}}</em> group. Members of the group will have access to any collections this group has been added to.": "Add and remove members to the <em>{{groupName}}</em> group. Members of the group will have access to any collections this group has been added to.",
|
||||
"Add people": "Add people",
|
||||
"Listing members of the <em>{{groupName}}</em> group.": "Listing members of the <em>{{groupName}}</em> group.",
|
||||
"This group has no members.": "This group has no members.",
|
||||
"{{userName}} was added to the group": "{{userName}} was added to the group",
|
||||
"Could not add user": "Could not add user",
|
||||
"Add members below to give them access to the group. Need to add someone who’s not yet a member?": "Add members below to give them access to the group. Need to add someone who’s not yet a member?",
|
||||
"Invite them to {{teamName}}": "Invite them to {{teamName}}",
|
||||
"Ask an admin to invite them first": "Ask an admin to invite them first",
|
||||
"Search by name": "Search by name",
|
||||
"Search people": "Search people",
|
||||
"No people matching your search": "No people matching your search",
|
||||
"No people left to add": "No people left to add",
|
||||
"Date created": "Date created",
|
||||
"Upload": "Upload",
|
||||
"How does this work?": "How does this work?",
|
||||
"You can import a zip file that was previously exported from the JSON option in another instance. In {{ appName }}, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.": "You can import a zip file that was previously exported from the JSON option in another instance. In {{ appName }}, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.",
|
||||
@@ -925,10 +926,11 @@
|
||||
"When enabled documents have a separate editing mode by default instead of being always editable. This setting can be overridden by user preferences.": "When enabled documents have a separate editing mode by default instead of being always editable. This setting can be overridden by user preferences.",
|
||||
"Commenting": "Commenting",
|
||||
"When enabled team members can add comments to documents.": "When enabled team members can add comments to documents.",
|
||||
"Create a group": "Create a group",
|
||||
"Could not load groups": "Could not load groups",
|
||||
"New group": "New group",
|
||||
"Groups can be used to organize and manage the people on your team.": "Groups can be used to organize and manage the people on your team.",
|
||||
"No groups have been created yet": "No groups have been created yet",
|
||||
"Create a group": "Create a group",
|
||||
"Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.": "Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.",
|
||||
"Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)": "Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)",
|
||||
"Import data": "Import data",
|
||||
|
||||
Reference in New Issue
Block a user