Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions apps/web/src/base/ComboBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,17 @@ function ComboboxTriggerButton({
TriggerButtonProps,
selectedItem,
renderRow,
placeholder,
id,
}) {
return (
<button
className="flex justify-between items-center px-2.5 py-1.5 bg-white border border-gray-300 rounded font-medium capitalize w-full [&_svg]:ml-1"
className="flex justify-between items-center px-2.5 py-2 bg-white border border-gray-300 rounded font-medium capitalize w-full [&_svg]:ml-1"
{...TriggerButtonProps}
id={id}
type="button"
>
{selectedItem ? renderRow(selectedItem) : "Select user"}
{selectedItem ? renderRow(selectedItem) : placeholder}
<Icon icon={ChevronDown} />
</button>
);
Expand All @@ -41,6 +42,8 @@ type ComboBoxProps = {
onFilter: (term: string) => void;
onChange: (item: unknown) => void;
itemToString?: (item: unknown) => string;
/** Text shown on the trigger when nothing is selected */
placeholder?: string;
id?: string;
};

Expand All @@ -56,6 +59,7 @@ export default function ComboBox({
onFilter,
onChange,
itemToString,
placeholder = "Select…",
id,
}: ComboBoxProps) {
itemToString = itemToString || defaultToString;
Expand Down Expand Up @@ -87,6 +91,7 @@ export default function ComboBox({
TriggerButtonProps={getToggleButtonProps()}
selectedItem={selectedItem}
renderRow={renderRow}
placeholder={placeholder}
id={id}
/>
<ul
Expand Down
55 changes: 0 additions & 55 deletions apps/web/src/users/components/PrimaryGroup.tsx

This file was deleted.

8 changes: 5 additions & 3 deletions apps/web/src/users/components/UserDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { CircleAlert, ShieldUserIcon } from "lucide-react";
import Label from "@/base/Label";
import Handle from "./Handle";
import Password from "./Password";
import PrimaryGroup from "./PrimaryGroup";
import { UserActivationBanner } from "./UserActivationBanner";
import UserAdministratorRole from "./UserAdministratorRole";
import UserGroups from "./UserGroups";
Expand Down Expand Up @@ -79,8 +78,11 @@ export default function UserDetail({ userId }: UserDetailProps) {

<div className="mb-4 md:grid md:grid-cols-2 md:gap-x-4">
<div>
<UserGroups userId={id} memberGroups={groups} />
<PrimaryGroup groups={groups} id={id} primaryGroup={primary_group} />
<UserGroups
userId={id}
memberGroups={groups}
primaryGroup={primary_group}
/>
</div>
<UserPermissions permissions={permissions} />
</div>
Expand Down
31 changes: 0 additions & 31 deletions apps/web/src/users/components/UserGroup.tsx

This file was deleted.

199 changes: 157 additions & 42 deletions apps/web/src/users/components/UserGroups.tsx
Original file line number Diff line number Diff line change
@@ -1,74 +1,189 @@
import { useUpdateUser } from "@administration/queries";
import { useFuse } from "@app/fuse";
import BoxGroup from "@base/BoxGroup";
import InputLabel from "@base/InputLabel";
import BoxGroupSection from "@base/BoxGroupSection";
import ComboBox from "@base/ComboBox";
import Icon from "@base/Icon";
import Link from "@base/Link";
import LoadingPlaceholder from "@base/LoadingPlaceholder";
import NoneFoundSection from "@base/NoneFoundSection";
import QueryError from "@base/QueryError";
import { RadioGroup, RadioGroupItem } from "@base/RadioGroup";
import { useListGroups } from "@groups/queries";
import type { GroupMinimal } from "@groups/types";
import { xor } from "es-toolkit/array";
import { useId } from "react";
import { UserGroup } from "./UserGroup";
import { X } from "lucide-react";

type UserGroupsType = {
/** The groups associated with the user */
/** A stable empty fallback so `useFuse` doesn't reset its term while loading */
const NO_GROUPS: GroupMinimal[] = [];

type UserGroupsProps = {
/** The groups the user is a member of */
memberGroups: GroupMinimal[];

/** The user's primary group, or null if none */
primaryGroup: GroupMinimal | null;

/** The unique user id */
userId: number;
};

/**
* A list of user groups
* Manages a user's group membership and primary group.
*
* A single-select combobox adds groups to the membership list. Each member is
* a row in a radio group that selects the primary group, with a button to
* revoke membership.
*/
export default function UserGroups({ memberGroups, userId }: UserGroupsType) {
export default function UserGroups({
memberGroups,
primaryGroup,
userId,
}: UserGroupsProps) {
const { data, isPending, isError } = useListGroups();
const mutation = useUpdateUser();
const labelId = useId();

if (isError && !data) {
return <QueryError noun="groups" />;
}
const [results, term, setTerm] = useFuse<GroupMinimal>(data ?? NO_GROUPS, [
"name",
]);

if (isPending) {
return <LoadingPlaceholder />;
const memberIds = new Set(memberGroups.map((group) => group.id));
const availableGroups = results.filter((group) => !memberIds.has(group.id));

function addGroup(id: number) {
mutation.mutate({
userId,
update: { groups: [...memberIds, id] },
});
}

function handleEdit(groupId: number) {
function removeGroup(id: number) {
mutation.mutate({
userId,
update: {
groups: xor(
memberGroups.map((g) => g.id),
[groupId],
),
groups: [...memberIds].filter((memberId) => memberId !== id),
...(primaryGroup?.id === id ? { primary_group: null } : {}),
},
});
}

return (
<div>
<InputLabel id={labelId}>Groups</InputLabel>
<BoxGroup
role="listbox"
aria-multiselectable
aria-labelledby={labelId}
className="mb-4"
>
{data.length ? (
data.map(({ id, name }) => (
<UserGroup
key={id}
id={id}
name={name}
toggled={memberGroups.some((g) => g.id === id)}
onClick={handleEdit}
/>
))
) : (
<NoneFoundSection key="noneFound" noun="groups" />
function setPrimaryGroup(value: string) {
mutation.mutate({
userId,
update: { primary_group: value === "none" ? null : Number(value) },
});
}

function renderAdd() {
if (isError && !data) {
return <QueryError noun="groups" />;
}

if (isPending) {
return <LoadingPlaceholder />;
}

if (data.length === 0) {
return (
<p className="text-gray-500">
No groups have been created yet.{" "}
<Link
to="/administration/groups"
className="text-blue-600 hover:underline"
>
Manage groups
</Link>
.
</p>
);
}

if (availableGroups.length === 0) {
return (
<p className="text-gray-500">This user is a member of every group.</p>
);
}

return (
<ComboBox
items={availableGroups}
selectedItem={null}
term={term}
onFilter={setTerm}
onChange={(group) => addGroup((group as GroupMinimal).id)}
itemToString={(group) => (group ? (group as GroupMinimal).name : "")}
renderRow={(group) => (
<span className="capitalize">{(group as GroupMinimal).name}</span>
)}
</BoxGroup>
placeholder="Add group"
/>
);
}

function renderMembership() {
if (memberGroups.length) {
return (
<RadioGroup
className="mt-4"
aria-label="Primary group"
value={primaryGroup ? String(primaryGroup.id) : "none"}
onValueChange={setPrimaryGroup}
>
<BoxGroup>
{memberGroups.map((group) => (
<BoxGroupSection
key={group.id}
className="flex items-center gap-3"
>
<RadioGroupItem
id={`primary-${group.id}`}
value={String(group.id)}
/>
<label
htmlFor={`primary-${group.id}`}
className="grow capitalize cursor-pointer select-none"
>
{group.name}
</label>
<button
type="button"
aria-label={`Remove ${group.name}`}
className="text-gray-500 hover:text-gray-800"
onClick={() => removeGroup(group.id)}
>
<Icon icon={X} />
</button>
</BoxGroupSection>
))}
<BoxGroupSection className="flex items-center gap-3">
<RadioGroupItem id="primary-none" value="none" />
<label
htmlFor="primary-none"
className="grow cursor-pointer select-none"
>
No primary group
</label>
</BoxGroupSection>
</BoxGroup>
</RadioGroup>
);
}

// When no groups exist at all, renderAdd already explains the situation.
if (data?.length === 0) {
return null;
}

return (
<p className="mt-4 text-gray-500">
This user is not a member of any groups.
</p>
);
}

return (
<div className="mb-4">
<span className="font-medium mb-2 inline-block">Groups</span>
{renderAdd()}
{renderMembership()}
</div>
);
}
Loading