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:
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>
|
||||
);
|
||||
}
|
||||
+99
@@ -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;
|
||||
+118
@@ -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;
|
||||
+215
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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,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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user