feat: attributes (#15964)

Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
authored and GitHub committed 2024-08-15 14:49:05 -07:00
1 parent ef4deb1657
commit 08e1b0a9c8
41 files changed
+1877 -39

No files matched your search

@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { attributesRouter } from "@calcom/trpc/server/routers/viewer/attributes/_router";
export default createNextApiHandler(attributesRouter);
@@ -0,0 +1,9 @@
import OrgAttributesEditPage from "@calcom/ee/organizations/pages/settings/attributes/attributes-edit-view";
import type { CalPageWrapper } from "@components/PageWrapper";
import PageWrapper from "@components/PageWrapper";
const Page = OrgAttributesEditPage as CalPageWrapper;
Page.PageWrapper = PageWrapper;
export default Page;
@@ -0,0 +1,9 @@
import OrgAttributesCreatePage from "@calcom/ee/organizations/pages/settings/attributes/attributes-create-view";
import type { CalPageWrapper } from "@components/PageWrapper";
import PageWrapper from "@components/PageWrapper";
const Page = OrgAttributesCreatePage as CalPageWrapper;
Page.PageWrapper = PageWrapper;
export default Page;
@@ -0,0 +1,9 @@
import OrgSettingsAttributesPage from "@calcom/ee/organizations/pages/settings/attributes/attributes-list-view";
import type { CalPageWrapper } from "@components/PageWrapper";
import PageWrapper from "@components/PageWrapper";
const Page = OrgSettingsAttributesPage as CalPageWrapper;
Page.PageWrapper = PageWrapper;
export default Page;
@@ -2527,6 +2527,19 @@
"event_expired": "This event is expired",
"skip_contact_creation": "Skip creating contacts if they do not exist in {{appName}} ",
"skip_writing_to_calendar_note": "If your ICS link is read-only (e.g., Proton Calendar), check the box above to avoid errors. You'll also need to manually update your calendar for changes.",
"attributes":"Attributes",
"new_attribute": "New attribute",
"add_attributes": "Add attributes",
"add_attributes_description": "Add attributes to your team members",
"new_option": "New option",
"update_profile":"Update member",
"attribute_updated_successfully": "Attribute updated successfully",
"attributes_edited_successfully": "Attributes edited successfully",
"attribute_meta_description": "Manage attributes for your team members",
"attributes_edit_description": "Edit attributes for your team members",
"back_to_attributes": "Back to attributes",
"delete_attribute": "Are you sure you want to delete this option?",
"delete_attribute_description":"This option is assigned to {{numberOfUsers}} members. Deleting it will remove it from their profile.",
"disable_all_emails_to_attendees": "Disable standard emails to attendees related to this event type",
"disable_all_emails_description": "Disables standard email communication related to this event type, including booking confirmations, reminders, and cancellations.",
"disable_all_emails_to_hosts": "Disable standard emails to hosts related to this event type",
@@ -2544,6 +2557,8 @@
"outlook_connect_atom_already_connected_label": "Connected Outlook Calendar",
"outlook_connect_atom_loading_label": "Checking Outlook Calendar",
"booking_question_response_variables": "Booking question response variables",
"managed_by_teamAdmins": "Managed by {{teamAdmins}}",
"number_of_options":"{{count}} options",
"reschedule_with_same_round_robin_host_title": "Reschedule with same Round-Robin host",
"reschedule_with_same_round_robin_host_description": "Rescheduled events will be assigned to the same host as initially scheduled",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
@@ -0,0 +1,152 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { zodResolver } from "@hookform/resolvers/zod";
import React, { useState } from "react";
import { Controller, useForm, useFieldArray } from "react-hook-form";
import { z } from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import {
Button,
Form,
SelectField,
InputField,
Label,
Input,
Dialog,
ConfirmationDialogContent,
} from "@calcom/ui";
const AttributeSchema = z.object({
attrName: z.string().min(1),
type: z.enum(["TEXT", "NUMBER", "SINGLE_SELECT", "MULTI_SELECT"]),
options: z.array(
z.object({ value: z.string(), id: z.string().optional(), assignedUsers: z.number().optional() })
),
});
type FormValues = z.infer<typeof AttributeSchema>;
const AttributeTypeOptions = [
{ value: "TEXT", label: "Text" },
{ value: "NUMBER", label: "Number" },
{ value: "SINGLE_SELECT", label: "Single Select" },
{ value: "MULTI_SELECT", label: "Multi Select" },
];
interface AttributeFormProps {
initialValues?: FormValues;
onSubmit: (values: FormValues) => void;
header: React.ReactNode;
}
export function AttributeForm({ initialValues, onSubmit, header }: AttributeFormProps) {
const { t } = useLocale();
const [deleteOptionDialog, setDeleteOptionDialog] = useState<{
id: number | undefined;
open: boolean;
}>({
id: undefined,
open: false,
});
const [listRef] = useAutoAnimate<HTMLDivElement>({
duration: 300,
easing: "ease-in-out",
});
const form = useForm<FormValues>({
resolver: zodResolver(AttributeSchema),
defaultValues: initialValues || {
attrName: "",
options: [{ value: "" }],
type: "TEXT",
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "options",
});
const watchedType = form.watch("type");
return (
<Form
form={form}
className="flex flex-col space-y-4 lg:space-y-6"
handleSubmit={(values) => {
onSubmit({
...values,
options: values.options,
});
}}>
{header}
<InputField label={t("name")} required {...form.register("attrName")} />
<Controller
name="type"
render={({ field: { value, onChange } }) => (
<SelectField
label="Type"
options={AttributeTypeOptions}
onChange={(option) => {
if (!option) return;
onChange(option.value);
}}
value={AttributeTypeOptions.find((attr) => attr.value === value)}
required
/>
)}
/>
{["SINGLE_SELECT", "MULTI_SELECT"].includes(watchedType) && (
<div className="bg-muted border-muted mt-6 rounded-lg border p-6">
<div className="flex flex-col gap-2">
<Label>{t("options")}</Label>
<div ref={listRef}>
{fields.map((field, index) => (
<div className="flex items-center gap-2" key={field.id}>
<Input {...form.register(`options.${index}.value`)} className="w-full" />
<Input {...form.register(`options.${index}.id`)} className="hidden" />
<Button
type="button"
variant="icon"
StartIcon="x"
color="minimal"
className="mb-2"
disabled={index === 0 && fields.length === 1}
onClick={() => {
if (field.assignedUsers && field.assignedUsers > 0) {
setDeleteOptionDialog({ id: index, open: true });
} else {
remove(index);
}
}}
/>
</div>
))}
</div>
</div>
<Button type="button" StartIcon="plus" color="secondary" onClick={() => append({ value: "" })}>
{t("new_option")}
</Button>
</div>
)}
<Dialog
open={deleteOptionDialog.open}
onOpenChange={() => setDeleteOptionDialog({ id: undefined, open: false })}>
<ConfirmationDialogContent
title={t("delete_attribute")}
confirmBtnText={t("delete")}
onConfirm={() => {
remove(deleteOptionDialog.id as number);
setDeleteOptionDialog({ id: undefined, open: false });
}}
loadingText={t("deleting_attribute")}>
<>
{t("delete_attribute_description", {
numberOfUsers: fields[deleteOptionDialog.id as number]?.assignedUsers || 0,
})}
</>
</ConfirmationDialogContent>
</Dialog>
</Form>
);
}
@@ -0,0 +1,22 @@
import { SkeletonText } from "@calcom/ui";
export function ListSkeleton() {
return (
<ul className="focus-within:border-emphasis flex justify-between p-4">
{Array.from({ length: 3 }).map((_, idx) => (
<li key={idx}>
<AttributeSkeleton />
</li>
))}
</ul>
);
}
function AttributeSkeleton() {
return (
<div>
<SkeletonText className="text-sm font-semibold leading-none" />
<SkeletonText className="text-default inline-flex items-center gap-1 text-sm font-normal leading-none" />
</div>
);
}
@@ -0,0 +1,99 @@
"use client";
import { useRouter } from "next/navigation";
import React from "react";
import { useFormContext } from "react-hook-form";
import { z } from "zod";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import SettingsLayout from "@calcom/features/settings/layouts/SettingsLayout";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Meta, Button, useMeta, showToast } from "@calcom/ui";
import { AttributeForm } from "./AttributesForm";
const CreateAttributeSchema = z.object({
// Calling this name would make sense but conflicts with rhf "watch" "name" field
attrName: z.string().min(1),
type: z.enum(["TEXT", "NUMBER", "SINGLE_SELECT", "MULTI_SELECT"]),
options: z.array(z.object({ value: z.string(), id: z.string() })),
});
type FormValues = z.infer<typeof CreateAttributeSchema>;
function CreateAttributesPage() {
const router = useRouter();
const mutation = trpc.viewer.attributes.create.useMutation({
onSuccess: () => {
showToast("Attribute created successfully", "success");
router.push("/settings/organizations/attributes");
},
onError: (err) => {
showToast(err.message, "error");
},
});
const { t } = useLocale();
return (
<>
<LicenseRequired>
<Meta title="Attribute" description="Create an attribute for your team members" />
<AttributeForm
header={<CreateAttributeHeader isPending={mutation.isPending} />}
onSubmit={(values) => {
// Create set of attributes to get unique values
const uniqueAttributes = new Set(values.options.map((option) => option.value));
mutation.mutate({
name: values.attrName,
type: values.type,
options: Array.from(uniqueAttributes).map((value) => ({ value })),
});
}}
/>
</LicenseRequired>
</>
);
}
function CreateAttributeHeader(props: { isPending: boolean }) {
const { meta } = useMeta();
const { t } = useLocale();
const formContext = useFormContext<FormValues>();
const watchedTitle = formContext.watch("attrName");
return (
<>
<div className="mb-6 mt-6 flex flex-grow items-center justify-between lg:mt-12">
<div className="-ml-12 flex items-center gap-4">
<Button
variant="icon"
StartIcon="arrow-left"
color="minimal"
href="/settings/organizations/attributes">
<span className="sr-only">{t("back_to_attributes")}</span>
</Button>
<div className="font-cal text-cal flex space-x-1 text-xl font-semibold leading-none">
<h1 className="text-emphasis">{meta.title}</h1>
{watchedTitle && (
<>
<span className="text-subtle">/</span> <span className="text-emphasis">{watchedTitle}</span>
</>
)}
</div>
</div>
<Button type="submit" data-testid="create-attribute-button" loading={props.isPending}>
{t("save")}
</Button>
</div>
</>
);
}
function getLayout(page: React.ReactElement) {
return <SettingsLayout hideHeader>{page}</SettingsLayout>;
}
CreateAttributesPage.getLayout = getLayout;
export default CreateAttributesPage;
@@ -0,0 +1,118 @@
"use client";
import { useRouter, useParams } from "next/navigation";
import React from "react";
import { useFormContext } from "react-hook-form";
import { z } from "zod";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import SettingsLayout from "@calcom/features/settings/layouts/SettingsLayout";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Meta, Button, useMeta, showToast } from "@calcom/ui";
import { AttributeForm } from "./AttributesForm";
const CreateAttributeSchema = z.object({
// Calling this name would make sense but conflicts with rhf "watch" "name" field
attrName: z.string().min(1),
type: z.enum(["TEXT", "NUMBER", "SINGLE_SELECT", "MULTI_SELECT"]),
options: z.array(z.object({ value: z.string(), id: z.string() })),
});
type FormValues = z.infer<typeof CreateAttributeSchema>;
function CreateAttributesPage() {
const router = useRouter();
const utils = trpc.useUtils();
const { t } = useLocale();
// Get the attribute id from the url
const { id } = useParams<{ id: string }>();
// ensure string with zod
const attribute = trpc.viewer.attributes.get.useQuery({ id: id as string });
const mutation = trpc.viewer.attributes.edit.useMutation({
onSuccess: () => {
showToast(t("attribute_edited_successfully"), "success");
utils.viewer.attributes.get.invalidate({
id: id as string,
});
utils.viewer.attributes.list.invalidate();
router.push("/settings/organizations/attributes");
},
onError: (err) => {
showToast(err.message, "error");
},
});
return (
<>
<LicenseRequired>
<Meta title="Attribute" description={t("edit_attribute_description")} />
{!attribute.isLoading && attribute.data ? (
<AttributeForm
initialValues={{
attrName: attribute.data.name,
type: attribute.data.type,
options: attribute.data.options,
}}
header={<EditAttributeHeader isPending={mutation.isPending} />}
onSubmit={(values) => {
mutation.mutate({
attributeId: id as string,
name: values.attrName,
type: values.type,
options: values.options,
});
}}
/>
) : (
<>Loading</>
)}
</LicenseRequired>
</>
);
}
function EditAttributeHeader(props: { isPending: boolean }) {
const { meta } = useMeta();
const { t } = useLocale();
const formContext = useFormContext<FormValues>();
const watchedTitle = formContext.watch("attrName");
return (
<>
<div className="mb-6 mt-6 flex flex-grow items-center justify-between lg:mt-12">
<div className="-ml-12 flex items-center gap-4">
<Button
variant="icon"
StartIcon="arrow-left"
color="minimal"
href="/settings/organizations/attributes">
<span className="sr-only">{t("back_to_attributes")}</span>
</Button>
<div className="font-cal text-cal flex space-x-1 text-xl font-semibold leading-none">
<h1 className="text-emphasis">{meta.title}</h1>
{watchedTitle && (
<>
<span className="text-subtle">/</span> <span className="text-emphasis">{watchedTitle}</span>
</>
)}
</div>
</div>
<Button type="submit" data-testid="create-attribute-button" loading={props.isPending}>
Save
</Button>
</div>
</>
);
}
function getLayout(page: React.ReactElement) {
return <SettingsLayout hideHeader>{page}</SettingsLayout>;
}
CreateAttributesPage.getLayout = getLayout;
export default CreateAttributesPage;
@@ -0,0 +1,215 @@
"use client";
import { useState } from "react";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import SettingsLayout from "@calcom/features/settings/layouts/SettingsLayout";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import {
Meta,
Switch,
Dropdown,
Button,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownItem,
DropdownMenuItem,
Icon,
showToast,
useMeta,
} from "@calcom/ui";
import { ListSkeleton } from "./ListSkeleton";
type AttributeItemProps = RouterOutputs["viewer"]["attributes"]["list"][number];
const TypeToLabelMap = {
TEXT: "Text",
NUMBER: "Number",
SINGLE_SELECT: "Single-select",
MULTI_SELECT: "Multi-select",
};
function AttributeItem({ attribute }: { attribute: AttributeItemProps }) {
const utils = trpc.useUtils();
const { t } = useLocale();
const [isEnabled, setIsEnabled] = useState(attribute.enabled);
const mutation = trpc.viewer.attributes.toggleActive.useMutation({
onSuccess: () => {
showToast(t("attribute_updated_successfully"), "success");
},
onError: (err) => {
showToast(err.message, "error");
},
});
const deleteMutation = trpc.viewer.attributes.delete.useMutation({
onSuccess: () => {
showToast(t("attribute_deleted_successfully"), "success");
utils.viewer.attributes.list.invalidate();
},
onError: (err) => {
showToast(err.message, "error");
},
});
const handleToggle = (checked: boolean) => {
// Optimistically update the local state
setIsEnabled(checked);
mutation.mutate(
{ attributeId: attribute.id },
{
onError: (err) => {
setIsEnabled(!checked);
showToast(err.message, "error");
},
}
);
};
const handleDelete = () => {
deleteMutation.mutate({ id: attribute.id });
};
return (
<ul className="focus-within:border-emphasis flex justify-between p-4" key={attribute.id}>
<div>
<h3 className="text-sm font-semibold leading-none">{attribute.name}</h3>
<p className="text-default inline-flex items-center gap-1 text-sm font-normal leading-none">
{TypeToLabelMap[attribute.type]}
{attribute.options?.length > 0 && (
<>
<span className="text-muted">•</span>
<span>{t("number_of_options", { count: attribute.options.length })}</span>
</>
)}
</p>
</div>
<div className="flex gap-4">
<Switch checked={isEnabled} onCheckedChange={handleToggle} />
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="icon"
color="secondary"
StartIcon="ellipsis"
className="ltr:radix-state-open:rounded-r-md rtl:radix-state-open:rounded-l-md"
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[200px]">
<DropdownMenuItem>
<DropdownItem
type="button"
StartIcon="pencil"
href={`/settings/organizations/attributes/${attribute.id}/edit`}>
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem
type="button"
StartIcon="trash-2"
color="destructive"
disabled={deleteMutation.isPending}
onClick={handleDelete}>
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
</ul>
);
}
function OrganizationAttributesPage() {
const { t } = useLocale();
const { data, isLoading } = trpc.viewer.attributes.list.useQuery();
if (isLoading) {
return (
<>
<Meta title={t("attributes")} description={t("attribute_meta_description")} />
<div className="border-subtle bg-default flex flex-col gap-4 rounded-lg border p-6">
<ListSkeleton />
</div>
</>
);
}
return (
<>
<Meta title={t("attributes")} description={t("attribute_meta_description")} />
<ListAttributeHeader />
<LicenseRequired>
<div className="border-subtle bg-default flex flex-col gap-4 rounded-lg border p-6">
{data && data?.length > 0 ? (
<>
<h2 className="text-emphasis text-base font-semibold leading-none">{t("custom")}</h2>
<li className="border-subtle bg-default divide-subtle flex flex-col divide-y rounded-lg border">
{data?.map((attribute) => (
<AttributeItem attribute={attribute} key={attribute.id} />
))}
</li>
<Button
className="w-fit"
StartIcon="plus"
color="minimal"
href="/settings/organizations/attributes/create">
{t("add")}
</Button>
</>
) : (
<div className="flex w-full flex-col items-center justify-center p-14">
<div className="bg-emphasis text-emphasis flex h-16 w-16 items-center justify-center rounded-full p-2">
<Icon name="loader" />
</div>
<h2 className="font-cal text-emphasis mt-6 text-xl font-semibold leading-none">
{t("add_attributes")}
</h2>
<p className="text-emphasis mt-3 text-sm font-normal leading-none">
{t("add_attributes_description")}
</p>
<Button
className="mt-8"
StartIcon="plus"
color="secondary"
href="/settings/organizations/attributes/create">
{t("new_attribute")}
</Button>
</div>
)}
</div>
</LicenseRequired>
</>
);
}
function ListAttributeHeader() {
const { meta } = useMeta();
return (
<>
<div className="min-h-6 mb-6 mt-6 flex flex-grow items-center justify-between lg:mt-12">
<div className="flex items-center gap-4 ">
<div className="flex flex-col space-y-1 ">
<h1 className="text-emphasis font-cal text-xl font-semibold leading-none">{meta.title}</h1>
<p className="text-subtle text-sm">{meta.description}</p>
</div>
</div>
</div>
</>
);
}
function getLayout(page: React.ReactElement) {
return <SettingsLayout hideHeader>{page}</SettingsLayout>;
}
OrganizationAttributesPage.getLayout = getLayout;
export default OrganizationAttributesPage;
+1
View File
@@ -13,4 +13,5 @@ export type AppFlags = {
"email-verification": boolean;
"google-workspace-directory": boolean;
"disable-signup": boolean;
attributes: boolean;
};
@@ -166,6 +166,16 @@ const useTabs = () => {
const newArray = (tab?.children ?? []).filter(
(child) => isOrgAdminOrOwner || !organizationAdminKeys.includes(child.name)
);
// TODO: figure out feature flag as it doesnt cause a re-render of the component when loaded.
// You have to refresh the page to see the changes.
if (true) {
newArray.splice(4, 0, {
name: "attributes",
href: "/settings/organizations/attributes",
});
}
return {
...tab,
children: newArray,
@@ -636,8 +646,12 @@ const MobileSettingsContainer = (props: { onSideContainerOpen?: () => void }) =>
export default function SettingsLayout({
children,
hideHeader,
...rest
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
}: {
children: React.ReactNode;
hideHeader?: boolean;
} & ComponentProps<typeof Shell>) {
const pathname = usePathname();
const state = useState(false);
const { t } = useLocale();
@@ -681,7 +695,7 @@ export default function SettingsLayout({
}>
<div className="flex flex-1 [&>*]:flex-1">
<div className="mx-auto max-w-full justify-center lg:max-w-3xl">
<ShellHeader />
{!hideHeader && <ShellHeader />}
<ErrorBoundary>
<Suspense fallback={<Icon name="loader" />}>{children}</Suspense>
</ErrorBoundary>
@@ -1,10 +1,9 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useSession } from "next-auth/react";
import type { Dispatch } from "react";
import { useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import { useMemo, useState } from "react";
import { Controller, useForm, useFormContext } from "react-hook-form";
import { z } from "zod";
import { shallow } from "zustand/shallow";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/enums";
@@ -13,19 +12,22 @@ import {
Form,
TextField,
ToggleGroup,
InputField,
TextAreaField,
TimezoneSelect,
Label,
showToast,
Avatar,
ImageUploader,
SelectField,
SheetHeader,
SheetBody,
SheetFooter,
Button,
SheetTitle,
} from "@calcom/ui";
import type { Action } from "../UserListTable";
import { SheetFooterControls } from "./SheetFooterControls";
import { useEditMode } from "./store";
type MembershipOption = {
@@ -33,6 +35,14 @@ type MembershipOption = {
label: string;
};
const stringOrNumber = z.string().or(z.number());
const attributeSchema = z.object({
id: z.string(),
options: z.array(z.object({ label: z.string().optional(), value: stringOrNumber.optional() })).optional(),
value: stringOrNumber.optional(),
});
const editSchema = z.object({
name: z.string(),
username: z.string(),
@@ -43,6 +53,7 @@ const editSchema = z.object({
timeZone: z.string(),
// schedules: z.array(z.string()),
// teams: z.array(z.string()),
attributes: z.array(attributeSchema).optional(),
});
type EditSchema = z.infer<typeof editSchema>;
@@ -58,7 +69,8 @@ export function EditForm({
domainUrl: string;
dispatch: Dispatch<Action>;
}) {
const [setMutationLoading] = useEditMode((state) => [state.setMutationloading], shallow);
const setEditMode = useEditMode((state) => state.setEditMode);
const [mutationLoading, setMutationLoading] = useState(false);
const { t } = useLocale();
const session = useSession();
const org = session?.data?.user?.org;
@@ -124,7 +136,6 @@ export function EditForm({
<>
<Form
form={form}
id="edit-user-form"
className="flex h-full flex-col"
handleSubmit={(values) => {
setMutationLoading(true);
@@ -137,10 +148,18 @@ export function EditForm({
avatar: values.avatar,
bio: values.bio,
timeZone: values.timeZone,
// @ts-expect-error theyre there in local types but for some reason it errors?
attributeOptions: values.attributes
? // @ts-expect-error same as above
{ userId: selectedUser?.id ?? "", attributes: values.attributes }
: undefined,
});
setEditMode(false);
}}>
<SheetHeader>
<div className="flex flex-col gap-2">
<SheetTitle>{t("update_profile")}</SheetTitle>
<div className="mt-6 flex flex-col gap-2">
<Controller
control={form.control}
name="avatar"
@@ -161,22 +180,15 @@ export function EditForm({
</div>
)}
/>
<div className="space-between flex flex-col leading-none">
<span className="text-emphasis text-lg font-semibold">
{selectedUser?.name ?? "Nameless User"}
</span>
<p className="subtle text-sm font-normal">
{domainUrl}/{selectedUser?.username}
</p>
</div>
</div>
</SheetHeader>
<SheetBody className="mt-6 flex h-full flex-col space-y-3">
<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>
<TextField label={t("username")} {...form.register("username")} />
<TextField label={t("name")} {...form.register("name")} />
<TextField label={t("email")} {...form.register("email")} />
<TextAreaField label={t("bio")} {...form.register("bio")} className="min-h-52" />
<TextAreaField label={t("bio")} {...form.register("bio")} className="min-h-24" />
<div>
<Label>{t("role")}</Label>
<ToggleGroup
@@ -189,15 +201,157 @@ export function EditForm({
}}
/>
</div>
<div>
<div className="mb-4">
<Label>{t("timezone")}</Label>
<TimezoneSelect value={watchTimezone ?? "America/Los_Angeles"} />
</div>
<AttributesList selectedUserId={selectedUser?.id} />
</SheetBody>
<SheetFooter>
<SheetFooterControls />
<Button
color="secondary"
type="button"
className="justify-center md:w-1/5"
onClick={() => {
setEditMode(false);
}}>
{t("cancel")}
</Button>
<Button type="submit" className="w-full justify-center">
{t("update")}
</Button>
</SheetFooter>
</Form>
</>
);
}
type AttributeType = z.infer<typeof attributeSchema>;
type DefaultValueType = {
[key: `attributes.${number}`]: AttributeType;
};
function AttributesList(props: { selectedUserId: number }) {
const { data: usersAttributes, isPending: usersAttributesPending } =
trpc.viewer.attributes.getByUserId.useQuery({
userId: props.selectedUserId,
});
const { data: attributes, isPending: attributesPending } = trpc.viewer.attributes.list.useQuery();
const enabledAttributes = attributes?.filter((attr) => attr.enabled);
const { t } = useLocale();
const { control, watch, getFieldState, setValue } = useFormContext();
const getOptionsByAttributeId = (attributeId: string) => {
const attribute = attributes?.find((attr) => attr.id === attributeId);
return attribute
? attribute.options.map((option) => ({
value: option.id,
label: option.value,
}))
: [];
};
const defaultValues = useMemo<DefaultValueType>(() => {
if (!usersAttributes || usersAttributesPending || !enabledAttributes) return {};
return enabledAttributes.reduce<DefaultValueType>((acc, enabledAttr, index) => {
const attr = usersAttributes.find((attr) => attr.id === enabledAttr.id);
const key = `attributes.${index}` as const;
if (!attr) {
acc[key] = { id: enabledAttr.id, value: "", options: [] };
} else if (attr.type === "MULTI_SELECT") {
acc[key] = {
id: attr.id,
options: attr.options.map((option) => ({ label: option.value, value: option.id })),
};
} else if (attr.type === "SINGLE_SELECT") {
acc[key] = {
id: attr.id,
options: [{ label: attr.options[0]?.value, value: attr.options[0]?.id }],
};
} else {
acc[key] = {
id: attr.id,
value: attr.options[0]?.value || "",
};
}
return acc;
}, {});
}, [usersAttributes, usersAttributesPending, enabledAttributes]);
if (!enabledAttributes || !usersAttributes) return null;
const attributeFieldState = getFieldState("attributes");
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>
{attributeFieldState.error && (
<p className="text-error mb-2 block text-sm font-medium leading-none">
{JSON.stringify(attributeFieldState.error)}
</p>
)}
{enabledAttributes.map((attr, index) => (
<Controller
name={`attributes.${index}`}
control={control}
key={attr.id}
defaultValue={defaultValues[`attributes.${index}`]}
render={({ field }) => {
return (
<div className="flex w-full items-center justify-center gap-2" key={attr.id}>
{["TEXT", "NUMBER"].includes(attr.type) && (
<InputField
{...field}
containerClassName="w-full"
labelClassName="text-emphasis mb-2 block text-sm font-medium leading-none"
label={attr.name}
type={attr.type === "TEXT" ? "text" : "number"}
value={field.value?.value || ""}
onChange={(e) => {
field.onChange({
id: attr.id,
value: e.target.value,
});
}}
/>
)}
{["SINGLE_SELECT", "MULTI_SELECT"].includes(attr.type) && (
<SelectField
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" ? field.value?.options : field.value?.options[0]}
onChange={(value) => {
if (attr.type === "MULTI_SELECT") {
field.onChange({
id: attr.id,
options: value.map((v: any) => ({ label: v.label, value: v.value })),
});
} else {
field.onChange({
id: attr.id,
options: [{ label: value.label, value: value.value }],
});
}
}}
/>
)}
</div>
);
}}
/>
))}
</div>
</div>
);
}
@@ -23,9 +23,26 @@ export function EditUserSheet({ state, dispatch }: { state: State; dispatch: Dis
const { user: selectedUser } = state.editSheet;
const orgBranding = useOrgBranding();
const [editMode, setEditMode] = useEditMode((state) => [state.editMode, state.setEditMode], shallow);
const { data: loadedUser, isPending } = trpc.viewer.organizations.getUser.useQuery({
userId: selectedUser?.id,
});
const { data: loadedUser, isPending } = trpc.viewer.organizations.getUser.useQuery(
{
// @ts-expect-error we obly enable the query if the user is selected
userId: selectedUser.id,
},
{
enabled: !!selectedUser?.id,
}
);
const { data: usersAttributes, isPending: usersAttributesPending } =
trpc.viewer.attributes.getByUserId.useQuery(
{
// @ts-expect-error we obly enable the query if the user is selected
userId: selectedUser.id,
},
{
enabled: !!selectedUser?.id,
}
);
const avatarURL = `${orgBranding?.fullDomain ?? WEBAPP_URL}/${loadedUser?.username}/avatar.png`;
@@ -45,7 +62,7 @@ export function EditUserSheet({ state, dispatch }: { state: State; dispatch: Dis
<>
{!editMode ? (
<>
<SheetHeader showCloseButton={false}>
<SheetHeader showCloseButton={false} className="w-full">
<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]">
@@ -64,8 +81,8 @@ export function EditUserSheet({ state, dispatch }: { state: State; dispatch: Dis
</p>
</div>
</SheetHeader>
<SheetBody className="flex flex-col space-y-8 p-4">
<div className="flex flex-col space-y-4">
<SheetBody className="flex flex-col space-y-4 p-4">
<div className="mb-4 flex flex-col space-y-4">
<h3 className="text-emphasis mb-1 text-base font-semibold">{t("profile")}</h3>
<DisplayInfo
label="Cal"
@@ -89,6 +106,26 @@ export function EditUserSheet({ state, dispatch }: { state: State; dispatch: Dis
icon="calendar"
/>
</div>
{usersAttributes && usersAttributes?.length > 0 && (
<div className="mt-4 flex flex-col">
<h3 className="text-emphasis mb-5 text-base font-semibold">{t("attributes")}</h3>
<div className="flex flex-col space-y-4">
{usersAttributes.map((attribute, index) => (
<>
<DisplayInfo
key={index}
label={attribute.name}
value={
["TEXT", "NUMBER", "SINGLE_SELECT"].includes(attribute.type)
? attribute.options[0].value
: attribute.options.map((option) => option.value)
}
/>
</>
))}
</div>
</div>
)}
</SheetBody>
<SheetFooter>
<SheetFooterControls />
@@ -50,7 +50,7 @@ export type State = {
deleteMember: Payload;
impersonateMember: Payload;
inviteMember: Payload;
editSheet: Payload;
editSheet: Payload & { user?: User };
};
export type Action =
@@ -0,0 +1,57 @@
-- CreateEnum
CREATE TYPE "AttributeType" AS ENUM ('TEXT', 'NUMBER', 'SINGLE_SELECT', 'MULTI_SELECT');
-- CreateTable
CREATE TABLE "AttributeOption" (
"id" TEXT NOT NULL,
"attributeId" TEXT NOT NULL,
"value" TEXT NOT NULL,
"slug" TEXT NOT NULL,
CONSTRAINT "AttributeOption_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Attribute" (
"id" TEXT NOT NULL,
"teamId" INTEGER NOT NULL,
"type" "AttributeType" NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"usersCanEditRelation" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Attribute_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AttributeToUser" (
"id" TEXT NOT NULL,
"memberId" INTEGER NOT NULL,
"attributeOptionId" TEXT NOT NULL,
CONSTRAINT "AttributeToUser_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AttributeOption_attributeId_value_key" ON "AttributeOption"("attributeId", "value");
-- CreateIndex
CREATE UNIQUE INDEX "Attribute_slug_key" ON "Attribute"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "AttributeToUser_memberId_attributeOptionId_key" ON "AttributeToUser"("memberId", "attributeOptionId");
-- AddForeignKey
ALTER TABLE "AttributeOption" ADD CONSTRAINT "AttributeOption_attributeId_fkey" FOREIGN KEY ("attributeId") REFERENCES "Attribute"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Attribute" ADD CONSTRAINT "Attribute_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AttributeToUser" ADD CONSTRAINT "AttributeToUser_memberId_fkey" FOREIGN KEY ("memberId") REFERENCES "Membership"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AttributeToUser" ADD CONSTRAINT "AttributeToUser_attributeOptionId_fkey" FOREIGN KEY ("attributeOptionId") REFERENCES "AttributeOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+57 -5
View File
@@ -421,6 +421,7 @@ model Team {
smsLockState SMSLockState @default(UNLOCKED)
platformBilling PlatformBilling?
activeOrgWorkflows WorkflowsOnTeams[]
attributes Attribute[]
smsLockReviewedByAdmin Boolean @default(false)
@@unique([slug, parentId])
@@ -451,14 +452,15 @@ enum MembershipRole {
}
model Membership {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
teamId Int
userId Int
accepted Boolean @default(false)
accepted Boolean @default(false)
role MembershipRole
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
disableImpersonation Boolean @default(false)
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
disableImpersonation Boolean @default(false)
AttributeToUser AttributeToUser[]
@@unique([userId, teamId])
@@index([teamId])
@@ -1436,3 +1438,53 @@ model PlatformBilling {
team Team @relation(fields: [id], references: [id], onDelete: Cascade)
}
enum AttributeType {
TEXT
NUMBER
SINGLE_SELECT
MULTI_SELECT
}
model AttributeOption {
id String @id @default(uuid())
attribute Attribute @relation(fields: [attributeId], references: [id], onDelete: Cascade)
attributeId String
value String
slug String
assignedUsers AttributeToUser[]
@@unique([attributeId, value])
}
model Attribute {
id String @id @default(uuid())
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
teamId Int
type AttributeType
name String
slug String @unique
enabled Boolean @default(true)
usersCanEditRelation Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
options AttributeOption[]
}
model AttributeToUser {
id String @id @default(uuid())
member Membership @relation(fields: [memberId], references: [id], onDelete: Cascade)
memberId Int
attributeOption AttributeOption @relation(fields: [attributeOptionId], references: [id], onDelete: Cascade)
attributeOptionId String
@@unique([memberId, attributeOptionId])
}
+1
View File
@@ -28,4 +28,5 @@ export const ENDPOINTS = [
"appsRouter",
"googleWorkspace",
"oAuth",
"attributes",
] as const;
@@ -11,6 +11,7 @@ import { timezonesRouter } from "../publicViewer/timezones/_router";
import { adminRouter } from "./admin/_router";
import { apiKeysRouter } from "./apiKeys/_router";
import { appsRouter } from "./apps/_router";
import { attributesRouter } from "./attributes/_router";
import { authRouter } from "./auth/_router";
import { availabilityRouter } from "./availability/_router";
import { bookingsRouter } from "./bookings/_router";
@@ -59,5 +60,6 @@ export const viewerRouter = mergeRouters(
oAuth: oAuthRouter,
googleWorkspace: googleWorkspaceRouter,
admin: adminRouter,
attributes: attributesRouter,
})
);
@@ -0,0 +1,55 @@
import authedProcedure, { authedOrgAdminProcedure } from "../../../procedures/authedProcedure";
import { router, importHandler } from "../../../trpc";
import { assignUserToAttributeSchema } from "./assignUserToAttribute.schema";
import { createAttributeSchema } from "./create.schema";
import { deleteAttributeSchema } from "./delete.schema";
import { editAttributeSchema } from "./edit.schema";
import { getAttributeSchema } from "./get.schema";
import { getByUserIdSchema } from "./getByUserId.schema";
import { toggleActiveSchema } from "./toggleActive.schema";
const NAMESPACE = "attributes";
const namespaced = (s: string) => `${NAMESPACE}.${s}`;
export const attributesRouter = router({
list: authedProcedure.query(async (opts) => {
const handler = await importHandler(namespaced("list"), () => import("./list.handler"));
return handler(opts);
}),
get: authedProcedure.input(getAttributeSchema).query(async (opts) => {
const handler = await importHandler(namespaced("get"), () => import("./get.handler"));
return handler(opts);
}),
getByUserId: authedProcedure.input(getByUserIdSchema).query(async ({ ctx, input }) => {
const handler = await importHandler(namespaced("getByUserId"), () => import("./getByUserId.handler"));
return handler({ ctx, input });
}),
// Mutations
create: authedOrgAdminProcedure.input(createAttributeSchema).mutation(async ({ ctx, input }) => {
const handler = await importHandler(namespaced("create"), () => import("./create.handler"));
return handler({ ctx, input });
}),
edit: authedOrgAdminProcedure.input(editAttributeSchema).mutation(async ({ ctx, input }) => {
const handler = await importHandler(namespaced("edit"), () => import("./edit.handler"));
return handler({ ctx, input });
}),
delete: authedOrgAdminProcedure.input(deleteAttributeSchema).mutation(async ({ ctx, input }) => {
const handler = await importHandler(namespaced("delete"), () => import("./delete.handler"));
return handler({ ctx, input });
}),
toggleActive: authedOrgAdminProcedure.input(toggleActiveSchema).mutation(async ({ ctx, input }) => {
const handler = await importHandler(namespaced("toggleActive"), () => import("./toggleActive.handler"));
return handler({ ctx, input });
}),
assignUserToAttribute: authedOrgAdminProcedure
.input(assignUserToAttributeSchema)
.mutation(async ({ ctx, input }) => {
const handler = await importHandler(
namespaced("assignUserToAttribute"),
() => import("./assignUserToAttribute.handler")
);
return handler({ ctx, input });
}),
});
@@ -0,0 +1,215 @@
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { ZAssignUserToAttribute } from "./assignUserToAttribute.schema";
type GetOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: ZAssignUserToAttribute;
};
const assignUserToAttributeHandler = async ({ input, ctx }: GetOptions) => {
const org = ctx.user.organization;
if (!org.id) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to be apart of an organization to use this feature",
});
}
// TODO: We need to also empty the users assignemnts for IDs that are not in in this filteredAttributes list
// Filter out attributes that don't have a value or options set
const filteredAttributes = input.attributes.filter((attribute) => attribute.value || attribute.options);
// Ensure this organization can access these attributes and attribute options
const attributes = await prisma.attribute.findMany({
where: {
id: {
in: filteredAttributes.map((attribute) => attribute.id),
},
teamId: org.id,
},
select: {
id: true,
type: true,
options: true,
},
});
if (attributes.length !== filteredAttributes.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 membership = await prisma.membership.findFirst({
where: {
userId: input.userId,
teamId: org.id,
},
});
if (!membership) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "This user is not apart of your organization",
});
}
// const promises: Promise<{ id: string }>[] = [];
filteredAttributes.map(async (attribute) => {
// TEXT, NUMBER
if (attribute.value && !attribute.options) {
const valueAsString = String(attribute.value);
// Check if it is already the value
const existingAttributeOption = await prisma.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 prisma.attributeOption.update({
where: {
id: existingAttributeOption.attributeOption.id,
},
data: {
value: valueAsString,
slug: slugify(valueAsString),
},
});
return;
}
await prisma.attributeOption.create({
data: {
value: valueAsString,
slug: slugify(valueAsString),
attribute: {
connect: {
id: attribute.id,
},
},
assignedUsers: {
create: {
memberId: membership.id,
},
},
},
select: {
id: true,
},
});
} else if (!attribute.value && attribute.options && attribute.options.length > 0) {
const options = attribute.options;
// Get all users attributes for this attribute
await prisma.attributeToUser.findMany({
where: {
attributeOption: {
attribute: {
id: attribute.id,
},
},
memberId: membership.id,
},
});
// Delete all users attributes for this attribute that are not in the options list
await prisma.attributeToUser.deleteMany({
where: {
attributeOption: {
attribute: {
id: attribute.id,
},
},
memberId: membership.id,
NOT: {
id: {
in: options.map((option) => option.value),
},
},
},
});
options?.map(async (option) => {
// Assign the attribute option to the user
await prisma.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
select: {
id: true,
},
});
});
}
});
return {
success: true,
message: "Attributes assigned successfully",
};
};
export default assignUserToAttributeHandler;
@@ -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 assignUserToAttributeSchema = z.object({
userId: z.number(),
attributes: attributeSchema.array(),
});
export type ZAssignUserToAttribute = z.infer<typeof assignUserToAttributeSchema>;
@@ -0,0 +1,68 @@
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import type { Attribute } from "@calcom/prisma/client";
import { Prisma } from "@calcom/prisma/client";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { ZCreateAttributeSchema } from "./create.schema";
type GetOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: ZCreateAttributeSchema;
};
const typesWithOptions = ["SINGLE_SELECT", "MULTI_SELECT"];
const createAttributesHandler = async ({ input, ctx }: GetOptions) => {
const org = ctx.user.organization;
if (!org.id) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to be apart of an organization to use this feature",
});
}
const slug = slugify(input.name);
const options = input.options.map((v) => v.value);
const optionsWithoutDuplicates = Array.from(new Set(options));
const typeHasOptions = typesWithOptions.includes(input.type);
let attributes: Attribute;
try {
attributes = await prisma.attribute.create({
data: {
slug,
name: input.name,
type: input.type,
teamId: org.id,
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
throw new Error("A record with that name already exists. Please choose another one.");
} else {
throw error; // Re-throw the error if it's not a unique constraint violation
}
}
// Only assign options for the types that have options
// TEXT/NUMBER don't have options
if (typeHasOptions) {
await prisma.attributeOption.createMany({
data: optionsWithoutDuplicates.map((value) => ({
attributeId: attributes.id,
value,
slug: slugify(value),
})),
});
}
return attributes;
};
export default createAttributesHandler;
@@ -0,0 +1,9 @@
import { z } from "zod";
export const createAttributeSchema = z.object({
name: z.string(),
type: z.enum(["TEXT", "NUMBER", "SINGLE_SELECT", "MULTI_SELECT"]),
options: z.array(z.object({ value: z.string() })),
});
export type ZCreateAttributeSchema = z.infer<typeof createAttributeSchema>;
@@ -0,0 +1,35 @@
import prisma from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { ZDeleteAttributeSchema } from "./delete.schema";
type DeleteOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: ZDeleteAttributeSchema;
};
const deleteAttributeHandler = async ({ input, ctx }: DeleteOptions) => {
const org = ctx.user.organization;
if (!org.id) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to be apart of an organization to use this feature",
});
}
const attribute = await prisma.attribute.delete({
where: {
teamId: org.id,
id: input.id,
},
});
return attribute;
};
export default deleteAttributeHandler;
@@ -0,0 +1,7 @@
import { z } from "zod";
export const deleteAttributeSchema = z.object({
id: z.string(),
});
export type ZDeleteAttributeSchema = z.infer<typeof deleteAttributeSchema>;
@@ -0,0 +1,143 @@
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { ZEditAttributeSchema } from "./edit.schema";
type GetOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: ZEditAttributeSchema;
};
const editAttributesHandler = async ({ input, ctx }: GetOptions) => {
const org = ctx.user.organization;
if (!org.id) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to be apart of an organization to use this feature",
});
}
const options = input.options;
const foundAttribute = await prisma.attribute.findUnique({
where: {
id: input.attributeId,
teamId: org.id,
},
select: {
id: true,
},
});
if (!foundAttribute) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Attribute not found",
});
}
const attributes = await prisma.attribute.update({
where: {
id: input.attributeId,
},
data: {
name: input.name,
type: input.type,
teamId: org.id,
},
select: {
id: true,
},
});
// Check all ids of options passed in are owned by the attribute and organization
await validateOptionsBelongToAttribute(options, attributes.id);
await prisma.$transaction(async (tx) => {
const updateOptions = options.filter((option) => option.id !== undefined && option.id !== "");
const updatedOptionsIds = updateOptions.map((option) => option.id!);
// We need to delete all options that are not present in this UpdateOptions.id (as they have been deleted)
await tx.attributeOption.deleteMany({
where: {
attributeId: attributes.id,
NOT: [
{
id: {
in: updatedOptionsIds,
},
},
],
},
});
const createOptions = options.filter((option) => option.id === undefined || option.id === "");
const updatePromises = updateOptions.map(async (option) => {
return tx.attributeOption.update({
where: {
id: option.id,
},
data: {
value: option.value,
slug: slugify(option.value),
},
});
});
// Create new options where Ids are not present in the options array
const createPromises = createOptions.map(async (option) => {
return tx.attributeOption.create({
data: {
attributeId: attributes.id,
value: option.value,
slug: slugify(option.value),
},
});
});
await Promise.all(updatePromises);
await Promise.all(createPromises);
});
return attributes;
};
async function validateOptionsBelongToAttribute(
options: ZEditAttributeSchema["options"],
attributeId: string
) {
// We have to use ! here to make sure typescript knows that the id is not undefined
const optionsWithId = options
.filter((option) => option.id !== undefined && option.id !== "")
.map((option) => option.id!);
console.log("optionsWithId", optionsWithId);
// Check all ids of options passed in are owned by the attribute
const optionsWithIdOwnedByAttribute = await prisma.attributeOption.findMany({
where: {
id: {
in: optionsWithId,
},
attributeId: attributeId,
},
select: {
id: true,
},
});
if (optionsWithIdOwnedByAttribute.length !== optionsWithId.length) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You can't edit options that are not owned by the attribute",
});
}
}
export default editAttributesHandler;
@@ -0,0 +1,10 @@
import { z } from "zod";
export const editAttributeSchema = z.object({
attributeId: z.string(),
name: z.string(),
type: z.enum(["TEXT", "NUMBER", "SINGLE_SELECT", "MULTI_SELECT"]),
options: z.array(z.object({ value: z.string(), id: z.string().optional() })),
});
export type ZEditAttributeSchema = z.infer<typeof editAttributeSchema>;
@@ -0,0 +1,73 @@
import { z } from "zod";
import prisma from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { ZGetAttributeSchema } from "./get.schema";
type GetOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: ZGetAttributeSchema;
};
const getAttributeHandler = async ({ input, ctx }: GetOptions) => {
const org = ctx.user.organization;
if (!org.id) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to be apart of an organization to use this feature",
});
}
const attribute = await prisma.attribute.findUnique({
where: {
teamId: org.id,
id: input.id,
},
select: {
id: true,
name: true,
type: true,
options: {
select: {
id: true,
value: true,
_count: {
select: {
assignedUsers: true,
},
},
},
},
},
});
const formattedAttribute = {
...attribute,
options: attribute?.options.map((option) => {
const { _count, ...rest } = option;
return {
...rest,
assignedUsers: _count?.assignedUsers || 0,
};
}),
};
const attrReturnValue = z.object({
id: z.string(),
name: z.string(),
type: z.enum(["TEXT", "NUMBER", "SINGLE_SELECT", "MULTI_SELECT"]),
options: z.array(
z.object({ value: z.string(), id: z.string().optional(), assignedUsers: z.number().optional() })
),
});
return attrReturnValue.parse(formattedAttribute);
};
export default getAttributeHandler;
@@ -0,0 +1,7 @@
import { z } from "zod";
export const getAttributeSchema = z.object({
id: z.string(),
});
export type ZGetAttributeSchema = z.infer<typeof getAttributeSchema>;
@@ -0,0 +1,108 @@
import prisma from "@calcom/prisma";
import type { AttributeType } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { ZGetByUserIdSchema } from "./getByUserId.schema";
type GetOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: ZGetByUserIdSchema;
};
type GroupedAttribute = {
id: string;
name: string;
type: AttributeType;
options: {
id: string;
slug: string;
value: string;
}[];
};
const getByUserIdHandler = async ({ input, ctx }: GetOptions) => {
const org = ctx.user.organization;
if (!org.id) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to be apart of an organization to use this feature",
});
}
// Ensure user is apart of the organization
const membership = await prisma.membership.findFirst({
where: {
userId: input.userId,
teamId: org.id,
},
});
if (!membership) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "This user is not apart of your organization",
});
}
const userAttributes = await prisma.attributeToUser.findMany({
where: {
member: {
id: membership.id,
},
attributeOption: {
attribute: {
enabled: true,
},
},
},
select: {
attributeOption: {
select: {
id: true,
value: true,
slug: true,
attribute: {
select: {
id: true,
name: true,
type: true,
},
},
},
},
},
});
const groupedAttributes = userAttributes.reduce<GroupedAttribute[]>((acc, attribute) => {
const { attributeOption } = attribute;
const { attribute: attrInfo, ...optionInfo } = attributeOption;
const existingGroup = acc.find((group) => group.id === attrInfo.id);
if (existingGroup) {
existingGroup.options.push(optionInfo);
} else {
acc.push({
id: attrInfo.id,
name: attrInfo.name,
type: attrInfo.type,
options: [
{
...optionInfo,
},
],
});
}
return acc;
}, []);
return groupedAttributes;
};
export default getByUserIdHandler;
@@ -0,0 +1,7 @@
import { z } from "zod";
export const getByUserIdSchema = z.object({
userId: z.number(),
});
export type ZGetByUserIdSchema = z.infer<typeof getByUserIdSchema>;
@@ -0,0 +1,33 @@
import prisma from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
type GetOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
};
const listHandler = async (opts: GetOptions) => {
const org = opts.ctx.user.organization;
if (!org.id) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to be apart of an organization to use this feature",
});
}
return await prisma.attribute.findMany({
where: {
teamId: org.id,
},
include: {
options: true,
},
});
};
export default listHandler;
@@ -0,0 +1,61 @@
import prisma from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { ZToggleActiveSchema } from "./toggleActive.schema";
type GetOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: ZToggleActiveSchema;
};
const toggleActiveHandler = async ({ input, ctx }: GetOptions) => {
const org = ctx.user.organization;
if (!org.id) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to be apart of an organization to use this feature",
});
}
// Ensure that this users org owns the attribute
const attribute = await prisma.attribute.findUnique({
where: {
id: input.attributeId,
teamId: org.id,
},
select: {
id: true,
enabled: true,
},
});
if (!attribute) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Attribute not found",
});
}
// Toggle the attribute
await prisma.attribute.update({
where: {
id: input.attributeId,
},
data: {
enabled: !attribute.enabled,
},
});
// Save us refetching the attribute to get the correct value
return {
...attribute,
enabled: !attribute.enabled,
};
};
export default toggleActiveHandler;
@@ -0,0 +1,7 @@
import { z } from "zod";
export const toggleActiveSchema = z.object({
attributeId: z.string(),
});
export type ZToggleActiveSchema = z.infer<typeof toggleActiveSchema>;
@@ -11,6 +11,7 @@ import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import assignUserToAttributeHandler from "../attributes/assignUserToAttribute.handler";
import type { TUpdateUserInputSchema } from "./updateUser.schema";
type UpdateUserOptions = {
@@ -163,6 +164,15 @@ export const updateUserHandler = async ({ ctx, input }: UpdateUserOptions) => {
await prisma.$transaction(transactions);
if (input.attributeOptions) {
await assignUserToAttributeHandler({
ctx: {
user: ctx.user,
},
input: input.attributeOptions,
});
}
if (input.role === MembershipRole.ADMIN || input.role === MembershipRole.OWNER) {
const teamIds = requestedMember.team.children
.map((sub_team) => sub_team.members.find((item) => item.userId === input.userId)?.teamId)
@@ -1,5 +1,7 @@
import { z } from "zod";
import { assignUserToAttributeSchema } from "../attributes/assignUserToAttribute.schema";
export const ZUpdateUserInputSchema = z.object({
userId: z.number(),
username: z.string().optional(),
@@ -9,6 +11,7 @@ export const ZUpdateUserInputSchema = z.object({
avatar: z.string().optional(),
role: z.enum(["ADMIN", "MEMBER", "OWNER"]),
timeZone: z.string(),
attributeOptions: assignUserToAttributeSchema.optional(),
});
export type TUpdateUserInputSchema = z.infer<typeof ZUpdateUserInputSchema>;
+1 -1
View File
@@ -1,3 +1,3 @@
export { Button } from "./Button";
export { Button, buttonClasses } from "./Button";
export type { ButtonBaseProps, ButtonProps } from "./Button";
export { default as LinkIconButton } from "./LinkIconButton";
@@ -127,7 +127,10 @@ export const SelectField = function SelectField<
<div className={cx(containerClassName)}>
<div className={cx(className)}>
{!!label && (
<Label htmlFor={id} {...labelProps} className={cx(props.error && "text-error")}>
<Label
htmlFor={id}
{...labelProps}
className={cx(props.error && "text-error", props.labelProps?.className)}>
{label}
</Label>
)}
+3 -3
View File
@@ -112,9 +112,9 @@ const SheetTitle = React.forwardRef<
ref={forwardedRef}
className={classNames(
// base
"text-base font-semibold",
"font-sans text-xl font-semibold",
// text color
"text-default",
"text-emphasis",
className
)}
{...props}
@@ -138,7 +138,7 @@ const SheetDescription = React.forwardRef<
return (
<SheetPrimitives.Description
ref={forwardedRef}
className={classNames("text-default", className)}
className={classNames("text-subtle text-sm leading-none", className)}
{...props}
/>
);
+1 -1
View File
@@ -11,7 +11,7 @@ export type { ArrowButtonProps } from "./components/arrow-button";
export { Badge, UpgradeTeamsBadge, InfoBadge } from "./components/badge";
export type { BadgeProps } from "./components/badge";
export { Breadcrumb, BreadcrumbContainer, BreadcrumbItem } from "./components/breadcrumb";
export { Button, LinkIconButton } from "./components/button";
export { Button, LinkIconButton, buttonClasses } from "./components/button";
export type { ButtonBaseProps, ButtonProps } from "./components/button";
export { ButtonGroup } from "./components/buttonGroup";
export { EditableHeading } from "./components/editable-heading";