feat: weights per attribute UI (#18109)
* wip attribut weights * edit sheet and display in table * add shrink so badges dont split * push changes to support weights on csv export * fix typing issue * type error * fall back to 100 weight --------- Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
This commit is contained in:
co-authored by
Udit Takkar
parent
b6d6b7cdb4
commit
ee3c08918a
@@ -2871,6 +2871,8 @@
|
||||
"lock_attribute_for_assignment_description": "Locking would only allow assignments from Directory Sync",
|
||||
"attribute_edited_successfully": "Attribute edited successfully",
|
||||
"new_group_option": "New group option",
|
||||
"attribute_weight_enabled":"Weights enabled",
|
||||
"attribute_weight_enabled_description":"By enabling weights, it would be possible to assign higher priority to certain attributes per user. The higher the weight, the higher the priority.",
|
||||
"routed": "Routed",
|
||||
"reassigned": "Reassigned",
|
||||
"rerouted": "Rerouted",
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
const AttributeFormSchema = z.object({
|
||||
attrName: z.string().min(1),
|
||||
isLocked: z.boolean().optional(),
|
||||
isWeightsEnabled: z.boolean().optional(),
|
||||
type: z.enum(["TEXT", "NUMBER", "SINGLE_SELECT", "MULTI_SELECT"]),
|
||||
options: z.array(
|
||||
z.object({
|
||||
@@ -193,6 +194,23 @@ export function AttributeForm({ initialValues, onSubmit, header }: AttributeForm
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{["SINGLE_SELECT", "MULTI_SELECT"].includes(watchedType) && (
|
||||
<Controller
|
||||
name="isWeightsEnabled"
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<SettingsToggle
|
||||
title={t("attribute_weight_enabled")}
|
||||
description={t("attribute_weight_enabled_description")}
|
||||
checked={value}
|
||||
onCheckedChange={(checked) => {
|
||||
onChange(checked);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<InputField label={t("name")} required {...form.register("attrName")} />
|
||||
<Controller
|
||||
name="type"
|
||||
|
||||
+1
-1
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import React from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -53,6 +52,7 @@ function CreateAttributesPage() {
|
||||
type: attribute.data.type,
|
||||
options: attribute.data.options,
|
||||
isLocked: attribute.data.isLocked,
|
||||
isWeightsEnabled: attribute.data.isWeightsEnabled,
|
||||
}}
|
||||
header={<EditAttributeHeader isPending={mutation.isPending} />}
|
||||
onSubmit={(values) => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
Icon,
|
||||
showToast,
|
||||
Badge,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { DeleteAttributeModal } from "./DeleteAttributeModal";
|
||||
@@ -75,6 +76,12 @@ function AttributeItem({
|
||||
<span>{t("number_of_options", { count: attribute.options.length })}</span>
|
||||
</>
|
||||
)}
|
||||
{attribute.isWeightsEnabled && (
|
||||
<>
|
||||
<span className="text-muted">•</span>
|
||||
<Badge>{t("attribute_weight_enabled")}</Badge>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
|
||||
@@ -285,6 +285,7 @@ function MassAssignAttributesBulkActionComponent({ table, filters }: Props) {
|
||||
slug: value.slug,
|
||||
contains: value.contains,
|
||||
isGroup: value.isGroup,
|
||||
weight: 100,
|
||||
}));
|
||||
newAttributes.push(...newAttributeValues);
|
||||
} else {
|
||||
@@ -296,6 +297,7 @@ function MassAssignAttributesBulkActionComponent({ table, filters }: Props) {
|
||||
slug: slugify(selectedAttributeOptions[0]),
|
||||
contains: [],
|
||||
isGroup: false,
|
||||
weight: 100,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ const attributeSchema = z.object({
|
||||
z.object({
|
||||
label: z.string().optional(),
|
||||
value: stringOrNumber.optional(),
|
||||
weight: z.number().optional(),
|
||||
createdByDSyncId: z.string().nullable().optional(),
|
||||
})
|
||||
)
|
||||
@@ -192,7 +193,7 @@ export function EditForm({
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<SheetBody className="mt-4 flex h-full flex-col space-y-3 px-1">
|
||||
<label className="text-emphasis mb-1 text-base font-semibold">{t("profile")}</label>
|
||||
<label className="text-emphasis mb-1 text-base font-bold">{t("profile")}</label>
|
||||
<TextField label={t("username")} {...form.register("username")} />
|
||||
<TextField label={t("name")} {...form.register("name")} />
|
||||
<TextField label={t("email")} {...form.register("email")} />
|
||||
@@ -247,11 +248,11 @@ function AttributesList(props: { selectedUserId: number }) {
|
||||
trpc.viewer.attributes.getByUserId.useQuery({
|
||||
userId: props.selectedUserId,
|
||||
});
|
||||
const { data: attributes, isPending: attributesPending } = trpc.viewer.attributes.list.useQuery();
|
||||
const { data: attributes } = trpc.viewer.attributes.list.useQuery();
|
||||
const enabledAttributes = attributes?.filter((attr) => attr.enabled);
|
||||
|
||||
const { t } = useLocale();
|
||||
const { control, watch, getFieldState, setValue } = useFormContext();
|
||||
const { control, getFieldState } = useFormContext();
|
||||
|
||||
const getOptionsByAttributeId = (attributeId: string) => {
|
||||
const attribute = attributes?.find((attr) => attr.id === attributeId);
|
||||
@@ -278,7 +279,8 @@ function AttributesList(props: { selectedUserId: number }) {
|
||||
options: attr.options.map((option) => ({
|
||||
label: option.value,
|
||||
value: option.id,
|
||||
createdByDSyncId: option.createdByDSyncId,
|
||||
createdByDSyncId: option.createdByDSyncId ?? null,
|
||||
weight: option.weight ?? 100,
|
||||
})),
|
||||
};
|
||||
} else if (attr.type === "SINGLE_SELECT") {
|
||||
@@ -289,10 +291,11 @@ function AttributesList(props: { selectedUserId: number }) {
|
||||
label: attr.options[0]?.value,
|
||||
value: attr.options[0]?.id,
|
||||
createdByDSyncId: attr.options[0]?.createdByDSyncId ?? null,
|
||||
weight: attr.options[0]?.weight ?? 100,
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
} else if (attr.type === "TEXT") {
|
||||
acc[key] = {
|
||||
id: attr.id,
|
||||
value: attr.options[0]?.value || "",
|
||||
@@ -308,7 +311,7 @@ function AttributesList(props: { selectedUserId: number }) {
|
||||
return (
|
||||
<div className="flex flex-col overflow-visible">
|
||||
<div className="flex flex-col gap-3 rounded-lg">
|
||||
<label className="text-emphasis mb-1 mt-6 text-base font-semibold">{t("attributes")}</label>
|
||||
<label className="text-emphasis mb-1 mt-6 text-base font-bold">{t("attributes")}</label>
|
||||
{attributeFieldState.error && (
|
||||
<p className="text-error mb-2 block text-sm font-medium leading-none">
|
||||
{JSON.stringify(attributeFieldState.error)}
|
||||
@@ -323,7 +326,7 @@ function AttributesList(props: { selectedUserId: number }) {
|
||||
render={({ field: { value, ...field } }) => {
|
||||
const fieldValue = value as Attribute | undefined | null;
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center gap-2" key={attr.id}>
|
||||
<div className="flex w-full items-center justify-center" key={attr.id}>
|
||||
{["TEXT", "NUMBER"].includes(attr.type) && (
|
||||
<InputField
|
||||
{...field}
|
||||
@@ -341,36 +344,85 @@ function AttributesList(props: { selectedUserId: number }) {
|
||||
/>
|
||||
)}
|
||||
{["SINGLE_SELECT", "MULTI_SELECT"].includes(attr.type) && (
|
||||
<SelectField
|
||||
isDisabled={attr.isLocked}
|
||||
name={field.name}
|
||||
containerClassName="w-full"
|
||||
isMulti={attr.type === "MULTI_SELECT"}
|
||||
labelProps={{
|
||||
className: "text-emphasis mb-2 block text-sm font-medium leading-none",
|
||||
}}
|
||||
label={attr.name}
|
||||
options={getOptionsByAttributeId(attr.id)}
|
||||
value={attr.type === "MULTI_SELECT" ? fieldValue?.options : fieldValue?.options?.[0]}
|
||||
onChange={(value) => {
|
||||
if (!value) return;
|
||||
const valueAsArray = value instanceof Array ? value : [value];
|
||||
<div className="w-full">
|
||||
<SelectField
|
||||
isDisabled={attr.isLocked}
|
||||
name={field.name}
|
||||
containerClassName="w-full"
|
||||
isMulti={attr.type === "MULTI_SELECT"}
|
||||
labelProps={{
|
||||
className: "text-emphasis mb-2 block text-sm font-medium leading-none",
|
||||
}}
|
||||
label={attr.name}
|
||||
options={getOptionsByAttributeId(attr.id)}
|
||||
value={attr.type === "MULTI_SELECT" ? fieldValue?.options : fieldValue?.options?.[0]}
|
||||
onChange={(value) => {
|
||||
if (!value) return;
|
||||
const valueAsArray = value instanceof Array ? value : [value];
|
||||
|
||||
const updatedOptions =
|
||||
attr.type === "MULTI_SELECT"
|
||||
? valueAsArray.map((v) => ({ label: v.label, value: v.value }))
|
||||
: [{ label: valueAsArray[0].label, value: valueAsArray[0].value }];
|
||||
const updatedOptions =
|
||||
attr.type === "MULTI_SELECT"
|
||||
? valueAsArray.map((v) => ({
|
||||
label: v.label,
|
||||
value: v.value,
|
||||
weight: v.weight || 100,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
label: valueAsArray[0].label,
|
||||
value: valueAsArray[0].value,
|
||||
weight: valueAsArray[0].weight || 100,
|
||||
},
|
||||
];
|
||||
|
||||
field.onChange({
|
||||
id: attr.id,
|
||||
// It is also ensured in the backend that the options not owned by cal.com are not removed
|
||||
options: getOptionsEnsuringNotOwnedByCalcomNotRemoved({
|
||||
earlierOptions: fieldValue?.options || [],
|
||||
updatedOptions,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
field.onChange({
|
||||
id: attr.id,
|
||||
options: getOptionsEnsuringNotOwnedByCalcomNotRemoved({
|
||||
earlierOptions: fieldValue?.options || [],
|
||||
updatedOptions,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{attr.isWeightsEnabled && fieldValue?.options && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<Label>Weights</Label>
|
||||
<div className="">
|
||||
{fieldValue.options.map((option, idx) => {
|
||||
return (
|
||||
<>
|
||||
<div key={option.value} className="flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor={`attributes.${index}.otions.${idx}.weight`}
|
||||
className="text-subtle">
|
||||
{option.label}
|
||||
</Label>
|
||||
<InputField
|
||||
noLabel
|
||||
name={`attributes.${index}.options.${idx}.weight`}
|
||||
type="number"
|
||||
step={10}
|
||||
value={option.weight || 100}
|
||||
onChange={(e) => {
|
||||
const newWeight = parseFloat(e.target.value) || 1;
|
||||
const newOptions = fieldValue?.options?.map((opt, i) =>
|
||||
i === idx ? { ...opt, weight: newWeight } : opt
|
||||
);
|
||||
field.onChange({
|
||||
id: attr.id,
|
||||
options: newOptions,
|
||||
});
|
||||
}}
|
||||
addOnSuffix={<span className="text-subtle text-sm">%</span>}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -62,7 +62,7 @@ export function EditUserSheet({
|
||||
setEditMode(false);
|
||||
dispatch({ type: "CLOSE_MODAL" });
|
||||
}}>
|
||||
<SheetContent className="bg-muted">
|
||||
<SheetContent className="bg-default">
|
||||
{!isPending && loadedUser ? (
|
||||
<>
|
||||
{!editMode ? (
|
||||
@@ -71,12 +71,7 @@ export function EditUserSheet({
|
||||
<div className="border-sublte bg-default w-full rounded-xl border p-4">
|
||||
<OrganizationBanner />
|
||||
<div className="bg-default ml-3 w-fit translate-y-[-50%] rounded-full p-1 ring-1 ring-[#0000000F]">
|
||||
<Avatar
|
||||
asChild
|
||||
size="lg"
|
||||
alt={`${loadedUser?.name} avatar`}
|
||||
imageSrc={loadedUser.avatarUrl}
|
||||
/>
|
||||
<Avatar asChild size="lg" alt={`${loadedUser?.name} avatar`} imageSrc={avatarURL} />
|
||||
</div>
|
||||
<h2 className="text-emphasis font-sans text-2xl font-semibold">
|
||||
{loadedUser?.name || "Nameless User"}
|
||||
|
||||
@@ -178,13 +178,32 @@ export function UserListTable() {
|
||||
);
|
||||
if (attributeValues.length === 0) return null;
|
||||
return (
|
||||
<div className={classNames(attribute.type === "NUMBER" ? "flex w-full justify-center" : "")}>
|
||||
<div
|
||||
className={classNames(
|
||||
attribute.type === "NUMBER" ? "flex w-full justify-center" : "flex flex-wrap"
|
||||
)}>
|
||||
{attributeValues.map((attributeValue, index) => {
|
||||
const isAGroupOption = attributeValue.contains?.length > 0;
|
||||
const suffix = attribute.isWeightsEnabled ? `${attributeValue.weight || 100}%` : undefined;
|
||||
return (
|
||||
<Badge key={index} variant={isAGroupOption ? "orange" : "gray"} className="mr-1">
|
||||
{attributeValue.value}
|
||||
</Badge>
|
||||
<div className="mr-1 inline-flex shrink-0" key={attributeValue.id}>
|
||||
<Badge
|
||||
variant={isAGroupOption ? "orange" : "gray"}
|
||||
className={classNames(suffix && "rounded-r-none")}>
|
||||
{attributeValue.value}
|
||||
</Badge>
|
||||
|
||||
{suffix ? (
|
||||
<Badge
|
||||
variant={isAGroupOption ? "orange" : "gray"}
|
||||
style={{
|
||||
backgroundColor: "color-mix(in hsl, var(--cal-bg-emphasis), black 5%)",
|
||||
}}
|
||||
className="rounded-l-none">
|
||||
{suffix}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface UserTableUser {
|
||||
attributeId: string;
|
||||
value: string;
|
||||
slug: string;
|
||||
weight?: number | null;
|
||||
contains: string[];
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -121,9 +121,12 @@ export const generateCsvRawForMembersTable = (
|
||||
if (!acc[attr.attributeId]) {
|
||||
acc[attr.attributeId] = [];
|
||||
}
|
||||
acc[attr.attributeId].push(attr.value);
|
||||
acc[attr.attributeId].push({
|
||||
value: attr.value,
|
||||
weight: attr.weight ?? undefined,
|
||||
});
|
||||
return acc;
|
||||
}, {} as Record<string, string[]>);
|
||||
}, {} as Record<string, { value: string; weight: number | undefined }[]>);
|
||||
|
||||
const requiredColumns = [
|
||||
email, // Members column
|
||||
@@ -133,9 +136,14 @@ export const generateCsvRawForMembersTable = (
|
||||
];
|
||||
|
||||
// Add attribute columns
|
||||
const attributeColumns = ATTRIBUTE_IDS.map((attrId) =>
|
||||
attributeMap[attrId] ? sanitizeValue(attributeMap[attrId].join(",")) : ""
|
||||
);
|
||||
const attributeColumns = ATTRIBUTE_IDS.map((attrId) => {
|
||||
const attributes = attributeMap[attrId];
|
||||
if (!attributes?.length) return "";
|
||||
|
||||
return sanitizeValue(
|
||||
attributes.map((attr) => (attr.weight ? `${attr.value} (${attr.weight}%)` : attr.value)).join(",")
|
||||
);
|
||||
});
|
||||
|
||||
return [...requiredColumns, ...attributeColumns];
|
||||
});
|
||||
|
||||
@@ -191,6 +191,7 @@ const assignUserToAttributeHandler = async ({ input, ctx }: GetOptions) => {
|
||||
create: {
|
||||
memberId: membership.id,
|
||||
attributeOptionId: option.value,
|
||||
weight: option.weight,
|
||||
},
|
||||
update: {}, // No update needed if it already exists
|
||||
select: {
|
||||
|
||||
@@ -2,7 +2,9 @@ import { z } from "zod";
|
||||
|
||||
const attributeSchema = z.object({
|
||||
id: z.string(),
|
||||
options: z.array(z.object({ label: z.string().optional(), value: z.string() })).optional(),
|
||||
options: z
|
||||
.array(z.object({ label: z.string().optional(), value: z.string(), weight: z.number().optional() }))
|
||||
.optional(),
|
||||
value: z.string().optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ const editAttributesHandler = async ({ input, ctx }: GetOptions) => {
|
||||
type: input.type,
|
||||
teamId: org.id,
|
||||
isLocked: input.isLocked,
|
||||
isWeightsEnabled: input.isWeightsEnabled,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -5,6 +5,7 @@ export const editAttributeSchema = z.object({
|
||||
name: z.string(),
|
||||
type: z.enum(["TEXT", "NUMBER", "SINGLE_SELECT", "MULTI_SELECT"]),
|
||||
isLocked: z.boolean().optional(),
|
||||
isWeightsEnabled: z.boolean().optional(),
|
||||
options: z.array(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
|
||||
@@ -34,6 +34,7 @@ const getAttributeHandler = async ({ input, ctx }: GetOptions) => {
|
||||
name: true,
|
||||
type: true,
|
||||
isLocked: true,
|
||||
isWeightsEnabled: true,
|
||||
options: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -65,6 +66,7 @@ const getAttributeHandler = async ({ input, ctx }: GetOptions) => {
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
isLocked: z.boolean().optional(),
|
||||
isWeightsEnabled: z.boolean().optional(),
|
||||
type: z.enum(["TEXT", "NUMBER", "SINGLE_SELECT", "MULTI_SELECT"]),
|
||||
options: z.array(
|
||||
z.object({
|
||||
|
||||
@@ -21,6 +21,7 @@ type GroupedAttribute = {
|
||||
id: string;
|
||||
slug: string;
|
||||
value: string;
|
||||
weight: number | null;
|
||||
createdByDSyncId: string | null;
|
||||
}[];
|
||||
};
|
||||
@@ -77,13 +78,14 @@ const getByUserIdHandler = async ({ input, ctx }: GetOptions) => {
|
||||
},
|
||||
},
|
||||
createdByDSyncId: true,
|
||||
weight: true,
|
||||
},
|
||||
});
|
||||
|
||||
const groupedAttributes = userAttributes.reduce<GroupedAttribute[]>((acc, assignment) => {
|
||||
const { attributeOption, createdByDSyncId } = assignment;
|
||||
const { attributeOption, createdByDSyncId, weight } = assignment;
|
||||
const { attribute: attrInfo, ...optionInfo } = attributeOption;
|
||||
const optionInfoWithCreatedByDSyncId = { ...optionInfo, createdByDSyncId };
|
||||
const optionInfoWithCreatedByDSyncId = { ...optionInfo, createdByDSyncId, weight };
|
||||
const existingGroup = acc.find((group) => group.id === attrInfo.id);
|
||||
|
||||
if (existingGroup) {
|
||||
|
||||
@@ -177,20 +177,29 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
|
||||
let attributes;
|
||||
|
||||
if (expand?.includes("attributes")) {
|
||||
attributes = await prisma.attributeOption.findMany({
|
||||
where: {
|
||||
assignedUsers: {
|
||||
some: {
|
||||
memberId: membership.id,
|
||||
attributes = await prisma.attributeToUser
|
||||
.findMany({
|
||||
where: {
|
||||
memberId: membership.id,
|
||||
},
|
||||
select: {
|
||||
attributeOption: true,
|
||||
weight: true,
|
||||
},
|
||||
orderBy: {
|
||||
attributeOption: {
|
||||
attribute: {
|
||||
name: "asc",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
attribute: {
|
||||
name: "asc",
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
.then((assignedUsers) =>
|
||||
assignedUsers.map((au) => ({
|
||||
...au.attributeOption,
|
||||
weight: au.weight ?? 100,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -19,6 +19,7 @@ export const lucideIconList = new Set([
|
||||
"book-open",
|
||||
"book-user",
|
||||
"building",
|
||||
"chevrons-down-up",
|
||||
"calendar-check-2",
|
||||
"calendar-days",
|
||||
"calendar-heart",
|
||||
|
||||
Reference in New Issue
Block a user