feat: mass assign attributes (#16995)
* UI popover on user table * fix multi select state * mass assignment * add i18n
This commit is contained in:
@@ -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";
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user