Add contextual menu to group table (#13220)

* Add contextual menu to group table

* feedback
This commit is contained in:
Tom Moor
2026-08-01 12:15:23 +00:00
committed by GitHub
parent e6908c1511
commit 3b6a3f45e3
15 changed files with 509 additions and 300 deletions
+13 -11
View File
@@ -1,7 +1,7 @@
import type { TFunction } from "i18next";
import { InputIcon } from "outline-icons";
import stores from "~/stores";
import type { Action } from "~/types";
import type { Action, ActionContext } from "~/types";
import { createAction } from "..";
/**
@@ -10,8 +10,9 @@ import { createAction } from "..";
*
* @param analyticsName - untranslated name for analytics.
* @param section - the section the action belongs to.
* @param title - the dialog title.
* @param content - renders the dialog, given a handler to close it.
* @param title - the dialog title, given the action context.
* @param content - renders the dialog, given a handler to close it and the
* action context.
* @param name - the menu item label, defaults to the title with an ellipsis.
* @param icon - optional icon for the menu item.
* @param keywords - optional additional search terms for the command bar.
@@ -36,8 +37,8 @@ export const dialogActionFactory = ({
}: {
analyticsName: string;
section: Action["section"];
title: (t: TFunction) => string;
content: (onSubmit: () => void) => React.ReactNode;
title: (t: TFunction, context: ActionContext) => string;
content: (onSubmit: () => void, context: ActionContext) => React.ReactNode;
name?: (t: TFunction) => string;
icon?: React.ReactNode;
keywords?: string;
@@ -47,22 +48,23 @@ export const dialogActionFactory = ({
stopEvent?: boolean;
}) =>
createAction({
name: ({ t }) => (name ? name(t) : `${title(t)}`),
name: (context) =>
name ? name(context.t) : `${title(context.t, context)}`,
analyticsName,
section,
icon,
keywords,
visible,
dangerous,
perform: ({ t, event }) => {
perform: (context) => {
if (stopEvent) {
event?.preventDefault();
event?.stopPropagation();
context.event?.preventDefault();
context.event?.stopPropagation();
}
stores.dialogs.openModal({
title: title(t),
content: content(stores.dialogs.closeAllModals),
title: title(context.t, context),
content: content(stores.dialogs.closeAllModals, context),
width,
});
},
+131
View File
@@ -0,0 +1,131 @@
import { PlusIcon } from "outline-icons";
import { toast } from "sonner";
import type { GroupPermission } from "@shared/types";
import { errToString } from "@shared/utils/error";
import { GroupPermissionHelper } from "@shared/utils/GroupPermissionHelper";
import stores from "~/stores";
import Group from "~/models/Group";
import User from "~/models/User";
import { AddPeopleToGroupDialog } from "~/scenes/Settings/components/GroupDialogs";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { createAction, createActionWithChildren } from "~/actions";
import { dialogActionFactory } from "~/actions/definitions/common";
import { GroupSection } from "~/actions/sections";
import type { ActionContext } from "~/types";
export const addGroupUsers = dialogActionFactory({
analyticsName: "Add people to group",
section: GroupSection,
name: (t) => `${t("Add people")}`,
title: (t, { getActiveModel }) =>
t(`Add people to {{groupName}}`, {
groupName: getActiveModel(Group)?.name ?? "",
}),
content: (onSubmit, { getActiveModel }) => {
const group = getActiveModel(Group);
return group ? <AddPeopleToGroupDialog group={group} /> : null;
},
icon: <PlusIcon />,
visible: (context) => canManageMembers(context),
});
/**
* Creates an action that sets the active group member's permission.
*
* @param permission - the permission to assign.
* @returns an action for use in menus.
*/
export const updateGroupUserPermissionActionFactory = (
permission: GroupPermission
) =>
createAction({
name: ({ t }) => GroupPermissionHelper.displayName(permission, t),
analyticsName: "Update group member permission",
section: GroupSection,
selected: (context) => getMembership(context)?.permission === permission,
perform: async ({ getActiveModel }) => {
const group = getActiveModel(Group);
const user = getActiveModel(User);
if (!group || !user) {
return;
}
try {
await stores.groupUsers.update({
groupId: group.id,
userId: user.id,
permission,
});
} catch (err) {
toast.error(errToString(err));
}
},
});
export const changeGroupUserPermission = createActionWithChildren({
name: ({ t }) => t("Change role"),
analyticsName: "Change group member permission",
section: GroupSection,
visible: (context) => canManageMembers(context),
children: GroupPermissionHelper.permissions.map((permission) =>
updateGroupUserPermissionActionFactory(permission)
),
});
export const removeGroupUser = createAction({
name: ({ t, currentUserId, getActiveModel }) =>
currentUserId === getActiveModel(User)?.id
? t("Leave group")
: t("Remove user"),
analyticsName: "Remove group member",
section: GroupSection,
dangerous: true,
visible: (context) => canManageMembers(context) && !!getMembership(context),
perform: async ({ t, currentUserId, getActiveModel }) => {
const group = getActiveModel(Group);
const user = getActiveModel(User);
if (!group || !user) {
return;
}
try {
await stores.groupUsers.delete({
groupId: group.id,
userId: user.id,
});
if (currentUserId === user.id) {
toast.success(t("You have left the group"));
return;
}
toast.success(
t(`{{userName}} was removed from the group`, {
userName: user.name,
}),
{
icon: <Avatar model={user} size={AvatarSize.Toast} />,
}
);
} catch (err) {
toast.error(errToString(err));
}
},
});
const canManageMembers = ({ getActiveModel }: ActionContext) => {
const group = getActiveModel(Group);
return (
!!group &&
stores.policies.abilities(group.id).update &&
!group.isExternallyManaged
);
};
const getMembership = ({ getActiveModel }: ActionContext) => {
const group = getActiveModel(Group);
const user = getActiveModel(User);
return group && user
? stores.groupUsers.membership(group.id, user.id)
: undefined;
};
+62 -36
View File
@@ -1,48 +1,74 @@
import { EditIcon, GroupIcon, TrashIcon } from "outline-icons";
import stores from "~/stores";
import type Group from "~/models/Group";
import Group from "~/models/Group";
import {
DeleteGroupDialog,
EditGroupDialog,
} from "~/scenes/Settings/components/GroupDialogs";
import { createInternalLinkAction } from "~/actions";
import { createExternalLinkAction, createInternalLinkAction } from "~/actions";
import { dialogActionFactory } from "~/actions/definitions/common";
import { GroupSection } from "~/actions/sections";
import { settingsPath } from "~/utils/routeHelpers";
export const groupMembersActionFactory = (group: Group) =>
createInternalLinkAction({
name: ({ t }) => t("Members"),
analyticsName: "Group members",
section: GroupSection,
icon: <GroupIcon />,
visible: () => stores.policies.abilities(group.id).read,
to: settingsPath("groups", group.id, "members"),
});
export const groupMembers = createInternalLinkAction({
name: ({ t }) => t("Members"),
analyticsName: "Group members",
section: GroupSection,
icon: <GroupIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Group).some((policy) => policy.abilities.read),
to: ({ getActiveModel }) => {
const group = getActiveModel(Group);
return group ? settingsPath("groups", group.id, "members") : "";
},
});
export const editGroupActionFactory = (group: Group) =>
dialogActionFactory({
analyticsName: "Edit group",
section: GroupSection,
name: (t) => `${t("Edit")}`,
title: (t) => t("Edit group"),
content: (onSubmit) => (
<EditGroupDialog group={group} onSubmit={onSubmit} />
),
icon: <EditIcon />,
visible: () => stores.policies.abilities(group.id).update,
});
export const editGroup = dialogActionFactory({
analyticsName: "Edit group",
section: GroupSection,
name: (t) => `${t("Edit")}`,
title: (t) => t("Edit group"),
content: (onSubmit, { getActiveModel }) => {
const group = getActiveModel(Group);
return group ? <EditGroupDialog group={group} onSubmit={onSubmit} /> : null;
},
icon: <EditIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Group).some((policy) => policy.abilities.update),
});
export const deleteGroupActionFactory = (group: Group) =>
dialogActionFactory({
analyticsName: "Delete group",
section: GroupSection,
name: (t) => `${t("Delete")}`,
title: (t) => t("Delete group"),
content: (onSubmit) => (
export const deleteGroup = dialogActionFactory({
analyticsName: "Delete group",
section: GroupSection,
name: (t) => `${t("Delete")}`,
title: (t) => t("Delete group"),
content: (onSubmit, { getActiveModel }) => {
const group = getActiveModel(Group);
return group ? (
<DeleteGroupDialog group={group} onSubmit={onSubmit} />
),
icon: <TrashIcon />,
dangerous: true,
visible: () => stores.policies.abilities(group.id).delete,
});
) : null;
},
icon: <TrashIcon />,
dangerous: true,
visible: ({ getActivePolicies }) =>
getActivePolicies(Group).some((policy) => policy.abilities.delete),
});
/** Read-only row surfacing the group's identifier. */
export const groupExternalId = createExternalLinkAction({
name: ({ getActiveModel }) => getActiveModel(Group)?.externalId ?? "",
section: GroupSection,
visible: ({ getActiveModel }) => !!getActiveModel(Group)?.externalId,
disabled: true,
url: "",
});
/** Read-only row surfacing the identifier of the group in its provider. */
export const groupProviderExternalId = createExternalLinkAction({
name: ({ getActiveModel }) =>
`External ID: ${getActiveModel(Group)?.externalGroup?.externalId ?? ""}`,
section: GroupSection,
visible: ({ getActiveModel }) =>
!!getActiveModel(Group)?.externalGroup?.externalId,
disabled: true,
url: "",
});
+18 -39
View File
@@ -1,12 +1,12 @@
import { useMemo } from "react";
import type Group from "~/models/Group";
import { ActionSeparator, createExternalLinkAction } from "~/actions";
import { ActionSeparator } from "~/actions";
import {
deleteGroupActionFactory,
editGroupActionFactory,
groupMembersActionFactory,
deleteGroup,
editGroup,
groupExternalId,
groupMembers,
groupProviderExternalId,
} from "~/actions/definitions/groups";
import { GroupSection } from "~/actions/sections";
import { useMenuAction } from "~/hooks/useMenuAction";
interface Options {
@@ -15,44 +15,23 @@ interface Options {
}
/**
* Hook that constructs the action menu for group management operations.
* Hook that constructs the action menu for group management operations. The
* group is read from the active models in the action context.
*
* @param targetGroup - the group to build actions for, or null to skip.
* @param options - optional configuration for the menu.
* @returns action with children for use in menus.
*/
export function useGroupMenuActions(
targetGroup: Group | null,
options?: Options
) {
export function useGroupMenuActions(options?: Options) {
const actions = useMemo(
() =>
!targetGroup
? []
: [
...(options?.hideMembers
? []
: [groupMembersActionFactory(targetGroup), ActionSeparator]),
editGroupActionFactory(targetGroup),
deleteGroupActionFactory(targetGroup),
ActionSeparator,
// Read-only rows surfacing the group's external identifiers.
createExternalLinkAction({
name: targetGroup.externalId ?? "",
section: GroupSection,
visible: !!targetGroup.externalId,
disabled: true,
url: "",
}),
createExternalLinkAction({
name: `External ID: ${targetGroup.externalGroup?.externalId ?? ""}`,
section: GroupSection,
visible: !!targetGroup.externalGroup?.externalId,
disabled: true,
url: "",
}),
],
[targetGroup, options?.hideMembers]
() => [
...(options?.hideMembers ? [] : [groupMembers, ActionSeparator]),
editGroup,
deleteGroup,
ActionSeparator,
groupExternalId,
groupProviderExternalId,
],
[options?.hideMembers]
);
return useMenuAction(actions);
+22
View File
@@ -0,0 +1,22 @@
import { useMemo } from "react";
import { ActionSeparator } from "~/actions";
import {
changeGroupUserPermission,
removeGroupUser,
} from "~/actions/definitions/groupUsers";
import { useMenuAction } from "~/hooks/useMenuAction";
/**
* Hook that constructs the action menu for a member of a group. The group and
* user are read from the active models in the action context.
*
* @returns action with children for use in menus.
*/
export function useGroupUserMenuActions() {
const actions = useMemo(
() => [changeGroupUserPermission, ActionSeparator, removeGroupUser],
[]
);
return useMenuAction(actions);
}
+38
View File
@@ -0,0 +1,38 @@
import { observer } from "mobx-react";
import { useTranslation } from "react-i18next";
import type Group from "~/models/Group";
import type User from "~/models/User";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useGroupUserMenuActions } from "~/hooks/useGroupUserMenuActions";
type Props = {
/** The group the user is a member of. */
group: Group;
/** The member of the group. */
user: User;
};
/**
* Overflow menu with the actions available for a member of a group.
*/
export const GroupMemberMenu = observer(function GroupMemberMenu({
group,
user,
}: Props) {
const { t } = useTranslation();
const rootAction = useGroupUserMenuActions();
return (
<ActionContextProvider value={{ activeModels: [group, user] }}>
<DropdownMenu
action={rootAction}
align="end"
ariaLabel={t("Group member options")}
>
<OverflowMenuButton />
</DropdownMenu>
</ActionContextProvider>
);
});
+11 -9
View File
@@ -1,9 +1,9 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type Group from "~/models/Group";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useGroupMenuActions } from "~/hooks/useGroupMenuActions";
type Props = {
@@ -14,16 +14,18 @@ type Props = {
function GroupMenu({ group, hideMembers }: Props) {
const { t } = useTranslation();
const rootAction = useGroupMenuActions(group, { hideMembers });
const rootAction = useGroupMenuActions({ hideMembers });
return (
<DropdownMenu
action={rootAction}
align="end"
ariaLabel={t("Group options")}
>
<OverflowMenuButton />
</DropdownMenu>
<ActionContextProvider value={{ activeModels: [group] }}>
<DropdownMenu
action={rootAction}
align="end"
ariaLabel={t("Group options")}
>
<OverflowMenuButton />
</DropdownMenu>
</ActionContextProvider>
);
}
+76 -79
View File
@@ -1,6 +1,6 @@
import type { ColumnSort } from "@tanstack/react-table";
import { observer } from "mobx-react";
import { GroupIcon, HiddenIcon, PlusIcon } from "outline-icons";
import { GroupIcon, HiddenIcon } from "outline-icons";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
@@ -20,8 +20,13 @@ import Text from "~/components/Text";
import Tooltip from "~/components/Tooltip";
import Error404 from "~/scenes/Errors/Error404";
import { createInternalLinkAction } from "~/actions";
import {
addGroupUsers,
removeGroupUser,
} from "~/actions/definitions/groupUsers";
import { NavigationSection } from "~/actions/sections";
import usePolicy from "~/hooks/usePolicy";
import { ActionContextProvider } from "~/hooks/useActionContext";
import useCurrentUser from "~/hooks/useCurrentUser";
import useQuery from "~/hooks/useQuery";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
@@ -29,7 +34,6 @@ import { useTableRequest } from "~/hooks/useTableRequest";
import type { FetchPageParams, PaginatedResponse } from "~/stores/base/Store";
import { PAGINATION_SYMBOL } from "~/stores/base/Store";
import GroupMenu from "~/menus/GroupMenu";
import { AddPeopleToGroupDialog } from "./components/GroupDialogs";
import GroupPermissionFilter from "./components/GroupPermissionFilter";
import { GroupMembersTable } from "./components/GroupMembersTable";
import { StickyFilters } from "./components/StickyFilters";
@@ -68,9 +72,9 @@ const GroupMembersPage = observer(function GroupMembersPage({
}) {
const { t } = useTranslation();
const theme = useTheme();
const { dialogs, groups, users, groupUsers } = useStores();
const { groups, users, groupUsers } = useStores();
const group = groups.get(groupId)!;
const can = usePolicy(group);
const currentUser = useCurrentUser();
const history = useHistory();
const location = useLocation();
const params = useQuery();
@@ -171,15 +175,6 @@ const GroupMembersPage = observer(function GroupMembersPage({
[]
);
const handleAddPeople = useCallback(() => {
dialogs.openModal({
title: t(`Add people to {{groupName}}`, {
groupName: group.name,
}),
content: <AddPeopleToGroupDialog group={group} />,
});
}, [t, group, dialogs]);
useEffect(() => {
if (error) {
toast.error(t("Could not load group members"));
@@ -204,76 +199,78 @@ const GroupMembersPage = observer(function GroupMembersPage({
);
return (
<Scene
title={group.name}
left={<Breadcrumb actions={breadcrumbActions} />}
actions={
<>
{can.update && (
<ActionContextProvider value={{ activeModels: [group] }}>
<Scene
title={group.name}
left={<Breadcrumb actions={breadcrumbActions} />}
actions={
<>
<Action>
<Button
type="button"
onClick={handleAddPeople}
disabled={group.isExternallyManaged}
icon={<PlusIcon />}
>
<ActionContextProvider value={{ activeModels: [currentUser] }}>
<Button neutral action={removeGroupUser} hideOnActionDisabled>
{t("Leave group")}
</Button>
</ActionContextProvider>
</Action>
<Action>
<Button action={addGroupUsers} hideOnActionDisabled>
{`${t("Add people")}`}
</Button>
</Action>
<Action>
<GroupMenu group={group} hideMembers />
</Action>
</>
}
wide
>
<Heading>
{group.name}
{group.disableMentions && (
<>
&nbsp;
<Tooltip content={t("This group is hidden")}>
<HiddenIcon size={32} color={theme.textSecondary} />
</Tooltip>
</>
)}
<Action>
<GroupMenu group={group} hideMembers />
</Action>
</>
}
wide
>
<Heading>
{group.name}
{group.disableMentions && (
<>
&nbsp;
<Tooltip content={t("This group is hidden")}>
<HiddenIcon size={32} color={theme.textSecondary} />
</Tooltip>
</>
)}
</Heading>
<Text as="p" type="secondary">
{group.externalGroup && (
<>
{t("Synced to {{ provider }}", {
provider: group.externalGroup.displayName,
})}
{group.description && <> &middot; </>}
</>
)}
{group.description || (!group.externalGroup && t("No description"))}
</Text>
<StickyFilters>
<InputSearch
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
<LargeGroupPermissionFilter
activeKey={reqParams.permission ?? ""}
onSelect={handlePermissionFilter}
/>
</StickyFilters>
<ConditionalFade animate={!data}>
<GroupMembersTable
group={group}
data={data ?? []}
sort={sort}
loading={loading}
page={{
hasNext: !!next,
fetchNext: next,
}}
/>
</ConditionalFade>
</Scene>
</Heading>
<Text as="p" type="secondary">
{group.externalGroup && (
<>
{t("Synced to {{ provider }}", {
provider: group.externalGroup.displayName,
})}
{group.description && <> &middot; </>}
</>
)}
{group.description || (!group.externalGroup && t("No description"))}
</Text>
<StickyFilters>
<InputSearch
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
<LargeGroupPermissionFilter
activeKey={reqParams.permission ?? ""}
onSelect={handlePermissionFilter}
/>
</StickyFilters>
<ConditionalFade animate={!data}>
<GroupMembersTable
group={group}
data={data ?? []}
sort={sort}
loading={loading}
page={{
hasNext: !!next,
fetchNext: next,
}}
/>
</ConditionalFade>
</Scene>
</ActionContextProvider>
);
});
@@ -4,6 +4,7 @@ import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { errToString } from "@shared/utils/error";
import { GroupPermissionHelper } from "@shared/utils/GroupPermissionHelper";
import Group from "~/models/Group";
import type User from "~/models/User";
import Invite from "~/scenes/Invite";
@@ -375,11 +376,11 @@ const GroupMemberListItem = observer(function ({
() =>
[
{
label: t("Group admin"),
label: GroupPermissionHelper.displayName(GroupPermission.Admin, t),
value: GroupPermission.Admin,
},
{
label: t("Member"),
label: GroupPermissionHelper.displayName(GroupPermission.Member, t),
value: GroupPermission.Member,
},
{
@@ -1,15 +1,16 @@
import { compact } from "es-toolkit/compat";
import { observer } from "mobx-react";
import * as React from "react";
import { useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { GroupPermission } from "@shared/types";
import { GroupPermissionHelper } from "@shared/utils/GroupPermissionHelper";
import type Group from "~/models/Group";
import type User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Badge from "~/components/Badge";
import { HEADER_HEIGHT } from "~/components/Header";
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
import { ContextMenu } from "~/components/Menu/ContextMenu";
import {
type Props as TableProps,
SortableTable,
@@ -17,10 +18,11 @@ import {
import { type Column as TableColumn } from "~/components/Table";
import Text from "~/components/Text";
import Time from "~/components/Time";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useGroupUserMenuActions } from "~/hooks/useGroupUserMenuActions";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import type { Permission } from "~/types";
import { EmptySelectValue } from "~/types";
import { GroupMemberMenu } from "~/menus/GroupMemberMenu";
import { FILTER_HEIGHT } from "./StickyFilters";
import { HStack } from "~/components/primitives/HStack";
@@ -31,6 +33,27 @@ type Props = Omit<TableProps<User>, "columns" | "rowHeight"> & {
group: Group;
};
const GroupMemberRowContextMenu = observer(function GroupMemberRowContextMenu({
group,
user,
menuLabel,
children,
}: {
group: Group;
user: User;
menuLabel: string;
children: React.ReactNode;
}) {
const action = useGroupUserMenuActions();
return (
<ActionContextProvider value={{ activeModels: [group, user] }}>
<ContextMenu action={action} ariaLabel={menuLabel}>
{children}
</ContextMenu>
</ActionContextProvider>
);
});
/**
* Table component for displaying group members with permission management.
*/
@@ -41,60 +64,19 @@ export const GroupMembersTable = observer(function GroupMembersTable({
const { t } = useTranslation();
const { groupUsers } = useStores();
const can = usePolicy(group);
const canManage = can.update && !group.isExternallyManaged;
const permissions = useMemo(
() =>
[
{
label: t("Group admin"),
value: GroupPermission.Admin,
},
{
label: t("Member"),
value: GroupPermission.Member,
},
{
divider: true,
label: t("Remove"),
value: EmptySelectValue,
},
] as Permission[],
[t]
);
const handlePermissionChange = useCallback(
async (
user: User,
permission: GroupPermission | typeof EmptySelectValue
) => {
try {
if (permission === EmptySelectValue) {
await groupUsers.delete({
userId: user.id,
groupId: group.id,
});
toast.success(
t(`{{userName}} was removed from the group`, {
userName: user.name,
}),
{
icon: <Avatar model={user} size={AvatarSize.Toast} />,
}
);
} else {
await groupUsers.update({
userId: user.id,
groupId: group.id,
permission,
});
}
} catch (err) {
toast.error((err as Error).message);
return false;
}
return true;
},
[t, groupUsers, group.id]
const applyContextMenu = useCallback(
(user: User, rowElement: React.ReactNode) => (
<GroupMemberRowContextMenu
group={group}
user={user}
menuLabel={t("Group member options")}
>
{rowElement}
</GroupMemberRowContextMenu>
),
[group, t]
);
const columns = useMemo<TableColumn<User>[]>(
@@ -105,22 +87,13 @@ export const GroupMembersTable = observer(function GroupMembersTable({
id: "name",
header: t("Name"),
accessor: (user) => user.name,
component: (user) => {
const gu = groupUsers.orderedData.find(
(m) => m.userId === user.id && m.groupId === group.id
);
return (
<HStack>
<Avatar model={user} size={AvatarSize.Large} />
<Text selectable>{user.name}</Text>
{user.isAdmin ? (
<Badge primary>{t("Admin")}</Badge>
) : gu?.permission === GroupPermission.Admin ? (
<Badge>{t("Group admin")}</Badge>
) : null}
</HStack>
);
},
component: (user) => (
<HStack>
<Avatar model={user} size={AvatarSize.Large} />
<Text selectable>{user.name}</Text>
{user.isAdmin && <Badge primary>{t("Admin")}</Badge>}
</HStack>
),
width: "3fr",
},
{
@@ -140,48 +113,38 @@ export const GroupMembersTable = observer(function GroupMembersTable({
),
width: "1fr",
},
can.update
{
type: "data",
id: "role",
header: t("Role"),
sortable: false,
accessor: (user) =>
groupUsers.membership(group.id, user.id)?.permission ?? "",
component: (user) => {
const permission = groupUsers.membership(
group.id,
user.id
)?.permission;
return permission ? (
<Badge primary={permission === GroupPermission.Admin}>
{GroupPermissionHelper.displayName(permission, t)}
</Badge>
) : null;
},
width: "1fr",
},
canManage
? {
type: "data",
id: "permission",
header: t("Permission"),
sortable: false,
accessor: (user) => {
const gu = groupUsers.orderedData.find(
(m) => m.userId === user.id && m.groupId === group.id
);
return gu?.permission ?? "";
},
component: (user: User) => (
<InputMemberPermissionSelect
permissions={permissions}
disabled={group.isExternallyManaged}
onChange={(permission) =>
handlePermissionChange(
user,
permission as GroupPermission | typeof EmptySelectValue
)
}
value={
groupUsers.orderedData.find(
(m) => m.userId === user.id && m.groupId === group.id
)?.permission
}
/>
type: "action",
id: "action",
component: (user) => (
<GroupMemberMenu group={group} user={user} />
),
width: "130px",
width: "50px",
}
: undefined,
]),
[
t,
can.update,
group.id,
group.isExternallyManaged,
groupUsers.orderedData,
permissions,
handlePermissionChange,
]
[t, canManage, group, groupUsers]
);
return (
@@ -189,6 +152,7 @@ export const GroupMembersTable = observer(function GroupMembersTable({
columns={columns}
rowHeight={ROW_HEIGHT}
stickyOffset={STICKY_OFFSET}
decorateRow={canManage ? applyContextMenu : undefined}
{...rest}
/>
);
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { GroupPermission } from "@shared/types";
import { GroupPermissionHelper } from "@shared/utils/GroupPermissionHelper";
import FilterOptions from "~/components/FilterOptions";
type Props = {
@@ -23,11 +24,11 @@ const GroupPermissionFilter = ({ activeKey, onSelect, ...rest }: Props) => {
},
{
key: GroupPermission.Admin,
label: t("Group admin"),
label: GroupPermissionHelper.displayName(GroupPermission.Admin, t),
},
{
key: GroupPermission.Member,
label: t("Member"),
label: GroupPermissionHelper.displayName(GroupPermission.Member, t),
},
],
[t]
@@ -18,6 +18,7 @@ import {
} from "~/components/SortableTable";
import { type Column as TableColumn } from "~/components/Table";
import { ContextMenu } from "~/components/Menu/ContextMenu";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useGroupMenuActions } from "~/hooks/useGroupMenuActions";
import Text from "~/components/Text";
import Time from "~/components/Time";
@@ -43,11 +44,13 @@ const GroupRowContextMenu = observer(function GroupRowContextMenu({
menuLabel: string;
children: React.ReactNode;
}) {
const action = useGroupMenuActions(group);
const action = useGroupMenuActions();
return (
<ContextMenu action={action} ariaLabel={menuLabel}>
{children}
</ContextMenu>
<ActionContextProvider value={{ activeModels: [group] }}>
<ContextMenu action={action} ariaLabel={menuLabel}>
{children}
</ContextMenu>
</ActionContextProvider>
);
});
+10
View File
@@ -120,4 +120,14 @@ export default class GroupUsersStore extends Store<GroupUser> {
inGroup = (groupId: string) =>
filter(this.orderedData, (member) => member.groupId === groupId);
/**
* Returns the membership of a user in a group, if loaded.
*
* @param groupId - the identifier of the group.
* @param userId - the identifier of the user.
* @returns the membership, if present in the store.
*/
membership = (groupId: string, userId: string) =>
this.get(`${userId}-${groupId}`);
}
+12 -8
View File
@@ -126,6 +126,13 @@
"Members": "Members",
"Edit group": "Edit group",
"Delete group": "Delete group",
"Add people": "Add people",
"Add people to {{groupName}}": "Add people to {{groupName}}",
"Change role": "Change role",
"Leave group": "Leave group",
"Remove user": "Remove user",
"You have left the group": "You have left the group",
"{{userName}} was removed from the group": "{{userName}} was removed from the group",
"Disconnect": "Disconnect",
"Disconnect analytics": "Disconnect analytics",
"Home": "Home",
@@ -193,7 +200,6 @@
"Promote to {{ role }}": "Promote to {{ role }}",
"Demote to {{ role }}": "Demote to {{ role }}",
"Update role": "Update role",
"Change role": "Change role",
"Change profile picture": "Change profile picture",
"Change name": "Change name",
"Change email": "Change email",
@@ -719,6 +725,7 @@
"Enable embeds": "Enable embeds",
"Emoji options": "Emoji options",
"File": "File",
"Group member options": "Group member options",
"Group options": "Group options",
"Cancel": "Cancel",
"Import menu options": "Import menu options",
@@ -1218,10 +1225,8 @@
"Search people": "Search people",
"No people matching your search": "No people matching your search",
"No people left to add": "No people left to add",
"Group admin": "Group admin",
"Member": "Member",
"{{userName}} was removed from the group": "{{userName}} was removed from the group",
"Last active": "Last active",
"Role": "Role",
"All permissions": "All permissions",
"All sources": "All sources",
"Manual": "Manual",
@@ -1242,7 +1247,6 @@
"You can import a zip file that was previously exported from an Outline installation collections, documents, and images will be imported. In Outline, 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 an Outline installation collections, documents, and images will be imported. In Outline, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.",
"Drag and drop the zip file from the Markdown export option in {{appName}}, or click to upload": "Drag and drop the zip file from the Markdown export option in {{appName}}, or click to upload",
"Configure": "Configure",
"Role": "Role",
"Guest": "Guest",
"Never used": "Never used",
"Are you sure you want to revoke access?": "Are you sure you want to revoke access?",
@@ -1307,9 +1311,7 @@
"You can use these optional instructions to tell MCP clients how to use your knowledge base.": "You can use these optional instructions to tell MCP clients how to use your knowledge base.",
"AI answers": "AI answers",
"Use AI to get direct answers to questions in search. This feature requires a paid license.": "Use AI to get direct answers to questions in search. This feature requires a paid license.",
"Add people to {{groupName}}": "Add people to {{groupName}}",
"Could not load group members": "Could not load group members",
"Add people": "Add people",
"Synced to {{ provider }}": "Synced to {{ provider }}",
"No description": "No description",
"Create a group": "Create a group",
@@ -1801,5 +1803,7 @@
"Write a caption": "Write a caption",
"Add title": "Add title",
"Add content": "Add content",
"Tomorrow": "Tomorrow"
"Tomorrow": "Tomorrow",
"Group admin": "Group admin",
"Member": "Member"
}
+29
View File
@@ -0,0 +1,29 @@
import { type TFunction } from "i18next";
import { GroupPermission } from "../types";
export class GroupPermissionHelper {
/**
* Get the display name for a group permission.
*
* @param permission The permission to get the display name for
* @param t The translation function
* @returns The display name for the permission
*/
static displayName(permission: GroupPermission, t: TFunction): string {
switch (permission) {
case GroupPermission.Admin:
return t("Group admin");
case GroupPermission.Member:
return t("Member");
default: {
const exhaustive: never = permission;
return exhaustive;
}
}
}
/**
* List of all group permissions, in order from lowest to highest.
*/
static permissions = [GroupPermission.Member, GroupPermission.Admin];
}