[breaking: deploy server before front] feat(view-sort): pick sort sub-field inline on the chip (#20445)

## Summary

Lets users choose which sub-field of a composite column to sort by —
directly from the sort chip — by clicking the sub-field label and
picking from a dropdown. Persists per view via a new nullable
\`subFieldName\` column on \`ViewSort\`.

Replaces #20438, which proposed a field-settings (admin) configuration
for the same problem. The chip-level approach is more discoverable (the
option lives where the user is looking) and per-view, so different views
on the same object can sort by different sub-fields.

### What changes for users

- **FullName columns**: previously sorted by \`firstName\` and
\`lastName\` together as a stable dual-key sort. Now the user can pick
which sub-field is primary (the other is the tie-breaker). Default
remains \`firstName\` primary, \`lastName\` tie-breaker.
- **Address columns**: previously not sortable at all (not in
\`SORTABLE_FIELD_METADATA_TYPES\`). Now sortable, with a chip dropdown
listing each enabled sub-field. Default is \`addressCity\` if enabled,
else the first enabled sub-field. Disabling a sub-field at the
field-metadata level (existing setting) removes it from the dropdown.
- **Other composite types** (Currency, Phones, Emails, Links, Actor) and
scalar fields keep their existing single-key sort behavior.

### UX

```
┌─────────────────────────┐    ┌─────────────────────────┐
│ ↑ Name · Last name  ✕ │    │ ↑ Address · City  ✕ │
└────────┬────────────────┘    └────────┬────────────────┘
         ▼ (click sub-field)            ▼
   ┌────────────┐                ┌────────────┐
   │ First name │                │ Address 1  │
   │ Last name ✓│                │ Address 2  │
   └────────────┘                │ City      ✓│
                                 │ State      │
                                 │ Postcode   │
                                 │ Country    │
                                 └────────────┘
```

The chip body still toggles direction on click — the \`Dropdown\`'s
internal wrapper calls \`stopPropagation\` so the sub-field click
doesn't bubble to the chip's onClick.

## What changed

**Backend:**
- \`ViewSortEntity\` — new nullable \`subFieldName: varchar\` column
- \`ViewSortDTO\`, \`CreateViewSortInput\`,
\`UpdateViewSortInputUpdates\` — new \`@Field(() => String, { nullable:
true })\`
- \`FLAT_VIEW_SORT_EDITABLE_PROPERTIES\` — \`'subFieldName'\` added so
the property flows through the update merge path
- \`ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME.viewSort\` —
new \`subFieldName\` entry with \`toCompare: true\` so cache diffs
notice it
- \`fromCreateViewSortInputToFlatViewSortToCreate\` — threads
\`subFieldName\` through
- Instance command migration (\`add-sub-field-name-to-view-sort\`) —
single \`ALTER TABLE core.viewSort ADD subFieldName varchar\` / \`DROP\`

**Frontend:**
- \`RecordSort\` and \`ViewSort\` types — \`subFieldName?: string |
null\`
- \`VIEW_SORT_FRAGMENT\` — adds \`subFieldName\` so the field
round-trips
- \`mapRecordSortToViewSort\` + \`areViewSortsEqual\` — carry the new
field through, include it in the diff so the usual
\`useSaveRecordSortsToViewSorts\` create/update flow fires when it
changes
- \`useSaveRecordSortsToViewSorts\` — passes \`subFieldName\` in both
\`CreateViewSortInput\` and \`UpdateViewSortInputUpdates\`
- \`getOrderByForFieldMetadataType(field, direction, subFieldName?)\` —
new optional third arg. \`turnSortsIntoOrderBy\` threads
\`sort.subFieldName\` into it.
- \`Address\` added to \`SORTABLE_FIELD_METADATA_TYPES\`
- New helpers: \`getEnabledAddressSubFields\` (filters by the field's
\`subFields\` setting, falls back to the 6 default visible address
sub-fields), \`getDefaultSortSubFieldForAddress\`,
\`getDefaultSortSubFieldForFullName\`
- New shared types/constants: \`AllowedFullNameSubField\`,
\`ALLOWED_FULL_NAME_SUBFIELDS\`, \`DEFAULT_VISIBLE_ADDRESS_SUBFIELDS\`
- \`SortOrFilterChip\` — new \`labelSubField?: ReactNode\` slot; renders
as \` · {sub-field}\` with subdued weight after the main label
- \`EditableSortChip\` — builds options from field metadata
(\`ALLOWED_FULL_NAME_SUBFIELDS\` for FullName,
\`getEnabledAddressSubFields\` for Address), uses i18n-wrapped labels,
persists picks via \`upsertRecordSort\`

## Test plan

- [x] \`npx nx typecheck\` passes for twenty-shared, twenty-front,
twenty-server
- [x] \`oxlint --type-aware\` on all 19 frontend + 9 server changed
files: 0 errors
- [x] \`prettier --check\`: clean
- [x] 16 unit tests pass — \`getOrderByForFieldMetadataType\` covers the
new \`subFieldName\` override branch for FULL_NAME and ADDRESS;
\`getDefaultSortSubFieldForAddress\` covers the city/first-enabled
fallback path; \`getDefaultSortSubFieldForFullName\` exercises its
constant
- [ ] Manual: sort a People view by Full Name → click the chip's
sub-field label → switch between First name and Last name → reload page
→ choice is preserved
- [ ] Manual: sort a Company view by Address → confirm dropdown lists
only enabled sub-fields → disable Address \`addressCity\` in field
settings → confirm dropdown options update and runtime falls back to the
first enabled sub-field

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2026-05-13 21:25:15 +02:00
committed by GitHub
parent 8bc21ec434
commit e16977f97b
45 changed files with 825 additions and 159 deletions
@@ -8,7 +8,8 @@ describe('getObjectOrderByField', () => {
)!;
const res = getOrderByFieldForObjectMetadataItem(objectMetadataItem);
expect(res).toEqual([
{ name: { firstName: 'AscNullsLast', lastName: 'AscNullsLast' } },
{ name: { firstName: 'AscNullsLast' } },
{ name: { lastName: 'AscNullsLast' } },
]);
});
});
@@ -0,0 +1,93 @@
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 per-sort sub-field is given', () => {
const field = buildField({
type: FieldMetadataType.FULL_NAME,
name: 'name',
});
expect(
getOrderByForFieldMetadataType({
field,
orderByDirection: 'AscNullsLast',
}),
).toEqual([
{ name: { firstName: 'AscNullsLast' } },
{ name: { lastName: 'AscNullsLast' } },
]);
});
it('uses the per-sort primaryCompositeSubField as the primary sort key', () => {
const field = buildField({
type: FieldMetadataType.FULL_NAME,
name: 'name',
});
expect(
getOrderByForFieldMetadataType({
field,
orderByDirection: 'DescNullsLast',
primaryCompositeSubField: 'lastName',
}),
).toEqual([
{ name: { lastName: 'DescNullsLast' } },
{ name: { firstName: 'DescNullsLast' } },
]);
});
});
describe('ADDRESS', () => {
it('falls back to addressCity when no per-sort sub-field is given', () => {
const field = buildField({
type: FieldMetadataType.ADDRESS,
name: 'address',
});
expect(
getOrderByForFieldMetadataType({
field,
orderByDirection: 'AscNullsLast',
}),
).toEqual([
{
address: {
addressCity: 'AscNullsLast',
},
},
]);
});
it('uses the per-sort primaryCompositeSubField when provided', () => {
const field = buildField({
type: FieldMetadataType.ADDRESS,
name: 'address',
});
expect(
getOrderByForFieldMetadataType({
field,
orderByDirection: 'DescNullsLast',
primaryCompositeSubField: 'addressCountry',
}),
).toEqual([
{
address: {
addressCountry: 'DescNullsLast',
},
},
]);
});
});
});
@@ -23,11 +23,11 @@ describe('getOrderByForRelationField', () => {
],
};
const result = getOrderByForRelationField(
const result = getOrderByForRelationField({
field,
relatedObjectMetadataItem,
'AscNullsLast',
);
orderByDirection: 'AscNullsLast',
});
// Should produce nested structure: { company: { name: 'AscNullsLast' } }
expect(result).toEqual([{ company: { name: 'AscNullsLast' } }]);
@@ -52,22 +52,15 @@ describe('getOrderByForRelationField', () => {
],
};
const result = getOrderByForRelationField(
const result = getOrderByForRelationField({
field,
relatedObjectMetadataItem,
'DescNullsLast',
);
orderByDirection: 'DescNullsLast',
});
// Should produce nested structure with composite field
expect(result).toEqual([
{
person: {
name: {
firstName: 'DescNullsLast',
lastName: 'DescNullsLast',
},
},
},
{ person: { name: { firstName: 'DescNullsLast' } } },
{ person: { name: { lastName: 'DescNullsLast' } } },
]);
});
@@ -84,11 +77,11 @@ describe('getOrderByForRelationField', () => {
fields: [],
};
const result = getOrderByForRelationField(
const result = getOrderByForRelationField({
field,
relatedObjectMetadataItem,
'AscNullsLast',
);
orderByDirection: 'AscNullsLast',
});
expect(result).toEqual([{ companyId: 'AscNullsLast' }]);
});
@@ -112,11 +105,11 @@ describe('getOrderByForRelationField', () => {
],
};
const result = getOrderByForRelationField(
const result = getOrderByForRelationField({
field,
relatedObjectMetadataItem,
'AscNullsLast',
);
orderByDirection: 'AscNullsLast',
});
// When labelIdentifierFieldMetadataId is not set, isLabelIdentifierField
// falls back to checking for a field named 'name'
@@ -142,11 +135,11 @@ describe('getOrderByForRelationField', () => {
],
};
const result = getOrderByForRelationField(
const result = getOrderByForRelationField({
field,
relatedObjectMetadataItem,
'DescNullsLast',
);
orderByDirection: 'DescNullsLast',
});
expect(result).toEqual([{ company: { name: 'DescNullsLast' } }]);
});
@@ -0,0 +1,55 @@
import { resolveAddressSortSubField } from '@/object-metadata/utils/resolveAddressSortSubField';
describe('resolveAddressSortSubField', () => {
it('returns the requested sub-field when it is enabled', () => {
expect(
resolveAddressSortSubField({
settings: { subFields: ['addressStreet1', 'addressState'] },
primaryCompositeSubField: 'addressState',
}),
).toBe('addressState');
});
it('falls back to addressCity when the requested sub-field is disabled', () => {
expect(
resolveAddressSortSubField({
settings: { subFields: ['addressStreet1', 'addressCity'] },
primaryCompositeSubField: 'addressState',
}),
).toBe('addressCity');
});
it('falls back to addressCity when the requested sub-field is not a recognized address sub-field', () => {
expect(
resolveAddressSortSubField({
settings: {},
primaryCompositeSubField: 'notARealSubField',
}),
).toBe('addressCity');
});
it('falls back to addressCity when no request is given', () => {
expect(resolveAddressSortSubField({ settings: null })).toBe('addressCity');
expect(resolveAddressSortSubField({ settings: undefined })).toBe(
'addressCity',
);
expect(resolveAddressSortSubField({ settings: {} })).toBe('addressCity');
});
it('falls back to the first enabled sub-field when addressCity is disabled', () => {
expect(
resolveAddressSortSubField({
settings: { subFields: ['addressStreet1', 'addressState'] },
}),
).toBe('addressStreet1');
});
it('falls back to first enabled sub-field even when the requested is recognized but disabled and addressCity is also disabled', () => {
expect(
resolveAddressSortSubField({
settings: { subFields: ['addressStreet1', 'addressCountry'] },
primaryCompositeSubField: 'addressState',
}),
).toBe('addressStreet1');
});
});
@@ -0,0 +1,37 @@
import { resolvePrimaryFullNameSortSubField } from '@/object-metadata/utils/resolvePrimaryFullNameSortSubField';
describe('resolvePrimaryFullNameSortSubField', () => {
it('returns the requested sub-field when it is a recognized full-name sub-field', () => {
expect(
resolvePrimaryFullNameSortSubField({
requestedPrimarySubField: 'lastName',
}),
).toBe('lastName');
expect(
resolvePrimaryFullNameSortSubField({
requestedPrimarySubField: 'firstName',
}),
).toBe('firstName');
});
it('falls back to firstName when no request is given', () => {
expect(resolvePrimaryFullNameSortSubField()).toBe('firstName');
expect(resolvePrimaryFullNameSortSubField({})).toBe('firstName');
expect(
resolvePrimaryFullNameSortSubField({ requestedPrimarySubField: null }),
).toBe('firstName');
expect(
resolvePrimaryFullNameSortSubField({
requestedPrimarySubField: undefined,
}),
).toBe('firstName');
});
it('falls back to firstName when the requested sub-field is not recognized', () => {
expect(
resolvePrimaryFullNameSortSubField({
requestedPrimarySubField: 'middleName',
}),
).toBe('firstName');
});
});
@@ -0,0 +1,19 @@
import { DEFAULT_VISIBLE_ADDRESS_SUBFIELDS } from 'twenty-shared/constants';
import {
type AllowedAddressSubField,
type FieldMetadataSettingsMapping,
type FieldMetadataType,
} from 'twenty-shared/types';
import { isNonEmptyArray } from 'twenty-shared/utils';
export const getEnabledAddressSubFields = (
settings:
| FieldMetadataSettingsMapping[FieldMetadataType.ADDRESS]
| null
| undefined,
): readonly AllowedAddressSubField[] => {
if (isNonEmptyArray(settings?.subFields)) {
return settings.subFields;
}
return DEFAULT_VISIBLE_ADDRESS_SUBFIELDS;
};
@@ -16,10 +16,10 @@ export const getOrderByFieldForObjectMetadataItem = (
getLabelIdentifierFieldMetadataItem(objectMetadataItem);
if (isDefined(labelIdentifierFieldMetadata)) {
return getOrderByForFieldMetadataType(
labelIdentifierFieldMetadata,
orderBy,
);
return getOrderByForFieldMetadataType({
field: labelIdentifierFieldMetadata,
orderByDirection: orderBy,
});
} else {
return [
{
@@ -1,6 +1,8 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { resolveAddressSortSubField } from '@/object-metadata/utils/resolveAddressSortSubField';
import { resolvePrimaryFullNameSortSubField } from '@/object-metadata/utils/resolvePrimaryFullNameSortSubField';
import {
type FieldEmailsValue,
@@ -8,30 +10,55 @@ import {
type FieldPhonesValue,
} from '@/object-record/record-field/ui/types/FieldMetadata';
import {
type FieldMetadataSettingsMapping,
type OrderBy,
type RecordGqlOperationOrderBy,
} from 'twenty-shared/types';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const getOrderByForFieldMetadataType = (
field: Pick<FieldMetadataItem, 'id' | 'name' | 'type'>,
direction: OrderBy | null | undefined,
): RecordGqlOperationOrderBy => {
export const getOrderByForFieldMetadataType = ({
field,
orderByDirection,
primaryCompositeSubField,
}: {
field: Pick<FieldMetadataItem, 'id' | 'name' | 'type' | 'settings'>;
orderByDirection: OrderBy | null | undefined;
primaryCompositeSubField?: string | null;
}): RecordGqlOperationOrderBy => {
switch (field.type) {
case FieldMetadataType.FULL_NAME:
case FieldMetadataType.FULL_NAME: {
const primarySubField = resolvePrimaryFullNameSortSubField({
requestedPrimarySubField: primaryCompositeSubField,
});
const secondarySubField =
primarySubField === 'firstName' ? 'lastName' : 'firstName';
const direction = orderByDirection ?? 'AscNullsLast';
return [
{ [field.name]: { [primarySubField]: direction } },
{ [field.name]: { [secondarySubField]: direction } },
];
}
case FieldMetadataType.ADDRESS: {
const subField = resolveAddressSortSubField({
settings: field.settings as
| FieldMetadataSettingsMapping[FieldMetadataType.ADDRESS]
| null
| undefined,
primaryCompositeSubField,
});
return [
{
[field.name]: {
firstName: direction ?? 'AscNullsLast',
lastName: direction ?? 'AscNullsLast',
[subField]: orderByDirection ?? 'AscNullsLast',
},
},
];
}
case FieldMetadataType.CURRENCY:
return [
{
[field.name]: {
amountMicros: direction ?? 'AscNullsLast',
amountMicros: orderByDirection ?? 'AscNullsLast',
},
},
];
@@ -39,7 +66,7 @@ export const getOrderByForFieldMetadataType = (
return [
{
[field.name]: {
name: direction ?? 'AscNullsLast',
name: orderByDirection ?? 'AscNullsLast',
},
},
];
@@ -47,7 +74,7 @@ export const getOrderByForFieldMetadataType = (
return [
{
[field.name]: {
primaryLinkUrl: direction ?? 'AscNullsLast',
primaryLinkUrl: orderByDirection ?? 'AscNullsLast',
} satisfies { [key in keyof FieldLinksValue]?: OrderBy },
},
];
@@ -55,7 +82,7 @@ export const getOrderByForFieldMetadataType = (
return [
{
[field.name]: {
primaryEmail: direction ?? 'AscNullsLast',
primaryEmail: orderByDirection ?? 'AscNullsLast',
} satisfies { [key in keyof FieldEmailsValue]?: OrderBy },
},
];
@@ -63,39 +90,43 @@ export const getOrderByForFieldMetadataType = (
return [
{
[field.name]: {
primaryPhoneNumber: direction ?? 'AscNullsLast',
primaryPhoneNumber: orderByDirection ?? 'AscNullsLast',
} satisfies { [key in keyof FieldPhonesValue]?: OrderBy },
},
];
default:
return [
{
[field.name]: direction ?? 'AscNullsLast',
[field.name]: orderByDirection ?? 'AscNullsLast',
},
];
}
};
export const getOrderByForRelationField = (
field: Pick<FieldMetadataItem, 'name'>,
export const getOrderByForRelationField = ({
field,
relatedObjectMetadataItem,
orderByDirection,
}: {
field: Pick<FieldMetadataItem, 'name'>;
relatedObjectMetadataItem: Pick<
EnrichedObjectMetadataItem,
'fields' | 'labelIdentifierFieldMetadataId'
>,
direction: OrderBy,
): RecordGqlOperationOrderBy => {
>;
orderByDirection: OrderBy;
}): RecordGqlOperationOrderBy => {
const labelIdentifierField = getLabelIdentifierFieldMetadataItem(
relatedObjectMetadataItem,
);
if (!labelIdentifierField) {
return [{ [`${field.name}Id`]: direction }];
return [{ [`${field.name}Id`]: orderByDirection }];
}
const labelFieldOrderBy = getOrderByForFieldMetadataType(
labelIdentifierField,
direction,
);
const labelFieldOrderBy = getOrderByForFieldMetadataType({
field: labelIdentifierField,
orderByDirection,
});
return [{ [field.name]: labelFieldOrderBy[0] }];
return labelFieldOrderBy.map((entry) => ({ [field.name]: entry }));
};
@@ -0,0 +1,42 @@
import { getEnabledAddressSubFields } from '@/object-metadata/utils/getEnabledAddressSubFields';
import {
ALLOWED_ADDRESS_SUBFIELDS,
type AllowedAddressSubField,
type FieldMetadataSettingsMapping,
type FieldMetadataType,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
const DEFAULT_SUB_FIELD: AllowedAddressSubField = 'addressCity';
const isAllowedAddressSubField = (
value: string | null | undefined,
): value is AllowedAddressSubField =>
ALLOWED_ADDRESS_SUBFIELDS.includes(value as AllowedAddressSubField);
export const resolveAddressSortSubField = ({
settings,
primaryCompositeSubField,
}: {
settings:
| FieldMetadataSettingsMapping[FieldMetadataType.ADDRESS]
| null
| undefined;
primaryCompositeSubField?: string | null;
}): AllowedAddressSubField => {
const enabledSubFields = getEnabledAddressSubFields(settings);
if (
isDefined(primaryCompositeSubField) &&
isAllowedAddressSubField(primaryCompositeSubField) &&
enabledSubFields.includes(primaryCompositeSubField)
) {
return primaryCompositeSubField;
}
if (enabledSubFields.includes(DEFAULT_SUB_FIELD)) {
return DEFAULT_SUB_FIELD;
}
return enabledSubFields[0] ?? DEFAULT_SUB_FIELD;
};
@@ -0,0 +1,26 @@
import { ALLOWED_FULL_NAME_SORT_SUBFIELDS } from 'twenty-shared/constants';
import { type AllowedFullNameSortSubField } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
const DEFAULT_PRIMARY_SUB_FIELD: AllowedFullNameSortSubField = 'firstName';
const isAllowedFullNameSortSubField = (
value: string | null | undefined,
): value is AllowedFullNameSortSubField =>
ALLOWED_FULL_NAME_SORT_SUBFIELDS.includes(
value as AllowedFullNameSortSubField,
);
export const resolvePrimaryFullNameSortSubField = ({
requestedPrimarySubField,
}: {
requestedPrimarySubField?: string | null;
} = {}): AllowedFullNameSortSubField => {
if (
isDefined(requestedPrimarySubField) &&
isAllowedFullNameSortSubField(requestedPrimarySubField)
) {
return requestedPrimarySubField;
}
return DEFAULT_PRIMARY_SUB_FIELD;
};