fix(front): allow null subfields in Phones default value so Save enables (#21847)

## Problem

Closes #21780.

When editing a **Phones** field in Settings → Data Model, changing the
**Default Country Code** does not enable the Save button — the form
becomes dirty but never valid, so the change can't be saved.

## Root cause

The settings form validates `defaultValue` with the record-value
`phonesFieldValueSchema`, which requires non-null strings:

```ts
primaryPhoneNumber: z.string(),
primaryPhoneCountryCode: z.string(),
```

But a Phones default value can legitimately have **null** subfields — a
default country code with no default number. The backend normalizes
empty subfields to `null`
(`nullify-empty-phones-default-value.util.ts`), and the shared contract
`FieldMetadataDefaultValuePhones` is `string | null`. So an existing
field whose stored default has `primaryPhoneNumber: null` makes the form
**permanently invalid**: changing the country code preserves the null
number → `isValid` stays `false` → `canSave = isDirty && isValid` keeps
Save disabled.

The sibling **address** field doesn't have this bug because
`addressFieldValueSchema` already makes every subfield `.nullable()`.
Phones was simply inconsistent.

## Fix

Add a dedicated `phonesFieldDefaultValueSchema` with nullable subfields
(mirroring the address pattern and matching
`FieldMetadataDefaultValuePhones`) and use it in the Phones settings
form. The stricter record-value `phonesFieldValueSchema` is left
untouched, so record input/persistence/empty-checks are unaffected.

## Test plan

- [x] Unit test covering the partial-null default value (and asserting
the record-value schema still rejects it)
- [x] `nx typecheck twenty-front` clean
- [x] `nx lint:diff-with-main twenty-front` clean
- Manual: open a Phones field, set a Default Country Code and save,
re-open, change the country code → Save now enables.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21847?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thomas Trompette
2026-06-19 15:07:37 +02:00
committed by GitHub
parent 705caab2b0
commit a59ed80422
3 changed files with 46 additions and 3 deletions
@@ -0,0 +1,27 @@
import { phonesFieldDefaultValueSchema } from '@/object-record/record-field/ui/validation-schemas/phonesFieldDefaultValueSchema';
import { phonesFieldValueSchema } from '@/object-record/record-field/ui/validation-schemas/phonesFieldValueSchema';
describe('phonesFieldDefaultValueSchema', () => {
it('should accept a fully populated default value', () => {
const value = {
primaryPhoneNumber: "'123456'",
primaryPhoneCountryCode: "'FR'",
primaryPhoneCallingCode: "'33'",
additionalPhones: null,
};
expect(phonesFieldDefaultValueSchema.safeParse(value).success).toBe(true);
});
it('should accept a default value with null primary phone subfields', () => {
const value = {
primaryPhoneNumber: null,
primaryPhoneCountryCode: "'FR'",
primaryPhoneCallingCode: "'33'",
additionalPhones: null,
};
expect(phonesFieldDefaultValueSchema.safeParse(value).success).toBe(true);
expect(phonesFieldValueSchema.safeParse(value).success).toBe(false);
});
});
@@ -0,0 +1,16 @@
import { z } from 'zod';
export const phonesFieldDefaultValueSchema = z.object({
primaryPhoneNumber: z.string().nullable(),
primaryPhoneCountryCode: z.string().nullable(),
primaryPhoneCallingCode: z.string().nullable().optional(),
additionalPhones: z
.array(
z.object({
number: z.string(),
callingCode: z.string(),
countryCode: z.string(),
}),
)
.nullable(),
});
@@ -1,7 +1,7 @@
import { Controller, useFormContext } from 'react-hook-form';
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { phonesFieldValueSchema } from '@/object-record/record-field/ui/validation-schemas/phonesFieldValueSchema';
import { phonesFieldDefaultValueSchema } from '@/object-record/record-field/ui/validation-schemas/phonesFieldDefaultValueSchema';
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
import { mergeSettingsSchemas } from '@/settings/data-model/fields/forms/utils/mergeSettingsSchema';
import { settingsDataModelFieldMaxValuesSchema } from '@/settings/data-model/fields/forms/utils/settingsDataModelFieldMaxValuesSchema';
@@ -28,7 +28,7 @@ type SettingsDataModelFieldPhonesFormProps = {
export const settingsDataModelFieldPhonesFormSchema = z
.object({
defaultValue: phonesFieldValueSchema,
defaultValue: phonesFieldDefaultValueSchema,
})
.merge(
mergeSettingsSchemas(
@@ -91,7 +91,7 @@ export const SettingsDataModelFieldPhonesForm = ({
<Select<string>
dropdownId="selectDefaultCountryCode"
value={stripSimpleQuotesFromString(
value?.primaryPhoneCountryCode,
value?.primaryPhoneCountryCode ?? '',
)}
onChange={(newPhoneCountryCode) =>
onChange({