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

452 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { debounce } from "es-toolkit/compat";
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import Group from "~/models/Group";
import type User from "~/models/User";
import Invite from "~/scenes/Invite";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Badge from "~/components/Badge";
import Button from "~/components/Button";
import ButtonLink from "~/components/ButtonLink";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DelayedMount from "~/components/DelayedMount";
import Empty from "~/components/Empty";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import PlaceholderList from "~/components/List/Placeholder";
import PaginatedList from "~/components/PaginatedList";
import { ListItem } from "~/components/Sharing/components/ListItem";
import Text from "~/components/Text";
import Time from "~/components/Time";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect";
import { GroupPermission } from "@shared/types";
import { GroupValidation } from "@shared/validations";
import type { Permission } from "~/types";
import { EmptySelectValue } from "~/types";
import type GroupUser from "~/models/GroupUser";
import Switch from "~/components/Switch";
import history from "~/utils/history";
import { settingsPath } from "~/utils/routeHelpers";
type Props = {
group: Group;
onSubmit: () => void;
};
export function CreateGroupDialog() {
const { dialogs, groups } = useStores();
const { t } = useTranslation();
const [name, setName] = React.useState<string | undefined>();
const [description, setDescription] = React.useState<string | undefined>();
const [isSaving, setIsSaving] = React.useState(false);
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setIsSaving(true);
const group = new Group(
{
name,
description,
},
groups
);
try {
await group.save();
dialogs.closeAllModals();
history.push(settingsPath("groups", group.id, "members"));
} catch (err) {
toast.error(err.message);
} finally {
setIsSaving(false);
}
},
[dialogs, groups, name, description]
);
return (
<form onSubmit={handleSubmit}>
<Text as="p" type="secondary">
<Trans>
Groups are for organizing your team. They work best when centered
around a function or a responsibility Support or Engineering for
example.
</Trans>
</Text>
<Flex column>
<Input
type="text"
label="Name"
onChange={(e) => setName(e.target.value)}
value={name}
maxLength={GroupValidation.maxNameLength}
showCharacterCount
required
autoFocus
flex
/>
<Input
type="textarea"
label="Description"
placeholder={t("Optional")}
onChange={(e) => setDescription(e.target.value)}
value={description || ""}
maxLength={GroupValidation.maxDescriptionLength}
flex
/>
</Flex>
<Text as="p" type="secondary">
<Trans>Youll be able to add people to the group next.</Trans>
</Text>
<Button type="submit" disabled={isSaving || !name}>
{isSaving ? `${t("Creating")}` : t("Continue")}
</Button>
</form>
);
}
export function EditGroupDialog({ group, onSubmit }: Props) {
const { t } = useTranslation();
const [name, setName] = React.useState(group.name);
const [description, setDescription] = React.useState(group.description || "");
const [disableMentions, setDisableMentions] = React.useState(
group.disableMentions || false
);
const [isSaving, setIsSaving] = React.useState(false);
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setIsSaving(true);
try {
await group.save({
name,
description,
disableMentions,
});
onSubmit();
} catch (err) {
toast.error(err.message);
} finally {
setIsSaving(false);
}
},
[group, onSubmit, name, description, disableMentions]
);
const handleNameChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setName(ev.target.value);
},
[]
);
return (
<form onSubmit={handleSubmit}>
<Text as="p" type="secondary">
{group.isExternallyManaged ? (
<Trans>
This group is managed by an external authentication provider. The
name is synced automatically and cannot be changed.
</Trans>
) : (
<Trans>
You can edit the name of this group at any time, however doing so
too often might confuse your team mates.
</Trans>
)}
</Text>
<Flex column>
<Input
type="text"
label={t("Name")}
onChange={handleNameChange}
value={name}
maxLength={GroupValidation.maxNameLength}
showCharacterCount
disabled={group.isExternallyManaged}
required
autoFocus
flex
/>
<Input
type="textarea"
label={t("Description")}
placeholder={t("Optional")}
onChange={(e) => setDescription(e.target.value)}
value={description}
maxLength={GroupValidation.maxDescriptionLength}
flex
/>
<Switch
id="mentions"
label={t("Hidden")}
note={t(
"Prevent this group from being mentionable in documents or comments"
)}
checked={disableMentions}
onChange={setDisableMentions}
/>
</Flex>
<Button type="submit" disabled={isSaving || !name}>
{isSaving ? `${t("Saving")}` : t("Save")}
</Button>
</form>
);
}
export function DeleteGroupDialog({ group, onSubmit }: Props) {
const { t } = useTranslation();
const handleSubmit = async () => {
await group.delete();
onSubmit();
};
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Im sure Delete")}
savingText={`${t("Deleting")}`}
danger
>
<Trans
defaults="Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with."
values={{
groupName: group.name,
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
);
}
export const AddPeopleToGroupDialog = observer(function ({
group,
}: Pick<Props, "group">) {
const { dialogs, users, groupUsers } = useStores();
const { t } = useTranslation();
const team = useCurrentTeam();
const can = usePolicy(team);
const [query, setQuery] = React.useState("");
const debouncedFetch = React.useMemo(
() => debounce((q) => users.fetchPage({ query: q }), 250),
[users]
);
const handleFilter = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
const updatedQuery = ev.target.value;
setQuery(updatedQuery);
void debouncedFetch(updatedQuery);
},
[debouncedFetch]
);
const handleAddUser = React.useCallback(
async (user: User) => {
try {
await groupUsers.create({
groupId: group.id,
userId: user.id,
});
toast.success(
t(`{{userName}} was added to the group`, {
userName: user.name,
}),
{
icon: <Avatar model={user} size={AvatarSize.Toast} />,
}
);
} catch (_err) {
toast.error(t("Could not add user"));
}
},
[t, groupUsers, group.id]
);
const handleInvitePeople = React.useCallback(() => {
dialogs.openModal({
title: t("Invite people"),
content: <Invite onSubmit={dialogs.closeAllModals} />,
replace: true,
});
}, [t, dialogs]);
const { loading } = useRequest(
React.useCallback(
() => groupUsers.fetchAll({ id: group.id }),
[groupUsers, group]
),
true
);
return (
<Flex column>
<Text as="p" type="secondary">
{t(
"Add members below to give them access to the group. Need to add someone whos not yet a member?"
)}{" "}
{can.inviteUser ? (
<ButtonLink onClick={handleInvitePeople}>
{t("Invite them to {{teamName}}", {
teamName: team.name,
})}
</ButtonLink>
) : (
t("Ask an admin to invite them first")
)}
.
</Text>
<Input
type="search"
placeholder={`${t("Search by name")}`}
value={query}
onChange={handleFilter}
label={t("Search people")}
labelHidden
autoFocus
flex
/>
{loading ? (
<DelayedMount>
<PlaceholderList count={5} />
</DelayedMount>
) : (
<PaginatedList<User>
empty={
query ? (
<Empty>{t("No people matching your search")}</Empty>
) : (
<Empty>{t("No people left to add")}</Empty>
)
}
items={users.notInGroup(group.id, query)}
fetch={query ? undefined : users.fetchPage}
renderItem={(item) => (
<GroupMemberListItem
key={item.id}
user={item}
group={group}
groupUser={undefined}
onAdd={() => handleAddUser(item)}
/>
)}
/>
)}
</Flex>
);
});
type GroupMemberListItemProps = {
user: User;
group: Group;
groupUser: GroupUser | undefined;
onAdd?: () => Promise<void>;
onRemove?: () => Promise<void>;
};
const GroupMemberListItem = observer(function ({
user,
group,
groupUser,
onAdd,
}: GroupMemberListItemProps) {
const { t } = useTranslation();
const { groupUsers } = useStores();
const can = usePolicy(group);
const permissions = React.useMemo(
() =>
[
{
label: t("Group admin"),
value: GroupPermission.Admin,
},
{
label: t("Member"),
value: GroupPermission.Member,
},
{
divider: true,
label: t("Remove"),
value: EmptySelectValue,
},
] as Permission[],
[t]
);
return (
<ListItem
title={user.name}
subtitle={
<>
{user.lastActiveAt ? (
<Trans>
Active <Time dateTime={user.lastActiveAt} /> ago
</Trans>
) : (
t("Never signed in")
)}{" "}
{user.isInvited && <Badge>{t("Invited")}</Badge>}
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
</>
}
image={<Avatar model={user} size={AvatarSize.Large} />}
actions={
<Flex align="center">
{onAdd ? (
<Button onClick={onAdd} neutral>
{t("Add")}
</Button>
) : (
<div style={{ marginRight: -8 }}>
<InputMemberPermissionSelect
permissions={permissions}
onChange={async (
permission: GroupPermission | typeof EmptySelectValue
) => {
try {
if (permission === EmptySelectValue) {
await groupUsers.delete({
userId: user.id,
groupId: group.id,
});
} else {
await groupUsers.update({
userId: user.id,
groupId: group.id,
permission,
});
}
} catch (err) {
toast.error(err.message);
return false;
}
return true;
}}
disabled={!can.update || group.isExternallyManaged}
value={groupUser?.permission}
/>
</div>
)}
</Flex>
}
/>
);
});