diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json
index 3d09a429cc..1e0f45dda9 100644
--- a/apps/web/public/static/locales/en/common.json
+++ b/apps/web/public/static/locales/en/common.json
@@ -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",
diff --git a/packages/features/ee/organizations/pages/settings/attributes/AttributesForm.tsx b/packages/features/ee/organizations/pages/settings/attributes/AttributesForm.tsx
index 0b4509da2d..96abe8d37c 100644
--- a/packages/features/ee/organizations/pages/settings/attributes/AttributesForm.tsx
+++ b/packages/features/ee/organizations/pages/settings/attributes/AttributesForm.tsx
@@ -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) && (
+ {
+ return (
+ {
+ onChange(checked);
+ }}
+ />
+ );
+ }}
+ />
+ )}
}
onSubmit={(values) => {
diff --git a/packages/features/ee/organizations/pages/settings/attributes/attributes-list-view.tsx b/packages/features/ee/organizations/pages/settings/attributes/attributes-list-view.tsx
index d75e0dbe2a..6db2c34c67 100644
--- a/packages/features/ee/organizations/pages/settings/attributes/attributes-list-view.tsx
+++ b/packages/features/ee/organizations/pages/settings/attributes/attributes-list-view.tsx
@@ -17,6 +17,7 @@ import {
DropdownMenuItem,
Icon,
showToast,
+ Badge,
} from "@calcom/ui";
import { DeleteAttributeModal } from "./DeleteAttributeModal";
@@ -75,6 +76,12 @@ function AttributeItem({
{t("number_of_options", { count: attribute.options.length })}
>
)}
+ {attribute.isWeightsEnabled && (
+ <>
+ •
+ {t("attribute_weight_enabled")}
+ >
+ )}
diff --git a/packages/features/users/components/UserTable/BulkActions/MassAssignAttributes.tsx b/packages/features/users/components/UserTable/BulkActions/MassAssignAttributes.tsx
index f8812fd16f..9dac7a88e3 100644
--- a/packages/features/users/components/UserTable/BulkActions/MassAssignAttributes.tsx
+++ b/packages/features/users/components/UserTable/BulkActions/MassAssignAttributes.tsx
@@ -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,
});
}
diff --git a/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx b/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx
index 3f40dfbf6b..5aa9c0590a 100644
--- a/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx
+++ b/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx
@@ -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({
-
+
@@ -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 (
-
+
{attributeFieldState.error && (
{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 (
-
+
{["TEXT", "NUMBER"].includes(attr.type) && (
)}
{["SINGLE_SELECT", "MULTI_SELECT"].includes(attr.type) && (
-
{
- if (!value) return;
- const valueAsArray = value instanceof Array ? value : [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 && (
+
+
+
+ {fieldValue.options.map((option, idx) => {
+ return (
+ <>
+
+
+ {
+ 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={%}
+ />
+
+ >
+ );
+ })}
+
+
+ )}
+
)}
);
diff --git a/packages/features/users/components/UserTable/EditSheet/EditUserSheet.tsx b/packages/features/users/components/UserTable/EditSheet/EditUserSheet.tsx
index 672a2f7b17..b5fe1f7de9 100644
--- a/packages/features/users/components/UserTable/EditSheet/EditUserSheet.tsx
+++ b/packages/features/users/components/UserTable/EditSheet/EditUserSheet.tsx
@@ -62,7 +62,7 @@ export function EditUserSheet({
setEditMode(false);
dispatch({ type: "CLOSE_MODAL" });
}}>
-
+
{!isPending && loadedUser ? (
<>
{!editMode ? (
@@ -71,12 +71,7 @@ export function EditUserSheet({
{loadedUser?.name || "Nameless User"}
diff --git a/packages/features/users/components/UserTable/UserListTable.tsx b/packages/features/users/components/UserTable/UserListTable.tsx
index 53ed8296c6..963ea49a05 100644
--- a/packages/features/users/components/UserTable/UserListTable.tsx
+++ b/packages/features/users/components/UserTable/UserListTable.tsx
@@ -178,13 +178,32 @@ export function UserListTable() {
);
if (attributeValues.length === 0) return null;
return (
-
+
{attributeValues.map((attributeValue, index) => {
const isAGroupOption = attributeValue.contains?.length > 0;
+ const suffix = attribute.isWeightsEnabled ? `${attributeValue.weight || 100}%` : undefined;
return (
-
- {attributeValue.value}
-
+
+
+ {attributeValue.value}
+
+
+ {suffix ? (
+
+ {suffix}
+
+ ) : null}
+
);
})}
diff --git a/packages/features/users/components/UserTable/types.ts b/packages/features/users/components/UserTable/types.ts
index 37291a9c8b..785e061ca5 100644
--- a/packages/features/users/components/UserTable/types.ts
+++ b/packages/features/users/components/UserTable/types.ts
@@ -21,6 +21,7 @@ export interface UserTableUser {
attributeId: string;
value: string;
slug: string;
+ weight?: number | null;
contains: string[];
}[];
}
diff --git a/packages/lib/csvUtils.ts b/packages/lib/csvUtils.ts
index 8e1c7487a7..529a4dbd70 100644
--- a/packages/lib/csvUtils.ts
+++ b/packages/lib/csvUtils.ts
@@ -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
);
+ }, {} as Record);
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];
});
diff --git a/packages/trpc/server/routers/viewer/attributes/assignUserToAttribute.handler.ts b/packages/trpc/server/routers/viewer/attributes/assignUserToAttribute.handler.ts
index 73b0f2c05e..e63d7e215c 100644
--- a/packages/trpc/server/routers/viewer/attributes/assignUserToAttribute.handler.ts
+++ b/packages/trpc/server/routers/viewer/attributes/assignUserToAttribute.handler.ts
@@ -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: {
diff --git a/packages/trpc/server/routers/viewer/attributes/assignUserToAttribute.schema.ts b/packages/trpc/server/routers/viewer/attributes/assignUserToAttribute.schema.ts
index 462f2815b8..f75ec35d03 100644
--- a/packages/trpc/server/routers/viewer/attributes/assignUserToAttribute.schema.ts
+++ b/packages/trpc/server/routers/viewer/attributes/assignUserToAttribute.schema.ts
@@ -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(),
});
diff --git a/packages/trpc/server/routers/viewer/attributes/edit.handler.ts b/packages/trpc/server/routers/viewer/attributes/edit.handler.ts
index 9551a874c7..3a601e68cf 100644
--- a/packages/trpc/server/routers/viewer/attributes/edit.handler.ts
+++ b/packages/trpc/server/routers/viewer/attributes/edit.handler.ts
@@ -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,
diff --git a/packages/trpc/server/routers/viewer/attributes/edit.schema.ts b/packages/trpc/server/routers/viewer/attributes/edit.schema.ts
index d82934c5d4..b5f2f3bb0d 100644
--- a/packages/trpc/server/routers/viewer/attributes/edit.schema.ts
+++ b/packages/trpc/server/routers/viewer/attributes/edit.schema.ts
@@ -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(),
diff --git a/packages/trpc/server/routers/viewer/attributes/get.handler.ts b/packages/trpc/server/routers/viewer/attributes/get.handler.ts
index 60e16ce6ac..0c964fbe79 100644
--- a/packages/trpc/server/routers/viewer/attributes/get.handler.ts
+++ b/packages/trpc/server/routers/viewer/attributes/get.handler.ts
@@ -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({
diff --git a/packages/trpc/server/routers/viewer/attributes/getByUserId.handler.ts b/packages/trpc/server/routers/viewer/attributes/getByUserId.handler.ts
index 2075c9e2db..a6f68199f7 100644
--- a/packages/trpc/server/routers/viewer/attributes/getByUserId.handler.ts
+++ b/packages/trpc/server/routers/viewer/attributes/getByUserId.handler.ts
@@ -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((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) {
diff --git a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts
index fae14baeeb..1f94304062 100644
--- a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts
+++ b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts
@@ -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 {
diff --git a/packages/ui/components/icon/icon-list.mjs b/packages/ui/components/icon/icon-list.mjs
index c8513f1ed7..5d15657c5f 100644
--- a/packages/ui/components/icon/icon-list.mjs
+++ b/packages/ui/components/icon/icon-list.mjs
@@ -19,6 +19,7 @@ export const lucideIconList = new Set([
"book-open",
"book-user",
"building",
+ "chevrons-down-up",
"calendar-check-2",
"calendar-days",
"calendar-heart",