Files
outline/app/scenes/Settings/components/GroupsTable.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

229 lines
6.6 KiB
TypeScript

import { compact } from "es-toolkit/compat";
import { observer } from "mobx-react";
import { GroupIcon, HiddenIcon } from "outline-icons";
import * as React from "react";
import { useCallback, useMemo } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
import { s, hover } from "@shared/styles";
import type 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 { ContextMenu } from "~/components/Menu/ContextMenu";
import { useGroupMenuActions } from "~/hooks/useGroupMenuActions";
import Text from "~/components/Text";
import Time from "~/components/Time";
import GroupMenu from "~/menus/GroupMenu";
import { FILTER_HEIGHT } from "./StickyFilters";
import NudeButton from "~/components/NudeButton";
import { AvatarSize } from "~/components/Avatar";
import { HStack } from "~/components/primitives/HStack";
import Tooltip from "~/components/Tooltip";
import { settingsPath } from "~/utils/routeHelpers";
const ROW_HEIGHT = 60;
const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
type Props = Omit<TableProps<Group>, "columns" | "rowHeight">;
const GroupRowContextMenu = observer(function GroupRowContextMenu({
group,
menuLabel,
children,
}: {
group: Group;
menuLabel: string;
children: React.ReactNode;
}) {
const action = useGroupMenuActions(group);
return (
<ContextMenu action={action} ariaLabel={menuLabel}>
{children}
</ContextMenu>
);
});
export function GroupsTable(props: Props) {
const { t } = useTranslation();
const theme = useTheme();
const history = useHistory();
const handleViewMembers = useCallback(
(group: Group) => {
history.push(settingsPath("groups", group.id, "members"));
},
[history]
);
const applyContextMenu = useCallback(
(group: Group, rowElement: React.ReactNode) => (
<GroupRowContextMenu group={group} menuLabel={t("Group options")}>
{rowElement}
</GroupRowContextMenu>
),
[t]
);
const columns = useMemo<TableColumn<Group>[]>(
() =>
compact<TableColumn<Group>>([
{
type: "data",
id: "name",
header: t("Name"),
accessor: (group) => group.name,
component: (group) => (
<HStack>
<Image>
<GroupIcon size={24} />
</Image>
<Flex column>
<Title onClick={() => handleViewMembers(group)}>
{group.name}
{group.disableMentions && (
<>
{" "}
<Tooltip content={t("This group is hidden")}>
<HiddenIcon size={16} color={theme.textSecondary} />
</Tooltip>
</>
)}
</Title>
<Text type="tertiary" size="small" weight="normal">
<Trans
defaults="{{ count }} member"
values={{ count: group.memberCount }}
/>
</Text>
</Flex>
</HStack>
),
width: "2fr",
},
{
type: "data",
id: "description",
header: t("Description"),
accessor: (group) => group.description || "",
component: (group) => (
<Text type="secondary" size="small" weight="normal">
{group.description}
</Text>
),
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;
if (users.length === 0) {
return null;
}
return (
<GroupMembers
onClick={() => handleViewMembers(group)}
width={
(users.length + (overflow > 0 ? 1 : 0)) * AvatarSize.Large
}
>
<Facepile users={users} overflow={overflow} />
</GroupMembers>
);
},
width: "1.5fr",
sortable: false,
},
{
type: "data",
id: "source",
header: t("Source"),
accessor: (group) => group.externalGroup?.displayName ?? "manual",
component: (group) =>
group.externalGroup ? (
<Flex column>
<Text type="secondary" size="small" weight="normal">
{group.externalGroup.displayName}
</Text>
{group.externalGroup.lastSyncedAt && (
<Text type="tertiary" size="xsmall" weight="normal">
<Trans>
Synced{" "}
<Time
dateTime={group.externalGroup.lastSyncedAt}
addSuffix
shorten
/>
</Trans>
</Text>
)}
</Flex>
) : null,
width: "1fr",
},
{
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, theme.textSecondary]
);
return (
<SortableTable
columns={columns}
rowHeight={ROW_HEIGHT}
stickyOffset={STICKY_OFFSET}
decorateRow={applyContextMenu}
{...props}
/>
);
}
const GroupMembers = styled(NudeButton)`
justify-content: flex-start;
display: flex;
`;
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);
}
`;