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

162 lines
4.7 KiB
TypeScript

import { compact } from "es-toolkit/compat";
import { observer } from "mobx-react";
import { useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import Text from "@shared/components/Text";
import type User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Badge from "~/components/Badge";
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 { useUserMenuActions } from "~/hooks/useUserMenuActions";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMobile from "~/hooks/useMobile";
import UserMenu from "~/menus/UserMenu";
import { FILTER_HEIGHT } from "./StickyFilters";
import { HStack } from "~/components/primitives/HStack";
import { VStack } from "~/components/primitives/VStack";
const ROW_HEIGHT = 50;
const STICKY_OFFSET = HEADER_HEIGHT + FILTER_HEIGHT;
type Props = Omit<TableProps<User>, "columns" | "rowHeight"> & {
canManage: boolean;
};
const UserRowContextMenu = observer(function UserRowContextMenu({
user,
menuLabel,
children,
}: {
user: User;
menuLabel: string;
children: React.ReactNode;
}) {
const action = useUserMenuActions(user);
return (
<ContextMenu action={action} ariaLabel={menuLabel}>
{children}
</ContextMenu>
);
});
export function MembersTable({ canManage, ...rest }: Props) {
const { t } = useTranslation();
const currentUser = useCurrentUser();
const isMobile = useMobile();
const applyContextMenu = useCallback(
(user: User, rowElement: React.ReactNode) => {
if (currentUser.id === user.id) {
return rowElement;
}
return (
<UserRowContextMenu user={user} menuLabel={t("User options")}>
{rowElement}
</UserRowContextMenu>
);
},
[currentUser.id, t]
);
const columns = useMemo<TableColumn<User>[]>(
() =>
compact<TableColumn<User>>([
{
type: "data",
id: "name",
header: t("Name"),
accessor: (user) => user.name,
component: (user) => (
<HStack>
<Avatar model={user} size={AvatarSize.Large} />
<VStack align="flex-start" spacing={0}>
<Text selectable>
{user.name} {currentUser.id === user.id && `(${t("You")})`}
</Text>
{isMobile && canManage && (
<Text type="tertiary" selectable>
{user.email}
</Text>
)}
</VStack>
</HStack>
),
width: "4fr",
},
canManage && !isMobile
? {
type: "data",
id: "email",
header: t("Email"),
accessor: (user) => user.email,
component: (user) => <>{user.email}</>,
width: "4fr",
}
: undefined,
isMobile
? undefined
: {
type: "data",
id: "lastActiveAt",
header: t("Last active"),
accessor: (user) => user.lastActiveAt,
component: (user) =>
user.lastActiveAt ? (
<Time dateTime={user.lastActiveAt} addSuffix shorten />
) : null,
width: "2fr",
},
{
type: "data",
id: "role",
header: t("Role"),
accessor: (user) => user.role,
component: (user) => (
<HStack spacing={4} wrap>
{user.isInvited && <Badge>{t("Invited")}</Badge>}
{user.isAdmin ? (
<Badge primary>{t("Admin")}</Badge>
) : user.isViewer ? (
<Badge>{t("Viewer")}</Badge>
) : user.isGuest ? (
<Badge>{t("Guest")}</Badge>
) : (
<Badge>{t("Editor")}</Badge>
)}
{user.isSuspended && <Badge>{t("Suspended")}</Badge>}
</HStack>
),
width: "2fr",
},
canManage
? {
type: "action",
id: "action",
component: (user) =>
currentUser.id !== user.id ? <UserMenu user={user} /> : null,
width: "50px",
}
: undefined,
]),
[t, currentUser, canManage, isMobile]
);
return (
<SortableTable
columns={columns}
rowHeight={ROW_HEIGHT}
stickyOffset={STICKY_OFFSET}
decorateRow={canManage ? applyContextMenu : undefined}
{...rest}
/>
);
}