Files
outline/app/scenes/Collection/components/MembershipPreview.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

112 lines
3.1 KiB
TypeScript

import { sortBy } from "es-toolkit/compat";
import { observer } from "mobx-react";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { PAGINATION_SYMBOL } from "~/stores/base/Store";
import type Collection from "~/models/Collection";
import { AvatarSize } from "~/components/Avatar";
import Facepile from "~/components/Facepile";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
type Props = {
collection: Collection;
limit?: number;
};
const MembershipPreview = ({ collection, limit = 8 }: Props) => {
const [isLoading, setIsLoading] = useState(false);
const [usersCount, setUsersCount] = useState(0);
const [groupsCount, setGroupsCount] = useState(0);
const { t } = useTranslation();
const { memberships, groupMemberships, users } = useStores();
const collectionUsers = users.inCollection(collection.id);
const isMobile = useMobile();
useEffect(() => {
const fetchData = async () => {
if (collection.permission || isMobile) {
return;
}
setIsLoading(true);
try {
const options = {
id: collection.id,
limit,
};
const [users, groups] = await Promise.all([
memberships.fetchPage(options),
groupMemberships.fetchPage(options),
]);
if (users[PAGINATION_SYMBOL]) {
setUsersCount(users[PAGINATION_SYMBOL].total ?? 0);
}
if (groups[PAGINATION_SYMBOL]) {
setGroupsCount(groups[PAGINATION_SYMBOL].total ?? 0);
}
} finally {
setIsLoading(false);
}
};
void fetchData();
}, [
isMobile,
collection.permission,
collection.id,
groupMemberships,
memberships,
limit,
]);
if (isLoading || collection.permission || isMobile) {
return null;
}
const overflow = usersCount + groupsCount - collectionUsers.length;
return (
<NudeButton
tooltip={{
content:
usersCount > 0
? groupsCount > 0
? groupsCount > 1
? t(
`{{ usersCount }} users and {{ groupsCount }} groups with access`,
{ usersCount, groupsCount, count: usersCount }
)
: t(`{{ usersCount }} users and a group have access`, {
usersCount,
count: usersCount,
})
: t(`{{ usersCount }} users with access`, {
usersCount,
count: usersCount,
})
: t(`{{ groupsCount }} groups with access`, {
groupsCount,
count: groupsCount,
}),
delay: 250,
}}
width="auto"
height="auto"
>
<Fade>
<Facepile
size={AvatarSize.Large}
users={sortBy(collectionUsers, "lastActiveAt")}
overflow={overflow}
limit={limit}
/>
</Fade>
</NudeButton>
);
};
export default observer(MembershipPreview);