[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:
@@ -813,6 +813,7 @@ type ViewSort {
|
||||
id: UUID!
|
||||
fieldMetadataId: UUID!
|
||||
direction: ViewSortDirection!
|
||||
subFieldName: String
|
||||
viewId: UUID!
|
||||
workspaceId: UUID!
|
||||
createdAt: DateTime!
|
||||
@@ -3552,6 +3553,7 @@ input CreateViewSortInput {
|
||||
id: UUID
|
||||
fieldMetadataId: UUID!
|
||||
direction: ViewSortDirection = ASC
|
||||
subFieldName: String
|
||||
viewId: UUID!
|
||||
}
|
||||
|
||||
@@ -3565,6 +3567,7 @@ input UpdateViewSortInput {
|
||||
|
||||
input UpdateViewSortInputUpdates {
|
||||
direction: ViewSortDirection
|
||||
subFieldName: String
|
||||
}
|
||||
|
||||
input DeleteViewSortInput {
|
||||
|
||||
@@ -550,6 +550,7 @@ export interface ViewSort {
|
||||
id: Scalars['UUID']
|
||||
fieldMetadataId: Scalars['UUID']
|
||||
direction: ViewSortDirection
|
||||
subFieldName?: Scalars['String']
|
||||
viewId: Scalars['UUID']
|
||||
workspaceId: Scalars['UUID']
|
||||
createdAt: Scalars['DateTime']
|
||||
@@ -3488,6 +3489,7 @@ export interface ViewSortGenqlSelection{
|
||||
id?: boolean | number
|
||||
fieldMetadataId?: boolean | number
|
||||
direction?: boolean | number
|
||||
subFieldName?: boolean | number
|
||||
viewId?: boolean | number
|
||||
workspaceId?: boolean | number
|
||||
createdAt?: boolean | number
|
||||
@@ -6040,7 +6042,7 @@ export interface UpsertViewWidgetViewFilterGroupInput {id?: (Scalars['UUID'] | n
|
||||
|
||||
export interface UpsertViewWidgetViewSortInput {id?: (Scalars['UUID'] | null),fieldMetadataId: Scalars['UUID'],direction?: (ViewSortDirection | null)}
|
||||
|
||||
export interface CreateViewSortInput {id?: (Scalars['UUID'] | null),fieldMetadataId: Scalars['UUID'],direction?: (ViewSortDirection | null),viewId: Scalars['UUID']}
|
||||
export interface CreateViewSortInput {id?: (Scalars['UUID'] | null),fieldMetadataId: Scalars['UUID'],direction?: (ViewSortDirection | null),subFieldName?: (Scalars['String'] | null),viewId: Scalars['UUID']}
|
||||
|
||||
export interface UpdateViewSortInput {
|
||||
/** The id of the view sort to update */
|
||||
@@ -6048,7 +6050,7 @@ id: Scalars['UUID'],
|
||||
/** The view sort to update */
|
||||
update: UpdateViewSortInputUpdates}
|
||||
|
||||
export interface UpdateViewSortInputUpdates {direction?: (ViewSortDirection | null)}
|
||||
export interface UpdateViewSortInputUpdates {direction?: (ViewSortDirection | null),subFieldName?: (Scalars['String'] | null)}
|
||||
|
||||
export interface DeleteViewSortInput {
|
||||
/** The id of the view sort to delete. */
|
||||
|
||||
@@ -1583,6 +1583,9 @@ export default {
|
||||
"direction": [
|
||||
67
|
||||
],
|
||||
"subFieldName": [
|
||||
1
|
||||
],
|
||||
"viewId": [
|
||||
3
|
||||
],
|
||||
@@ -9222,6 +9225,9 @@ export default {
|
||||
"direction": [
|
||||
67
|
||||
],
|
||||
"subFieldName": [
|
||||
1
|
||||
],
|
||||
"viewId": [
|
||||
3
|
||||
],
|
||||
@@ -9244,6 +9250,9 @@ export default {
|
||||
"direction": [
|
||||
67
|
||||
],
|
||||
"subFieldName": [
|
||||
1
|
||||
],
|
||||
"__typename": [
|
||||
1
|
||||
]
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
@@ -14,4 +14,5 @@ export const SORTABLE_FIELD_METADATA_TYPES = [
|
||||
FieldMetadataType.ACTOR,
|
||||
FieldMetadataType.LINKS,
|
||||
FieldMetadataType.PHONES,
|
||||
FieldMetadataType.ADDRESS,
|
||||
];
|
||||
|
||||
+2
-1
@@ -23,7 +23,8 @@ describe('useGetObjectOrderByField', () => {
|
||||
);
|
||||
|
||||
expect(result.current).toEqual([
|
||||
{ name: { firstName: 'AscNullsLast', lastName: 'AscNullsLast' } },
|
||||
{ name: { firstName: 'AscNullsLast' } },
|
||||
{ name: { lastName: 'AscNullsLast' } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { getEnabledAddressSubFields } from '@/object-metadata/utils/getEnabledAddressSubFields';
|
||||
import { resolveAddressSortSubField } from '@/object-metadata/utils/resolveAddressSortSubField';
|
||||
import { resolvePrimaryFullNameSortSubField } from '@/object-metadata/utils/resolvePrimaryFullNameSortSubField';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { ALLOWED_FULL_NAME_SORT_SUBFIELDS } from 'twenty-shared/constants';
|
||||
import {
|
||||
type AllowedAddressSubField,
|
||||
type AllowedFullNameSortSubField,
|
||||
type FieldMetadataSettingsMapping,
|
||||
} from 'twenty-shared/types';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
export type SortSubFieldChoice = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type SortSubFieldChoices = {
|
||||
options: SortSubFieldChoice[];
|
||||
selectedValue: string;
|
||||
selectedLabel: string;
|
||||
};
|
||||
|
||||
export const useSortSubFieldChoicesForField = ({
|
||||
fieldMetadataItem,
|
||||
primaryCompositeSubField,
|
||||
}: {
|
||||
fieldMetadataItem: Pick<FieldMetadataItem, 'type' | 'settings'>;
|
||||
primaryCompositeSubField: string | null | undefined;
|
||||
}): SortSubFieldChoices | undefined => {
|
||||
const { t } = useLingui();
|
||||
|
||||
if (fieldMetadataItem.type === FieldMetadataType.FULL_NAME) {
|
||||
const labels: Record<AllowedFullNameSortSubField, string> = {
|
||||
firstName: t`First name`,
|
||||
lastName: t`Last name`,
|
||||
};
|
||||
const selectedValue = resolvePrimaryFullNameSortSubField({
|
||||
requestedPrimarySubField: primaryCompositeSubField,
|
||||
});
|
||||
return {
|
||||
options: ALLOWED_FULL_NAME_SORT_SUBFIELDS.map((value) => ({
|
||||
value,
|
||||
label: labels[value],
|
||||
})),
|
||||
selectedValue,
|
||||
selectedLabel: labels[selectedValue],
|
||||
};
|
||||
}
|
||||
|
||||
if (fieldMetadataItem.type === FieldMetadataType.ADDRESS) {
|
||||
const labels: 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`,
|
||||
};
|
||||
const addressSettings = fieldMetadataItem.settings as
|
||||
| FieldMetadataSettingsMapping[FieldMetadataType.ADDRESS]
|
||||
| null
|
||||
| undefined;
|
||||
const selectedValue = resolveAddressSortSubField({
|
||||
settings: addressSettings,
|
||||
primaryCompositeSubField,
|
||||
});
|
||||
return {
|
||||
options: getEnabledAddressSubFields(addressSettings).map((value) => ({
|
||||
value,
|
||||
label: labels[value],
|
||||
})),
|
||||
selectedValue,
|
||||
selectedLabel: labels[selectedValue],
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
+2
-1
@@ -8,7 +8,8 @@ describe('getObjectOrderByField', () => {
|
||||
)!;
|
||||
const res = getOrderByFieldForObjectMetadataItem(objectMetadataItem);
|
||||
expect(res).toEqual([
|
||||
{ name: { firstName: 'AscNullsLast', lastName: 'AscNullsLast' } },
|
||||
{ name: { firstName: 'AscNullsLast' } },
|
||||
{ name: { lastName: 'AscNullsLast' } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+93
@@ -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',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+17
-24
@@ -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' } }]);
|
||||
});
|
||||
|
||||
+55
@@ -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');
|
||||
});
|
||||
});
|
||||
+37
@@ -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 [
|
||||
{
|
||||
|
||||
+56
-25
@@ -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;
|
||||
};
|
||||
+26
@@ -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;
|
||||
};
|
||||
+10
-6
@@ -50,17 +50,21 @@ export const turnSortsIntoOrderBy = (
|
||||
);
|
||||
|
||||
if (isDefined(relatedObjectMetadata)) {
|
||||
return getOrderByForRelationField(
|
||||
correspondingField,
|
||||
relatedObjectMetadata,
|
||||
direction,
|
||||
);
|
||||
return getOrderByForRelationField({
|
||||
field: correspondingField,
|
||||
relatedObjectMetadataItem: relatedObjectMetadata,
|
||||
orderByDirection: direction,
|
||||
});
|
||||
}
|
||||
// Fallback if related object not found - sort by FK
|
||||
return [{ [`${correspondingField.name}Id`]: direction }];
|
||||
}
|
||||
|
||||
return getOrderByForFieldMetadataType(correspondingField, direction);
|
||||
return getOrderByForFieldMetadataType({
|
||||
field: correspondingField,
|
||||
orderByDirection: direction,
|
||||
primaryCompositeSubField: sort.subFieldName,
|
||||
});
|
||||
})
|
||||
.filter(isDefined);
|
||||
|
||||
|
||||
@@ -4,4 +4,5 @@ export type RecordSort = {
|
||||
id: string;
|
||||
fieldMetadataId: string;
|
||||
direction: ViewSortDirection;
|
||||
subFieldName?: string | null;
|
||||
};
|
||||
|
||||
+12
-26
@@ -1,32 +1,18 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { DEFAULT_VISIBLE_ADDRESS_SUBFIELDS } from 'twenty-shared/constants';
|
||||
import { 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],
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { QueryRunner } from 'typeorm';
|
||||
|
||||
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
|
||||
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
|
||||
|
||||
@RegisteredInstanceCommand('2.5.0', 1778502963794)
|
||||
export class AddSubFieldNameToViewSortFastInstanceCommand
|
||||
implements FastInstanceCommand
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."viewSort" ADD "subFieldName" character varying`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."viewSort" DROP COLUMN "subFieldName"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
@@ -34,6 +34,7 @@ import { AddApplicationIdToPublicDomainFastInstanceCommand } from 'src/database/
|
||||
import { AddIsInternalMessagesImportEnabledFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1778525104406-add-is-internal-messages-import-enabled';
|
||||
import { CreateSigningKeyTableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1778550000000-create-signing-key-table';
|
||||
import { EncryptConnectedAccountTokensSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens';
|
||||
import { AddSubFieldNameToViewSortFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1778502963794-add-sub-field-name-to-view-sort';
|
||||
|
||||
export const INSTANCE_COMMANDS = [
|
||||
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
|
||||
@@ -70,4 +71,5 @@ export const INSTANCE_COMMANDS = [
|
||||
AddIsInternalMessagesImportEnabledFastInstanceCommand,
|
||||
CreateSigningKeyTableFastInstanceCommand,
|
||||
EncryptConnectedAccountTokensSlowInstanceCommand,
|
||||
AddSubFieldNameToViewSortFastInstanceCommand,
|
||||
];
|
||||
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should match snapshot 1`] = `
|
||||
{
|
||||
@@ -383,6 +383,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
|
||||
"viewSort": {
|
||||
"propertiesToCompare": [
|
||||
"direction",
|
||||
"subFieldName",
|
||||
"fieldMetadataUniversalIdentifier",
|
||||
"viewUniversalIdentifier",
|
||||
"deletedAt",
|
||||
|
||||
+5
@@ -1394,6 +1394,11 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
|
||||
toStringify: false,
|
||||
universalProperty: undefined,
|
||||
},
|
||||
subFieldName: {
|
||||
toCompare: true,
|
||||
toStringify: false,
|
||||
universalProperty: undefined,
|
||||
},
|
||||
fieldMetadataId: {
|
||||
toCompare: true,
|
||||
toStringify: false,
|
||||
|
||||
+1
@@ -2,4 +2,5 @@ import { type MetadataEntityPropertyName } from 'src/engine/metadata-modules/fla
|
||||
|
||||
export const FLAT_VIEW_SORT_EDITABLE_PROPERTIES = [
|
||||
'direction',
|
||||
'subFieldName',
|
||||
] as const satisfies MetadataEntityPropertyName<'viewSort'>[];
|
||||
|
||||
+1
@@ -53,6 +53,7 @@ export const fromCreateViewSortInputToFlatViewSortToCreate = ({
|
||||
deletedAt: null,
|
||||
universalIdentifier: createViewSortInput.universalIdentifier ?? v4(),
|
||||
direction: createViewSortInput.direction ?? ViewSortDirection.ASC,
|
||||
subFieldName: createViewSortInput.subFieldName ?? null,
|
||||
applicationUniversalIdentifier: flatApplication.universalIdentifier,
|
||||
};
|
||||
};
|
||||
|
||||
+6
-1
@@ -1,6 +1,6 @@
|
||||
import { Field, HideField, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IsEnum, IsOptional, IsUUID } from 'class-validator';
|
||||
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ViewSortDirection } from 'twenty-shared/types';
|
||||
@@ -24,6 +24,11 @@ export class CreateViewSortInput {
|
||||
})
|
||||
direction?: ViewSortDirection;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Field(() => String, { nullable: true })
|
||||
subFieldName?: string | null;
|
||||
|
||||
@IsUUID()
|
||||
@Field(() => UUIDScalarType, { nullable: false })
|
||||
viewId: string;
|
||||
|
||||
+6
@@ -4,6 +4,7 @@ import {
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
@@ -18,6 +19,11 @@ class UpdateViewSortInputUpdates {
|
||||
@IsEnum(ViewSortDirection)
|
||||
@Field(() => ViewSortDirection, { nullable: true })
|
||||
direction?: ViewSortDirection;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Field(() => String, { nullable: true })
|
||||
subFieldName?: string | null;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
|
||||
@@ -21,6 +21,9 @@ export class ViewSortDTO {
|
||||
})
|
||||
direction: ViewSortDirection;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
subFieldName?: string | null;
|
||||
|
||||
@Field(() => UUIDScalarType, { nullable: false })
|
||||
viewId: string;
|
||||
|
||||
|
||||
+3
@@ -49,6 +49,9 @@ export class ViewSortEntity extends SyncableEntity {
|
||||
})
|
||||
direction: ViewSortDirection;
|
||||
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
subFieldName?: string | null;
|
||||
|
||||
@Column({ nullable: false, type: 'uuid' })
|
||||
viewId: string;
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export const VIEW_SORT_GQL_FIELDS = `
|
||||
id
|
||||
fieldMetadataId
|
||||
direction
|
||||
subFieldName
|
||||
viewId
|
||||
createdAt
|
||||
updatedAt
|
||||
|
||||
+39
@@ -153,4 +153,43 @@ describe('View Sort creation should succeed', () => {
|
||||
direction: ViewSortDirection.DESC,
|
||||
});
|
||||
});
|
||||
|
||||
it('should default subFieldName to null when not provided', async () => {
|
||||
const { data } = await createOneViewSort({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
viewId: createdViewId,
|
||||
fieldMetadataId: testFieldMetadataId,
|
||||
},
|
||||
});
|
||||
|
||||
createdViewSortId = data?.createViewSort?.id;
|
||||
|
||||
expect(data.createViewSort).toMatchObject({
|
||||
id: expect.any(String),
|
||||
viewId: createdViewId,
|
||||
fieldMetadataId: testFieldMetadataId,
|
||||
subFieldName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should persist subFieldName when provided', async () => {
|
||||
const { data } = await createOneViewSort({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
viewId: createdViewId,
|
||||
fieldMetadataId: testFieldMetadataId,
|
||||
subFieldName: 'lastName',
|
||||
},
|
||||
});
|
||||
|
||||
createdViewSortId = data?.createViewSort?.id;
|
||||
|
||||
expect(data.createViewSort).toMatchObject({
|
||||
id: expect.any(String),
|
||||
viewId: createdViewId,
|
||||
fieldMetadataId: testFieldMetadataId,
|
||||
subFieldName: 'lastName',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+63
@@ -153,4 +153,67 @@ describe('View Sort update should succeed', () => {
|
||||
direction: ViewSortDirection.ASC,
|
||||
});
|
||||
});
|
||||
|
||||
it('should set subFieldName on a sort that did not have one', async () => {
|
||||
const { data } = await updateOneViewSort({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
id: createdViewSortId,
|
||||
update: {
|
||||
subFieldName: 'lastName',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(data.updateViewSort).toMatchObject({
|
||||
id: createdViewSortId,
|
||||
subFieldName: 'lastName',
|
||||
});
|
||||
});
|
||||
|
||||
it('should overwrite an existing subFieldName', async () => {
|
||||
await updateOneViewSort({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
id: createdViewSortId,
|
||||
update: { subFieldName: 'lastName' },
|
||||
},
|
||||
});
|
||||
|
||||
const { data } = await updateOneViewSort({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
id: createdViewSortId,
|
||||
update: { subFieldName: 'firstName' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(data.updateViewSort).toMatchObject({
|
||||
id: createdViewSortId,
|
||||
subFieldName: 'firstName',
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear subFieldName when set back to null', async () => {
|
||||
await updateOneViewSort({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
id: createdViewSortId,
|
||||
update: { subFieldName: 'lastName' },
|
||||
},
|
||||
});
|
||||
|
||||
const { data } = await updateOneViewSort({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
id: createdViewSortId,
|
||||
update: { subFieldName: null },
|
||||
},
|
||||
});
|
||||
|
||||
expect(data.updateViewSort).toMatchObject({
|
||||
id: createdViewSortId,
|
||||
subFieldName: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export const ALLOWED_FULL_NAME_SORT_SUBFIELDS = [
|
||||
'firstName',
|
||||
'lastName',
|
||||
] as const;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { type AllowedAddressSubField } from '@/types/AddressFieldsType';
|
||||
|
||||
export const DEFAULT_VISIBLE_ADDRESS_SUBFIELDS = [
|
||||
'addressStreet1',
|
||||
'addressStreet2',
|
||||
'addressCity',
|
||||
'addressState',
|
||||
'addressPostcode',
|
||||
'addressCountry',
|
||||
] as const satisfies readonly AllowedAddressSubField[];
|
||||
@@ -7,6 +7,7 @@
|
||||
* |___/
|
||||
*/
|
||||
|
||||
export { ALLOWED_FULL_NAME_SORT_SUBFIELDS } from './AllowedFullNameSortSubfields';
|
||||
export { AUTO_SELECT_FAST_MODEL_ID } from './AutoSelectFastModelId';
|
||||
export { AUTO_SELECT_SMART_MODEL_ID } from './AutoSelectSmartModelId';
|
||||
export { BACKEND_BATCH_REQUEST_MAX_COUNT } from './BackendBatchRequestMaxCount';
|
||||
@@ -17,6 +18,7 @@ export { CURRENCY_CODE_LABELS } from './CurrencyCodeLabels';
|
||||
export { DATE_TYPE_FORMAT } from './DateTypeFormat';
|
||||
export { DEFAULT_NUMBER_OF_GROUPS_LIMIT } from './DefaultNumberOfGroupsLimit';
|
||||
export { DEFAULT_RELATIVE_DATE_FILTER_VALUE } from './DefaultRelativeDateFilterValue';
|
||||
export { DEFAULT_VISIBLE_ADDRESS_SUBFIELDS } from './DefaultVisibleAddressSubfields';
|
||||
export { DOCUMENTATION_BASE_URL } from './DocumentationBaseUrl';
|
||||
export { DOCUMENTATION_DEFAULT_LANGUAGE } from './DocumentationDefaultLanguage';
|
||||
export { DOCUMENTATION_DEFAULT_PATH } from './DocumentationDefaultPath';
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { type ALLOWED_FULL_NAME_SORT_SUBFIELDS } from '@/constants/AllowedFullNameSortSubfields';
|
||||
|
||||
export type AllowedFullNameSortSubField =
|
||||
(typeof ALLOWED_FULL_NAME_SORT_SUBFIELDS)[number];
|
||||
@@ -10,6 +10,7 @@
|
||||
export type { AllowedAddressSubField } from './AddressFieldsType';
|
||||
export { ALLOWED_ADDRESS_SUBFIELDS } from './AddressFieldsType';
|
||||
export { AggregateOperations } from './AggregateOperations';
|
||||
export type { AllowedFullNameSortSubField } from './AllowedFullNameSortSubField';
|
||||
export { AppBasePath } from './AppBasePath';
|
||||
export { AppPath } from './AppPath';
|
||||
export type { Arrayable } from './Arrayable';
|
||||
|
||||
Reference in New Issue
Block a user