Files
calendar/packages/features/users/components/UserTable/BulkActions/MassAssignAttributes.tsx
T
853f9bc436 perf: do not import from @calcom/ui barrel file (#20184)
* Icon and IconName

* Button and ButtonGroup

* UserAvatar

* AvatarGroup

* Avatar

* WizardLayout

* Dialogs

* EmptyScreen

* showToast and TextField

* Editor

* Skeleton

* Skeleton

* TopBanner and showToast

* Button again

* more

* perf: Remove app-store reference from @calcom/ui

* more

* Fixing types

* Icon

* Fixed casing

* dropdown

* more

* Select

* more

* Badge

* List

* more

* Divider

* more

* fix

* fix type check

* refactor

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix type check

* fix

* fix

* fix

* fix

* more

* more

* more

* more

* add index file to components/command

* fix

* fix

* fix

* fix imports

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix build errors

* fix build errors

* fix

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-03-19 19:00:55 -03:00

342 lines
11 KiB
TypeScript

import type { Table } from "@tanstack/react-table";
import { createContext, useContext, useState, useMemo, type PropsWithChildren } from "react";
import type { Dispatch, SetStateAction } from "react";
import { DataTableSelectionBar, type ColumnFilter } from "@calcom/features/data-table";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { Attribute as _Attribute, AttributeOption } from "@calcom/prisma/client";
import { trpc } from "@calcom/trpc";
import classNames from "@calcom/ui/classNames";
import { Alert } from "@calcom/ui/components/alert";
import { Button } from "@calcom/ui/components/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@calcom/ui/components/command";
import { Input } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { Popover, PopoverContent, PopoverTrigger } from "@calcom/ui/components/popover";
import { showToast } from "@calcom/ui/components/toast";
import type { UserTableUser } from "../types";
interface Props {
table: Table<UserTableUser>;
filters: ColumnFilter[];
}
type Attribute = _Attribute & { options: AttributeOption[] };
type AttributesContextType = {
selectedAttribute: string | undefined;
setSelectedAttribute: Dispatch<SetStateAction<string | undefined>>;
foundAttributeInCache: Attribute | undefined;
selectedAttributeOptions: string[];
setSelectedAttributeOptions: Dispatch<SetStateAction<string[]>>;
attributes: Attribute[] | undefined;
};
const AttributesContext = createContext<AttributesContextType | null>(null);
function AttributesProvider({ children }: PropsWithChildren) {
const { data: attributes } = trpc.viewer.attributes.list.useQuery();
const [selectedAttribute, setSelectedAttribute] = useState<string>();
const [selectedAttributeOptions, setSelectedAttributeOptions] = useState<string[]>([]);
const foundAttributeInCache = useMemo(
() => attributes?.find((attr) => attr.id === selectedAttribute),
[selectedAttribute, attributes]
);
const value: AttributesContextType = {
selectedAttribute,
setSelectedAttribute,
selectedAttributeOptions,
setSelectedAttributeOptions,
foundAttributeInCache,
attributes,
};
return <AttributesContext.Provider value={value}>{children}</AttributesContext.Provider>;
}
function useAttributes() {
const context = useContext(AttributesContext);
if (!context) {
throw new Error("useAttributes must be used within an AttributesProvider");
}
return context;
}
function getTranslateableStringFromType(type: string) {
switch (type) {
case "SINGLE_SELECT":
return "single_select";
case "MULTI_SELECT":
return "multi_select";
case "TEXT":
return "text";
case "NUMBER":
return "number";
default:
return undefined;
}
}
function SelectedAttributeToAssign() {
const { t } = useLocale();
const {
foundAttributeInCache: foundAttribute,
selectedAttributeOptions,
setSelectedAttributeOptions,
} = useAttributes();
if (!foundAttribute) {
return null;
}
const translateableType = getTranslateableStringFromType(foundAttribute.type);
const isSelectable = foundAttribute.type === "SINGLE_SELECT" || foundAttribute.type === "MULTI_SELECT";
return (
<CommandList>
<div className="flex items-center gap-2 border-b px-3 py-2">
<span className="block">{foundAttribute.name}</span>
{translateableType && <span className="text-muted block text-xs">({t(translateableType)})</span>}
</div>
<CommandGroup>
{isSelectable ? (
<>
{foundAttribute.options.map((option) => {
return (
<CommandItem
key={option.id}
className="hover:cursor-pointer"
onSelect={() => {
if (foundAttribute.type === "SINGLE_SELECT") {
setSelectedAttributeOptions([option.id]);
} else {
setSelectedAttributeOptions((prev: string[]) => {
if (prev.includes(option.id)) {
return prev.filter((id: string) => id !== option.id);
}
return [...prev, option.id];
});
}
}}>
<span>{option.value}</span>
<div
className={classNames(
"ml-auto flex h-4 w-4 items-center justify-center rounded-sm border"
)}>
{selectedAttributeOptions?.includes(option.id) ? (
<Icon name="check" className={classNames("h-4 w-4")} />
) : null}
</div>
</CommandItem>
);
})}
</>
) : (
<>
<CommandItem>
<Input
defaultValue={selectedAttributeOptions[0] || ""}
type={foundAttribute.type === "TEXT" ? "text" : "number"}
onBlur={(e) => {
// trigger onBlur so it's set as Apply is pressed (but not onChange) which triggers
// a re-render which also loses focus.
setSelectedAttributeOptions([e.target.value]);
}}
/>
</CommandItem>
</>
)}
</CommandGroup>
</CommandList>
);
}
function Content({ showMultiSelectWarning }: { showMultiSelectWarning: boolean }) {
const { t } = useLocale();
const {
selectedAttribute,
setSelectedAttribute,
selectedAttributeOptions,
setSelectedAttributeOptions,
attributes,
foundAttributeInCache,
} = useAttributes();
if (!selectedAttribute) {
return (
<>
<CommandInput placeholder={t("search")} />
<CommandList>
<CommandEmpty>No attributes found</CommandEmpty>
<CommandGroup>
{attributes &&
attributes.map((option) => {
return (
<CommandItem
key={option.id}
className="hover:cursor-pointer"
onSelect={() => {
setSelectedAttribute(option.id);
setSelectedAttributeOptions([]);
}}>
<span>{option.name}</span>
<div
className={classNames("ml-auto flex h-4 w-4 items-center justify-center rounded-sm")}>
<Icon name="chevron-right" className={classNames("h-4 w-4")} />
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</>
);
}
if (showMultiSelectWarning) {
return (
<div className="max-h-[300px] overflow-y-auto overflow-x-hidden px-3 py-2">
<Alert
severity="warning"
message="You are mass assigning to a multi select. This will assign the attribute to all selected users and
will not override existing values."
/>
</div>
);
}
if (selectedAttribute) {
return <SelectedAttributeToAssign />;
}
return null;
}
function MassAssignAttributesBulkActionComponent({ table, filters }: Props) {
const {
selectedAttribute,
setSelectedAttribute,
selectedAttributeOptions,
setSelectedAttributeOptions,
foundAttributeInCache,
} = useAttributes();
const [showMultiSelectWarning, setShowMultiSelectWarning] = useState(false);
const { t } = useLocale();
const utils = trpc.useUtils();
const bulkAssignAttributes = trpc.viewer.attributes.bulkAssignAttributes.useMutation({
onSuccess: (success) => {
setSelectedAttribute(undefined);
setSelectedAttributeOptions([]);
utils.viewer.organizations.listMembers.invalidate();
showToast(success.message, "success");
},
onError: (error) => {
showToast(`Error assigning attributes: ${error.message}`, "error");
},
});
return (
<Popover
onOpenChange={(open) => {
if (!open) {
setSelectedAttribute(undefined);
setSelectedAttributeOptions([]);
setShowMultiSelectWarning(false);
}
}}>
<PopoverTrigger asChild>
<DataTableSelectionBar.Button icon="tags" color="secondary">
{t("add_attributes")}
</DataTableSelectionBar.Button>
</PopoverTrigger>
{/* We dont really use shadows much - but its needed here */}
<PopoverContent className="p-0 shadow-md" align="start" sideOffset={12}>
<Command>
<Content showMultiSelectWarning={showMultiSelectWarning} />
</Command>
<div className="my-1.5 flex w-full justify-end gap-2 p-1.5">
{selectedAttribute ? (
<>
<Button
color="secondary"
className="rounded-md"
size="sm"
onClick={() => {
setSelectedAttribute(undefined);
setSelectedAttributeOptions([]);
setShowMultiSelectWarning(false);
}}>
{t("clear")}
</Button>
<Button
className="rounded-md"
loading={bulkAssignAttributes.isPending}
size="sm"
onClick={() => {
if (
foundAttributeInCache &&
foundAttributeInCache.type === "MULTI_SELECT" &&
!showMultiSelectWarning
) {
setShowMultiSelectWarning(true);
} else {
if (!foundAttributeInCache) {
return;
}
setShowMultiSelectWarning(false);
let attributesToAssign;
if (
foundAttributeInCache?.type === "MULTI_SELECT" ||
foundAttributeInCache?.type === "SINGLE_SELECT"
) {
attributesToAssign = [
{
id: foundAttributeInCache.id,
options: selectedAttributeOptions.map((v) => ({
value: v,
})),
},
];
} else {
attributesToAssign = [
{ id: foundAttributeInCache.id, value: selectedAttributeOptions[0] },
];
}
bulkAssignAttributes.mutate({
attributes: attributesToAssign,
userIds: table.getSelectedRowModel().rows.map((row) => row.original.id),
});
}
}}>
{t("apply")}
</Button>
</>
) : null}
</div>
</PopoverContent>
</Popover>
);
}
export function MassAssignAttributesBulkAction({ table, filters }: Props) {
return (
<AttributesProvider>
<MassAssignAttributesBulkActionComponent table={table} filters={filters} />
</AttributesProvider>
);
}