feat: disable changing field type of routing forms if responses exist (#21911)

* feat: add field type change warning dialog to routing forms

- Add FieldTypeChangeWarningDialog component to prevent field type changes
- Show informative dialog explaining data integrity concerns when users try to change field types
- Provide alternative suggestion to create new field instead of changing existing one
- Add i18n translations for dialog messages
- Integrate dialog into routing form edit page field type selector
- Bypass Teams plan restriction for testing routing forms functionality
- Fix ESLint error by properly handling scrollIntoView in embed mode with disable comment

Prevents data integrity issues by blocking field type changes (e.g. multiselect → text)
that could make existing form responses incompatible and cause data loss.

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: make field type dialog more selective to allow legitimate changes during form creation

- Only show warning dialog when there's actual data integrity risk
- Allow field type changes for new fields or fields without meaningful labels
- Fixes E2E test timeout by not blocking legitimate field type changes
- Maintains data protection for existing fields with real data

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: complete field type dialog implementation with translations and component

- Add FieldTypeChangeWarningDialog component with proper i18n support
- Add all required translation strings for dialog messages
- Ensure dialog only shows for actual data integrity risks
- Allow legitimate field type changes during form creation

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* simplify field type dialog: one button, button replaces SelectField, remove forms page changes

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* use Button component

* feat: restore SelectField with conditional dialog for field type changes

- Restore original SelectField dropdown to allow users to see available field types
- Add conditional logic to show dialog only when different type is selected AND form has responses
- Prevent field type changes by not calling onChange when dialog is shown
- Pass hasFormResponses prop from FormEdit to Field component
- Maintain data integrity while providing clear user guidance

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* controlled value

* convert dialog to tooltip

* Update common.json

* remove unused texts

* allow type changing for new field

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <hey@eunjae.dev>
This commit is contained in:
devin-ai-integration[bot]
2025-06-26 07:58:05 +00:00
committed by GitHub
co-authored by eunjae@cal.com
parent 3fd9ef366f
commit aea0661303
2 changed files with 64 additions and 35 deletions
+10 -9
View File
@@ -2835,7 +2835,7 @@
"salesforce_skip_entry_creation": "Skip creating {{entry}} record if they do not exist in Salesforce",
"salesforce_if_account_does_not_exist": "If the contact does not exist under an account, create new lead from attendee",
"salesforce_create_new_contact_under_account": "Create a new contact under an account based on email domain of attendee and existing contacts",
"salesforce_change_record_owner_on_booking":"Change record owner on booking",
"salesforce_change_record_owner_on_booking": "Change record owner on booking",
"mass_assign_attributes": "Mass assign attributes",
"reroute_preview_custom_message": "It results in showing custom message. Try changing the response to route to an event",
"reroute_preview_external_redirect": "It results in redirecting to {{externalUrl}}. Try changing the response to route to an event",
@@ -2888,6 +2888,7 @@
"yes_reassign": "Yes, reassign",
"filter_by_attributes": "Filter by attributes",
"reassign_reason": "Reason for reassigning",
"field_type_change_suggestion": "Changing the field type may cause data loss. Please create a new field instead.",
"reassigned_by": "Reassigned by",
"row": "row",
"rows": "rows",
@@ -2933,7 +2934,7 @@
"routing_form_insights_assignment_reason": "Assignment Reason",
"access_denied": "Access Denied",
"salesforce_route_to_owner": "Contact owner will be the Round Robin host if available",
"salesforce_add_attendees_as":"Add new attendees as",
"salesforce_add_attendees_as": "Add new attendees as",
"salesforce_do_not_route_to_owner": "Contact owner will not be forced (can still be host if it matches the attributes and Round Robin criteria)",
"salesforce_route_to_custom_lookup_field": "Route to a user that matches a lookup field on an account",
"salesforce_option": "Salesforce Option",
@@ -2996,7 +2997,7 @@
"shortfall": "Shortfall",
"routing_form": "Routing Form",
"go_back_and_save": "Go back and save",
"save_changes":"Save changes",
"save_changes": "Save changes",
"leave_without_saving": "Leave without saving",
"leave_without_saving_description": "Are you sure you want to leave without saving changes to your routing form?",
"something_unexpected_occurred": "Something unexpected occurred",
@@ -3031,9 +3032,9 @@
"authorize": "Authorize",
"next": "Next",
"hide": "Hide",
"show":"Show",
"utm_params":"UTM parameters",
"no_utm_params":"No UTM parameters available.",
"show": "Show",
"utm_params": "UTM parameters",
"no_utm_params": "No UTM parameters available.",
"asc": "Asc",
"desc": "Desc",
"verify_email_change": "Verify email change",
@@ -3103,7 +3104,7 @@
"user_has_no_team_yet": "You don't have a team yet",
"no_team_members": "You don't have team members yet",
"recurring_event_doesnt_support_seats": "Recurring event doesn't support seats feature. Make it non-recurring to enable seats.",
"booking_limit_per_booker_doesnt_support_recurring": "Recurring event doesn't support booking limit per booker feature. Disable booking limit per booker to enable Recurring event.",
"booking_limit_per_booker_doesnt_support_recurring": "Recurring event doesn't support booking limit per booker feature. Disable booking limit per booker to enable Recurring event.",
"seats_doesnt_support_recurring": "Seats feature doesn't support recurring event. Disable seats feature to make it recurring.",
"recurring_event_seats_error": "Recurring event doesn't support seats feature. Disable seats feature or make the event non-recurring.",
"segment": "Segment",
@@ -3181,8 +3182,8 @@
"resend": "Resend",
"verification_email_sent": "Verification email sent",
"select_all_members": "Select all members",
"select_all_members_tooltip":"Selecting all members will add all future team members when they are added to the team.",
"form_settings":"Form Settings",
"select_all_members_tooltip": "Selecting all members will add all future team members when they are added to the team.",
"form_settings": "Form Settings",
"remove_whitelist_status": "Remove whitelist status",
"whitelist_user_workflows": "Whitelist user workflows",
"user_workflows_whitelisted": "User workflows whitelisted",
@@ -18,6 +18,7 @@ import {
MultiOptionInput,
} from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { Tooltip } from "@calcom/ui/components/tooltip";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
@@ -36,6 +37,7 @@ function Field({
moveUp,
moveDown,
appUrl,
disableTypeChange,
}: {
fieldIndex: number;
hookForm: HookForm;
@@ -53,6 +55,7 @@ function Field({
fn: () => void;
};
appUrl: string;
disableTypeChange: boolean;
}) {
const { t } = useLocale();
@@ -123,32 +126,53 @@ function Field({
defaultValue={routerField?.type}
render={({ field: { value, onChange } }) => {
const defaultValue = FieldTypes.find((fieldType) => fieldType.value === value);
return (
<SelectField
maxMenuHeight={200}
styles={{
singleValue: (baseStyles) => ({
...baseStyles,
fontSize: "14px",
}),
option: (baseStyles) => ({
...baseStyles,
fontSize: "14px",
}),
}}
label="Type"
isDisabled={!!router}
containerClassName="data-testid-field-type"
options={FieldTypes}
onChange={(option) => {
if (!option) {
return;
}
onChange(option.value);
}}
defaultValue={defaultValue}
/>
);
if (disableTypeChange) {
return (
<div className="data-testid-field-type">
<Label htmlFor="field-type-button">{t("type")}</Label>
<Tooltip content={t("field_type_change_suggestion")}>
<Button
type="button"
disabled
color="secondary"
className={classNames(
"h-8 w-full justify-between text-left text-sm",
!!router && "bg-subtle cursor-not-allowed"
)}>
<span className="text-default">{defaultValue?.label || "Select field type"}</span>
<Icon name="chevron-down" className="text-default h-4 w-4" />
</Button>
</Tooltip>
</div>
);
} else {
return (
<SelectField
maxMenuHeight={200}
styles={{
singleValue: (baseStyles) => ({
...baseStyles,
fontSize: "14px",
}),
option: (baseStyles) => ({
...baseStyles,
fontSize: "14px",
}),
}}
label="Type"
isDisabled={!!router}
containerClassName="data-testid-field-type"
options={FieldTypes}
onChange={(option) => {
if (!option) {
return;
}
onChange(option.value);
}}
defaultValue={defaultValue}
/>
);
}
}}
/>
</div>
@@ -213,6 +237,7 @@ const FormEdit = ({
} = useFieldArray({
control: hookForm.control,
name: fieldsNamespace,
keyName: "_id",
});
const [animationRef] = useAutoAnimate<HTMLDivElement>();
@@ -236,12 +261,15 @@ const FormEdit = ({
<div className="w-full py-4 lg:py-8">
<div ref={animationRef} className="flex w-full flex-col rounded-md">
{hookFormFields.map((field, key) => {
const existingField = Boolean((form.fields || []).find((f) => f.id === field.id));
const hasFormResponses = (form._count?.responses ?? 0) > 0;
return (
<Field
appUrl={appUrl}
fieldIndex={key}
hookForm={hookForm}
hookFieldNamespace={`${fieldsNamespace}.${key}`}
disableTypeChange={existingField && hasFormResponses}
deleteField={{
check: () => hookFormFields.length > 1,
fn: () => {