mirror of
https://github.com/outline/outline.git
synced 2026-08-03 13:27:25 +03:00
Add contextual menu to group table (#13220)
* Add contextual menu to group table * feedback
This commit is contained in:
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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: "",
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
<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 && (
|
||||
<>
|
||||
|
||||
<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 && <> · </>}
|
||||
</>
|
||||
)}
|
||||
{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 && <> · </>}
|
||||
</>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user