From 2b399a57ece79773e40fff60f2e1f4478dc07f62 Mon Sep 17 00:00:00 2001 From: Laura Whitaker Date: Tue, 3 Dec 2024 09:48:53 -0700 Subject: [PATCH] Add additional conditional support to KeywordList filter (#2447) * Add additional condtionals as KeywordList filter search options * Fix null conditionals in toListWorkflowFilters and account for is and is null values --- .../list-filter.svelte | 96 ++++++++++++------- .../search-attribute-menu.svelte | 8 +- .../query/filter-workflow-query.test.ts | 24 ++++- .../utilities/query/filter-workflow-query.ts | 32 +++++-- .../query/search-attribute-filter.test.ts | 24 +++++ .../query/search-attribute-filter.ts | 11 +++ .../query/to-list-workflow-filters.test.ts | 44 +++++++-- .../query/to-list-workflow-filters.ts | 5 +- src/lib/utilities/query/tokenize.test.ts | 4 +- 9 files changed, 188 insertions(+), 60 deletions(-) diff --git a/src/lib/components/search-attribute-filter/list-filter.svelte b/src/lib/components/search-attribute-filter/list-filter.svelte index 794f4a196..55748999c 100644 --- a/src/lib/components/search-attribute-filter/list-filter.svelte +++ b/src/lib/components/search-attribute-filter/list-filter.svelte @@ -3,51 +3,79 @@ import Button from '$lib/holocene/button.svelte'; import ChipInput from '$lib/holocene/input/chip-input.svelte'; + import Input from '$lib/holocene/input/input.svelte'; import { translate } from '$lib/i18n/translate'; + import { isInConditional } from '$lib/utilities/is'; + import { formatListFilterValue } from '$lib/utilities/query/search-attribute-filter'; + import ConditionalMenu from './conditional-menu.svelte'; import { FILTER_CONTEXT, type FilterContext } from './index.svelte'; const { filter, handleSubmit } = getContext(FILTER_CONTEXT); - $: ({ value } = $filter); - $: list = - value.length > 0 - ? value - .slice(1, -1) - .split(', ') - .map((v) => v.slice(1, -1)) - : []; + $: ({ value, conditional } = $filter); + $: _value = value; + $: chips = formatListFilterValue(_value); + $: options = [ + { value: 'in', label: 'In' }, + { value: '=', label: translate('common.equal-to') }, + { value: '!=', label: translate('common.not-equal-to') }, + ]; function onSubmit() { - $filter.conditional = 'IN'; - list = list.map((item) => `"${item}"`); - $filter.value = `(${list.join(', ')})`; + $filter.value = `(${chips.map((item) => `"${item}"`).join(', ')})`; handleSubmit(); } + + function handleKeydown(e: KeyboardEvent) { + if (e.key === 'Enter' && _value !== '') { + e.preventDefault(); + $filter.value = _value; + handleSubmit(); + } + }
- - translate('workflows.remove-keyword-label', { keyword: chip })} - placeholder="{translate('common.type-or-paste-in')} {$filter.attribute}" - unroundLeft - unroundRight - external - /> -
- - -
+ + {#if isInConditional(conditional)} + + translate('workflows.remove-keyword-label', { keyword: chip })} + placeholder="{translate('common.enter')} {$filter.attribute}" + unroundLeft + unroundRight + external + /> +
+ + +
+ {:else} + + + {/if} +
diff --git a/src/lib/components/search-attribute-filter/search-attribute-menu.svelte b/src/lib/components/search-attribute-filter/search-attribute-menu.svelte index 53ffdc656..6c4ab4daf 100644 --- a/src/lib/components/search-attribute-filter/search-attribute-menu.svelte +++ b/src/lib/components/search-attribute-filter/search-attribute-menu.svelte @@ -12,7 +12,10 @@ import { translate } from '$lib/i18n/translate'; import type { SearchAttributeFilter } from '$lib/models/search-attribute-filters'; import type { SearchAttributeOption } from '$lib/stores/search-attributes'; - import type { SearchAttributeType } from '$lib/types/workflows'; + import { + SEARCH_ATTRIBUTE_TYPE, + type SearchAttributeType, + } from '$lib/types/workflows'; import { getFocusedElementId } from '$lib/utilities/query/search-attribute-filter'; import { emptyFilter } from '$lib/utilities/query/to-list-workflow-filters'; @@ -34,7 +37,8 @@ function handleNewQuery(value: string, type: SearchAttributeType) { searchAttributeValue = ''; - filter.set({ ...emptyFilter(), attribute: value, conditional: '=', type }); + const conditional = type === SEARCH_ATTRIBUTE_TYPE.KEYWORDLIST ? 'in' : '='; + filter.set({ ...emptyFilter(), attribute: value, conditional, type }); $focusedElementId = getFocusedElementId($filter); } diff --git a/src/lib/utilities/query/filter-workflow-query.test.ts b/src/lib/utilities/query/filter-workflow-query.test.ts index dc946cfbe..33833402b 100644 --- a/src/lib/utilities/query/filter-workflow-query.test.ts +++ b/src/lib/utilities/query/filter-workflow-query.test.ts @@ -207,13 +207,31 @@ describe('toListWorkflowQueryFromFilters', () => { { attribute: 'CustomKeywordListField', type: 'KeywordList', - conditional: 'IN', + conditional: 'in', operator: '', parenthesis: '', value: '("Hello", "World")', }, + { + attribute: 'CustomKeywordListField', + type: 'KeywordList', + conditional: 'is', + operator: '', + parenthesis: '', + value: null, + }, + { + attribute: 'CustomKeywordListField', + type: 'KeywordList', + conditional: '=', + operator: '', + parenthesis: '', + value: 'Hello', + }, ]; - const query = toListWorkflowQueryFromFilters(filters); - expect(query).toBe('`CustomKeywordListField`IN("Hello", "World")'); + const query = toListWorkflowQueryFromFilters(combineFilters(filters)); + expect(query).toBe( + '`CustomKeywordListField`in("Hello", "World") AND `CustomKeywordListField` is null AND `CustomKeywordListField`="Hello"', + ); }); }); diff --git a/src/lib/utilities/query/filter-workflow-query.ts b/src/lib/utilities/query/filter-workflow-query.ts index ea92234b3..a991d07ea 100644 --- a/src/lib/utilities/query/filter-workflow-query.ts +++ b/src/lib/utilities/query/filter-workflow-query.ts @@ -8,7 +8,7 @@ import { type SearchAttributeType, } from '$lib/types/workflows'; -import { isNullConditional, isStartsWith } from '../is'; +import { isInConditional, isNullConditional, isStartsWith } from '../is'; import { isDuration, isDurationString, toDate, tomorrow } from '../to-duration'; export type QueryKey = @@ -40,14 +40,22 @@ const isValid = (value: unknown, conditional: string): boolean => { return true; }; -const formatValue = ( - value: string, - type: SearchAttributeType, -): string | boolean => { +const formatValue = ({ + value, + type, + conditional, +}: { + value: string; + type: SearchAttributeType; + conditional: string; +}): string | boolean => { if (type === SEARCH_ATTRIBUTE_TYPE.BOOL) { return value.toLowerCase() === 'true' ? true : false; } - if (type === SEARCH_ATTRIBUTE_TYPE.KEYWORDLIST) { + if ( + type === SEARCH_ATTRIBUTE_TYPE.KEYWORDLIST && + isInConditional(conditional) + ) { return value; } return `"${value}"`; @@ -90,10 +98,18 @@ const toFilterQueryStatement = ( } if (isStartsWith(conditional)) { - return `\`${queryKey}\` ${conditional} ${formatValue(value, type)}`; + return `\`${queryKey}\` ${conditional} ${formatValue({ + value, + type, + conditional, + })}`; } - return `\`${queryKey}\`${conditional}${formatValue(value, type)}`; + return `\`${queryKey}\`${conditional}${formatValue({ + value, + type, + conditional, + })}`; }; const toQueryStatementsFromFilters = ( diff --git a/src/lib/utilities/query/search-attribute-filter.test.ts b/src/lib/utilities/query/search-attribute-filter.test.ts index 188e3a606..dcc0b5c4b 100644 --- a/src/lib/utilities/query/search-attribute-filter.test.ts +++ b/src/lib/utilities/query/search-attribute-filter.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'; import type { SearchAttributes } from '$lib/types/workflows'; import { + formatListFilterValue, isBooleanFilter, isDateTimeFilter, isDurationFilter, @@ -150,3 +151,26 @@ describe('isDateTimeFilter', () => { ).toBe(false); }); }); + +describe('formatListFilterValue', () => { + it('should return an empty array if there is no value', () => { + expect(formatListFilterValue('')).toStrictEqual([]); + }); + + it('should return an array of strings if the value starts with "(" and ends with ")"', () => { + expect(formatListFilterValue('("one")')).toStrictEqual(['one']); + expect(formatListFilterValue('("one", "two")')).toStrictEqual([ + 'one', + 'two', + ]); + expect(formatListFilterValue('("one","two","three")')).toStrictEqual([ + 'one', + 'two', + 'three', + ]); + }); + + it('should return an array with the value', () => { + expect(formatListFilterValue('example')).toStrictEqual(['example']); + }); +}); diff --git a/src/lib/utilities/query/search-attribute-filter.ts b/src/lib/utilities/query/search-attribute-filter.ts index a15e3716c..d899bad61 100644 --- a/src/lib/utilities/query/search-attribute-filter.ts +++ b/src/lib/utilities/query/search-attribute-filter.ts @@ -87,3 +87,14 @@ export function getFocusedElementId(filter: SearchAttributeFilter) { return ''; } + +export function formatListFilterValue(value: string): string[] { + if (value.startsWith('(') && value.endsWith(')')) { + return value + .slice(1, -1) + .split(',') + .map((v) => v.trim().slice(1, -1)); + } + if (value) return [value]; + return []; +} diff --git a/src/lib/utilities/query/to-list-workflow-filters.test.ts b/src/lib/utilities/query/to-list-workflow-filters.test.ts index cbf3e9f9b..89fc702f5 100644 --- a/src/lib/utilities/query/to-list-workflow-filters.test.ts +++ b/src/lib/utilities/query/to-list-workflow-filters.test.ts @@ -33,7 +33,7 @@ const workflowQueryWithSpaces = const prefixQuery = '`WorkflowType` STARTS_WITH "hello"'; const isEmptyQuery = '`WorkflowType` is null'; const isNotEmptyQuery = '`StartTime` IS NOT NULL'; -const keywordListQuery = '`CustomKeywordListField`IN("Hello", "World")'; +const keywordListQuery = '`CustomKeywordListField`in("Hello", "World")'; const attributes = { CloseTime: 'Datetime', @@ -276,7 +276,7 @@ describe('toListWorkflowFilters', () => { { attribute: 'CustomKeywordListField', type: 'KeywordList', - conditional: 'IN', + conditional: 'in', operator: '', parenthesis: '', value: '("Hello", "World")', @@ -285,7 +285,7 @@ describe('toListWorkflowFilters', () => { expect(result).toEqual(expectedFilters); }); - it('should parse a query with a KeywordList type and other types ', () => { + it('should parse a query with a KeywordList type and other types', () => { const result = toListWorkflowFilters( keywordListQuery + ' AND ' + workflowQuery4 + ' AND ' + keywordListQuery, attributes, @@ -294,7 +294,7 @@ describe('toListWorkflowFilters', () => { { attribute: 'CustomKeywordListField', type: 'KeywordList', - conditional: 'IN', + conditional: 'in', operator: 'AND', parenthesis: '', value: '("Hello", "World")', @@ -342,7 +342,7 @@ describe('toListWorkflowFilters', () => { { attribute: 'CustomKeywordListField', type: 'KeywordList', - conditional: 'IN', + conditional: 'in', operator: '', parenthesis: '', value: '("Hello", "World")', @@ -1176,7 +1176,7 @@ describe('combineFilters', () => { conditional: 'is', operator: '', parenthesis: '', - value: 'null', + value: null, }, ]; expect(result).toEqual(expectedFilters); @@ -1191,7 +1191,7 @@ describe('combineFilters', () => { conditional: 'IS NOT', operator: '', parenthesis: '', - value: 'NULL', + value: null, }, ]; expect(result).toEqual(expectedFilters); @@ -1209,7 +1209,7 @@ describe('combineFilters', () => { conditional: 'is', operator: 'AND', parenthesis: '', - value: 'null', + value: null, }, { attribute: 'StartTime', @@ -1217,7 +1217,33 @@ describe('combineFilters', () => { conditional: 'IS NOT', operator: '', parenthesis: '', - value: 'NULL', + value: null, + }, + ]; + expect(result).toEqual(expectedFilters); + }); + + it('should parse a query with "is" and "is not" as a value', () => { + const result = toListWorkflowFilters( + '`WorkflowId`="is" AND `WorkflowType`="is not"', + attributes, + ); + const expectedFilters = [ + { + attribute: 'WorkflowId', + type: 'Keyword', + conditional: '=', + operator: 'AND', + parenthesis: '', + value: 'is', + }, + { + attribute: 'WorkflowType', + type: 'Keyword', + conditional: '=', + operator: '', + parenthesis: '', + value: 'is not', }, ]; expect(result).toEqual(expectedFilters); diff --git a/src/lib/utilities/query/to-list-workflow-filters.ts b/src/lib/utilities/query/to-list-workflow-filters.ts index d5f7e6877..a2ac933e3 100644 --- a/src/lib/utilities/query/to-list-workflow-filters.ts +++ b/src/lib/utilities/query/to-list-workflow-filters.ts @@ -95,9 +95,10 @@ export const toListWorkflowFilters = ( if (isNullConditional(nextToken)) { const combinedTokens = `${nextToken} ${tokenTwoAhead}`; - filter.value = isNullConditional(combinedTokens) + const value = isNullConditional(combinedTokens) ? getThreeAhead(tokens, index) : tokenTwoAhead; + filter.value = value.toLocaleLowerCase() === 'null' ? null : value; } else if (isDatetimeStatement(attributes[token])) { const start = tokenTwoAhead; const hasValidStartTime = isValidDate(start); @@ -125,7 +126,7 @@ export const toListWorkflowFilters = ( } } - if (isConditional(token)) { + if (!filter.conditional && isConditional(token)) { const combinedTokens = `${token} ${nextToken}`; if (isNullConditional(combinedTokens)) { filter.conditional = combinedTokens; diff --git a/src/lib/utilities/query/tokenize.test.ts b/src/lib/utilities/query/tokenize.test.ts index dbd99f428..d7e54b50e 100644 --- a/src/lib/utilities/query/tokenize.test.ts +++ b/src/lib/utilities/query/tokenize.test.ts @@ -11,7 +11,7 @@ const combinedQuery = '`WorkflowId`="Hello" and `WorkflowType`="World" and `StartTime` BETWEEN "2022-04-18T18:09:49-06:00" AND "2022-04-20T18:09:49-06:00"'; const valuesWithSpacesQuery = '`Custom Key Word`="Hello there world" AND `WorkflowId`="one and two = three" OR `WorkflowType`="example=\'one\'"'; -const keywordListQuery = '`CustomKeywordListField`IN("Hello", "World")'; +const keywordListQuery = '`CustomKeywordListField`in("Hello", "World")'; describe('tokenize', () => { it('should eliminate spaces', () => { @@ -94,7 +94,7 @@ describe('tokenize', () => { expect(tokenize(query)).toEqual([ 'CustomKeywordListField', - 'IN', + 'in', '("Hello", "World")', ]); });