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:
sean-brydon
2024-12-12 11:54:10 +00:00
committed by GitHub
co-authored by Udit Takkar
parent b6d6b7cdb4
commit ee3c08918a
18 changed files with 190 additions and 67 deletions
@@ -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,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[];
}[];
}
+13 -5
View File
@@ -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",