fix: bulk attribute assignment (#17896)

remove comment
This commit is contained in:
Eunjae Lee
2024-11-29 10:00:20 +00:00
committed by GitHub
parent f631abfdc4
commit e8daa4f92f
3 changed files with 235 additions and 168 deletions
@@ -2823,5 +2823,8 @@
"exclude_emails_match_found_error_message": "Please enter a valid work email address",
"disable_org_url_label": "Disable public organization profile and redirect",
"disable_org_url_description": "Redirects {{orgSlug}}.cal.com to {{destination}}",
"single_select": "Single Select",
"multi_select": "Multi Select",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -1,6 +1,6 @@
import type { Table } from "@tanstack/react-table";
import type { Dispatch, SetStateAction } from "react";
import { useState } from "react";
import { useState, Fragment } from "react";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -121,7 +121,7 @@ export function EventTypesList({ table, orgTeams }: Props) {
selectedEvents.has(id) || (areAllUsersHostForTeam && !removeHostFromEvents.has(id))
);
return (
<>
<Fragment key={team.teamId}>
<ListItem
isTeam
onSelect={() => {
@@ -200,7 +200,7 @@ export function EventTypesList({ table, orgTeams }: Props) {
/>
);
})}
</>
</Fragment>
);
})}
</CommandGroup>
@@ -1,11 +1,12 @@
import type { Table } from "@tanstack/react-table";
import type { ColumnFiltersState } from "@tanstack/react-table";
import { parseAsString, useQueryState, parseAsArrayOf } from "nuqs";
import { useState } from "react";
import { createContext, useContext, useState, useMemo, type PropsWithChildren } from "react";
import type { Dispatch, SetStateAction } from "react";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
import type { Attribute as _Attribute, AttributeOption } from "@calcom/prisma/client";
import { trpc } from "@calcom/trpc";
import {
Alert,
@@ -31,50 +32,78 @@ interface Props {
filters: ColumnFiltersState;
}
function useSelectedAttributes() {
const [selectedAttribute, setSelectedAttribute] = useQueryState("a", parseAsString);
const utils = trpc.useUtils();
const attributeData = utils.viewer.attributes.list.getData();
const foundAttribute = attributeData?.find((attr) => attr.id === selectedAttribute);
type Attribute = _Attribute & { options: AttributeOption[] };
return {
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,
foundAttributeInCache: foundAttribute,
selectedAttributeOptions,
setSelectedAttributeOptions,
foundAttributeInCache,
attributes,
};
return <AttributesContext.Provider value={value}>{children}</AttributesContext.Provider>;
}
function useSelectedAttributeOption() {
return useQueryState("ao", parseAsArrayOf(parseAsString).withDefault([]));
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 [selectedAttributeOption, setSelectedAttributeOption] = useSelectedAttributeOption();
const { selectedAttribute, setSelectedAttribute } = useSelectedAttributes();
const utils = trpc.useUtils();
const attributeData = utils.viewer.attributes.list.getData();
const foundAttribute = attributeData?.find((attr) => attr.id === selectedAttribute);
const {
foundAttributeInCache: foundAttribute,
selectedAttributeOptions,
setSelectedAttributeOptions,
} = useAttributes();
if (!foundAttribute) {
setSelectedAttribute(null);
return null;
}
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;
}
}
const translateableType = getTranslateableStringFromType(foundAttribute.type);
const isSelectable = foundAttribute.type === "SINGLE_SELECT" || foundAttribute.type === "MULTI_SELECT";
@@ -95,11 +124,11 @@ function SelectedAttributeToAssign() {
className="hover:cursor-pointer"
onSelect={() => {
if (foundAttribute.type === "SINGLE_SELECT") {
setSelectedAttributeOption([option.id]);
setSelectedAttributeOptions([option.id]);
} else {
setSelectedAttributeOption((prev) => {
setSelectedAttributeOptions((prev: string[]) => {
if (prev.includes(option.id)) {
return prev.filter((id) => id !== option.id);
return prev.filter((id: string) => id !== option.id);
}
return [...prev, option.id];
});
@@ -110,7 +139,7 @@ function SelectedAttributeToAssign() {
className={classNames(
"ml-auto flex h-4 w-4 items-center justify-center rounded-sm border"
)}>
{selectedAttributeOption?.includes(option.id) ? (
{selectedAttributeOptions?.includes(option.id) ? (
<Icon name="check" className={classNames("h-4 w-4")} />
) : null}
</div>
@@ -122,12 +151,12 @@ function SelectedAttributeToAssign() {
<>
<CommandItem>
<Input
defaultValue={selectedAttributeOption[0] || ""}
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.
setSelectedAttributeOption([e.target.value]);
setSelectedAttributeOptions([e.target.value]);
}}
/>
</CommandItem>
@@ -138,12 +167,78 @@ function SelectedAttributeToAssign() {
);
}
export function MassAssignAttributesBulkAction({ table, filters }: Props) {
const { selectedAttribute, setSelectedAttribute, foundAttributeInCache } = useSelectedAttributes();
const [selectedAttributeOptions, setSelectedAttributeOptions] = useSelectedAttributeOption();
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.useContext();
const utils = trpc.useUtils();
const bulkAssignAttributes = trpc.viewer.attributes.bulkAssignAttributes.useMutation({
onSuccess: (success) => {
// Optimistically update the infinite query data
@@ -161,6 +256,13 @@ export function MassAssignAttributesBulkAction({ table, filters }: Props) {
},
// @ts-expect-error i really dont know how to type this
(oldData) => {
if (!oldData) {
return {
pages: [],
pageParams: [],
};
}
const newPages = oldData?.pages.map((page) => ({
...page,
rows: page.rows.map((row) => {
@@ -208,7 +310,7 @@ export function MassAssignAttributesBulkAction({ table, filters }: Props) {
}
);
setSelectedAttribute(null);
setSelectedAttribute(undefined);
setSelectedAttributeOptions([]);
showToast(success.message, "success");
},
@@ -216,131 +318,93 @@ export function MassAssignAttributesBulkAction({ table, filters }: Props) {
showToast(`Error assigning attributes: ${error.message}`, "error");
},
});
const { data } = trpc.viewer.attributes.list.useQuery();
function Content() {
if (!selectedAttribute) {
return (
<>
<CommandInput placeholder={t("search")} />
<CommandList>
<CommandEmpty>No attributes found</CommandEmpty>
<CommandGroup>
{data &&
data.map((option) => {
return (
<CommandItem
key={option.id}
className="hover:cursor-pointer"
onSelect={() => {
setSelectedAttribute(option.id);
}}>
<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;
}
return (
<>
<Popover>
<PopoverTrigger asChild>
<Button StartIcon="map-pin">{t("add_attributes")}</Button>
</PopoverTrigger>
{/* We dont really use shadows much - but its needed here */}
<PopoverContent className="p-0 shadow-md" align="start" sideOffset={12}>
<Command>
<Content />
</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(null);
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),
});
<Popover
onOpenChange={(open) => {
if (!open) {
setSelectedAttribute(undefined);
setSelectedAttributeOptions([]);
setShowMultiSelectWarning(false);
}
}}>
<PopoverTrigger asChild>
<Button StartIcon="map-pin">{t("add_attributes")}</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;
}
}}>
{t("apply")}
</Button>
</>
) : null}
</div>
</PopoverContent>
</Popover>
</>
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>
);
}