Files
outline/app/scenes/Settings/components/GroupMembersTable.tsx
T
Tom Moor 0139b91b5d chore: Replace lodash with es-toolkit (#12281)
* chore: Replace lodash with es-toolkit

Migrate all direct lodash imports to es-toolkit/compat for a smaller,
faster, lodash-compatible utility library. Transitive lodash usage from
other packages remains unchanged.

* fix: Restore isPlainObject semantics in CanCan policy

The lodash migration aliased `isObject` to `lodash/isPlainObject` and
the codemod incorrectly mapped the local name to es-toolkit's `isObject`,
which also returns true for arrays and functions. This caused condition
objects in policy definitions to be skipped, breaking authorization
checks across the codebase.

* fix: Restore unicode-aware length counting in validators

es-toolkit/compat's size() returns string.length, while lodash's _.size()
counts unicode code points. Switch to [...value].length to preserve the
previous behavior so multi-byte characters like emoji count as one.
2026-05-06 21:03:47 -04:00

196 lines
5.6 KiB
TypeScript

import { compact } from "es-toolkit/compat";
import { observer } from "mobx-react";
import { useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { GroupPermission } from "@shared/types";
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 {
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 usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import type { Permission } from "~/types";
import { EmptySelectValue } from "~/types";
import { FILTER_HEIGHT } from "./StickyFilters";
import { HStack } from "~/components/primitives/HStack";
const ROW_HEIGHT = 50;
const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
type Props = Omit<TableProps<User>, "columns" | "rowHeight"> & {
group: Group;
};
/**
* Table component for displaying group members with permission management.
*/
export const GroupMembersTable = observer(function GroupMembersTable({
group,
...rest
}: Props) {
const { t } = useTranslation();
const { groupUsers } = useStores();
const can = usePolicy(group);
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 columns = useMemo<TableColumn<User>[]>(
() =>
compact<TableColumn<User>>([
{
type: "data",
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>
);
},
width: "3fr",
},
{
type: "data",
id: "lastActiveAt",
header: t("Last active"),
accessor: (user) => user.lastActiveAt,
component: (user) => (
<HStack spacing={4} wrap>
{user.lastActiveAt ? (
<Time dateTime={user.lastActiveAt} addSuffix />
) : (
<Text type="tertiary">{t("Never signed in")}</Text>
)}
{user.isInvited && <Badge>{t("Invited")}</Badge>}
</HStack>
),
width: "1fr",
},
can.update
? {
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
}
/>
),
width: "130px",
}
: undefined,
]),
[
t,
can.update,
group.id,
group.isExternallyManaged,
groupUsers.orderedData,
permissions,
handlePermissionChange,
]
);
return (
<SortableTable
columns={columns}
rowHeight={ROW_HEIGHT}
stickyOffset={STICKY_OFFSET}
{...rest}
/>
);
});