mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
0139b91b5d
* 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.
229 lines
6.6 KiB
TypeScript
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);
|
|
}
|
|
`;
|