diff --git a/.changeset/tender-birds-think.md b/.changeset/tender-birds-think.md new file mode 100644 index 0000000000..fabbbe3775 --- /dev/null +++ b/.changeset/tender-birds-think.md @@ -0,0 +1,5 @@ +--- +"@calcom/atoms": patch +--- + +fix: unlocked fields not saved for managed event type diff --git a/packages/features/ee/managed-event-types/hooks/useLockedFieldsManager.test.tsx b/packages/features/ee/managed-event-types/hooks/useLockedFieldsManager.test.tsx new file mode 100644 index 0000000000..236580f87a --- /dev/null +++ b/packages/features/ee/managed-event-types/hooks/useLockedFieldsManager.test.tsx @@ -0,0 +1,564 @@ +/** + * @vitest-environment jsdom + */ + +import { SchedulingType } from "@calcom/prisma/enums"; +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import useLockedFieldsManager from "./useLockedFieldsManager"; + +describe("useLockedFieldsManager", () => { + const mockTranslate = vi.fn((key: string) => key); + + const createMockFormMethods = (getValuesReturn: unknown = undefined) => { + const setValueMock = vi.fn(); + const getValuesMock = vi.fn().mockReturnValue(getValuesReturn); + + return { + setValue: setValueMock, + getValues: getValuesMock, + watch: vi.fn(), + register: vi.fn(), + handleSubmit: vi.fn(), + formState: { errors: {} }, + control: {}, + } as unknown as ReturnType; + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("isManagedEventType", () => { + it("should return true when schedulingType is MANAGED", () => { + const mockFormMethods = createMockFormMethods(); + const eventType = { + schedulingType: SchedulingType.MANAGED, + userId: 1, + metadata: {}, + id: 1, + }; + + const { result } = renderHook(() => + useLockedFieldsManager({ + eventType, + translate: mockTranslate, + formMethods: mockFormMethods, + }) + ); + + expect(result.current.isManagedEventType).toBe(true); + }); + + it("should return false when schedulingType is not MANAGED", () => { + const mockFormMethods = createMockFormMethods(); + const eventType = { + schedulingType: null, + userId: 1, + metadata: {}, + id: 1, + }; + + const { result } = renderHook(() => + useLockedFieldsManager({ + eventType, + translate: mockTranslate, + formMethods: mockFormMethods, + }) + ); + + expect(result.current.isManagedEventType).toBe(false); + }); + }); + + describe("isChildrenManagedEventType", () => { + it("should return true when metadata has managedEventConfig and schedulingType is not MANAGED", () => { + const mockFormMethods = createMockFormMethods(); + const eventType = { + schedulingType: null, + userId: 1, + metadata: { + managedEventConfig: { unlockedFields: {} }, + }, + id: 1, + }; + + const { result } = renderHook(() => + useLockedFieldsManager({ + eventType, + translate: mockTranslate, + formMethods: mockFormMethods, + }) + ); + + expect(result.current.isChildrenManagedEventType).toBe(true); + }); + + it("should return false when metadata has managedEventConfig but schedulingType is MANAGED", () => { + const mockFormMethods = createMockFormMethods(); + const eventType = { + schedulingType: SchedulingType.MANAGED, + userId: 1, + metadata: { + managedEventConfig: { unlockedFields: {} }, + }, + id: 1, + }; + + const { result } = renderHook(() => + useLockedFieldsManager({ + eventType, + translate: mockTranslate, + formMethods: mockFormMethods, + }) + ); + + expect(result.current.isChildrenManagedEventType).toBe(false); + }); + + it("should return false when metadata does not have managedEventConfig", () => { + const mockFormMethods = createMockFormMethods(); + const eventType = { + schedulingType: null, + userId: 1, + metadata: {}, + id: 1, + }; + + const { result } = renderHook(() => + useLockedFieldsManager({ + eventType, + translate: mockTranslate, + formMethods: mockFormMethods, + }) + ); + + expect(result.current.isChildrenManagedEventType).toBe(false); + }); + }); + + describe("setUnlockedFields", () => { + it("should initialize empty object when getValues returns undefined", () => { + const mockFormMethods = createMockFormMethods(undefined); + const eventType = { + schedulingType: SchedulingType.MANAGED, + userId: 1, + metadata: {}, + id: 1, + }; + + const { result } = renderHook(() => + useLockedFieldsManager({ + eventType, + translate: mockTranslate, + formMethods: mockFormMethods, + }) + ); + + const LockedSwitchComponent = result.current.useLockedSwitch("title"); + const rendered = LockedSwitchComponent(); + + if (rendered?.props.onCheckedChange) { + rendered.props.onCheckedChange(false); + } + + expect(mockFormMethods.setValue).toHaveBeenCalledWith( + "metadata.managedEventConfig.unlockedFields", + { title: true }, + { shouldDirty: true } + ); + }); + + it("should handle delete on empty object when getValues returns undefined", () => { + const mockFormMethods = createMockFormMethods(undefined); + const eventType = { + schedulingType: SchedulingType.MANAGED, + userId: 1, + metadata: {}, + id: 1, + }; + + const { result } = renderHook(() => + useLockedFieldsManager({ + eventType, + translate: mockTranslate, + formMethods: mockFormMethods, + }) + ); + + const LockedSwitchComponent = result.current.useLockedSwitch("title"); + const rendered = LockedSwitchComponent(); + + if (rendered?.props.onCheckedChange) { + rendered.props.onCheckedChange(true); + } + + expect(mockFormMethods.setValue).toHaveBeenCalledWith( + "metadata.managedEventConfig.unlockedFields", + {}, + { shouldDirty: true } + ); + }); + + it("should preserve existing fields when adding a new unlocked field", () => { + const existingFields = { description: true }; + const mockFormMethods = createMockFormMethods(existingFields); + const eventType = { + schedulingType: SchedulingType.MANAGED, + userId: 1, + metadata: { managedEventConfig: { unlockedFields: existingFields } }, + id: 1, + }; + + const { result } = renderHook(() => + useLockedFieldsManager({ + eventType, + translate: mockTranslate, + formMethods: mockFormMethods, + }) + ); + + const LockedSwitchComponent = result.current.useLockedSwitch("title"); + const rendered = LockedSwitchComponent(); + + if (rendered?.props.onCheckedChange) { + rendered.props.onCheckedChange(false); + } + + expect(mockFormMethods.setValue).toHaveBeenCalledWith( + "metadata.managedEventConfig.unlockedFields", + { description: true, title: true }, + { shouldDirty: true } + ); + }); + + it("should remove field from existing unlockedFields when locking", () => { + const existingFields = { title: true, description: true }; + const mockFormMethods = createMockFormMethods(existingFields); + const eventType = { + schedulingType: SchedulingType.MANAGED, + userId: 1, + metadata: { managedEventConfig: { unlockedFields: existingFields } }, + id: 1, + }; + + const { result } = renderHook(() => + useLockedFieldsManager({ + eventType, + translate: mockTranslate, + formMethods: mockFormMethods, + }) + ); + + const LockedSwitchComponent = result.current.useLockedSwitch("title"); + const rendered = LockedSwitchComponent(); + + if (rendered?.props.onCheckedChange) { + rendered.props.onCheckedChange(true); + } + + expect(mockFormMethods.setValue).toHaveBeenCalledWith( + "metadata.managedEventConfig.unlockedFields", + { description: true }, + { shouldDirty: true } + ); + }); + }); + + describe("useLockedSwitch", () => { + it("should return a component for managed event type", () => { + const mockFormMethods = createMockFormMethods(); + const eventType = { + schedulingType: SchedulingType.MANAGED, + userId: 1, + metadata: {}, + id: 1, + }; + + const { result } = renderHook(() => + useLockedFieldsManager({ + eventType, + translate: mockTranslate, + formMethods: mockFormMethods, + }) + ); + + const LockedSwitchComponent = result.current.useLockedSwitch("title"); + const rendered = LockedSwitchComponent(); + + expect(rendered).not.toBeNull(); + expect(rendered?.props["data-testid"]).toBe("locked-indicator-title"); + }); + + it("should return null for non-managed event type", () => { + const mockFormMethods = createMockFormMethods(); + const eventType = { + schedulingType: SchedulingType.ROUND_ROBIN, + userId: 1, + metadata: {}, + id: 1, + }; + + const { result } = renderHook(() => + useLockedFieldsManager({ + eventType, + translate: mockTranslate, + formMethods: mockFormMethods, + }) + ); + + const LockedSwitchComponent = result.current.useLockedSwitch("title"); + const rendered = LockedSwitchComponent(); + + expect(rendered).toBeNull(); + }); + }); + + describe("shouldLockDisableProps", () => { + it("should return isLocked=true for managed event type when field is not unlocked", () => { + const mockFormMethods = createMockFormMethods({ + managedEventConfig: { unlockedFields: {} }, + }); + const eventType = { + schedulingType: SchedulingType.MANAGED, + userId: 1, + metadata: { managedEventConfig: { unlockedFields: {} } }, + id: 1, + }; + + const { result, rerender } = renderHook(() => + useLockedFieldsManager({ + eventType, + translate: mockTranslate, + formMethods: mockFormMethods, + }) + ); + + act(() => { + result.current.shouldLockDisableProps("title"); + }); + rerender(); + + const props = result.current.shouldLockDisableProps("title"); + expect(props.isLocked).toBe(true); + expect(props.disabled).toBe(false); + }); + + it("should return isLocked=false for managed event type when field is unlocked", () => { + const mockFormMethods = createMockFormMethods({ + managedEventConfig: { unlockedFields: { title: true } }, + }); + const eventType = { + schedulingType: SchedulingType.MANAGED, + userId: 1, + metadata: { managedEventConfig: { unlockedFields: { title: true } } }, + id: 1, + }; + + const { result, rerender } = renderHook(() => + useLockedFieldsManager({ + eventType, + translate: mockTranslate, + formMethods: mockFormMethods, + }) + ); + + act(() => { + result.current.shouldLockDisableProps("title"); + }); + rerender(); + + const props = result.current.shouldLockDisableProps("title"); + expect(props.isLocked).toBe(false); + }); + + it("should return disabled=true for children managed event type when field is locked", () => { + const mockFormMethods = createMockFormMethods({ + managedEventConfig: { unlockedFields: {} }, + }); + const eventType = { + schedulingType: null, + userId: 1, + metadata: { managedEventConfig: { unlockedFields: {} } }, + id: 1, + }; + + const { result, rerender } = renderHook(() => + useLockedFieldsManager({ + eventType, + translate: mockTranslate, + formMethods: mockFormMethods, + }) + ); + + act(() => { + result.current.shouldLockDisableProps("title"); + }); + rerender(); + + const props = result.current.shouldLockDisableProps("title"); + expect(props.disabled).toBe(true); + expect(props.isLocked).toBe(true); + }); + + it("should return disabled=false for children managed event type when field is unlocked", () => { + const mockFormMethods = createMockFormMethods({ + managedEventConfig: { unlockedFields: { title: true } }, + }); + const eventType = { + schedulingType: null, + userId: 1, + metadata: { managedEventConfig: { unlockedFields: { title: true } } }, + id: 1, + }; + + const { result, rerender } = renderHook(() => + useLockedFieldsManager({ + eventType, + translate: mockTranslate, + formMethods: mockFormMethods, + }) + ); + + act(() => { + result.current.shouldLockDisableProps("title"); + }); + rerender(); + + const props = result.current.shouldLockDisableProps("title"); + expect(props.disabled).toBe(false); + expect(props.isLocked).toBe(false); + }); + }); + + describe("useLockedLabel", () => { + it("should return correct properties for managed event type", () => { + const mockFormMethods = createMockFormMethods({ + managedEventConfig: { unlockedFields: {} }, + }); + const eventType = { + schedulingType: SchedulingType.MANAGED, + userId: 1, + metadata: { managedEventConfig: { unlockedFields: {} } }, + id: 1, + }; + + const { result, rerender } = renderHook(() => + useLockedFieldsManager({ + eventType, + translate: mockTranslate, + formMethods: mockFormMethods, + }) + ); + + act(() => { + result.current.useLockedLabel("title"); + }); + rerender(); + + const labelProps = result.current.useLockedLabel("title"); + expect(labelProps.isLocked).toBe(true); + expect(labelProps.disabled).toBe(false); + expect(labelProps.LockedIcon).toBeDefined(); + }); + + it("should return disabled=true for children managed event type with locked field", () => { + const mockFormMethods = createMockFormMethods({ + managedEventConfig: { unlockedFields: {} }, + }); + const eventType = { + schedulingType: null, + userId: 1, + metadata: { managedEventConfig: { unlockedFields: {} } }, + id: 1, + }; + + const { result, rerender } = renderHook(() => + useLockedFieldsManager({ + eventType, + translate: mockTranslate, + formMethods: mockFormMethods, + }) + ); + + act(() => { + result.current.useLockedLabel("title"); + }); + rerender(); + + const labelProps = result.current.useLockedLabel("title"); + expect(labelProps.disabled).toBe(true); + expect(labelProps.isLocked).toBe(true); + }); + }); + + describe("shouldLockIndicator", () => { + it("should return LockedIndicator for managed event type", () => { + const mockFormMethods = createMockFormMethods({ + managedEventConfig: { unlockedFields: {} }, + }); + const eventType = { + schedulingType: SchedulingType.MANAGED, + userId: 1, + metadata: { managedEventConfig: { unlockedFields: {} } }, + id: 1, + }; + + const { result } = renderHook(() => + useLockedFieldsManager({ + eventType, + translate: mockTranslate, + formMethods: mockFormMethods, + }) + ); + + const indicator = result.current.shouldLockIndicator("title"); + expect(indicator).toBeTruthy(); + }); + + it("should return LockedIndicator for children managed event type", () => { + const mockFormMethods = createMockFormMethods({ + managedEventConfig: { unlockedFields: {} }, + }); + const eventType = { + schedulingType: null, + userId: 1, + metadata: { managedEventConfig: { unlockedFields: {} } }, + id: 1, + }; + + const { result } = renderHook(() => + useLockedFieldsManager({ + eventType, + translate: mockTranslate, + formMethods: mockFormMethods, + }) + ); + + const indicator = result.current.shouldLockIndicator("title"); + expect(indicator).toBeTruthy(); + }); + + it("should return falsy for non-managed event type", () => { + const mockFormMethods = createMockFormMethods(); + const eventType = { + schedulingType: SchedulingType.ROUND_ROBIN, + userId: 1, + metadata: {}, + id: 1, + }; + + const { result } = renderHook(() => + useLockedFieldsManager({ + eventType, + translate: mockTranslate, + formMethods: mockFormMethods, + }) + ); + + const indicator = result.current.shouldLockIndicator("title"); + expect(indicator).toBeFalsy(); + }); + }); +}); diff --git a/packages/features/ee/managed-event-types/hooks/useLockedFieldsManager.tsx b/packages/features/ee/managed-event-types/hooks/useLockedFieldsManager.tsx index 0ec4dccee0..29fd30e3d0 100644 --- a/packages/features/ee/managed-event-types/hooks/useLockedFieldsManager.tsx +++ b/packages/features/ee/managed-event-types/hooks/useLockedFieldsManager.tsx @@ -19,7 +19,7 @@ export const LockedSwitch = ( [fieldState, setFieldState]: [Record, Dispatch>>], fieldName: string, setUnlockedFields: (fieldName: string, val: boolean | undefined) => void, - options = { simple: false } + _options = { simple: false } ) => { return isManagedEventType ? ( { const path = "metadata.managedEventConfig.unlockedFields"; - const metaUnlockedFields = getValues(path); - if (!metaUnlockedFields) return; + const metaUnlockedFields = getValues(path) || {}; if (val === undefined) { delete metaUnlockedFields[fieldName as keyof typeof metaUnlockedFields]; setValue(path, { ...metaUnlockedFields }, { shouldDirty: true }); diff --git a/packages/platform/examples/base/src/pages/api/managed-user.ts b/packages/platform/examples/base/src/pages/api/managed-user.ts index ee6013ac3b..f974a2842a 100644 --- a/packages/platform/examples/base/src/pages/api/managed-user.ts +++ b/packages/platform/examples/base/src/pages/api/managed-user.ts @@ -130,6 +130,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse< managedUserResponseFour.user.id, ]); + await createManagedEventType(+organizationId, team.id, [ + managedUserResponseOne.user.id, + managedUserResponseTwo.user.id, + managedUserResponseThree.user.id, + managedUserResponseFour.user.id, + ]); + await createOrgMembershipAdmin(+organizationId, managedUserResponseFive.user.id); return res.status(200).json({ @@ -261,6 +268,31 @@ async function createRoundRobinEventType(orgId: number, teamId: number, userIds: ); } +async function createManagedEventType(orgId: number, teamId: number, userIds: number[]) { + await fetch( + // eslint-disable-next-line turbo/no-undeclared-env-vars + `${process.env.NEXT_PUBLIC_CALCOM_API_URL ?? ""}/organizations/${orgId}/teams/${teamId}/event-types`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + // eslint-disable-next-line turbo/no-undeclared-env-vars + [X_CAL_SECRET_KEY]: process.env.X_CAL_SECRET_KEY ?? "", + // eslint-disable-next-line turbo/no-undeclared-env-vars + [X_CAL_CLIENT_ID]: process.env.NEXT_PUBLIC_X_CAL_ID ?? "", + origin: "http://localhost:4321", + }, + body: JSON.stringify({ + lengthInMinutes: 60, + title: "Platform example managed", + slug: "platform-example-managed", + schedulingType: "managed", + hosts: userIds.map((userId) => ({ userId })), + }), + } + ); +} + async function createDefaultSchedule(accessToken: string) { const name = "Default Schedule"; const timeZone = "Europe/London"; @@ -273,7 +305,6 @@ async function createDefaultSchedule(accessToken: string) { method: "POST", headers: { "Content-Type": "application/json", - // eslint-disable-next-line turbo/no-undeclared-env-vars Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify({