mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore: update design of add users dialog (#24287)
update design to match Figma design Figma: https://www.figma.com/design/klGTlHSPQwI4KBvAMdebrx/Customer-Usage-Controls-for-AI-Governance-Add-On?node-id=448-9898&m=dev Before: <img width="482" height="638" alt="Screenshot 2026-04-13 at 16 48 32" src="https://github.com/user-attachments/assets/940dbbd0-4817-4365-aad2-08148550109c" /> After: <img width="474" height="653" alt="Screenshot 2026-04-13 at 16 45 55" src="https://github.com/user-attachments/assets/c63591fa-1513-4c66-b48f-82df794ca080" />
This commit is contained in:
@@ -13,18 +13,7 @@ import { AvatarDataSkeleton } from "#/components/Avatar/AvatarDataSkeleton";
|
||||
import { Checkbox } from "#/components/Checkbox/Checkbox";
|
||||
import { EmptyState } from "#/components/EmptyState/EmptyState";
|
||||
import { SearchField } from "#/components/SearchField/SearchField";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from "#/components/Table/Table";
|
||||
import {
|
||||
TableLoaderSkeleton,
|
||||
TableRowSkeleton,
|
||||
} from "#/components/TableLoader/TableLoader";
|
||||
import { useDebouncedFunction } from "#/hooks/debounce";
|
||||
import { useClickableTableRow } from "#/hooks/useClickableTableRow";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { prepareQuery } from "#/utils/filters";
|
||||
|
||||
@@ -120,9 +109,9 @@ const InnerMultiSelect = <T extends SelectedUser>({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2", className)}>
|
||||
<div className={cn("flex flex-col gap-4", className)}>
|
||||
<SearchField
|
||||
className="w-full"
|
||||
className="h-12 w-full rounded-lg"
|
||||
value={inputValue}
|
||||
aria-label="Search users"
|
||||
onChange={(query) => {
|
||||
@@ -136,17 +125,22 @@ const InnerMultiSelect = <T extends SelectedUser>({
|
||||
}}
|
||||
placeholder="Search users..."
|
||||
/>
|
||||
<div className="max-h-[360px] overflow-auto">
|
||||
<Table>
|
||||
<TableBody className="[&_td]:h-[72px]">
|
||||
<div className="h-96 w-full rounded-lg border border-border border-solid">
|
||||
<div className="h-full overflow-hidden p-px">
|
||||
<div
|
||||
className="h-full overflow-y-auto overflow-x-hidden overscroll-contain"
|
||||
onWheel={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<UsersTable
|
||||
error={error}
|
||||
onChange={onChange}
|
||||
selected={selected}
|
||||
users={users}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -167,11 +161,9 @@ const UsersTable = <T extends SelectedUser>({
|
||||
}: UsersTable<T>) => {
|
||||
if (error) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<ErrorAlert error={error} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<div className="p-3">
|
||||
<ErrorAlert error={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -181,66 +173,72 @@ const UsersTable = <T extends SelectedUser>({
|
||||
|
||||
if (users.length === 0) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState message="No users found" isCompact />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<div className="p-3">
|
||||
<EmptyState message="No users found" isCompact />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return users.map((user) => {
|
||||
const checked = selected.some((u) => userMatches(u, user));
|
||||
return (
|
||||
<UserRow
|
||||
key={user.username}
|
||||
user={user}
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
>
|
||||
<TableCell className="border-0">
|
||||
<div className="flex items-center gap-5">
|
||||
<Checkbox
|
||||
data-testid={`checkbox-${user.username}`}
|
||||
checked={checked}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onCheckedChange={(checked) => {
|
||||
onChange(user, Boolean(checked));
|
||||
}}
|
||||
aria-label={`Select user ${user.username}`}
|
||||
/>
|
||||
<AvatarData
|
||||
title={user.username}
|
||||
subtitle={user.email}
|
||||
src={user.avatar_url}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</UserRow>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{users.map((user, index) => {
|
||||
const checked = selected.some((u) => userMatches(u, user));
|
||||
return (
|
||||
<UserRow
|
||||
key={user.username}
|
||||
user={user}
|
||||
checked={checked}
|
||||
isFirst={index === 0}
|
||||
isLast={index === users.length - 1}
|
||||
onChange={onChange}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
data-testid={`checkbox-${user.username}`}
|
||||
checked={checked}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onCheckedChange={(checked) => {
|
||||
onChange(user, Boolean(checked));
|
||||
}}
|
||||
aria-label={`Select user ${user.username}`}
|
||||
/>
|
||||
<AvatarData
|
||||
title={user.username}
|
||||
subtitle={user.email}
|
||||
src={user.avatar_url}
|
||||
/>
|
||||
</div>
|
||||
</UserRow>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TableLoader: FC = () => {
|
||||
const skeletonRows = Array.from({ length: 6 }, (_, index) => index);
|
||||
|
||||
return (
|
||||
<TableLoaderSkeleton>
|
||||
<TableRowSkeleton>
|
||||
<TableCell className="w-2/6">
|
||||
<div className="flex items-center gap-5">
|
||||
<div>
|
||||
{skeletonRows.map((row) => (
|
||||
<div className="flex min-h-[64px] items-center px-4 py-3" key={row}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox disabled />
|
||||
<AvatarDataSkeleton />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRowSkeleton>
|
||||
</TableLoaderSkeleton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface UserRowProps<T extends SelectedUser> {
|
||||
checked: boolean;
|
||||
children?: ReactNode;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
onChange: (user: T, checked: boolean) => void;
|
||||
user: T;
|
||||
}
|
||||
@@ -248,23 +246,40 @@ interface UserRowProps<T extends SelectedUser> {
|
||||
const UserRow = <T extends SelectedUser>({
|
||||
checked,
|
||||
children,
|
||||
isFirst,
|
||||
isLast,
|
||||
onChange,
|
||||
user,
|
||||
}: UserRowProps<T>) => {
|
||||
const clickableProps = useClickableTableRow({
|
||||
onClick: () => onChange(user, !checked),
|
||||
});
|
||||
return (
|
||||
<TableRow
|
||||
{...clickableProps}
|
||||
<div
|
||||
data-testid={`user-${user.username}`}
|
||||
className={cn([
|
||||
checked ? "bg-muted hover:bg-muted" : undefined,
|
||||
clickableProps.className,
|
||||
])}
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
"hover:[&>div]:ring-1 hover:[&>div]:ring-inset hover:[&>div]:ring-border-hover",
|
||||
checked
|
||||
? "[&>div]:bg-surface-secondary hover:[&>div]:bg-surface-secondary"
|
||||
: undefined,
|
||||
)}
|
||||
onClick={() => onChange(user, !checked)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onChange(user, !checked);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</TableRow>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[64px] items-center px-4 py-3",
|
||||
isFirst && "rounded-tl-md rounded-tr-md",
|
||||
isLast && "rounded-bl-md rounded-br-md",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -14,7 +14,12 @@ import type {
|
||||
import { Avatar } from "#/components/Avatar/Avatar";
|
||||
import { AvatarData } from "#/components/Avatar/AvatarData";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
} from "#/components/Dialog/Dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -26,6 +31,7 @@ import { UsersFilter } from "#/components/Filter/UsersFilter";
|
||||
import { LastSeen } from "#/components/LastSeen/LastSeen";
|
||||
import { MultiMemberSelect } from "#/components/MultiUserSelect/MultiUserSelect";
|
||||
import { PaginationContainer } from "#/components/PaginationWidget/PaginationContainer";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -134,17 +140,33 @@ const AddUsersDialog: FC<AddUsersDialogProps> = ({
|
||||
const [selected, setSelected] = useState<OrganizationMemberWithUserData[]>(
|
||||
[],
|
||||
);
|
||||
const closeDialog = () => {
|
||||
setAddUserDialogOpen(false);
|
||||
setFilter("");
|
||||
setSelected([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="lg" onClick={() => setAddUserDialogOpen(true)}>
|
||||
<UserPlusIcon />
|
||||
Add users
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
<Dialog
|
||||
open={addUserDialogOpen}
|
||||
title="Add users"
|
||||
disabled={submitting}
|
||||
description={
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
closeDialog();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
data-testid="dialog"
|
||||
className="max-w-md gap-4 border-border-default bg-surface-primary p-8 text-content-primary"
|
||||
>
|
||||
<DialogTitle className="font-semibold text-content-primary">
|
||||
Add user(s)
|
||||
</DialogTitle>
|
||||
<MultiMemberSelect
|
||||
organizationId={organizationId}
|
||||
filter={filter}
|
||||
@@ -158,25 +180,39 @@ const AddUsersDialog: FC<AddUsersDialogProps> = ({
|
||||
}}
|
||||
selected={selected}
|
||||
/>
|
||||
}
|
||||
hideCancel={false}
|
||||
cancelText="Cancel"
|
||||
confirmText="Add users"
|
||||
onClose={() => setAddUserDialogOpen(false)}
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await onSubmit(selected);
|
||||
setAddUserDialogOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "Failed to add members."), {
|
||||
description: getErrorDetail(error),
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DialogFooter className="mt-4 flex-row justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={closeDialog}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={submitting || selected.length === 0}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await onSubmit(selected);
|
||||
closeDialog();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
getErrorMessage(error, "Failed to add members."),
|
||||
{
|
||||
description: getErrorDetail(error),
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Spinner loading={submitting} />
|
||||
Add users
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user