fix(server): allow ordering operators on SELECT and RATING fields (#21506)
## Problem
On a record **show page**, prev/next navigation fails with toasts like:
```
Invalid filter : Operator "lt" is not valid for this "severity" SELECT field
```
…but only when the record is reached via in-app navigation from an index
view sorted by a SELECT field (a hard reload is clean).
## Root cause
The record show page paginates with **keyset (cursor) pagination**.
`useRecordShowPagePagination` builds `before`/`after` filters via
`computeCursorArgFilter`, which emits `{ field: { gt|lt: value } }` for
**every** field in the parent view's `orderBy`, regardless of type.
When a view is sorted by a **SELECT** field (e.g. the *All Bugs* view
sorts by `severity`), the keyset filter becomes `{ severity: { gt:
"HIGH" } }` / `{ severity: { lt: "HIGH" } }`. SELECT/RATING fields use
`ENUM_FILTER_OPERATORS` (`eq, neq, in, containsAny, is, isEmptyArray`) —
no ordering operators — so filter validation rejects them, breaking both
the neighbour queries and the rank-in-view ("X of Y") count query. A
hard reload has no parent-view `orderBy`, so neighbour queries are
skipped → no error.
This isn't a technical limitation: the column is comparable, `ORDER BY
severity` works, and the backend already emits the same comparison for
its own cursor path (`buildCursorWhereCondition`) — that path just
bypasses the user-filter allowlist. Ordering operators were omitted from
`ENUM_FILTER_OPERATORS` because they aren't meaningful *user* filters
for categorical fields, but keyset pagination legitimately needs them.
## Fix
Add `gt/gte/lt/lte` to `ENUM_FILTER_OPERATORS`, so SELECT/RATING keyset
filters are accepted — exactly as `UUID_FILTER_OPERATORS` already
carries these operators for the `id` cursor tiebreaker. The filter UI
never offers these operands for SELECT, so this only enables the
keyset/cursor use case.
## Tests
- `get-operators-for-field-type.util.spec.ts`: SELECT/RATING now include
`gt/gte/lt/lte`.
- `validate-operator-for-field-type-or-throw.util.spec.ts`: regression —
ordering operators on a SELECT field no longer throw.
This commit is contained in:
+4
-4
@@ -1,8 +1,8 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`FilterArgProcessorService failing filter inputs validation ACTOR should throw for invalid filter #1: {"actorField":{"invalidSubField":{"eq":"test"}}} 1`] = `"Sub field "invalidSubField" not found for composite type: ACTOR"`;
|
||||
|
||||
exports[`FilterArgProcessorService failing filter inputs validation ACTOR should throw for invalid filter #2: {"actorField":{"source":{"invalidOperator":"test"}}} 1`] = `"Operator "invalidOperator" is not valid for field "actorField.source" of type SELECT - Allowed operators: eq, neq, in, containsAny, is, isEmptyArray"`;
|
||||
exports[`FilterArgProcessorService failing filter inputs validation ACTOR should throw for invalid filter #2: {"actorField":{"source":{"invalidOperator":"test"}}} 1`] = `"Operator "invalidOperator" is not valid for field "actorField.source" of type SELECT - Allowed operators: eq, neq, gt, gte, lt, lte, in, containsAny, is, isEmptyArray"`;
|
||||
|
||||
exports[`FilterArgProcessorService failing filter inputs validation ACTOR should throw for invalid filter #3: {"actorField":{"workspaceMemberId":{"eq":"invalid-uuid"}}} 1`] = `"Invalid UUID value 'invalid-uuid' for field "actorField.workspaceMemberId""`;
|
||||
|
||||
@@ -124,7 +124,7 @@ exports[`FilterArgProcessorService failing filter inputs validation POSITION sho
|
||||
|
||||
exports[`FilterArgProcessorService failing filter inputs validation POSITION should throw for invalid filter #4: {"position":{}} 1`] = `"Filter for field "position" must have exactly one operator"`;
|
||||
|
||||
exports[`FilterArgProcessorService failing filter inputs validation RATING should throw for invalid filter #1: {"ratingField":{"invalidOperator":"RATING_1"}} 1`] = `"Operator "invalidOperator" is not valid for field "ratingField" of type RATING - Allowed operators: eq, neq, in, containsAny, is, isEmptyArray"`;
|
||||
exports[`FilterArgProcessorService failing filter inputs validation RATING should throw for invalid filter #1: {"ratingField":{"invalidOperator":"RATING_1"}} 1`] = `"Operator "invalidOperator" is not valid for field "ratingField" of type RATING - Allowed operators: eq, neq, gt, gte, lt, lte, in, containsAny, is, isEmptyArray"`;
|
||||
|
||||
exports[`FilterArgProcessorService failing filter inputs validation RATING should throw for invalid filter #2: {"ratingField":{}} 1`] = `"Filter for field "ratingField" must have exactly one operator"`;
|
||||
|
||||
@@ -144,7 +144,7 @@ exports[`FilterArgProcessorService failing filter inputs validation RICH_TEXT sh
|
||||
|
||||
exports[`FilterArgProcessorService failing filter inputs validation RICH_TEXT should throw for invalid filter #2: {"richTextField":{"markdown":{"invalidOperator":"test"}}} 1`] = `"Operator "invalidOperator" is not valid for field "richTextField.markdown" of type TEXT - Allowed operators: eq, neq, gt, gte, lt, lte, in, is, like, ilike, startsWith, endsWith"`;
|
||||
|
||||
exports[`FilterArgProcessorService failing filter inputs validation SELECT should throw for invalid filter #1: {"selectField":{"invalidOperator":"OPTION_1"}} 1`] = `"Operator "invalidOperator" is not valid for field "selectField" of type SELECT - Allowed operators: eq, neq, in, containsAny, is, isEmptyArray"`;
|
||||
exports[`FilterArgProcessorService failing filter inputs validation SELECT should throw for invalid filter #1: {"selectField":{"invalidOperator":"OPTION_1"}} 1`] = `"Operator "invalidOperator" is not valid for field "selectField" of type SELECT - Allowed operators: eq, neq, gt, gte, lt, lte, in, containsAny, is, isEmptyArray"`;
|
||||
|
||||
exports[`FilterArgProcessorService failing filter inputs validation SELECT should throw for invalid filter #2: {"selectField":{}} 1`] = `"Filter for field "selectField" must have exactly one operator"`;
|
||||
|
||||
|
||||
+8
@@ -110,6 +110,10 @@ export const successfulFilterInputsByFieldMetadataType: {
|
||||
{ filter: { selectField: { in: ['OPTION_1', 'OPTION_2'] } } },
|
||||
{ filter: { selectField: { is: 'NULL' } } },
|
||||
{ filter: { selectField: { is: 'NOT_NULL' } } },
|
||||
{ filter: { selectField: { gt: 'OPTION_1' } } },
|
||||
{ filter: { selectField: { gte: 'OPTION_1' } } },
|
||||
{ filter: { selectField: { lt: 'OPTION_1' } } },
|
||||
{ filter: { selectField: { lte: 'OPTION_1' } } },
|
||||
],
|
||||
[FieldMetadataType.RATING]: [
|
||||
{ filter: { ratingField: { eq: 'RATING_1' } } },
|
||||
@@ -117,6 +121,10 @@ export const successfulFilterInputsByFieldMetadataType: {
|
||||
{ filter: { ratingField: { in: ['RATING_1', 'RATING_2'] } } },
|
||||
{ filter: { ratingField: { is: 'NULL' } } },
|
||||
{ filter: { ratingField: { is: 'NOT_NULL' } } },
|
||||
{ filter: { ratingField: { gt: 'RATING_1' } } },
|
||||
{ filter: { ratingField: { gte: 'RATING_1' } } },
|
||||
{ filter: { ratingField: { lt: 'RATING_1' } } },
|
||||
{ filter: { ratingField: { lte: 'RATING_1' } } },
|
||||
],
|
||||
[FieldMetadataType.MULTI_SELECT]: [
|
||||
{ filter: { multiSelectField: { containsAny: ['OPTION_1'] } } },
|
||||
|
||||
+4
@@ -65,6 +65,10 @@ export const MULTI_SELECT_FILTER_OPERATORS: FilterOperator[] = [
|
||||
export const ENUM_FILTER_OPERATORS: FilterOperator[] = [
|
||||
'eq',
|
||||
'neq',
|
||||
'gt',
|
||||
'gte',
|
||||
'lt',
|
||||
'lte',
|
||||
'in',
|
||||
'containsAny',
|
||||
'is',
|
||||
|
||||
+14
@@ -31,4 +31,18 @@ describe('getOperatorsForFieldType', () => {
|
||||
expect(result).toContain('containsIlike');
|
||||
expect(result).toContain('isEmptyArray');
|
||||
});
|
||||
|
||||
it.each([FieldMetadataType.SELECT, FieldMetadataType.RATING])(
|
||||
'should allow enum operators and ordering operators for %s',
|
||||
(fieldType) => {
|
||||
const result = getOperatorsForFieldType(fieldType);
|
||||
|
||||
expect(result).toContain('eq');
|
||||
expect(result).toContain('in');
|
||||
expect(result).toContain('gt');
|
||||
expect(result).toContain('gte');
|
||||
expect(result).toContain('lt');
|
||||
expect(result).toContain('lte');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
+15
@@ -46,4 +46,19 @@ describe('validateOperatorForFieldTypeOrThrow', () => {
|
||||
validateOperatorForFieldTypeOrThrow('like', fieldMetadata, 'testField'),
|
||||
).toThrow(CommonQueryRunnerException);
|
||||
});
|
||||
|
||||
it.each(['gt', 'gte', 'lt', 'lte'] as const)(
|
||||
'should not throw when ordering operator "%s" is used on a SELECT field',
|
||||
(operator) => {
|
||||
const fieldMetadata = createFieldMetadata(FieldMetadataType.SELECT);
|
||||
|
||||
expect(() =>
|
||||
validateOperatorForFieldTypeOrThrow(
|
||||
operator,
|
||||
fieldMetadata,
|
||||
'severity',
|
||||
),
|
||||
).not.toThrow();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user