Files
outline/app/components/Sharing/Document/DocumentMemberList.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

239 lines
7.2 KiB
TypeScript

import { orderBy } from "es-toolkit/compat";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { Link, useHistory } from "react-router-dom";
import { toast } from "sonner";
import styled, { useTheme } from "styled-components";
import { s } from "@shared/styles";
import { DocumentPermission } from "@shared/types";
import type Document from "~/models/Document";
import type UserMembership from "~/models/UserMembership";
import { GroupAvatar } from "~/components/Avatar";
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import type { Permission } from "~/types";
import { EmptySelectValue } from "~/types";
import { homePath } from "~/utils/routeHelpers";
import { ListItem } from "../components/ListItem";
import { GroupMembersPopover } from "../components";
import DocumentMemberListItem from "./DocumentMemberListItem";
import ButtonLink from "~/components/ButtonLink";
type Props = {
/** Document to which team members are supposed to be invited */
document: Document;
/** Children to be rendered before the list of members */
children?: React.ReactNode;
/** List of users that have been invited during the current editing session */
invitedInSession: string[];
};
function DocumentMemberList({ document, invitedInSession }: Props) {
const { userMemberships, groupMemberships } = useStores();
const user = useCurrentUser();
const history = useHistory();
const can = usePolicy(document);
const { t } = useTranslation();
const theme = useTheme();
const handleRemoveUser = React.useCallback(
async (item) => {
try {
await userMemberships.delete({
documentId: document.id,
userId: item.id,
} as UserMembership);
if (item.id === user.id) {
history.push(homePath());
} else {
toast.success(
t(`{{ userName }} was removed from the document`, {
userName: item.name,
})
);
}
} catch (_err) {
toast.error(t("Could not remove user"));
}
},
[t, history, userMemberships, user, document]
);
const handleUpdateUser = React.useCallback(
async (userToUpdate, permission) => {
try {
await userMemberships.create({
documentId: document.id,
userId: userToUpdate.id,
permission,
});
toast.success(
t(`Permissions for {{ userName }} updated`, {
userName: userToUpdate.name,
})
);
} catch (_err) {
toast.error(t("Could not update user"));
}
},
[t, userMemberships, document]
);
// Order newly added users first during the current editing session, on reload members are
// ordered by name
const members = React.useMemo(
() =>
orderBy(
Array.from(
new Map(
document.members.map((memberUser) => [memberUser.id, memberUser])
).values()
),
(memberUser) =>
(invitedInSession.includes(memberUser.id) ? "_" : "") +
memberUser.name.toLocaleLowerCase(),
"asc"
),
[document.members, invitedInSession]
);
const permissions = React.useMemo(
() =>
[
{
label: t("View only"),
value: DocumentPermission.Read,
},
{
label: t("Can edit"),
value: DocumentPermission.ReadWrite,
},
{
label: t("Manage"),
value: DocumentPermission.Admin,
},
{
divider: true,
label: t("Remove"),
value: EmptySelectValue,
},
] as Permission[],
[t]
);
return (
<>
{Array.from(
new Map(
groupMemberships
.inDocument(document.id)
.map((membership) => [membership.group.id, membership])
).values()
)
.sort((a, b) =>
(
(invitedInSession.includes(a.group.id) ? "_" : "") + a.group.name
).localeCompare(
(invitedInSession.includes(b.group.id) ? "_" : "") + b.group.name
)
)
.map((membership) => {
const MaybeLink = membership?.source ? StyledLink : React.Fragment;
return (
<ListItem
key={membership.id}
image={
<GroupAvatar
group={membership.group}
backgroundColor={theme.modalBackground}
/>
}
title={membership.group.name}
subtitle={
membership.sourceId ? (
<Trans>
Has access through{" "}
<MaybeLink
// @ts-expect-error to prop does not exist on React.Fragment
to={membership.source?.document?.path ?? ""}
>
parent
</MaybeLink>
</Trans>
) : (
<GroupMembersPopover group={membership.group}>
<StyledButtonLink>
{t("{{ count }} member", {
count: membership.group.memberCount,
})}
</StyledButtonLink>
</GroupMembersPopover>
)
}
actions={
<div style={{ marginRight: -8 }}>
<InputMemberPermissionSelect
permissions={permissions}
onChange={async (
permission: DocumentPermission | typeof EmptySelectValue
) => {
if (permission === EmptySelectValue) {
await groupMemberships.delete({
documentId: document.id,
groupId: membership.groupId,
});
} else {
await groupMemberships.create({
documentId: document.id,
groupId: membership.groupId,
permission,
});
}
}}
disabled={!can.manageUsers}
value={membership.permission}
/>
</div>
}
/>
);
})}
{members.map((item) => (
<DocumentMemberListItem
key={item.id}
user={item}
membership={item.getMembership(document)}
onRemove={() => handleRemoveUser(item)}
onUpdate={
can.manageUsers
? (permission) => handleUpdateUser(item, permission)
: undefined
}
onLeave={
item.id === user.id ? () => handleRemoveUser(item) : undefined
}
/>
))}
</>
);
}
const StyledButtonLink = styled(ButtonLink)`
color: ${s("textTertiary")};
&:hover {
text-decoration: underline;
}
`;
const StyledLink = styled(Link)`
color: ${s("textTertiary")};
text-decoration: underline;
`;
export default observer(DocumentMemberList);