fix(address): show saved address in record detail when street1 is null (#21033)

## Fixes #20084

### Problem
A saved address is visible in the **table view** but shows **"Empty"**
in the **record detail page** when `addressStreet1` is `null`.

This reproduces with the default seed data out of the box — e.g.
**Google** (city "Mountain View", no street), **Microsoft** (Redmond),
**Meta** (Menlo Park) — which is why several users reported hitting it
immediately.

### Root cause
The frontend zod schema required `addressStreet1` to be a **non-null**
string:

```ts
// isFieldAddressValue.ts
export const addressSchema = z.object({
  addressStreet1: z.string(),          // ← required non-null
  addressStreet2: z.string().nullable(),
  ...
});
```

…but the backend composite type marks it `isRequired: false`
(`address.composite-type.ts`), and the DB column is nullable. So the API
legitimately returns `addressStreet1: null` when only other subfields
are filled.

The two views diverge on how they render:

- **Record detail** gates the value behind `useIsFieldEmpty()` →
`isFieldValueEmpty()`, which for addresses calls
`isFieldAddressValue()`. With `addressStreet1: null` the `safeParse`
**fails**, so `isFieldValueEmpty` returns `true` and the `"Empty"`
placeholder is shown (`RecordInlineCellDisplayMode`).
- **Table view** (`RecordTableCellDisplayMode`) renders
`AddressFieldDisplay` directly with **no** empty check, so the address
stays visible.

This was a latent mismatch since the address guard was introduced.

### Fix
Make `addressStreet1` nullable to match the backend and the other
subfields:

- `addressSchema` → `addressStreet1: z.string().nullable()`
- `FieldAddressValue.addressStreet1` → `string | null`
- `FieldAddressDraftValue.addressStreet1` → `string | null` (keeps the
input/draft type consistent; the text input already renders `?? ''`)

The change is strictly more permissive — persisting and the settings
default-value form still accept string values; they now also accept
`null`.

### Tests
- `isFieldAddressValue.test.ts` — guard returns `true` for
`addressStreet1: null` with other subfields filled.
- `isFieldValueEmpty.test.ts` — new address coverage: empty address is
empty; **`street1: null` + city filled is NOT empty**; normal address is
not empty. (Added an `addressFieldDefinition` mock.)

Both new assertions were confirmed to **fail before the fix** and pass
after.

### Verification
- `npx jest isFieldValueEmpty isFieldAddressValue
normalize-address-field-value-for-persist` → 17 passed
- `npx nx typecheck twenty-front` → pass
- `npx nx lint:diff-with-main twenty-front` → 0 warnings, 0 errors
This commit is contained in:
Félix Malfait
2026-05-29 08:38:47 +02:00
committed by GitHub
parent 3afdabb93e
commit 3c97d9648b
33 changed files with 192 additions and 178 deletions
@@ -1,10 +1,8 @@
import { Separator } from '@/settings/components/Separator';
import { Controller, useFormContext } from 'react-hook-form';
import {
addressSchema as addressFieldDefaultValueSchema,
addressSettingsSchema,
} from '@/object-record/record-field/ui/types/guards/isFieldAddressValue';
import { addressFieldSettingsSchema } from '@/object-record/record-field/ui/validation-schemas/addressFieldSettingsSchema';
import { addressFieldValueSchema } from '@/object-record/record-field/ui/validation-schemas/addressFieldValueSchema';
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
import { MultiSelectAddressFields } from '@/settings/data-model/fields/forms/address/components/MultiSelectAddressFields';
import { DEFAULT_SELECTION_ADDRESS_WITH_MESSAGES } from '@/settings/data-model/fields/forms/address/constants/DefaultSelectionAddressWithMessages';
@@ -33,8 +31,8 @@ type SettingsDataModelFieldAddressFormProps = {
};
export const settingsDataModelFieldAddressFormSchema = z.object({
defaultValue: addressFieldDefaultValueSchema,
settings: addressSettingsSchema,
defaultValue: addressFieldValueSchema,
settings: addressFieldSettingsSchema,
});
export type SettingsDataModelFieldTextFormValues = z.infer<
@@ -1,7 +1,7 @@
import { Controller, useFormContext } from 'react-hook-form';
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { phonesSchema as phonesFieldDefaultValueSchema } from '@/object-record/record-field/ui/types/guards/isFieldPhonesValue';
import { phonesFieldValueSchema } from '@/object-record/record-field/ui/validation-schemas/phonesFieldValueSchema';
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: phonesFieldDefaultValueSchema,
defaultValue: phonesFieldValueSchema,
})
.merge(
mergeSettingsSchemas(