[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
@@ -1,7 +1,8 @@
import { styled } from '@linaria/react';
import { useContext } from 'react';
import { useContext, type ReactNode } from 'react';
import { type IconComponent, IconX } from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { isDefined } from 'twenty-shared/utils';
const StyledChip = styled.div<{ variant: SortOrFilterChipVariant }>`
align-items: center;
@@ -96,6 +97,16 @@ const StyledSortValue = styled.span`
font-weight: ${themeCssVariables.font.weight.medium};
`;
const StyledSubFieldSeparator = styled.span`
font-weight: ${themeCssVariables.font.weight.regular};
opacity: 0.6;
padding: 0 ${themeCssVariables.spacing[1]};
`;
const StyledSubFieldValue = styled.span`
font-weight: ${themeCssVariables.font.weight.regular};
`;
const StyledKeyLabelContainer = styled.div`
display: flex;
`;
@@ -107,6 +118,7 @@ export type SortOrFilterChipType = 'sort' | 'filter';
type SortOrFilterChipProps = {
labelKey?: string;
labelValue: string;
labelSubField?: ReactNode;
variant?: SortOrFilterChipVariant;
Icon?: IconComponent;
onRemove: () => void;
@@ -118,6 +130,7 @@ type SortOrFilterChipProps = {
export const SortOrFilterChip = ({
labelKey,
labelValue,
labelSubField,
variant = 'default',
Icon,
onRemove,
@@ -146,6 +159,12 @@ export const SortOrFilterChip = ({
) : (
<StyledFilterValue>{labelValue}</StyledFilterValue>
)}
{isDefined(labelSubField) && (
<>
<StyledSubFieldSeparator>·</StyledSubFieldSeparator>
<StyledSubFieldValue>{labelSubField}</StyledSubFieldValue>
</>
)}
</StyledKeyLabelContainer>
<StyledDelete
variant={variant}
@@ -1,9 +1,18 @@
import { useFieldMetadataItemByIdOrThrow } from '@/object-metadata/hooks/useFieldMetadataItemByIdOrThrow';
import { useSortSubFieldChoicesForField } from '@/object-metadata/hooks/useSortSubFieldChoicesForField';
import { useRemoveRecordSort } from '@/object-record/record-sort/hooks/useRemoveRecordSort';
import { useUpsertRecordSort } from '@/object-record/record-sort/hooks/useUpsertRecordSort';
import { type RecordSort } from '@/object-record/record-sort/types/RecordSort';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { SortOrFilterChip } from '@/views/components/SortOrFilterChip';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconArrowDown, IconArrowUp } from 'twenty-ui/display';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { ViewSortDirection } from '~/generated-metadata/graphql';
type EditableSortChipProps = {
@@ -11,42 +20,112 @@ type EditableSortChipProps = {
};
export const EditableSortChip = ({ recordSort }: EditableSortChipProps) => {
const { t } = useLingui();
const { removeRecordSort } = useRemoveRecordSort();
const { upsertRecordSort } = useUpsertRecordSort();
const handleRemoveClick = () => {
removeRecordSort(recordSort.fieldMetadataId);
};
const { closeDropdown } = useCloseDropdown();
const { fieldMetadataItem } = useFieldMetadataItemByIdOrThrow(
recordSort.fieldMetadataId,
);
const handleClick = () => {
const newSort: RecordSort = {
...recordSort,
direction:
recordSort.direction === ViewSortDirection.ASC
? ViewSortDirection.DESC
: ViewSortDirection.ASC,
};
upsertRecordSort(newSort);
const subFieldChoices = useSortSubFieldChoicesForField({
fieldMetadataItem,
primaryCompositeSubField: recordSort.subFieldName,
});
const dropdownId = `sort-chip-${recordSort.id}`;
const setDirection = (direction: ViewSortDirection) => {
upsertRecordSort({ ...recordSort, direction });
};
const toggleDirection = () => {
setDirection(
recordSort.direction === ViewSortDirection.ASC
? ViewSortDirection.DESC
: ViewSortDirection.ASC,
);
};
const handleRemove = () => {
removeRecordSort(recordSort.fieldMetadataId);
};
const handleSubFieldSelect = (value: string) => {
upsertRecordSort({ ...recordSort, subFieldName: value });
closeDropdown(dropdownId);
};
const handleDirectionSelect = (direction: ViewSortDirection) => {
setDirection(direction);
closeDropdown(dropdownId);
};
const Icon =
recordSort.direction === ViewSortDirection.DESC
? IconArrowDown
: IconArrowUp;
if (!isDefined(subFieldChoices)) {
return (
<SortOrFilterChip
key={recordSort.fieldMetadataId}
testId={recordSort.fieldMetadataId}
labelValue={fieldMetadataItem.label}
Icon={Icon}
onRemove={handleRemove}
onClick={toggleDirection}
type="sort"
/>
);
}
return (
<SortOrFilterChip
key={recordSort.fieldMetadataId}
testId={recordSort.fieldMetadataId}
labelValue={fieldMetadataItem.label}
Icon={
recordSort.direction === ViewSortDirection.DESC
? IconArrowDown
: IconArrowUp
<Dropdown
dropdownId={dropdownId}
clickableComponent={
<SortOrFilterChip
key={recordSort.fieldMetadataId}
testId={recordSort.fieldMetadataId}
labelValue={fieldMetadataItem.label}
labelSubField={subFieldChoices.selectedLabel}
Icon={Icon}
onRemove={handleRemove}
type="sort"
/>
}
onRemove={handleRemoveClick}
onClick={handleClick}
type="sort"
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItemSelect
LeftIcon={IconArrowUp}
text={t`Ascending`}
selected={recordSort.direction === ViewSortDirection.ASC}
onClick={() => handleDirectionSelect(ViewSortDirection.ASC)}
/>
<MenuItemSelect
LeftIcon={IconArrowDown}
text={t`Descending`}
selected={recordSort.direction === ViewSortDirection.DESC}
onClick={() => handleDirectionSelect(ViewSortDirection.DESC)}
/>
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
{subFieldChoices.options.map((option) => (
<MenuItemSelect
key={option.value}
text={option.label}
selected={option.value === subFieldChoices.selectedValue}
onClick={() => handleSubFieldSelect(option.value)}
/>
))}
</DropdownMenuItemsContainer>
</DropdownContent>
}
dropdownOffset={{ y: 8, x: 0 }}
dropdownPlacement="bottom-start"
/>
);
};
@@ -5,6 +5,7 @@ export const VIEW_SORT_FRAGMENT = gql`
id
fieldMetadataId
direction
subFieldName
viewId
createdAt
deletedAt
@@ -59,6 +59,7 @@ export const useSaveRecordSortsToViewSorts = () => {
fieldMetadataId: viewSort.fieldMetadataId,
viewId: currentView.id,
direction: viewSort.direction,
subFieldName: viewSort.subFieldName ?? null,
},
}));
@@ -67,6 +68,7 @@ export const useSaveRecordSortsToViewSorts = () => {
id: viewSort.id,
update: {
direction: viewSort.direction,
subFieldName: viewSort.subFieldName ?? null,
},
},
}));
@@ -6,5 +6,6 @@ export type ViewSort = {
createdAt?: string;
updatedAt?: string;
direction: ViewSortDirection;
subFieldName?: string | null;
viewId?: string;
};
@@ -1,23 +1,25 @@
import { type ViewSort } from '~/generated-metadata/graphql';
import { compareStrictlyExceptForNullAndUndefined } from '~/utils/compareStrictlyExceptForNullAndUndefined';
type ViewSortComparableFields =
| 'fieldMetadataId'
| 'direction'
| 'subFieldName';
export const areViewSortsEqual = (
viewSortA: Pick<ViewSort, 'fieldMetadataId' | 'direction'>,
viewSortB: Pick<ViewSort, 'fieldMetadataId' | 'direction'>,
viewSortA: Pick<ViewSort, ViewSortComparableFields>,
viewSortB: Pick<ViewSort, ViewSortComparableFields>,
) => {
const propertiesToCompare: (keyof Pick<
ViewSort,
'fieldMetadataId' | 'direction'
>)[] = ['fieldMetadataId', 'direction'];
const propertiesToCompare: ViewSortComparableFields[] = [
'fieldMetadataId',
'direction',
'subFieldName',
];
return propertiesToCompare.every((property) =>
compareStrictlyExceptForNullAndUndefined(
viewSortA[
property as keyof Pick<ViewSort, 'fieldMetadataId' | 'direction'>
],
viewSortB[
property as keyof Pick<ViewSort, 'fieldMetadataId' | 'direction'>
],
viewSortA[property],
viewSortB[property],
),
);
};
@@ -6,5 +6,6 @@ export const mapRecordSortToViewSort = (recordSort: RecordSort): ViewSort => {
id: recordSort.id,
fieldMetadataId: recordSort.fieldMetadataId,
direction: recordSort.direction,
subFieldName: recordSort.subFieldName ?? null,
};
};