From 08e1b0a9c8b4d36c91d24b9f9710817e3280d001 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Thu, 15 Aug 2024 22:49:05 +0100 Subject: [PATCH] feat: attributes (#15964) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Joe Au-Yeung Co-authored-by: Omar López --- apps/web/pages/api/trpc/attributes/[trpc].ts | 4 + .../organizations/attributes/[id]/edit.tsx | 9 + .../organizations/attributes/create.tsx | 9 + .../organizations/attributes/index.tsx | 9 + apps/web/public/static/locales/en/common.json | 15 ++ .../settings/attributes/AttributesForm.tsx | 152 +++++++++++++ .../settings/attributes/ListSkeleton.tsx | 22 ++ .../attributes/attributes-create-view.tsx | 99 ++++++++ .../attributes/attributes-edit-view.tsx | 118 ++++++++++ .../attributes/attributes-list-view.tsx | 215 ++++++++++++++++++ packages/features/flags/config.ts | 1 + .../settings/layouts/SettingsLayout.tsx | 18 +- .../UserTable/EditSheet/EditUserForm.tsx | 192 ++++++++++++++-- .../UserTable/EditSheet/EditUserSheet.tsx | 49 +++- .../components/UserTable/UserListTable.tsx | 2 +- .../migration.sql | 57 +++++ packages/prisma/schema.prisma | 62 ++++- packages/trpc/react/shared.ts | 1 + .../trpc/server/routers/viewer/_router.tsx | 2 + .../routers/viewer/attributes/_router.ts | 55 +++++ .../assignUserToAttribute.handler.ts | 215 ++++++++++++++++++ .../assignUserToAttribute.schema.ts | 14 ++ .../viewer/attributes/create.handler.ts | 68 ++++++ .../viewer/attributes/create.schema.ts | 9 + .../viewer/attributes/delete.handler.ts | 35 +++ .../viewer/attributes/delete.schema.ts | 7 + .../routers/viewer/attributes/edit.handler.ts | 143 ++++++++++++ .../routers/viewer/attributes/edit.schema.ts | 10 + .../routers/viewer/attributes/get.handler.ts | 73 ++++++ .../routers/viewer/attributes/get.schema.ts | 7 + .../viewer/attributes/getByUserId.handler.ts | 108 +++++++++ .../viewer/attributes/getByUserId.schema.ts | 7 + .../routers/viewer/attributes/list.handler.ts | 33 +++ .../viewer/attributes/toggleActive.handler.ts | 61 +++++ .../viewer/attributes/toggleActive.schema.ts | 7 + .../organizations/updateUser.handler.ts | 10 + .../viewer/organizations/updateUser.schema.ts | 3 + packages/ui/components/button/index.ts | 2 +- packages/ui/components/form/select/Select.tsx | 5 +- packages/ui/components/sheet/sheet.tsx | 6 +- packages/ui/index.tsx | 2 +- 41 files changed, 1877 insertions(+), 39 deletions(-) create mode 100644 apps/web/pages/api/trpc/attributes/[trpc].ts create mode 100644 apps/web/pages/settings/organizations/attributes/[id]/edit.tsx create mode 100644 apps/web/pages/settings/organizations/attributes/create.tsx create mode 100644 apps/web/pages/settings/organizations/attributes/index.tsx create mode 100644 packages/features/ee/organizations/pages/settings/attributes/AttributesForm.tsx create mode 100644 packages/features/ee/organizations/pages/settings/attributes/ListSkeleton.tsx create mode 100644 packages/features/ee/organizations/pages/settings/attributes/attributes-create-view.tsx create mode 100644 packages/features/ee/organizations/pages/settings/attributes/attributes-edit-view.tsx create mode 100644 packages/features/ee/organizations/pages/settings/attributes/attributes-list-view.tsx create mode 100644 packages/prisma/migrations/20240808084028_add_attribute_tables/migration.sql create mode 100644 packages/trpc/server/routers/viewer/attributes/_router.ts create mode 100644 packages/trpc/server/routers/viewer/attributes/assignUserToAttribute.handler.ts create mode 100644 packages/trpc/server/routers/viewer/attributes/assignUserToAttribute.schema.ts create mode 100644 packages/trpc/server/routers/viewer/attributes/create.handler.ts create mode 100644 packages/trpc/server/routers/viewer/attributes/create.schema.ts create mode 100644 packages/trpc/server/routers/viewer/attributes/delete.handler.ts create mode 100644 packages/trpc/server/routers/viewer/attributes/delete.schema.ts create mode 100644 packages/trpc/server/routers/viewer/attributes/edit.handler.ts create mode 100644 packages/trpc/server/routers/viewer/attributes/edit.schema.ts create mode 100644 packages/trpc/server/routers/viewer/attributes/get.handler.ts create mode 100644 packages/trpc/server/routers/viewer/attributes/get.schema.ts create mode 100644 packages/trpc/server/routers/viewer/attributes/getByUserId.handler.ts create mode 100644 packages/trpc/server/routers/viewer/attributes/getByUserId.schema.ts create mode 100644 packages/trpc/server/routers/viewer/attributes/list.handler.ts create mode 100644 packages/trpc/server/routers/viewer/attributes/toggleActive.handler.ts create mode 100644 packages/trpc/server/routers/viewer/attributes/toggleActive.schema.ts diff --git a/apps/web/pages/api/trpc/attributes/[trpc].ts b/apps/web/pages/api/trpc/attributes/[trpc].ts new file mode 100644 index 0000000000..01713e3462 --- /dev/null +++ b/apps/web/pages/api/trpc/attributes/[trpc].ts @@ -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); diff --git a/apps/web/pages/settings/organizations/attributes/[id]/edit.tsx b/apps/web/pages/settings/organizations/attributes/[id]/edit.tsx new file mode 100644 index 0000000000..b38c5d9b5d --- /dev/null +++ b/apps/web/pages/settings/organizations/attributes/[id]/edit.tsx @@ -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; diff --git a/apps/web/pages/settings/organizations/attributes/create.tsx b/apps/web/pages/settings/organizations/attributes/create.tsx new file mode 100644 index 0000000000..1283513416 --- /dev/null +++ b/apps/web/pages/settings/organizations/attributes/create.tsx @@ -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; diff --git a/apps/web/pages/settings/organizations/attributes/index.tsx b/apps/web/pages/settings/organizations/attributes/index.tsx new file mode 100644 index 0000000000..31b59ee34a --- /dev/null +++ b/apps/web/pages/settings/organizations/attributes/index.tsx @@ -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; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 566d934578..b04c5ea5ea 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" diff --git a/packages/features/ee/organizations/pages/settings/attributes/AttributesForm.tsx b/packages/features/ee/organizations/pages/settings/attributes/AttributesForm.tsx new file mode 100644 index 0000000000..6c5e72a173 --- /dev/null +++ b/packages/features/ee/organizations/pages/settings/attributes/AttributesForm.tsx @@ -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; + +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({ + duration: 300, + easing: "ease-in-out", + }); + + const form = useForm({ + 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 ( +
{ + onSubmit({ + ...values, + options: values.options, + }); + }}> + {header} + + ( + { + if (!option) return; + onChange(option.value); + }} + value={AttributeTypeOptions.find((attr) => attr.value === value)} + required + /> + )} + /> + {["SINGLE_SELECT", "MULTI_SELECT"].includes(watchedType) && ( +
+
+ +
+ {fields.map((field, index) => ( +
+ + +
+ ))} +
+
+ +
+ )} + setDeleteOptionDialog({ id: undefined, open: false })}> + { + 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, + })} + + + + + ); +} diff --git a/packages/features/ee/organizations/pages/settings/attributes/ListSkeleton.tsx b/packages/features/ee/organizations/pages/settings/attributes/ListSkeleton.tsx new file mode 100644 index 0000000000..117c0bb43b --- /dev/null +++ b/packages/features/ee/organizations/pages/settings/attributes/ListSkeleton.tsx @@ -0,0 +1,22 @@ +import { SkeletonText } from "@calcom/ui"; + +export function ListSkeleton() { + return ( +
    + {Array.from({ length: 3 }).map((_, idx) => ( +
  • + +
  • + ))} +
+ ); +} + +function AttributeSkeleton() { + return ( +
+ + +
+ ); +} diff --git a/packages/features/ee/organizations/pages/settings/attributes/attributes-create-view.tsx b/packages/features/ee/organizations/pages/settings/attributes/attributes-create-view.tsx new file mode 100644 index 0000000000..32194a3fda --- /dev/null +++ b/packages/features/ee/organizations/pages/settings/attributes/attributes-create-view.tsx @@ -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; + +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 ( + <> + + + } + 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 })), + }); + }} + /> + + + ); +} + +function CreateAttributeHeader(props: { isPending: boolean }) { + const { meta } = useMeta(); + const { t } = useLocale(); + const formContext = useFormContext(); + + const watchedTitle = formContext.watch("attrName"); + + return ( + <> +
+
+ +
+

{meta.title}

+ {watchedTitle && ( + <> + / {watchedTitle} + + )} +
+
+ +
+ + ); +} +function getLayout(page: React.ReactElement) { + return {page}; +} + +CreateAttributesPage.getLayout = getLayout; + +export default CreateAttributesPage; diff --git a/packages/features/ee/organizations/pages/settings/attributes/attributes-edit-view.tsx b/packages/features/ee/organizations/pages/settings/attributes/attributes-edit-view.tsx new file mode 100644 index 0000000000..ec3ad5ff2a --- /dev/null +++ b/packages/features/ee/organizations/pages/settings/attributes/attributes-edit-view.tsx @@ -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; + +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 ( + <> + + + {!attribute.isLoading && attribute.data ? ( + } + onSubmit={(values) => { + mutation.mutate({ + attributeId: id as string, + name: values.attrName, + type: values.type, + options: values.options, + }); + }} + /> + ) : ( + <>Loading + )} + + + ); +} + +function EditAttributeHeader(props: { isPending: boolean }) { + const { meta } = useMeta(); + const { t } = useLocale(); + const formContext = useFormContext(); + + const watchedTitle = formContext.watch("attrName"); + + return ( + <> +
+
+ +
+

{meta.title}

+ {watchedTitle && ( + <> + / {watchedTitle} + + )} +
+
+ +
+ + ); +} + +function getLayout(page: React.ReactElement) { + return {page}; +} + +CreateAttributesPage.getLayout = getLayout; + +export default CreateAttributesPage; diff --git a/packages/features/ee/organizations/pages/settings/attributes/attributes-list-view.tsx b/packages/features/ee/organizations/pages/settings/attributes/attributes-list-view.tsx new file mode 100644 index 0000000000..60c401c006 --- /dev/null +++ b/packages/features/ee/organizations/pages/settings/attributes/attributes-list-view.tsx @@ -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 ( +
    +
    +

    {attribute.name}

    +

    + {TypeToLabelMap[attribute.type]} + {attribute.options?.length > 0 && ( + <> + • + {t("number_of_options", { count: attribute.options.length })} + + )} +

    +
    +
    + + + +
    +
+ ); +} + +function OrganizationAttributesPage() { + const { t } = useLocale(); + const { data, isLoading } = trpc.viewer.attributes.list.useQuery(); + + if (isLoading) { + return ( + <> + +
+ +
+ + ); + } + + return ( + <> + + + +
+ {data && data?.length > 0 ? ( + <> +

{t("custom")}

+
  • + {data?.map((attribute) => ( + + ))} +
  • + + + ) : ( +
    +
    + +
    +

    + {t("add_attributes")} +

    + +

    + {t("add_attributes_description")} +

    + +
    + )} +
    +
    + + ); +} + +function ListAttributeHeader() { + const { meta } = useMeta(); + + return ( + <> +
    +
    +
    +

    {meta.title}

    +

    {meta.description}

    +
    +
    +
    + + ); +} + +function getLayout(page: React.ReactElement) { + return {page}; +} + +OrganizationAttributesPage.getLayout = getLayout; + +export default OrganizationAttributesPage; diff --git a/packages/features/flags/config.ts b/packages/features/flags/config.ts index 5125b09c2a..852b417bd6 100644 --- a/packages/features/flags/config.ts +++ b/packages/features/flags/config.ts @@ -13,4 +13,5 @@ export type AppFlags = { "email-verification": boolean; "google-workspace-directory": boolean; "disable-signup": boolean; + attributes: boolean; }; diff --git a/packages/features/settings/layouts/SettingsLayout.tsx b/packages/features/settings/layouts/SettingsLayout.tsx index 8513eee80a..fc82123b7a 100644 --- a/packages/features/settings/layouts/SettingsLayout.tsx +++ b/packages/features/settings/layouts/SettingsLayout.tsx @@ -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) { +}: { + children: React.ReactNode; + hideHeader?: boolean; +} & ComponentProps) { const pathname = usePathname(); const state = useState(false); const { t } = useLocale(); @@ -681,7 +695,7 @@ export default function SettingsLayout({ }>
    - + {!hideHeader && } }>{children} diff --git a/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx b/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx index 44f7c2c6be..860116bf9b 100644 --- a/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx +++ b/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx @@ -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; @@ -58,7 +69,8 @@ export function EditForm({ domainUrl: string; dispatch: Dispatch; }) { - 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({ <>
    { 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); }}> -
    + {t("update_profile")} + +
    )} /> -
    - - {selectedUser?.name ?? "Nameless User"} - -

    - {domainUrl}/{selectedUser?.username} -

    -
    - + + - +
    -
    +
    + - + + + ); } + +type AttributeType = z.infer; + +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(() => { + if (!usersAttributes || usersAttributesPending || !enabledAttributes) return {}; + + return enabledAttributes.reduce((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 ( +
    +
    + + {attributeFieldState.error && ( +

    + {JSON.stringify(attributeFieldState.error)} +

    + )} + {enabledAttributes.map((attr, index) => ( + { + return ( +
    + {["TEXT", "NUMBER"].includes(attr.type) && ( + { + field.onChange({ + id: attr.id, + value: e.target.value, + }); + }} + /> + )} + {["SINGLE_SELECT", "MULTI_SELECT"].includes(attr.type) && ( + { + 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 }], + }); + } + }} + /> + )} +
    + ); + }} + /> + ))} +
    +
    + ); +} diff --git a/packages/features/users/components/UserTable/EditSheet/EditUserSheet.tsx b/packages/features/users/components/UserTable/EditSheet/EditUserSheet.tsx index aa14919ce1..fd657f9f99 100644 --- a/packages/features/users/components/UserTable/EditSheet/EditUserSheet.tsx +++ b/packages/features/users/components/UserTable/EditSheet/EditUserSheet.tsx @@ -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 ? ( <> - +
    @@ -64,8 +81,8 @@ export function EditUserSheet({ state, dispatch }: { state: State; dispatch: Dis

    - -
    + +

    {t("profile")}

    + {usersAttributes && usersAttributes?.length > 0 && ( +
    +

    {t("attributes")}

    +
    + {usersAttributes.map((attribute, index) => ( + <> + option.value) + } + /> + + ))} +
    +
    + )}
    diff --git a/packages/features/users/components/UserTable/UserListTable.tsx b/packages/features/users/components/UserTable/UserListTable.tsx index d5f10cc768..a5d553a5ed 100644 --- a/packages/features/users/components/UserTable/UserListTable.tsx +++ b/packages/features/users/components/UserTable/UserListTable.tsx @@ -50,7 +50,7 @@ export type State = { deleteMember: Payload; impersonateMember: Payload; inviteMember: Payload; - editSheet: Payload; + editSheet: Payload & { user?: User }; }; export type Action = diff --git a/packages/prisma/migrations/20240808084028_add_attribute_tables/migration.sql b/packages/prisma/migrations/20240808084028_add_attribute_tables/migration.sql new file mode 100644 index 0000000000..0d386a8ce2 --- /dev/null +++ b/packages/prisma/migrations/20240808084028_add_attribute_tables/migration.sql @@ -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; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 3d8f8d9c6b..3b8fc9de27 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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]) +} diff --git a/packages/trpc/react/shared.ts b/packages/trpc/react/shared.ts index 8496093cab..9593b90740 100644 --- a/packages/trpc/react/shared.ts +++ b/packages/trpc/react/shared.ts @@ -28,4 +28,5 @@ export const ENDPOINTS = [ "appsRouter", "googleWorkspace", "oAuth", + "attributes", ] as const; diff --git a/packages/trpc/server/routers/viewer/_router.tsx b/packages/trpc/server/routers/viewer/_router.tsx index 80cc41c916..7911503b4f 100644 --- a/packages/trpc/server/routers/viewer/_router.tsx +++ b/packages/trpc/server/routers/viewer/_router.tsx @@ -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, }) ); diff --git a/packages/trpc/server/routers/viewer/attributes/_router.ts b/packages/trpc/server/routers/viewer/attributes/_router.ts new file mode 100644 index 0000000000..169fbc255e --- /dev/null +++ b/packages/trpc/server/routers/viewer/attributes/_router.ts @@ -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 }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/attributes/assignUserToAttribute.handler.ts b/packages/trpc/server/routers/viewer/attributes/assignUserToAttribute.handler.ts new file mode 100644 index 0000000000..d00d9f236e --- /dev/null +++ b/packages/trpc/server/routers/viewer/attributes/assignUserToAttribute.handler.ts @@ -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; + }; + 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; diff --git a/packages/trpc/server/routers/viewer/attributes/assignUserToAttribute.schema.ts b/packages/trpc/server/routers/viewer/attributes/assignUserToAttribute.schema.ts new file mode 100644 index 0000000000..462f2815b8 --- /dev/null +++ b/packages/trpc/server/routers/viewer/attributes/assignUserToAttribute.schema.ts @@ -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; diff --git a/packages/trpc/server/routers/viewer/attributes/create.handler.ts b/packages/trpc/server/routers/viewer/attributes/create.handler.ts new file mode 100644 index 0000000000..0dd8a62b4f --- /dev/null +++ b/packages/trpc/server/routers/viewer/attributes/create.handler.ts @@ -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; + }; + 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; diff --git a/packages/trpc/server/routers/viewer/attributes/create.schema.ts b/packages/trpc/server/routers/viewer/attributes/create.schema.ts new file mode 100644 index 0000000000..52154b328e --- /dev/null +++ b/packages/trpc/server/routers/viewer/attributes/create.schema.ts @@ -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; diff --git a/packages/trpc/server/routers/viewer/attributes/delete.handler.ts b/packages/trpc/server/routers/viewer/attributes/delete.handler.ts new file mode 100644 index 0000000000..301a002a67 --- /dev/null +++ b/packages/trpc/server/routers/viewer/attributes/delete.handler.ts @@ -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; + }; + 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; diff --git a/packages/trpc/server/routers/viewer/attributes/delete.schema.ts b/packages/trpc/server/routers/viewer/attributes/delete.schema.ts new file mode 100644 index 0000000000..a411d31b15 --- /dev/null +++ b/packages/trpc/server/routers/viewer/attributes/delete.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const deleteAttributeSchema = z.object({ + id: z.string(), +}); + +export type ZDeleteAttributeSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/attributes/edit.handler.ts b/packages/trpc/server/routers/viewer/attributes/edit.handler.ts new file mode 100644 index 0000000000..95e32fe07b --- /dev/null +++ b/packages/trpc/server/routers/viewer/attributes/edit.handler.ts @@ -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; + }; + 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; diff --git a/packages/trpc/server/routers/viewer/attributes/edit.schema.ts b/packages/trpc/server/routers/viewer/attributes/edit.schema.ts new file mode 100644 index 0000000000..9c23e7fa75 --- /dev/null +++ b/packages/trpc/server/routers/viewer/attributes/edit.schema.ts @@ -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; diff --git a/packages/trpc/server/routers/viewer/attributes/get.handler.ts b/packages/trpc/server/routers/viewer/attributes/get.handler.ts new file mode 100644 index 0000000000..0e674f38aa --- /dev/null +++ b/packages/trpc/server/routers/viewer/attributes/get.handler.ts @@ -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; + }; + 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; diff --git a/packages/trpc/server/routers/viewer/attributes/get.schema.ts b/packages/trpc/server/routers/viewer/attributes/get.schema.ts new file mode 100644 index 0000000000..7421b7a006 --- /dev/null +++ b/packages/trpc/server/routers/viewer/attributes/get.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const getAttributeSchema = z.object({ + id: z.string(), +}); + +export type ZGetAttributeSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/attributes/getByUserId.handler.ts b/packages/trpc/server/routers/viewer/attributes/getByUserId.handler.ts new file mode 100644 index 0000000000..a7cf04451a --- /dev/null +++ b/packages/trpc/server/routers/viewer/attributes/getByUserId.handler.ts @@ -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; + }; + 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((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; diff --git a/packages/trpc/server/routers/viewer/attributes/getByUserId.schema.ts b/packages/trpc/server/routers/viewer/attributes/getByUserId.schema.ts new file mode 100644 index 0000000000..37a7ab81db --- /dev/null +++ b/packages/trpc/server/routers/viewer/attributes/getByUserId.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const getByUserIdSchema = z.object({ + userId: z.number(), +}); + +export type ZGetByUserIdSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/attributes/list.handler.ts b/packages/trpc/server/routers/viewer/attributes/list.handler.ts new file mode 100644 index 0000000000..0281e7c087 --- /dev/null +++ b/packages/trpc/server/routers/viewer/attributes/list.handler.ts @@ -0,0 +1,33 @@ +import prisma from "@calcom/prisma"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; + +type GetOptions = { + ctx: { + user: NonNullable; + }; +}; + +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; diff --git a/packages/trpc/server/routers/viewer/attributes/toggleActive.handler.ts b/packages/trpc/server/routers/viewer/attributes/toggleActive.handler.ts new file mode 100644 index 0000000000..2e5c9deddc --- /dev/null +++ b/packages/trpc/server/routers/viewer/attributes/toggleActive.handler.ts @@ -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; + }; + 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; diff --git a/packages/trpc/server/routers/viewer/attributes/toggleActive.schema.ts b/packages/trpc/server/routers/viewer/attributes/toggleActive.schema.ts new file mode 100644 index 0000000000..33337407f0 --- /dev/null +++ b/packages/trpc/server/routers/viewer/attributes/toggleActive.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const toggleActiveSchema = z.object({ + attributeId: z.string(), +}); + +export type ZToggleActiveSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/organizations/updateUser.handler.ts b/packages/trpc/server/routers/viewer/organizations/updateUser.handler.ts index 7e3d29e01d..9d286ef82d 100644 --- a/packages/trpc/server/routers/viewer/organizations/updateUser.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/updateUser.handler.ts @@ -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) diff --git a/packages/trpc/server/routers/viewer/organizations/updateUser.schema.ts b/packages/trpc/server/routers/viewer/organizations/updateUser.schema.ts index 1f0fde2b32..04717ed112 100644 --- a/packages/trpc/server/routers/viewer/organizations/updateUser.schema.ts +++ b/packages/trpc/server/routers/viewer/organizations/updateUser.schema.ts @@ -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; diff --git a/packages/ui/components/button/index.ts b/packages/ui/components/button/index.ts index f440d53c74..2d1bad9644 100644 --- a/packages/ui/components/button/index.ts +++ b/packages/ui/components/button/index.ts @@ -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"; diff --git a/packages/ui/components/form/select/Select.tsx b/packages/ui/components/form/select/Select.tsx index 3f17561b3d..e52dab8b1f 100644 --- a/packages/ui/components/form/select/Select.tsx +++ b/packages/ui/components/form/select/Select.tsx @@ -127,7 +127,10 @@ export const SelectField = function SelectField<
    {!!label && ( -