Files
calendar/packages/app-store/_components/crm/WriteToObjectSettings.tsx
T
Amit SharmaGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
eacfcd15a4 feat: Hubspot write to meeting object (#26039)
* feat: Hubspot write to meeting object

* fix: translate hardcoded strings and remove debug console.log

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* refactor

* fix: improve static value detection for placeholder replacement

- Changed condition from startsWith/endsWith to includes('{') to properly detect embedded placeholders
- Strings like 'Hello {name}!' are now correctly processed for placeholder replacement
- Pure placeholder tokens that can't be resolved return null
- Partially resolved values are returned as-is

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* refactor: split WriteToObjectSettings into types and utils files

- Extract interfaces, enums, and type definitions to WriteToObjectSettings.types.ts
- Extract constants and utility functions to WriteToObjectSettings.utils.ts
- Update main component to use the new utility functions
- Improves code organization and maintainability

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* revert: restore original static value detection logic

Per user request - the startsWith/endsWith check was intentional

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* test: add unit tests for HubSpot CRM service

Tests cover:
- ensureFieldsExistOnMeeting: field validation against HubSpot properties
- getTextValueFromBookingTracking: UTM parameter extraction
- getTextValueFromBookingResponse: placeholder replacement
- getDateFieldValue: date field resolution for different date types
- getFieldValue: main field value resolution for all field types
- generateWriteToMeetingBody: write-to-meeting body generation
- getContacts: contact search functionality

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* fix: correct type errors in HubSpot CRM service tests

- Import WhenToWrite enum from crm-enums
- Replace string literals with WhenToWrite.EVERY_BOOKING enum values
- Add missing whenToWrite property to field config objects

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* fix: use type casting for intentional type errors in tests

- Cast numeric fieldValue to string for testing non-string handling
- Cast invalid field type string to CrmFieldType for testing unsupported types

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* fix: prevent unhandled promise rejections in HubSpot CRM tests

- Re-apply mockGetAppKeysFromSlug implementation in beforeEach to ensure
  mock is always set correctly after clearAllMocks
- Change vi.resetAllMocks() to vi.clearAllMocks() to preserve mock
  implementations between tests
- Add explicit types to satisfy biome lint rules
- This fixes the 'Cannot read properties of undefined (reading client_id)'
  errors that were causing CI to fail with exit code 1

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* refactor: convert HubSpot tests to black-box testing through public methods

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* fix: properly type mock CalendarEvent in HubSpot tests

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* fix: properly type TFunction in mock CalendarEvent

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* chore: graceful owner fail test

* refactor

* fix: type check

* fix: remove null fields

* Update packages/app-store/hubspot/lib/CrmService.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-01-06 14:58:46 -03:00

507 lines
19 KiB
TypeScript

import { useState, useMemo } from "react";
import { WhenToWrite } from "@calcom/app-store/_lib/crm-enums";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Switch } from "@calcom/ui/components/form";
import { InputField } from "@calcom/ui/components/form";
import { Select } from "@calcom/ui/components/form";
import { Section } from "@calcom/ui/components/section";
import { showToast } from "@calcom/ui/components/toast";
import type { WriteToRecordEntrySchema, WriteToObjectSettingsProps } from "./WriteToObjectSettings.types";
import {
DATE_FIELD_TYPE,
CHECKBOX_FIELD_TYPE,
buildFieldTypeOptions,
buildDateFieldValueOptions,
buildWhenToWriteOptions,
buildCheckboxFieldValueOptions,
} from "./WriteToObjectSettings.utils";
const WriteToObjectSettings = ({
bookingAction,
optionLabel,
optionEnabled,
optionSwitchOnChange,
writeToObjectData,
updateWriteToObjectData,
supportedFieldTypes,
supportedDateFields,
supportedWriteTriggers = [WhenToWrite.EVERY_BOOKING, WhenToWrite.FIELD_EMPTY],
}: WriteToObjectSettingsProps) => {
const { t } = useLocale();
const fieldTypeOptions = useMemo(
() => buildFieldTypeOptions(supportedFieldTypes, t),
[supportedFieldTypes, t]
);
const dateFieldValueOptions = useMemo(
() => buildDateFieldValueOptions(bookingAction, supportedDateFields, t),
[supportedDateFields, bookingAction, t]
);
const whenToWriteOptions = useMemo(
() => buildWhenToWriteOptions(supportedWriteTriggers, bookingAction, t),
[supportedWriteTriggers, bookingAction, t]
);
const showWhenToWriteColumn = whenToWriteOptions.length > 1;
const checkboxFieldValueOptions = useMemo(() => buildCheckboxFieldValueOptions(t), [t]);
const [fieldTypeSelectedOption, setFieldTypeSelectedOption] = useState(fieldTypeOptions[0]);
const [dateFieldSelectedOption, setDateFieldSelectedOption] = useState(dateFieldValueOptions[0]);
const [checkboxFieldSelectedOption, setCheckboxFieldSelectedOption] = useState(
checkboxFieldValueOptions[0]
);
const [whenToWriteSelectedOption, setWhenToWriteSelectedOption] = useState(whenToWriteOptions[0]);
const [editingRows, setEditingRows] = useState<Record<string, boolean>>({});
const [editingData, setEditingData] = useState<Record<string, WriteToRecordEntrySchema>>({});
const [newOnWriteToRecordEntry, setNewOnWriteToRecordEntry] = useState<WriteToRecordEntrySchema>({
field: "",
fieldType: fieldTypeSelectedOption.value,
value: "",
whenToWrite: whenToWriteSelectedOption.value,
});
const startEditing = (key: string) => {
Object.keys(editingRows).forEach((rowKey) => {
if (editingRows[rowKey] && rowKey !== key) {
cancelEditing(rowKey);
}
});
setEditingRows((prev) => ({ ...prev, [key]: true }));
setEditingData((prev) => ({
...prev,
[key]: {
field: key,
fieldType: writeToObjectData[key].fieldType,
value: writeToObjectData[key].value,
whenToWrite: writeToObjectData[key].whenToWrite,
},
}));
};
const cancelEditing = (key: string) => {
setEditingRows((prev) => ({ ...prev, [key]: false }));
setEditingData((prev) => {
const newData = { ...prev };
delete newData[key];
return newData;
});
};
const saveEditing = (key: string) => {
const editData = editingData[key];
if (!editData) return;
if (!editData.field.trim()) {
showToast(t("field_name_cannot_be_empty"), "error");
return;
}
if (editData.field !== key && Object.keys(writeToObjectData).includes(editData.field.trim())) {
showToast(t("field_already_exists"), "error");
return;
}
const newWriteToObjectData = { ...writeToObjectData };
if (editData.field !== key) {
delete newWriteToObjectData[key];
}
newWriteToObjectData[editData.field.trim()] = {
fieldType: editData.fieldType,
value: editData.value,
whenToWrite: editData.whenToWrite,
};
updateWriteToObjectData(newWriteToObjectData);
cancelEditing(key);
};
return (
<>
<Section.SubSectionHeader icon="star" labelFor="write-to-object-settings" title={optionLabel}>
<Switch
checked={optionEnabled}
onCheckedChange={optionSwitchOnChange}
id="write-to-object-settings"
size="sm"
/>
</Section.SubSectionHeader>
{optionEnabled ? (
<Section.SubSectionContent>
<div className="text-subtle flex gap-3 px-3 py-[6px] text-sm font-medium">
<div className="flex-1">{t("field_name")}</div>
<div className="flex-1">{t("field_type")}</div>
<div className="flex-1">{t("value")}</div>
{showWhenToWriteColumn && <div className="flex-1">{t("when_to_write")}</div>}
<div className="w-20" />
</div>
<Section.SubSectionNested>
{Object.keys(writeToObjectData).map((key) => {
const isEditing = editingRows[key];
const editData = editingData[key];
return (
<div className="flex items-center gap-2" key={key}>
<div className="flex-1">
{isEditing ? (
<InputField
value={editData?.field || key}
onChange={(e) =>
setEditingData((prev) => ({
...prev,
[key]: { ...editData, field: e.target.value },
}))
}
size="sm"
className="w-full"
/>
) : (
<InputField value={key} readOnly size="sm" className="w-full" />
)}
</div>
<div className="flex-1">
{isEditing ? (
<Select
size="sm"
className="w-full"
options={fieldTypeOptions}
value={fieldTypeOptions.find((option) => option.value === editData?.fieldType)}
onChange={(e) => {
if (e) {
setEditingData((prev) => ({
...prev,
[key]: {
...editData,
fieldType: e.value,
...(e.value === DATE_FIELD_TYPE && {
value: dateFieldValueOptions[0].value,
}),
...(e.value === CHECKBOX_FIELD_TYPE && {
value: checkboxFieldValueOptions[0].value,
}),
},
}));
}
}}
/>
) : (
<Select
size="sm"
className="w-full"
value={fieldTypeOptions.find(
(option) => option.value === writeToObjectData[key].fieldType
)}
isDisabled={true}
/>
)}
</div>
<div className="flex-1">
{isEditing ? (
editData?.fieldType === DATE_FIELD_TYPE ? (
<Select
size="sm"
className="w-full"
options={dateFieldValueOptions}
value={dateFieldValueOptions.find((option) => option.value === editData.value)}
onChange={(e) => {
if (e) {
setEditingData((prev) => ({
...prev,
[key]: { ...editData, value: e.value },
}));
}
}}
/>
) : editData?.fieldType === CHECKBOX_FIELD_TYPE ? (
<Select
size="sm"
className="w-full"
options={checkboxFieldValueOptions}
value={checkboxFieldValueOptions.find((option) => option.value === editData.value)}
onChange={(e) => {
if (e) {
setEditingData((prev) => ({
...prev,
[key]: { ...editData, value: e.value },
}));
}
}}
/>
) : (
<InputField
value={(editData?.value as string) || ""}
onChange={(e) =>
setEditingData((prev) => ({
...prev,
[key]: { ...editData, value: e.target.value },
}))
}
size="sm"
className="w-full"
/>
)
) : writeToObjectData[key].fieldType === DATE_FIELD_TYPE ? (
<Select
size="sm"
className="w-full"
value={dateFieldValueOptions.find(
(option) => option.value === writeToObjectData[key].value
)}
isDisabled={true}
/>
) : writeToObjectData[key].fieldType === CHECKBOX_FIELD_TYPE ? (
<Select
size="sm"
className="w-full"
value={checkboxFieldValueOptions.find(
(option) => option.value === writeToObjectData[key].value
)}
isDisabled={true}
/>
) : (
<InputField
value={writeToObjectData[key].value as string}
readOnly
size="sm"
className="w-full"
/>
)}
</div>
{showWhenToWriteColumn && (
<div className="flex-1">
{isEditing ? (
<Select
size="sm"
className="w-full"
options={whenToWriteOptions}
value={whenToWriteOptions.find((option) => option.value === editData?.whenToWrite)}
onChange={(e) => {
if (e) {
setEditingData((prev) => ({
...prev,
[key]: { ...editData, whenToWrite: e.value },
}));
}
}}
/>
) : (
<Select
size="sm"
className="w-full"
value={whenToWriteOptions.find(
(option) => option.value === writeToObjectData[key].whenToWrite
)}
isDisabled={true}
/>
)}
</div>
)}
<div className="flex w-20 justify-center gap-1">
{isEditing ? (
<>
<Button
size="sm"
StartIcon="check"
variant="icon"
color="primary"
onClick={() => saveEditing(key)}
/>
<Button
size="sm"
StartIcon="x"
variant="icon"
color="secondary"
onClick={() => cancelEditing(key)}
/>
</>
) : (
<>
<Button
size="sm"
StartIcon="pencil"
variant="icon"
color="minimal"
onClick={() => startEditing(key)}
/>
<Button
size="sm"
StartIcon="x"
variant="icon"
color="minimal"
onClick={() => {
const newObject = { ...writeToObjectData };
delete newObject[key];
updateWriteToObjectData(newObject);
}}
/>
</>
)}
</div>
</div>
);
})}
<div className="flex gap-2">
<div className="flex-1">
<InputField
size="sm"
className="w-full"
value={newOnWriteToRecordEntry.field}
onChange={(e) =>
setNewOnWriteToRecordEntry({
...newOnWriteToRecordEntry,
field: e.target.value,
})
}
/>
</div>
<div className="flex-1">
<Select
size="sm"
className="w-full"
options={fieldTypeOptions}
value={fieldTypeSelectedOption}
onChange={(e) => {
if (e) {
setFieldTypeSelectedOption(e);
setNewOnWriteToRecordEntry({
...newOnWriteToRecordEntry,
fieldType: e.value,
...(e.value === DATE_FIELD_TYPE && { value: dateFieldSelectedOption.value }),
...(e.value === CHECKBOX_FIELD_TYPE && {
value: checkboxFieldSelectedOption.value,
}),
});
}
}}
/>
</div>
<div className="flex-1">
{newOnWriteToRecordEntry.fieldType === DATE_FIELD_TYPE ? (
<Select
size="sm"
className="w-full"
options={dateFieldValueOptions}
value={dateFieldSelectedOption}
onChange={(e) => {
if (e) {
setDateFieldSelectedOption(e);
setNewOnWriteToRecordEntry({
...newOnWriteToRecordEntry,
value: e.value,
});
}
}}
/>
) : newOnWriteToRecordEntry.fieldType === CHECKBOX_FIELD_TYPE ? (
<Select
size="sm"
className="w-full"
options={checkboxFieldValueOptions}
value={checkboxFieldSelectedOption}
onChange={(e) => {
if (e) {
setCheckboxFieldSelectedOption(e);
setNewOnWriteToRecordEntry({
...newOnWriteToRecordEntry,
value: e.value,
});
}
}}
/>
) : (
<InputField
size="sm"
className="w-full"
value={newOnWriteToRecordEntry.value as string}
onChange={(e) =>
setNewOnWriteToRecordEntry({
...newOnWriteToRecordEntry,
value: e.target.value,
})
}
/>
)}
</div>
{showWhenToWriteColumn && (
<div className="flex-1">
<Select
size="sm"
className="w-full"
options={whenToWriteOptions}
value={whenToWriteSelectedOption}
onChange={(e) => {
if (e) {
setWhenToWriteSelectedOption(e);
setNewOnWriteToRecordEntry({
...newOnWriteToRecordEntry,
whenToWrite: e.value,
});
}
}}
/>
</div>
)}
<div className="w-20" />
</div>
</Section.SubSectionNested>
<Button
className="text-subtle mt-2 w-fit"
StartIcon="plus"
color="minimal"
size="sm"
disabled={
!(
newOnWriteToRecordEntry.field &&
newOnWriteToRecordEntry.fieldType &&
newOnWriteToRecordEntry.value !== "" &&
newOnWriteToRecordEntry.whenToWrite
)
}
onClick={() => {
if (Object.keys(writeToObjectData).includes(newOnWriteToRecordEntry.field.trim())) {
showToast(t("field_already_exists"), "error");
return;
}
updateWriteToObjectData({
...writeToObjectData,
[newOnWriteToRecordEntry.field.trim()]: {
fieldType: newOnWriteToRecordEntry.fieldType,
value: newOnWriteToRecordEntry.value,
whenToWrite: newOnWriteToRecordEntry.whenToWrite,
},
});
setNewOnWriteToRecordEntry({
field: "",
fieldType: fieldTypeOptions[0].value,
value: "",
whenToWrite: whenToWriteOptions[0].value,
});
}}>
{t("add_new_field")}
</Button>
</Section.SubSectionContent>
) : null}
</>
);
};
export {
BookingActionEnum,
type SelectOption,
type WriteToRecordEntry,
type WriteToRecordEntrySchema,
type WriteToObjectSettingsProps,
} from "./WriteToObjectSettings.types";
export default WriteToObjectSettings;