[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:
@@ -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}
|
||||
|
||||
+104
-25
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user