diff --git a/src/lib/components/workflow/filter-search/boolean-filter.svelte b/src/lib/components/workflow/filter-search/boolean-filter.svelte index ee426d772..8adeb3b4c 100644 --- a/src/lib/components/workflow/filter-search/boolean-filter.svelte +++ b/src/lib/components/workflow/filter-search/boolean-filter.svelte @@ -8,6 +8,7 @@ MenuItem, } from '$lib/holocene/menu'; import { translate } from '$lib/i18n/translate'; + import { isNullConditional } from '$lib/utilities/is'; import { FILTER_CONTEXT, type FilterContext } from './index.svelte'; @@ -15,10 +16,14 @@ const options = [ { value: 'true', label: translate('common.true') }, { value: 'false', label: translate('common.false') }, + { value: 'is', label: translate('common.is-null') }, + { value: 'is not', label: translate('common.is-not-null') }, ]; $: selectedOption = - options.find((o) => o.value === $filter.value) ?? options[0]; + options.find( + (o) => o.value === $filter.value || o.value === $filter.conditional, + ) ?? options[0]; $: selectedLabel = selectedOption?.label; @@ -34,9 +39,16 @@ {#each options as { value, label }} { - $filter.value = value; + if (isNullConditional(value)) { + $filter.conditional = value; + $filter.value = null; + } else { + $filter.conditional = '='; + $filter.value = value; + } handleSubmit(); }} + class="text-nowrap" > {label} diff --git a/src/lib/components/workflow/filter-search/conditional-menu.svelte b/src/lib/components/workflow/filter-search/conditional-menu.svelte index e9db4e7af..e77ec62d1 100644 --- a/src/lib/components/workflow/filter-search/conditional-menu.svelte +++ b/src/lib/components/workflow/filter-search/conditional-menu.svelte @@ -7,10 +7,12 @@ MenuContainer, MenuItem, } from '$lib/holocene/menu'; + import { translate } from '$lib/i18n/translate'; + import { isNullConditional } from '$lib/utilities/is'; import { FILTER_CONTEXT, type FilterContext } from './index.svelte'; - const { filter, focusedElementId } = + const { filter, focusedElementId, handleSubmit } = getContext(FILTER_CONTEXT); const defaultConditionOptions = [ { value: '>' }, @@ -27,17 +29,28 @@ export let noBorderLeft = false; export let noBorderRight = false; - $: filterConditionalOption = options.find( + let conditionalOptions = [ + ...options, + { value: 'is', label: translate('common.is-null') }, + { value: 'is not', label: translate('common.is-not-null') }, + ]; + + $: filterConditionalOption = conditionalOptions.find( (o) => o.value === $filter.conditional, ); - $: options, updateFilterConditional(); - $: selectedOption = filterConditionalOption ?? options[0]; + $: filterConditionalOption, updateFilterConditional(); + $: isNullFilter = isNullConditional($filter.conditional); + $: selectedOption = filterConditionalOption ?? conditionalOptions[0]; $: selectedLabel = selectedOption?.label ?? selectedOption?.value; + function handleNullFilter() { + $filter.value = null; + handleSubmit(); + } + function updateFilterConditional() { - if (!filterConditionalOption) { - $filter.conditional = options[0].value; - } + if (!filterConditionalOption) + $filter.conditional = conditionalOptions[0].value; } @@ -45,7 +58,9 @@ - {#each options as { value, label }} + {#each conditionalOptions as { value, label }} { $filter.conditional = value; $focusedElementId = inputId; + if (isNullConditional(value)) handleNullFilter(); }} > {label ?? value} @@ -65,3 +81,6 @@ {/each} +{#if !isNullFilter} + +{/if} diff --git a/src/lib/components/workflow/filter-search/datetime-filter.svelte b/src/lib/components/workflow/filter-search/datetime-filter.svelte index a9d175c9f..dd383bfeb 100644 --- a/src/lib/components/workflow/filter-search/datetime-filter.svelte +++ b/src/lib/components/workflow/filter-search/datetime-filter.svelte @@ -125,17 +125,16 @@ }; -
- =', label: translate('common.after') }, - ]} - noBorderLeft - noBorderRight - /> +=', label: translate('common.after') }, + ]} + noBorderLeft + noBorderRight +> -
+ diff --git a/src/lib/components/workflow/filter-search/duration-filter.svelte b/src/lib/components/workflow/filter-search/duration-filter.svelte index 9245ba40f..2d05a808e 100644 --- a/src/lib/components/workflow/filter-search/duration-filter.svelte +++ b/src/lib/components/workflow/filter-search/duration-filter.svelte @@ -33,20 +33,21 @@ }; - - + + + diff --git a/src/lib/components/workflow/filter-search/filter-list.svelte b/src/lib/components/workflow/filter-search/filter-list.svelte index 352fc47f4..4daf0581e 100644 --- a/src/lib/components/workflow/filter-search/filter-list.svelte +++ b/src/lib/components/workflow/filter-search/filter-list.svelte @@ -17,7 +17,7 @@ type TimeFormat, } from '$lib/stores/time-format'; import { formatDate } from '$lib/utilities/format-date'; - import { isStartsWith } from '$lib/utilities/is'; + import { isNullConditional, isStartsWith } from '$lib/utilities/is'; import { isDateTimeFilter, isTextFilter, @@ -114,7 +114,10 @@ {:else} {attribute} - {#if isDateTimeFilter({ attribute, type })} + {#if isNullConditional(conditional)} + {conditional} + {value} + {:else if isDateTimeFilter({ attribute, type })} {#if customDate} {formatDateTimeRange(value, $timeFormat, $relativeTime)} {:else} diff --git a/src/lib/components/workflow/filter-search/index.svelte b/src/lib/components/workflow/filter-search/index.svelte index 6228f9b4d..b30f82b68 100644 --- a/src/lib/components/workflow/filter-search/index.svelte +++ b/src/lib/components/workflow/filter-search/index.svelte @@ -294,10 +294,6 @@ @apply rounded-r-none; } - :global(#conditional-menu-button) { - @apply rounded-none; - } - :global(#time-range-filter), :global(#boolean-filter) { @apply rounded-l-none; diff --git a/src/lib/components/workflow/filter-search/number-filter.svelte b/src/lib/components/workflow/filter-search/number-filter.svelte index daf405926..8c29ec89c 100644 --- a/src/lib/components/workflow/filter-search/number-filter.svelte +++ b/src/lib/components/workflow/filter-search/number-filter.svelte @@ -24,8 +24,7 @@ }; -
- + -
+
diff --git a/src/lib/components/workflow/filter-search/search-attribute-menu.svelte b/src/lib/components/workflow/filter-search/search-attribute-menu.svelte index 471141412..9ff1539ef 100644 --- a/src/lib/components/workflow/filter-search/search-attribute-menu.svelte +++ b/src/lib/components/workflow/filter-search/search-attribute-menu.svelte @@ -28,7 +28,7 @@ function isOptionDisabled(value: string, filters: WorkflowFilter[]) { return filters.some( (filter) => - (filter.conditional === '=' || filter.conditional === '!=') && + ['=', '!=', 'is', 'is not'].includes(filter.conditional) && filter.attribute === value, ); } diff --git a/src/lib/components/workflow/filter-search/text-filter.svelte b/src/lib/components/workflow/filter-search/text-filter.svelte index 4a9f62291..bdf535f23 100644 --- a/src/lib/components/workflow/filter-search/text-filter.svelte +++ b/src/lib/components/workflow/filter-search/text-filter.svelte @@ -30,16 +30,17 @@ ]; - - + + + diff --git a/src/lib/i18n/locales/en/common.ts b/src/lib/i18n/locales/en/common.ts index d97b32876..0ffadbf8e 100644 --- a/src/lib/i18n/locales/en/common.ts +++ b/src/lib/i18n/locales/en/common.ts @@ -38,8 +38,8 @@ export const Strings = { 'greater-than-or-equal-to': 'Greater than or equal to', 'less-than': 'Less than', 'less-than-or-equal-to': 'Less than or equal to', - is: 'Is', - 'is-not': 'Is Not', + 'is-null': 'Is null', + 'is-not-null': 'Is not null', all: 'All', submit: 'Submit', reason: 'Reason', diff --git a/src/lib/utilities/is.test.ts b/src/lib/utilities/is.test.ts index 333043453..f3210da06 100644 --- a/src/lib/utilities/is.test.ts +++ b/src/lib/utilities/is.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { isExecutionStatus, isNull, + isNullConditional, isNumber, isObject, isOperator, @@ -337,3 +338,19 @@ describe('isQuote', () => { expect(isQuote(undefined)).toBe(false); }); }); + +describe('isNullConditional', () => { + it('should return true for is', () => { + expect(isNullConditional('IS')).toBe(true); + expect(isNullConditional('is')).toBe(true); + }); + + it('should return true for is not', () => { + expect(isNullConditional('IS NOT')).toBe(true); + expect(isNullConditional('is not')).toBe(true); + }); + + it('should return false for null', () => { + expect(isNullConditional(null)).toBe(false); + }); +}); diff --git a/src/lib/utilities/is.ts b/src/lib/utilities/is.ts index 004e13e37..97c2410c9 100644 --- a/src/lib/utilities/is.ts +++ b/src/lib/utilities/is.ts @@ -56,6 +56,8 @@ const conditionals = [ '<', '!', 'starts_with', + 'is', + 'is not', ] as const; const joins = ['and', 'or'] as const; @@ -153,6 +155,13 @@ export const isBetween = (x: unknown) => { return x === 'between'; }; +export const isNullConditional = (x: unknown) => { + if (!isString(x)) return false; + x = x.toLocaleLowerCase(); + + return x === 'is' || x === 'is not'; +}; + export const isSortOrder = ( sortOrder: string | EventSortOrder, ): sortOrder is EventSortOrder => { diff --git a/src/lib/utilities/query/filter-workflow-query.ts b/src/lib/utilities/query/filter-workflow-query.ts index 2af3b6c74..d9bb619f1 100644 --- a/src/lib/utilities/query/filter-workflow-query.ts +++ b/src/lib/utilities/query/filter-workflow-query.ts @@ -7,7 +7,7 @@ import type { SearchAttributesValue, } from '$lib/types/workflows'; -import { isStartsWith } from '../is'; +import { isNullConditional, isStartsWith } from '../is'; import { isDuration, isDurationString, toDate, tomorrow } from '../to-duration'; export type QueryKey = @@ -30,8 +30,8 @@ const filterKeys: Readonly> = { runId: 'RunId', } as const; -const isValid = (value: unknown): boolean => { - if (value === null) return false; +const isValid = (value: unknown, conditional: string): boolean => { + if (value === null && !isNullConditional(conditional)) return false; if (value === undefined) return false; if (value === '') return false; if (typeof value === 'string' && value === 'undefined') return false; @@ -74,6 +74,10 @@ const toFilterQueryStatement = ( return `${queryKey} ${value}`; } + if (isNullConditional(conditional)) { + return `\`${queryKey}\` ${conditional} ${value}`; + } + if (isDuration(value) || isDurationString(value)) { if (archived || get(supportsAdvancedVisibility)) { return `${queryKey} ${conditional} "${toDate(value)}"`; @@ -103,7 +107,7 @@ const toQueryStatementsFromFilters = ( parenthesis, customDate, }) => { - if (isValid(value)) { + if (isValid(value, conditional)) { let statement = toFilterQueryStatement( attribute, type, 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 65cfbdf66..cf25c183c 100644 --- a/src/lib/utilities/query/to-list-workflow-filters.test.ts +++ b/src/lib/utilities/query/to-list-workflow-filters.test.ts @@ -31,6 +31,8 @@ const customAttributesWithSpacesQuery = const workflowQueryWithSpaces = '`WorkflowId`="One and Two" AND `Custom Keyword Field`="Hello = world"'; const prefixQuery = '`WorkflowType` STARTS_WITH "hello"'; +const isEmptyQuery = '`WorkflowType` is null'; +const isNotEmptyQuery = '`StartTime` IS NOT NULL'; const attributes = { CloseTime: 'Datetime', @@ -1081,4 +1083,60 @@ describe('combineFilters', () => { }, ]); }); + + it('should parse a query with IS NULL', () => { + const result = toListWorkflowFilters(isEmptyQuery, attributes); + const expectedFilters = [ + { + attribute: 'WorkflowType', + type: 'Keyword', + conditional: 'is', + operator: '', + parenthesis: '', + value: 'null', + }, + ]; + expect(result).toEqual(expectedFilters); + }); + + it('should parse a query with IS NOT NULL', () => { + const result = toListWorkflowFilters(isNotEmptyQuery, attributes); + const expectedFilters = [ + { + attribute: 'StartTime', + type: 'Datetime', + conditional: 'IS NOT', + operator: '', + parenthesis: '', + value: 'NULL', + }, + ]; + expect(result).toEqual(expectedFilters); + }); + + it('should parse a query with IS NULL and IS NOT NULL', () => { + const result = toListWorkflowFilters( + `${isEmptyQuery} AND ${isNotEmptyQuery}`, + attributes, + ); + const expectedFilters = [ + { + attribute: 'WorkflowType', + type: 'Keyword', + conditional: 'is', + operator: 'AND', + parenthesis: '', + value: 'null', + }, + { + attribute: 'StartTime', + type: 'Datetime', + conditional: 'IS NOT', + operator: '', + parenthesis: '', + value: 'NULL', + }, + ]; + 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 8c22fa412..f8909daad 100644 --- a/src/lib/utilities/query/to-list-workflow-filters.ts +++ b/src/lib/utilities/query/to-list-workflow-filters.ts @@ -7,7 +7,13 @@ import { toListWorkflowQueryFromFilters } from '$lib/utilities/query/filter-work import { tokenize } from './tokenize'; import { isValidDate } from '../format-date'; -import { isBetween, isConditional, isJoin, isParenthesis } from '../is'; +import { + isBetween, + isConditional, + isJoin, + isNullConditional, + isParenthesis, +} from '../is'; import { durationKeys } from '../to-duration'; import { updateQueryParameters } from '../update-query-parameters'; type Tokens = string[]; @@ -20,9 +26,15 @@ const is = return false; }; +const getOneAhead = (tokens: Tokens, index: number): string => + tokens[index + 1]; + const getTwoAhead = (tokens: Tokens, index: number): string => tokens[index + 2]; +const getThreeAhead = (tokens: Tokens, index: number): string => + tokens[index + 3]; + const getTwoBehind = (tokens: Tokens, index: number): string => tokens[index - 2]; @@ -70,14 +82,23 @@ export const toListWorkflowFilters = ( try { tokens.forEach((token, index) => { + const nextToken = getOneAhead(tokens, index); + const tokenTwoAhead = getTwoAhead(tokens, index); + if (attributes[token]) { filter.attribute = token; filter.type = attributes[token]; - if (isDatetimeStatement(attributes[token])) { - const start = getTwoAhead(tokens, index); + + if (isNullConditional(nextToken)) { + const combinedTokens = `${nextToken} ${tokenTwoAhead}`; + filter.value = isNullConditional(combinedTokens) + ? getThreeAhead(tokens, index) + : tokenTwoAhead; + } else if (isDatetimeStatement(attributes[token])) { + const start = tokenTwoAhead; const hasValidStartTime = isValidDate(start); - if (isBetween(tokens[index + 1])) { + if (isBetween(nextToken)) { const end = tokens[index + 4]; const hasValidEndTime = isValidDate(end); @@ -93,15 +114,20 @@ export const toListWorkflowFilters = ( console.error('Error parsing Datetime field from query'); } } else if (isBoolStatement(filter.type)) { - filter.value = tokens[index + 1].replace('=', ''); + filter.value = nextToken.replace('=', ''); filter.conditional = '='; } else { - filter.value = getTwoAhead(tokens, index); + filter.value = tokenTwoAhead; } } if (isConditional(token)) { - filter.conditional = token; + const combinedTokens = `${token} ${nextToken}`; + if (isNullConditional(combinedTokens)) { + filter.conditional = combinedTokens; + } else { + filter.conditional = token; + } } if (isParenthesis(token)) {