Compare commits

...
Author SHA1 Message Date
Félix MalfaitandClaude Opus 4.7 f7f9e829b6 fix(address-form): sync stale defaultSortSubField to form state
The select previously displayed a computed fallback when the stored
defaultSortSubField was no longer in the enabled subFields, but the form
state was untouched — submitting could persist the stale value. Add an
effect that keeps form state in sync with the resolved fallback whenever
subFields or the stored value changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 12:06:03 +02:00
Félix MalfaitandClaude Opus 4.7 634977e00e feat(field-metadata): configurable default sort sub-field for FullName and Address
Adds a `defaultSortSubField` advanced-mode setting on FullName and Address
composite fields so admins can pick which sub-field a column sort defaults
to (e.g. lastName for FullName, addressCity for Address). Address becomes
sortable for the first time; FullName previously sorted by firstName+lastName
unconditionally and now uses the configured sub-field as the primary key
with the other as a tie-breaker.

The Address sub-field dropdown is constrained to the currently-enabled
sub-fields — disabling a sub-field removes it from the sort options too,
and the runtime falls back gracefully if a previously-saved default is
later disabled.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 11:55:56 +02:00
24 changed files with 585 additions and 36 deletions
@@ -14,4 +14,5 @@ export const SORTABLE_FIELD_METADATA_TYPES = [
FieldMetadataType.ACTOR,
FieldMetadataType.LINKS,
FieldMetadataType.PHONES,
FieldMetadataType.ADDRESS,
];
@@ -6,6 +6,8 @@ import {
import {
type FieldMetadataMultiItemSettings,
type FieldMetadataSettingsMapping,
type FieldMetadataType,
type PartialFieldMetadataItemOption,
} from 'twenty-shared/types';
import { type ThemeColor } from 'twenty-ui/theme';
@@ -36,6 +38,8 @@ export type FieldMetadataItem = Omit<
| FieldDateMetadataSettings
| FieldMetadataMultiItemSettings
| FieldRelationMetadataSettings
| FieldMetadataSettingsMapping[FieldMetadataType.ADDRESS]
| FieldMetadataSettingsMapping[FieldMetadataType.FULL_NAME]
| null;
isLabelSyncedWithName?: boolean | null;
morphId?: string | null;
@@ -0,0 +1,52 @@
import {
ADDRESS_DEFAULT_SORT_SUB_FIELD,
getDefaultSortSubFieldForAddress,
} from '@/object-metadata/utils/getDefaultSortSubFieldForAddress';
describe('getDefaultSortSubFieldForAddress', () => {
it('returns the configured sub-field when it is in the enabled subFields', () => {
expect(
getDefaultSortSubFieldForAddress({
subFields: ['addressStreet1', 'addressState'],
defaultSortSubField: 'addressState',
}),
).toBe('addressState');
});
it('returns the configured sub-field when subFields is unset', () => {
expect(
getDefaultSortSubFieldForAddress({
defaultSortSubField: 'addressCountry',
}),
).toBe('addressCountry');
});
it('falls back to addressCity when no sub-field is configured', () => {
expect(getDefaultSortSubFieldForAddress(null)).toBe(
ADDRESS_DEFAULT_SORT_SUB_FIELD,
);
expect(getDefaultSortSubFieldForAddress(undefined)).toBe(
ADDRESS_DEFAULT_SORT_SUB_FIELD,
);
expect(getDefaultSortSubFieldForAddress({})).toBe(
ADDRESS_DEFAULT_SORT_SUB_FIELD,
);
});
it('falls back to addressCity when the configured sub-field is disabled', () => {
expect(
getDefaultSortSubFieldForAddress({
subFields: ['addressStreet1', 'addressCity'],
defaultSortSubField: 'addressState',
}),
).toBe('addressCity');
});
it('falls back to the first enabled sub-field when addressCity is disabled', () => {
expect(
getDefaultSortSubFieldForAddress({
subFields: ['addressStreet1', 'addressState'],
}),
).toBe('addressStreet1');
});
});
@@ -0,0 +1,30 @@
import {
FULL_NAME_DEFAULT_SORT_SUB_FIELD,
getDefaultSortSubFieldForFullName,
} from '@/object-metadata/utils/getDefaultSortSubFieldForFullName';
describe('getDefaultSortSubFieldForFullName', () => {
it('returns the configured sub-field when set', () => {
expect(
getDefaultSortSubFieldForFullName({ defaultSortSubField: 'lastName' }),
).toBe('lastName');
});
it('falls back to firstName when settings are null', () => {
expect(getDefaultSortSubFieldForFullName(null)).toBe(
FULL_NAME_DEFAULT_SORT_SUB_FIELD,
);
});
it('falls back to firstName when settings are undefined', () => {
expect(getDefaultSortSubFieldForFullName(undefined)).toBe(
FULL_NAME_DEFAULT_SORT_SUB_FIELD,
);
});
it('falls back to firstName when defaultSortSubField is not set', () => {
expect(getDefaultSortSubFieldForFullName({})).toBe(
FULL_NAME_DEFAULT_SORT_SUB_FIELD,
);
});
});
@@ -0,0 +1,84 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getOrderByForFieldMetadataType } from '@/object-metadata/utils/getOrderByForFieldMetadataType';
import { FieldMetadataType } from '~/generated-metadata/graphql';
const buildField = (
overrides: Pick<FieldMetadataItem, 'type' | 'name'> &
Partial<Pick<FieldMetadataItem, 'id' | 'settings'>>,
): Pick<FieldMetadataItem, 'id' | 'name' | 'type' | 'settings'> => ({
id: 'field-id',
...overrides,
});
describe('getOrderByForFieldMetadataType', () => {
describe('FULL_NAME', () => {
it('sorts by firstName then lastName when no setting is configured', () => {
const field = buildField({
type: FieldMetadataType.FULL_NAME,
name: 'name',
});
expect(getOrderByForFieldMetadataType(field, 'AscNullsLast')).toEqual([
{
name: {
firstName: 'AscNullsLast',
lastName: 'AscNullsLast',
},
},
]);
});
it('uses the configured sub-field as the primary sort key', () => {
const field = buildField({
type: FieldMetadataType.FULL_NAME,
name: 'name',
settings: { defaultSortSubField: 'lastName' },
});
const result = getOrderByForFieldMetadataType(field, 'DescNullsLast');
const keys = Object.keys(result[0].name as Record<string, unknown>);
expect(keys[0]).toBe('lastName');
expect(keys[1]).toBe('firstName');
expect((result[0].name as Record<string, string>).lastName).toBe(
'DescNullsLast',
);
});
});
describe('ADDRESS', () => {
it('falls back to addressCity when no sub-field is configured', () => {
const field = buildField({
type: FieldMetadataType.ADDRESS,
name: 'address',
});
expect(getOrderByForFieldMetadataType(field, 'AscNullsLast')).toEqual([
{
address: {
addressCity: 'AscNullsLast',
},
},
]);
});
it('uses the configured sub-field', () => {
const field = buildField({
type: FieldMetadataType.ADDRESS,
name: 'address',
settings: {
subFields: ['addressStreet1', 'addressCountry'],
defaultSortSubField: 'addressCountry',
},
});
expect(getOrderByForFieldMetadataType(field, 'DescNullsLast')).toEqual([
{
address: {
addressCountry: 'DescNullsLast',
},
},
]);
});
});
});
@@ -0,0 +1,30 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getEnabledAddressSubFields } from '@/object-metadata/utils/getEnabledAddressSubFields';
import {
type AllowedAddressSubField,
type FieldMetadataSettingsMapping,
type FieldMetadataType,
} from 'twenty-shared/types';
export const ADDRESS_DEFAULT_SORT_SUB_FIELD: AllowedAddressSubField =
'addressCity';
type AddressSettings = FieldMetadataSettingsMapping[FieldMetadataType.ADDRESS];
export const getDefaultSortSubFieldForAddress = (
settings: FieldMetadataItem['settings'] | null | undefined,
): AllowedAddressSubField => {
const addressSettings = settings as AddressSettings | null | undefined;
const enabledSubFields = getEnabledAddressSubFields(settings);
const configuredSubField = addressSettings?.defaultSortSubField;
if (configuredSubField && enabledSubFields.includes(configuredSubField)) {
return configuredSubField;
}
if (enabledSubFields.includes(ADDRESS_DEFAULT_SORT_SUB_FIELD)) {
return ADDRESS_DEFAULT_SORT_SUB_FIELD;
}
return enabledSubFields[0] ?? ADDRESS_DEFAULT_SORT_SUB_FIELD;
};
@@ -0,0 +1,21 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import {
type AllowedFullNameSubField,
type FieldMetadataSettingsMapping,
type FieldMetadataType,
} from 'twenty-shared/types';
export const FULL_NAME_DEFAULT_SORT_SUB_FIELD: AllowedFullNameSubField =
'firstName';
type FullNameSettings =
FieldMetadataSettingsMapping[FieldMetadataType.FULL_NAME];
export const getDefaultSortSubFieldForFullName = (
settings: FieldMetadataItem['settings'] | null | undefined,
): AllowedFullNameSubField => {
const fullNameSettings = settings as FullNameSettings | null | undefined;
return (
fullNameSettings?.defaultSortSubField ?? FULL_NAME_DEFAULT_SORT_SUB_FIELD
);
};
@@ -0,0 +1,19 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import {
DEFAULT_VISIBLE_ADDRESS_SUBFIELDS,
type AllowedAddressSubField,
type FieldMetadataSettingsMapping,
type FieldMetadataType,
} from 'twenty-shared/types';
type AddressSettings = FieldMetadataSettingsMapping[FieldMetadataType.ADDRESS];
export const getEnabledAddressSubFields = (
settings: FieldMetadataItem['settings'] | null | undefined,
): readonly AllowedAddressSubField[] => {
const addressSettings = settings as AddressSettings | null | undefined;
if (addressSettings?.subFields && addressSettings.subFields.length > 0) {
return addressSettings.subFields;
}
return DEFAULT_VISIBLE_ADDRESS_SUBFIELDS;
};
@@ -1,5 +1,7 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { getDefaultSortSubFieldForAddress } from '@/object-metadata/utils/getDefaultSortSubFieldForAddress';
import { getDefaultSortSubFieldForFullName } from '@/object-metadata/utils/getDefaultSortSubFieldForFullName';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import {
@@ -14,19 +16,33 @@ import {
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const getOrderByForFieldMetadataType = (
field: Pick<FieldMetadataItem, 'id' | 'name' | 'type'>,
field: Pick<FieldMetadataItem, 'id' | 'name' | 'type' | 'settings'>,
direction: OrderBy | null | undefined,
): RecordGqlOperationOrderBy => {
switch (field.type) {
case FieldMetadataType.FULL_NAME:
case FieldMetadataType.FULL_NAME: {
const primarySubField = getDefaultSortSubFieldForFullName(field.settings);
const secondarySubField =
primarySubField === 'firstName' ? 'lastName' : 'firstName';
return [
{
[field.name]: {
firstName: direction ?? 'AscNullsLast',
lastName: direction ?? 'AscNullsLast',
[primarySubField]: direction ?? 'AscNullsLast',
[secondarySubField]: direction ?? 'AscNullsLast',
},
},
];
}
case FieldMetadataType.ADDRESS: {
const subField = getDefaultSortSubFieldForAddress(field.settings);
return [
{
[field.name]: {
[subField]: direction ?? 'AscNullsLast',
},
},
];
}
case FieldMetadataType.CURRENCY:
return [
{
@@ -5,6 +5,7 @@ import { type CurrencyCode } from 'twenty-shared/constants';
import {
ConnectedAccountProvider,
type AllowedAddressSubField,
type AllowedFullNameSubField,
type FieldMetadataMultiItemSettings,
type FileCategory,
} from 'twenty-shared/types';
@@ -98,7 +99,9 @@ export type FieldCurrencyMetadata = BaseFieldMetadata & {
export type FieldFullNameMetadata = BaseFieldMetadata & {
placeHolder: string;
settings?: null;
settings?: {
defaultSortSubField?: AllowedFullNameSubField | null;
} | null;
};
export type FieldEmailMetadata = BaseFieldMetadata & {
@@ -123,6 +126,7 @@ export type FieldAddressMetadata = BaseFieldMetadata & {
placeHolder: string;
settings?: {
subFields?: AllowedAddressSubField[] | null;
defaultSortSubField?: AllowedAddressSubField | null;
};
};
@@ -25,4 +25,5 @@ export const addressSettingsSchema = z.object({
.min(1)
.optional()
.nullable(),
defaultSortSubField: z.enum(ALLOWED_ADDRESS_SUBFIELDS).optional().nullable(),
});
@@ -1,6 +1,7 @@
import { Separator } from '@/settings/components/Separator';
import { Controller, useFormContext } from 'react-hook-form';
import { getDefaultSortSubFieldForAddress } from '@/object-metadata/utils/getDefaultSortSubFieldForAddress';
import {
addressSchema as addressFieldDefaultValueSchema,
addressSettingsSchema,
@@ -13,8 +14,13 @@ import { useCountries } from '@/ui/input/components/internal/hooks/useCountries'
import { Select } from '@/ui/input/components/Select';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useLingui } from '@lingui/react/macro';
import { type MouseEvent } from 'react';
import { useEffect, type MouseEvent } from 'react';
import {
DEFAULT_VISIBLE_ADDRESS_SUBFIELDS,
type AllowedAddressSubField,
} from 'twenty-shared/types';
import {
IconArrowsSort,
IconCircleOff,
IconList,
IconMap,
@@ -46,7 +52,10 @@ export const SettingsDataModelFieldAddressForm = ({
existingFieldMetadataId,
}: SettingsDataModelFieldAddressFormProps) => {
const { t } = useLingui();
const { control } = useFormContext<SettingsDataModelFieldTextFormValues>();
const { control, watch, setValue } =
useFormContext<SettingsDataModelFieldTextFormValues>();
const watchedSubFields = watch('settings.subFields');
const watchedDefaultSortSubField = watch('settings.defaultSortSubField');
const countries = [
{
label: t`No country`,
@@ -65,6 +74,7 @@ export const SettingsDataModelFieldAddressForm = ({
const {
initialDisplaySubFields,
initialDefaultValue,
initialDefaultSortSubField,
resetDefaultValueField,
} = useAddressSettingsFormInitialValues({ existingFieldMetadataId });
@@ -74,6 +84,37 @@ export const SettingsDataModelFieldAddressForm = ({
closeDropdown('addressSubFieldsId');
};
useEffect(() => {
const enabledSubFieldSet = new Set<string>(
watchedSubFields ?? initialDisplaySubFields,
);
const resolvedSubField = getDefaultSortSubFieldForAddress({
subFields: DEFAULT_VISIBLE_ADDRESS_SUBFIELDS.filter((subField) =>
enabledSubFieldSet.has(subField),
),
defaultSortSubField: watchedDefaultSortSubField,
});
if (resolvedSubField !== watchedDefaultSortSubField) {
setValue('settings.defaultSortSubField', resolvedSubField);
}
}, [
watchedSubFields,
watchedDefaultSortSubField,
initialDisplaySubFields,
setValue,
]);
const subFieldLabels: Record<AllowedAddressSubField, string> = {
addressStreet1: t`Address 1`,
addressStreet2: t`Address 2`,
addressCity: t`City`,
addressState: t`State`,
addressPostcode: t`Postcode`,
addressCountry: t`Country`,
addressLat: t`Latitude`,
addressLng: t`Longitude`,
};
return (
<>
<Controller
@@ -144,6 +185,40 @@ export const SettingsDataModelFieldAddressForm = ({
);
}}
/>
<Separator />
<Controller
name="settings.defaultSortSubField"
defaultValue={initialDefaultSortSubField}
control={control}
render={({ field: { onChange, value } }) => {
const enabledSubFieldSet = new Set<string>(
watchedSubFields ?? initialDisplaySubFields,
);
const sortOptions: SelectOption<AllowedAddressSubField>[] =
DEFAULT_VISIBLE_ADDRESS_SUBFIELDS.filter((subField) =>
enabledSubFieldSet.has(subField),
).map((subField) => ({
label: subFieldLabels[subField],
value: subField,
}));
return (
<SettingsOptionCardContentSelect
Icon={IconArrowsSort}
title={t`Default Sort Sub-Field`}
description={t`Pick which sub-field is used when sorting this column`}
>
<Select<AllowedAddressSubField>
dropdownId="addressDefaultSortSubFieldId"
disabled={disabled || sortOptions.length === 0}
value={value ?? initialDefaultSortSubField}
onChange={onChange}
options={sortOptions}
selectSizeVariant="small"
/>
</SettingsOptionCardContentSelect>
);
}}
/>
</>
);
};
@@ -1,32 +1,20 @@
import { msg } from '@lingui/core/macro';
import { type AllowedAddressSubField } from 'twenty-shared/types';
import {
DEFAULT_VISIBLE_ADDRESS_SUBFIELDS,
type AllowedAddressSubField,
} from 'twenty-shared/types';
export const DEFAULT_SELECTION_ADDRESS_WITH_MESSAGES: {
value: AllowedAddressSubField;
label: ReturnType<typeof msg>;
}[] = [
{
value: 'addressStreet1',
label: msg`Address 1`,
},
{
value: 'addressStreet2',
label: msg`Address 2`,
},
{
value: 'addressCity',
label: msg`City`,
},
{
value: 'addressState',
label: msg`State`,
},
{
value: 'addressPostcode',
label: msg`Postcode`,
},
{
value: 'addressCountry',
label: msg`Country`,
},
];
}[] = DEFAULT_VISIBLE_ADDRESS_SUBFIELDS.map((value) => ({
value,
label: {
addressStreet1: msg`Address 1`,
addressStreet2: msg`Address 2`,
addressCity: msg`City`,
addressState: msg`State`,
addressPostcode: msg`Postcode`,
addressCountry: msg`Country`,
}[value],
}));
@@ -153,6 +153,20 @@ describe('useAddressSettingsFormInitialValues', () => {
]);
});
it('should fall back to addressCity as the initial default sort sub-field when no settings are configured', () => {
const { result } = renderHook(
() =>
useAddressSettingsFormInitialValues({
existingFieldMetadataId: 'new-field',
}),
{
wrapper: Wrapper,
},
);
expect(result.current.initialDefaultSortSubField).toEqual('addressCity');
});
it('should call resetField with all address subFields when resetDefaultValueField is called', () => {
const { result } = renderHook(
() =>
@@ -1,4 +1,5 @@
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { getDefaultSortSubFieldForAddress } from '@/object-metadata/utils/getDefaultSortSubFieldForAddress';
import { type SettingsDataModelFieldTextFormValues } from '@/settings/data-model/fields/forms/address/components/SettingsDataModelFieldAddressForm';
import { DEFAULT_SELECTION_ADDRESS_WITH_MESSAGES } from '@/settings/data-model/fields/forms/address/constants/DefaultSelectionAddressWithMessages';
import { useFormContext } from 'react-hook-form';
@@ -23,6 +24,10 @@ export const useAddressSettingsFormInitialValues = ({
? fieldMetadataItem.settings.subFields
: allAddressSubFields;
const initialDefaultSortSubField = getDefaultSortSubFieldForAddress(
fieldMetadataItem?.settings,
);
const defaultDefaultValue = {
addressStreet1: "''",
addressStreet2: null,
@@ -47,6 +52,7 @@ export const useAddressSettingsFormInitialValues = ({
return {
initialDefaultValue,
initialDisplaySubFields,
initialDefaultSortSubField,
resetDefaultValueField,
};
};
@@ -15,6 +15,8 @@ import { settingsDataModelFieldCurrencyFormSchema } from '@/settings/data-model/
import { SettingsDataModelFieldCurrencySettingsFormCard } from '@/settings/data-model/fields/forms/currency/components/SettingsDataModelFieldCurrencySettingsFormCard';
import { settingsDataModelFieldDateFormSchema } from '@/settings/data-model/fields/forms/date/components/SettingsDataModelFieldDateForm';
import { SettingsDataModelFieldDateSettingsFormCard } from '@/settings/data-model/fields/forms/date/components/SettingsDataModelFieldDateSettingsFormCard';
import { settingsDataModelFieldFullNameFormSchema } from '@/settings/data-model/fields/forms/full-name/components/SettingsDataModelFieldFullNameForm';
import { SettingsDataModelFieldFullNameSettingsFormCard } from '@/settings/data-model/fields/forms/full-name/components/SettingsDataModelFieldFullNameSettingsFormCard';
import { settingsDataModelFieldMorphRelationFormSchema } from '@/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldRelationForm';
import { settingsDataModelFieldNumberFormSchema } from '@/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberForm';
import { SettingsDataModelFieldNumberSettingsFormCard } from '@/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberSettingsFormCard';
@@ -93,6 +95,10 @@ const addressFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.ADDRESS) })
.extend(settingsDataModelFieldAddressFormSchema.shape);
const fullNameFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.FULL_NAME) })
.extend(settingsDataModelFieldFullNameFormSchema.shape);
const phonesFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.PHONES) })
.extend(settingsDataModelFieldPhonesFormSchema.shape)
@@ -142,6 +148,7 @@ const otherFieldsFormSchema = z
FieldMetadataType.DATE_TIME,
FieldMetadataType.NUMBER,
FieldMetadataType.ADDRESS,
FieldMetadataType.FULL_NAME,
FieldMetadataType.PHONES,
FieldMetadataType.TEXT,
FieldMetadataType.EMAILS,
@@ -168,6 +175,7 @@ export const settingsDataModelFieldSettingsFormSchema = z.discriminatedUnion(
numberFieldFormSchema,
textFieldFormSchema,
addressFieldFormSchema,
fullNameFieldFormSchema,
phonesFieldFormSchema,
emailsFieldFormSchema,
linksFieldFormSchema,
@@ -297,6 +305,16 @@ export const SettingsDataModelFieldSettingsFormCard = ({
);
}
if (fieldType === FieldMetadataType.FULL_NAME) {
return (
<SettingsDataModelFieldFullNameSettingsFormCard
existingFieldMetadataId={existingFieldMetadataId}
objectNameSingular={objectNameSingular}
disabled={disabled}
/>
);
}
if (fieldType === FieldMetadataType.PHONES) {
return (
<SettingsDataModelFieldPhonesSettingsFormCard
@@ -0,0 +1,80 @@
import { Controller, useFormContext } from 'react-hook-form';
import {
ALLOWED_FULL_NAME_SUBFIELDS,
type AllowedFullNameSubField,
} from 'twenty-shared/types';
import { IconArrowsSort } from 'twenty-ui/display';
import { type SelectOption } from 'twenty-ui/input';
import { z } from 'zod';
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
import { useFullNameSettingsFormInitialValues } from '@/settings/data-model/fields/forms/full-name/hooks/useFullNameSettingsFormInitialValues';
import { Select } from '@/ui/input/components/Select';
import { useLingui } from '@lingui/react/macro';
export const fullNameSettingsSchema = z.object({
defaultSortSubField: z
.enum(ALLOWED_FULL_NAME_SUBFIELDS)
.optional()
.nullable(),
});
export const settingsDataModelFieldFullNameFormSchema = z.object({
settings: fullNameSettingsSchema,
});
export type SettingsDataModelFieldFullNameFormValues = z.infer<
typeof settingsDataModelFieldFullNameFormSchema
>;
type SettingsDataModelFieldFullNameFormProps = {
disabled?: boolean;
existingFieldMetadataId: string;
};
export const SettingsDataModelFieldFullNameForm = ({
disabled,
existingFieldMetadataId,
}: SettingsDataModelFieldFullNameFormProps) => {
const { t } = useLingui();
const { control } =
useFormContext<SettingsDataModelFieldFullNameFormValues>();
const { initialDefaultSortSubField } = useFullNameSettingsFormInitialValues({
existingFieldMetadataId,
});
const subFieldLabels: Record<AllowedFullNameSubField, string> = {
firstName: t`First Name`,
lastName: t`Last Name`,
};
const sortOptions: SelectOption<AllowedFullNameSubField>[] =
ALLOWED_FULL_NAME_SUBFIELDS.map((subField) => ({
label: subFieldLabels[subField],
value: subField,
}));
return (
<Controller
name="settings.defaultSortSubField"
defaultValue={initialDefaultSortSubField}
control={control}
render={({ field: { onChange, value } }) => (
<SettingsOptionCardContentSelect
Icon={IconArrowsSort}
title={t`Default Sort Sub-Field`}
description={t`Pick which sub-field is used when sorting this column`}
>
<Select<AllowedFullNameSubField>
dropdownId="fullNameDefaultSortSubFieldId"
disabled={disabled}
value={value ?? initialDefaultSortSubField}
onChange={onChange}
options={sortOptions}
selectSizeVariant="small"
/>
</SettingsOptionCardContentSelect>
)}
/>
);
};
@@ -0,0 +1,50 @@
import { useFormContext } from 'react-hook-form';
import { FieldMetadataType } from 'twenty-shared/types';
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import {
SettingsDataModelFieldFullNameForm,
type SettingsDataModelFieldFullNameFormValues,
} from '@/settings/data-model/fields/forms/full-name/components/SettingsDataModelFieldFullNameForm';
import { SettingsDataModelFieldPreviewWidget } from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewWidget';
import { type SettingsDataModelFieldEditFormValues } from '~/pages/settings/data-model/SettingsObjectFieldEdit';
type SettingsDataModelFieldFullNameSettingsFormCardProps = {
disabled?: boolean;
existingFieldMetadataId: string;
objectNameSingular: string;
};
export const SettingsDataModelFieldFullNameSettingsFormCard = ({
disabled,
existingFieldMetadataId,
objectNameSingular,
}: SettingsDataModelFieldFullNameSettingsFormCardProps) => {
const { watch } = useFormContext<
SettingsDataModelFieldFullNameFormValues &
SettingsDataModelFieldEditFormValues
>();
return (
<SettingsDataModelPreviewFormCard
preview={
<SettingsDataModelFieldPreviewWidget
fieldMetadataItem={{
icon: watch('icon'),
label: watch('label'),
type: FieldMetadataType.FULL_NAME,
settings: {
defaultSortSubField: watch('settings.defaultSortSubField'),
},
}}
objectNameSingular={objectNameSingular}
/>
}
form={
<SettingsDataModelFieldFullNameForm
disabled={disabled}
existingFieldMetadataId={existingFieldMetadataId}
/>
}
/>
);
};
@@ -0,0 +1,22 @@
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { getDefaultSortSubFieldForFullName } from '@/object-metadata/utils/getDefaultSortSubFieldForFullName';
type UseFullNameSettingsFormInitialValuesProps = {
existingFieldMetadataId: string;
};
export const useFullNameSettingsFormInitialValues = ({
existingFieldMetadataId,
}: UseFullNameSettingsFormInitialValuesProps) => {
const { fieldMetadataItem } = useFieldMetadataItemById(
existingFieldMetadataId,
);
const initialDefaultSortSubField = getDefaultSortSubFieldForFullName(
fieldMetadataItem?.settings,
);
return {
initialDefaultSortSubField,
};
};
@@ -115,7 +115,6 @@ type NotDefinedSettings = {
// oxlint-disable-next-line unused-imports/no-unused-vars
type SettingsAssertions = [
Expect<HasAllProperties<CurrencyFieldMetadata, NotDefinedSettings>>,
Expect<HasAllProperties<FullNameFieldMetadata, NotDefinedSettings>>,
Expect<HasAllProperties<RatingFieldMetadata, NotDefinedSettings>>,
Expect<HasAllProperties<SelectFieldMetadata, NotDefinedSettings>>,
Expect<HasAllProperties<MultiSelectFieldMetadata, NotDefinedSettings>>,
@@ -205,6 +204,16 @@ type SettingsAssertions = [
}
>
>,
Expect<
HasAllProperties<
FullNameFieldMetadata,
{
settings: JsonbProperty<
FieldMetadataSettingsMapping[FieldMetadataType.FULL_NAME]
>;
}
>
>,
Expect<
HasAllProperties<
@@ -10,3 +10,12 @@ export const ALLOWED_ADDRESS_SUBFIELDS = [
] as const;
export type AllowedAddressSubField = (typeof ALLOWED_ADDRESS_SUBFIELDS)[number];
export const DEFAULT_VISIBLE_ADDRESS_SUBFIELDS = [
'addressStreet1',
'addressStreet2',
'addressCity',
'addressState',
'addressPostcode',
'addressCountry',
] as const satisfies readonly AllowedAddressSubField[];
@@ -1,6 +1,7 @@
import { type AllowedAddressSubField } from '@/types/AddressFieldsType';
import { type FieldMetadataMultiItemSettings } from '@/types/FieldMetadataMultiItemSettings';
import { type FieldMetadataType } from '@/types/FieldMetadataType';
import { type AllowedFullNameSubField } from '@/types/FullNameFieldsType';
import { type IsExactly } from '@/types/IsExactly';
import { type RelationOnDeleteAction } from '@/types/RelationOnDeleteAction.type';
import { type RelationType } from '@/types/RelationType';
@@ -49,6 +50,11 @@ type FieldMetadataRelationSettings = {
type FieldMetadataAddressSettings = {
subFields?: AllowedAddressSubField[];
defaultSortSubField?: AllowedAddressSubField;
};
type FieldMetadataFullNameSettings = {
defaultSortSubField?: AllowedFullNameSubField;
};
type FieldMetadataFilesSettings = {
@@ -67,6 +73,7 @@ export type FieldMetadataSettingsMapping = {
[FieldMetadataType.TEXT]: FieldMetadataTextSettings | null;
[FieldMetadataType.RELATION]: FieldMetadataRelationSettings;
[FieldMetadataType.ADDRESS]: FieldMetadataAddressSettings | null;
[FieldMetadataType.FULL_NAME]: FieldMetadataFullNameSettings | null;
[FieldMetadataType.MORPH_RELATION]: FieldMetadataRelationSettings;
[FieldMetadataType.TS_VECTOR]: FieldMetadataTsVectorSettings | null;
[FieldMetadataType.PHONES]: FieldMetadataMultiItemSettings | null;
@@ -0,0 +1,4 @@
export const ALLOWED_FULL_NAME_SUBFIELDS = ['firstName', 'lastName'] as const;
export type AllowedFullNameSubField =
(typeof ALLOWED_FULL_NAME_SUBFIELDS)[number];
+6 -1
View File
@@ -8,7 +8,10 @@
*/
export type { AllowedAddressSubField } from './AddressFieldsType';
export { ALLOWED_ADDRESS_SUBFIELDS } from './AddressFieldsType';
export {
ALLOWED_ADDRESS_SUBFIELDS,
DEFAULT_VISIBLE_ADDRESS_SUBFIELDS,
} from './AddressFieldsType';
export { AggregateOperations } from './AggregateOperations';
export { AppBasePath } from './AppBasePath';
export { AppPath } from './AppPath';
@@ -123,6 +126,8 @@ export { FILTERABLE_FIELD_TYPES } from './FilterableFieldType';
export { FirstDayOfTheWeek } from './FirstDayOfTheWeek';
export type { FormatRecordSerializedRelationProperties } from './FormatRecordSerializedRelationProperties.type';
export type { FromTo } from './FromToType';
export type { AllowedFullNameSubField } from './FullNameFieldsType';
export { ALLOWED_FULL_NAME_SUBFIELDS } from './FullNameFieldsType';
export { HTTPMethod } from './HttpMethod';
export type { IndexOf } from './IndexOf.type';
export type { IsEmptyObject } from './IsEmptyObject.type';