feat: mass assign attributes (#16995)

* UI popover on user table

* fix multi select state

* mass assignment

* add i18n
This commit is contained in:
sean-brydon
2024-10-09 12:02:48 +02:00
committed by GitHub
parent 6d7f7108e5
commit c7b1fbdf67
9 changed files with 518 additions and 2 deletions
+1
View File
@@ -105,6 +105,7 @@
"next-seo": "^6.0.0",
"next-themes": "^0.2.0",
"nodemailer": "^6.7.8",
"nuqs": "^1.20.0",
"otplib": "^12.0.1",
"qrcode": "^1.5.1",
"raw-body": "^2.5.1",
@@ -2637,5 +2637,6 @@
"month_to_date": "month to date",
"year_to_date": "year to date",
"custom_range": "custom range",
"mass_assign_attributes": "Mass assign attributes",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -0,0 +1,279 @@
import type { Table } from "@tanstack/react-table";
import { parseAsString, useQueryState, parseAsArrayOf } from "nuqs";
import { useState } from "react";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import {
Alert,
Button,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Icon,
Input,
Popover,
PopoverContent,
PopoverTrigger,
showToast,
} from "@calcom/ui";
import type { User } from "../UserListTable";
interface Props {
table: Table<User>;
}
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);
return {
selectedAttribute,
setSelectedAttribute,
foundAttributeInCache: foundAttribute,
};
}
function useSelectedAttributeOption() {
return useQueryState("ao", parseAsArrayOf(parseAsString).withDefault([]));
}
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);
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";
return (
<CommandList>
<div className="flex flex items-center 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") {
setSelectedAttributeOption([option.id]);
} else {
setSelectedAttributeOption((prev) => {
if (prev.includes(option.id)) {
return prev.filter((id) => 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"
)}>
{selectedAttributeOption?.includes(option.id) ? (
<Icon name="check" className={classNames("h-4 w-4")} />
) : null}
</div>
</CommandItem>
);
})}
</>
) : (
<>
<CommandItem>
<Input
value={selectedAttributeOption[0] || ""}
type={foundAttribute.type === "TEXT" ? "text" : "number"}
onChange={(e) => {
setSelectedAttributeOption([e.target.value]);
}}
/>
</CommandItem>
</>
)}
</CommandGroup>
</CommandList>
);
}
export function MassAssignAttributesBulkAction({ table }: Props) {
const { selectedAttribute, setSelectedAttribute, foundAttributeInCache } = useSelectedAttributes();
const [selectedAttributeOptions, setSelectedAttributeOptions] = useSelectedAttributeOption();
const [showMultiSelectWarning, setShowMultiSelectWarning] = useState(false);
const { t } = useLocale();
const bulkAssignAttributes = trpc.viewer.attributes.bulkAssignAttributes.useMutation({
onSuccess: (success) => {
showToast(success.message, "success");
},
onError: (error) => {
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="users">{t("mass_assign_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),
});
setSelectedAttribute(null);
setSelectedAttributeOptions([]);
}
}}>
{t("apply")}
</Button>
</>
) : null}
</div>
</PopoverContent>
</Popover>
</>
);
}
@@ -15,6 +15,7 @@ import { Avatar, Badge, Button, Checkbox, DataTable } from "@calcom/ui";
import { useOrgBranding } from "../../../ee/organizations/context/provider";
import { DeleteBulkUsers } from "./BulkActions/DeleteBulkUsers";
import { EventTypesList } from "./BulkActions/EventTypesList";
import { MassAssignAttributesBulkAction } from "./BulkActions/MassAssignAttributes";
import { TeamListBulkAction } from "./BulkActions/TeamList";
import { ChangeUserRoleModal } from "./ChangeUserRoleModal";
import { DeleteMemberModal } from "./DeleteMemberModal";
@@ -343,6 +344,10 @@ export function UserListTable() {
type: "render",
render: (table) => <TeamListBulkAction table={table} />,
},
{
type: "render",
render: (table) => <MassAssignAttributesBulkAction table={table} />,
},
{
type: "action",
icon: "handshake",
@@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX "AttributeOption_attributeId_value_key";
-2
View File
@@ -1529,8 +1529,6 @@ model AttributeOption {
value String
slug String
assignedUsers AttributeToUser[]
@@unique([attributeId, value])
}
model Attribute {
@@ -1,6 +1,7 @@
import authedProcedure, { authedOrgAdminProcedure } from "../../../procedures/authedProcedure";
import { router, importHandler } from "../../../trpc";
import { assignUserToAttributeSchema } from "./assignUserToAttribute.schema";
import { bulkAssignAttributesSchema } from "./bulkAssignAttributes.schema";
import { createAttributeSchema } from "./create.schema";
import { deleteAttributeSchema } from "./delete.schema";
import { editAttributeSchema } from "./edit.schema";
@@ -52,4 +53,14 @@ export const attributesRouter = router({
);
return handler({ ctx, input });
}),
bulkAssignAttributes: authedOrgAdminProcedure
.input(bulkAssignAttributesSchema)
.mutation(async ({ ctx, input }) => {
const handler = await importHandler(
namespaced("bulkAssignAttributes"),
() => import("./bulkAssignAttributes.handler")
);
return handler({ ctx, input });
}),
});
@@ -0,0 +1,205 @@
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { ZBulkAssignAttributes } from "./bulkAssignAttributes.schema";
type GetOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: ZBulkAssignAttributes;
};
const bulkAssignAttributesHandler = async ({ input, ctx }: GetOptions) => {
const org = ctx.user.organization;
if (!org.id) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to be part of an organization to use this feature",
});
}
// Ensure this organization can access these attributes and attribute options
const attributes = await prisma.attribute.findMany({
where: {
id: {
in: input.attributes.map((attribute) => attribute.id),
},
teamId: org.id,
},
select: {
id: true,
type: true,
options: true,
},
});
if (attributes.length !== input.attributes.length) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You do not have access to these attributes",
});
}
const arrayOfAttributeOptionIds = attributes.flatMap(
(attribute) => attribute.options?.map((option) => option.id) || []
);
const attributeOptionIds = Array.from(new Set(arrayOfAttributeOptionIds));
const attributeOptions = await prisma.attributeOption.findMany({
where: {
id: {
in: attributeOptionIds,
},
attribute: {
teamId: org.id,
},
},
select: {
id: true,
value: true,
slug: true,
},
});
if (attributeOptions.length !== attributeOptionIds.length) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You do not have access to these attribute options",
});
}
const results = await Promise.all(
input.userIds.map(async (userId) => {
return prisma.$transaction(async (tx) => {
const membership = await tx.membership.findFirst({
where: {
userId: userId,
// @ts-expect-error we check this higher in the logic
teamId: org?.id,
},
});
if (!membership) {
return {
userId,
success: false,
message: "User is not part of your organization",
};
}
for (const attribute of input.attributes) {
// TEXT, NUMBER
if (attribute.value && !attribute.options) {
const valueAsString = String(attribute.value);
// Check if it is already the value
const existingAttributeOption = await tx.attributeToUser.findFirst({
where: {
memberId: membership.id,
attributeOption: {
attribute: {
id: attribute.id,
},
},
},
select: {
id: true,
attributeOption: {
select: {
id: true,
},
},
},
});
if (existingAttributeOption) {
// Update the value if it already exists
await tx.attributeOption.update({
where: {
id: existingAttributeOption.attributeOption.id,
},
data: {
value: valueAsString,
slug: slugify(valueAsString),
},
});
} else {
await tx.attributeOption.create({
data: {
value: valueAsString,
slug: slugify(valueAsString),
attribute: {
connect: {
id: attribute.id,
},
},
assignedUsers: {
create: {
memberId: membership.id,
},
},
},
});
}
} else if (!attribute.value && attribute.options && attribute.options.length > 0) {
const options = attribute.options;
for (const option of options) {
// Assign the attribute option to the user
await tx.attributeToUser.upsert({
where: {
memberId_attributeOptionId: {
memberId: membership.id,
attributeOptionId: option.value,
},
},
create: {
memberId: membership.id,
attributeOptionId: option.value,
},
update: {}, // No update needed if it already exists
});
}
}
// Delete the attribute from the user
if (!attribute.value && !attribute.options) {
await tx.attributeToUser.deleteMany({
where: {
memberId: membership.id,
attributeOption: {
attribute: {
id: attribute.id,
},
},
},
});
}
}
return {
userId,
success: true,
message: "Attributes assigned successfully",
};
});
})
);
const successCount = results.filter((result) => result.success).length;
const failureCount = results.length - successCount;
return {
success: true,
message: `Attributes assigned successfully for ${successCount} users. Failed for ${failureCount} users.`,
results,
};
};
export default bulkAssignAttributesHandler;
@@ -0,0 +1,14 @@
import { z } from "zod";
const attributeSchema = z.object({
id: z.string(),
options: z.array(z.object({ label: z.string().optional(), value: z.string() })).optional(),
value: z.string().optional(),
});
export const bulkAssignAttributesSchema = z.object({
userIds: z.number().array(),
attributes: attributeSchema.array(),
});
export type ZBulkAssignAttributes = z.infer<typeof bulkAssignAttributesSchema>;