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:
Hemachandar
2025-01-10 06:36:09 +05:30
committed by GitHub
parent 81d7492e5e
commit e42b533b07
17 changed files with 796 additions and 727 deletions
-89
View File
@@ -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
View File
@@ -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>
-44
View File
@@ -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("Im 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);
-73
View File
@@ -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 whos 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);
-126
View File
@@ -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);
-3
View File
@@ -1,3 +0,0 @@
import GroupMembers from "./GroupMembers";
export default GroupMembers;
-90
View File
@@ -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>Youll 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
View File
@@ -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>
);
}
+1 -11
View File
@@ -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>Youll 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("Im 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 whos 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")};
`;
+3 -3
View File
@@ -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,
+27 -25
View File
@@ -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 whos not yet a member?": "Add members below to give them access to the group. Need to add someone whos 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.",
"Youll be able to add people to the group next.": "Youll be able to add people to the group next.",
"Continue": "Continue",
"Created by me": "Created by me",
"Weird, this shouldnt ever be empty": "Weird, this shouldnt ever be empty",
"You havent created any documents yet": "You havent 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 workspaces subdomain.": "To continue, enter your workspaces 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.",
"Youll be able to add people to the group next.": "Youll 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 whos not yet a member?": "Add members below to give them access to the group. Need to add someone whos 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",