@orchestrator-ui/orchestrator-ui-components 8.9.3 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.turbo/turbo-build.log +8 -8
  2. package/.turbo/turbo-lint.log +1 -1
  3. package/.turbo/turbo-test.log +17 -50
  4. package/CHANGELOG.md +19 -0
  5. package/dist/index.d.ts +2081 -2517
  6. package/dist/index.js +1247 -1579
  7. package/dist/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/components/WfoError/WfoError.tsx +2 -2
  10. package/src/components/WfoInlineNoteEdit/WfoSubscriptionDetailNoteEdit.tsx +4 -8
  11. package/src/components/WfoInlineNoteEdit/WfoSubscriptionNoteEdit.tsx +6 -7
  12. package/src/components/WfoPageTemplate/paths.ts +0 -1
  13. package/src/components/WfoSubscriptionsList/index.ts +1 -2
  14. package/src/components/WfoSubscriptionsList/subscriptionListItem.ts +14 -0
  15. package/src/components/WfoSubscriptionsList/subscriptionListTabs.ts +1 -20
  16. package/src/components/WfoSummary/WfoLatestActiveSubscriptionsSummaryCard.tsx +17 -13
  17. package/src/components/WfoSummary/WfoLatestOutOfSyncSubscriptionSummaryCard.tsx +28 -15
  18. package/src/components/WfoTable/WfoStructuredSearchTable/WfoFieldSelector.spec.tsx +0 -16
  19. package/src/components/WfoTable/WfoStructuredSearchTable/WfoFieldSelector.tsx +4 -10
  20. package/src/components/WfoTable/WfoStructuredSearchTable/WfoFilterBuilder.tsx +9 -6
  21. package/src/components/WfoTable/WfoStructuredSearchTable/WfoRestoreLoop.spec.tsx +2 -2
  22. package/src/components/WfoTable/WfoStructuredSearchTable/WfoStructuredSearchTable.tsx +3 -5
  23. package/src/components/WfoTable/WfoStructuredSearchTable/useSearchWithDebouncedCallback.spec.tsx +56 -3
  24. package/src/components/WfoTable/WfoStructuredSearchTable/utils.spec.ts +60 -1
  25. package/src/components/WfoTable/WfoStructuredSearchTable/utils.ts +27 -3
  26. package/src/configuration/version.ts +1 -1
  27. package/src/messages/en-GB.json +1 -1
  28. package/src/messages/nl-NL.json +1 -1
  29. package/src/pages/index.ts +0 -1
  30. package/src/pages/startPage/index.ts +1 -0
  31. package/src/pages/startPage/mappers.ts +15 -8
  32. package/src/pages/startPage/queryVariables.ts +0 -29
  33. package/src/pages/startPage/searchPayloads.ts +22 -0
  34. package/src/pages/subscriptions/WfoSubscriptionsListPage.tsx +511 -39
  35. package/src/rtk/endpoints/index.ts +0 -1
  36. package/src/rtk/endpoints/search.ts +3 -0
  37. package/src/rtk/endpoints/subscriptionListMutation.spec.ts +80 -0
  38. package/src/rtk/endpoints/subscriptionListMutation.ts +71 -52
  39. package/src/rtk/utils.spec.ts +30 -1
  40. package/src/rtk/utils.ts +2 -1
  41. package/src/types/search.ts +0 -1
  42. package/src/types/types.ts +0 -2
  43. package/src/utils/getDefaultTableConfig.ts +1 -6
  44. package/src/utils/getQueryParams.ts +1 -0
  45. package/src/components/WfoSubscriptionsList/WfoSubscriptionsList.tsx +0 -243
  46. package/src/components/WfoSubscriptionsList/subscriptionResultMappers.ts +0 -49
  47. package/src/pages/WfoSearchPocPage.tsx +0 -575
  48. package/src/rtk/endpoints/subscriptionListSummary.ts +0 -66
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orchestrator-ui/orchestrator-ui-components",
3
- "version": "8.9.3",
3
+ "version": "9.0.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Library of UI Components used to display the workflow orchestrator frontend",
6
6
  "author": {
@@ -17,7 +17,7 @@ export const WfoError = () => {
17
17
  const { theme } = useOrchestratorTheme();
18
18
  return (
19
19
  <EuiFlexGroup direction="row" alignItems="center" gutterSize="s">
20
- <WfoXCircleFill color={theme.colors.danger} />
20
+ <WfoXCircleFill color={theme.colors.danger} width={32} height={32} />
21
21
  <h1>{t('errorMessage')}</h1>
22
22
  </EuiFlexGroup>
23
23
  );
@@ -30,7 +30,7 @@ export const WfoErrorWithMessage = ({ error }: WfoErrorWithMessageProps) => {
30
30
  return (
31
31
  <>
32
32
  <EuiFlexGroup direction="row" alignItems="center" gutterSize="s">
33
- <WfoXCircleFill color={theme.colors.danger} />
33
+ <WfoXCircleFill color={theme.colors.danger} width={32} height={32} />
34
34
  <EuiText color={theme.colors.textDanger}>
35
35
  {t('errorMessage')} ({message})
36
36
  </EuiText>
@@ -3,7 +3,7 @@ import React from 'react';
3
3
 
4
4
  import { useGetSubscriptionDetailQuery } from '@/rtk';
5
5
  import { useStartProcessMutation } from '@/rtk/endpoints/forms';
6
- import { useUpdateSubscriptionDetailNoteOptimisticMutation } from '@/rtk/endpoints/subscriptionListMutation';
6
+ import { useUpdateSubscriptionNoteOptimisticMutation } from '@/rtk/endpoints/subscriptionListMutation';
7
7
  import { SubscriptionDetail } from '@/types';
8
8
  import { INVISIBLE_CHARACTER } from '@/utils';
9
9
 
@@ -18,13 +18,13 @@ export const WfoSubscriptionDetailNoteEdit: FC<WfoSubscriptionDetailNoteEditProp
18
18
  subscriptionId,
19
19
  onlyShowOnHover = false,
20
20
  }) => {
21
- const { data, endpointName } = useGetSubscriptionDetailQuery({
21
+ const { data } = useGetSubscriptionDetailQuery({
22
22
  subscriptionId,
23
23
  });
24
24
 
25
25
  const selectedItem = data?.subscription ?? { note: '' };
26
26
  const [startProcess] = useStartProcessMutation();
27
- const [updateSub] = useUpdateSubscriptionDetailNoteOptimisticMutation();
27
+ const [updateSubscriptionNoteOptimistic] = useUpdateSubscriptionNoteOptimisticMutation();
28
28
 
29
29
  const triggerNoteModifyWorkflow = (note: string) => {
30
30
  const noteModifyPayload = [{ subscription_id: subscriptionId }, { note: note }];
@@ -33,11 +33,7 @@ export const WfoSubscriptionDetailNoteEdit: FC<WfoSubscriptionDetailNoteEditProp
33
33
  userInputs: noteModifyPayload,
34
34
  });
35
35
 
36
- updateSub({
37
- queryName: endpointName ?? '',
38
- subscriptionId: subscriptionId,
39
- note: note,
40
- });
36
+ updateSubscriptionNoteOptimistic({ subscriptionId, note });
41
37
  };
42
38
 
43
39
  return (
@@ -8,7 +8,7 @@ import { INVISIBLE_CHARACTER } from '@/utils';
8
8
 
9
9
  interface WfoSubscriptionNoteEditProps {
10
10
  onlyShowOnHover?: boolean;
11
- queryVariables: Record<string, unknown>;
11
+ queryVariables: object;
12
12
  endpointName: string | undefined;
13
13
  subscriptionId: string;
14
14
  note: string | null;
@@ -22,7 +22,7 @@ export const WfoSubscriptionNoteEdit: FC<WfoSubscriptionNoteEditProps> = ({
22
22
  note,
23
23
  }) => {
24
24
  const [startProcess] = useStartProcessMutation();
25
- const [updateSub] = useUpdateSubscriptionNoteOptimisticMutation();
25
+ const [updateSubscriptionNoteOptimistic] = useUpdateSubscriptionNoteOptimisticMutation();
26
26
 
27
27
  const triggerNoteModifyWorkflow = (note: string) => {
28
28
  const noteModifyPayload = [{ subscription_id: subscriptionId }, { note: note === INVISIBLE_CHARACTER ? '' : note }];
@@ -31,11 +31,10 @@ export const WfoSubscriptionNoteEdit: FC<WfoSubscriptionNoteEditProps> = ({
31
31
  userInputs: noteModifyPayload,
32
32
  });
33
33
 
34
- updateSub({
35
- queryName: endpointName ?? '',
36
- subscriptionId: subscriptionId,
37
- graphQlQueryVariables: queryVariables,
38
- note: note,
34
+ updateSubscriptionNoteOptimistic({
35
+ subscriptionId,
36
+ note,
37
+ listQuery: { queryName: endpointName ?? '', queryVariables },
39
38
  });
40
39
  };
41
40
 
@@ -3,7 +3,6 @@ export const PATH_START_NEW_WORKFLOW = '/workflows/new';
3
3
  export const PATH_START_NEW_TASK = '/tasks/new';
4
4
  export const PATH_WORKFLOWS = '/workflows';
5
5
  export const PATH_SUBSCRIPTIONS = '/subscriptions';
6
- export const PATH_SUBSCRIPTIONS_BETA = '/beta-subscriptions';
7
6
  export const PATH_METADATA = '/metadata';
8
7
  export const PATH_METADATA_PRODUCTS = '/metadata/products';
9
8
  export const PATH_METADATA_PRODUCT_BLOCKS = '/metadata/productblocks';
@@ -1,3 +1,2 @@
1
- export * from './subscriptionResultMappers';
1
+ export * from './subscriptionListItem';
2
2
  export * from './subscriptionListTabs';
3
- export * from './WfoSubscriptionsList';
@@ -0,0 +1,14 @@
1
+ import { Subscription } from '@/types';
2
+
3
+ export type SubscriptionListItem = Pick<
4
+ Subscription,
5
+ 'subscriptionId' | 'description' | 'status' | 'insync' | 'note'
6
+ > & {
7
+ startDate: Date | null;
8
+ endDate: Date | null;
9
+ productName: string;
10
+ tag: string | null;
11
+ customerFullname: string;
12
+ customerShortcode: string;
13
+ metadata: object | null;
14
+ };
@@ -1,5 +1,4 @@
1
1
  import { WfoFilterTab } from '../../components';
2
- import { SubscriptionListItem } from './subscriptionResultMappers';
3
2
 
4
3
  export enum WfoSubscriptionListTab {
5
4
  ACTIVE = 'ACTIVE',
@@ -8,36 +7,18 @@ export enum WfoSubscriptionListTab {
8
7
  ALL = 'ALL',
9
8
  }
10
9
 
11
- export const subscriptionListTabs: WfoFilterTab<WfoSubscriptionListTab, SubscriptionListItem>[] = [
10
+ export const subscriptionListTabs: WfoFilterTab<WfoSubscriptionListTab>[] = [
12
11
  {
13
12
  id: WfoSubscriptionListTab.ACTIVE,
14
13
  translationKey: 'active',
15
- alwaysOnFilters: [
16
- {
17
- field: 'status',
18
- value: 'active',
19
- },
20
- ],
21
14
  },
22
15
  {
23
16
  id: WfoSubscriptionListTab.TERMINATED,
24
17
  translationKey: 'terminated',
25
- alwaysOnFilters: [
26
- {
27
- field: 'status',
28
- value: 'terminated',
29
- },
30
- ],
31
18
  },
32
19
  {
33
20
  id: WfoSubscriptionListTab.TRANSIENT,
34
21
  translationKey: 'transient',
35
- alwaysOnFilters: [
36
- {
37
- field: 'status',
38
- value: 'initial-provisioning-migrating',
39
- },
40
- ],
41
22
  },
42
23
  {
43
24
  id: WfoSubscriptionListTab.ALL,
@@ -3,30 +3,34 @@ import React from 'react';
3
3
  import { useTranslations } from 'next-intl';
4
4
 
5
5
  import { PATH_SUBSCRIPTIONS, SummaryCardStatus, WfoSummaryCard } from '@/components';
6
- import { mapSubscriptionSummaryToSummaryCardListItem } from '@/pages/startPage/mappers';
7
- import { subscriptionsListSummaryQueryVariables } from '@/pages/startPage/queryVariables';
8
- import { useGetSubscriptionSummaryListQuery } from '@/rtk';
6
+ import { mapSubscriptionSearchResultToSummaryCardListItem } from '@/pages/startPage/mappers';
7
+ import { getSubscriptionSummarySearchPayload } from '@/pages/startPage/searchPayloads';
8
+ import { useSearchQuery } from '@/rtk';
9
+ import { Filter } from '@/types';
9
10
  import { optionalArrayMapper } from '@/utils';
10
11
 
12
+ // Elasticsearch equivalent of the list page's default Active tab filter
13
+ // (`subscription.status == "active"`), so the count on the card matches the list.
14
+ const activeSubscriptionsFilter = {
15
+ bool: {
16
+ must: [{ term: { 'subscription.status': 'active' } }],
17
+ },
18
+ } as unknown as Filter;
19
+
20
+ const activeSubscriptionsSearchPayload = getSubscriptionSummarySearchPayload(activeSubscriptionsFilter);
21
+
11
22
  export const WfoLatestActiveSubscriptionsSummaryCard = () => {
12
23
  const t = useTranslations('startPage.activeSubscriptions');
13
24
 
14
- const {
15
- data: subscriptionsSummaryResult,
16
- isFetching,
17
- isLoading,
18
- } = useGetSubscriptionSummaryListQuery(subscriptionsListSummaryQueryVariables);
25
+ const { data, isFetching, isLoading } = useSearchQuery(activeSubscriptionsSearchPayload);
19
26
 
20
27
  return (
21
28
  <WfoSummaryCard
22
29
  headerTitle={t('headerTitle')}
23
- headerValue={subscriptionsSummaryResult?.pageInfo.totalItems ?? '-'}
30
+ headerValue={data?.cursor?.total_items ?? '-'}
24
31
  headerStatus={SummaryCardStatus.Neutral}
25
32
  listTitle={t('listTitle')}
26
- listItems={optionalArrayMapper(
27
- subscriptionsSummaryResult?.subscriptions,
28
- mapSubscriptionSummaryToSummaryCardListItem,
29
- )}
33
+ listItems={optionalArrayMapper(data?.data, mapSubscriptionSearchResultToSummaryCardListItem)}
30
34
  button={{ name: t('buttonText'), url: PATH_SUBSCRIPTIONS }}
31
35
  isLoading={isLoading}
32
36
  isFetching={isFetching}
@@ -3,37 +3,50 @@ import React from 'react';
3
3
  import { useTranslations } from 'next-intl';
4
4
 
5
5
  import { PATH_SUBSCRIPTIONS, SummaryCardStatus, WfoSubscriptionListTab, WfoSummaryCard } from '@/components';
6
- import { mapSubscriptionSummaryToSummaryCardListItem } from '@/pages/startPage/mappers';
7
- import { outOfSyncSubscriptionsListSummaryQueryVariables } from '@/pages/startPage/queryVariables';
8
- import { useGetSubscriptionSummaryListQuery } from '@/rtk';
6
+ import { mapSubscriptionSearchResultToSummaryCardListItem } from '@/pages/startPage/mappers';
7
+ import { getSubscriptionSummarySearchPayload } from '@/pages/startPage/searchPayloads';
8
+ import { useSearchQuery } from '@/rtk';
9
+ import { Filter } from '@/types';
9
10
  import { optionalArrayMapper } from '@/utils';
10
11
  import { WfoQueryParams, getUrlWithQueryParams } from '@/utils/getQueryParams';
11
12
 
13
+ const OUT_OF_SYNC_CEL =
14
+ 'subscription.insync == false && (subscription.status == "provisioning" || subscription.status == "active")';
15
+
16
+ // Elasticsearch equivalent of OUT_OF_SYNC_CEL. The list page produces the same clauses from the
17
+ // CEL in the button link, so the count on the card matches the list.
18
+ const outOfSyncSubscriptionsFilter = {
19
+ bool: {
20
+ must: [
21
+ { term: { 'subscription.insync': false } },
22
+ {
23
+ bool: {
24
+ should: [{ term: { 'subscription.status': 'active' } }, { term: { 'subscription.status': 'provisioning' } }],
25
+ },
26
+ },
27
+ ],
28
+ },
29
+ } as unknown as Filter;
30
+
31
+ const outOfSyncSubscriptionsSearchPayload = getSubscriptionSummarySearchPayload(outOfSyncSubscriptionsFilter);
32
+
12
33
  export const WfoLatestOutOfSyncSubscriptionSummaryCard = () => {
13
34
  const t = useTranslations('startPage.outOfSyncSubscriptions');
14
35
 
15
- const {
16
- data: outOfSyncSubscriptionsSummaryResult,
17
- isFetching,
18
- isLoading,
19
- } = useGetSubscriptionSummaryListQuery(outOfSyncSubscriptionsListSummaryQueryVariables);
36
+ const { data, isFetching, isLoading } = useSearchQuery(outOfSyncSubscriptionsSearchPayload);
20
37
 
21
38
  const queryParams = {
22
39
  [WfoQueryParams.ACTIVE_TAB]: WfoSubscriptionListTab.ALL,
23
- [WfoQueryParams.SORT_BY]: 'field-startDate_order-ASC',
24
- [WfoQueryParams.QUERY_STRING]: 'status:(provisioning|active) insync:false',
40
+ [WfoQueryParams.FILTER_STRING]: OUT_OF_SYNC_CEL,
25
41
  };
26
42
 
27
43
  return (
28
44
  <WfoSummaryCard
29
45
  headerTitle={t('headerTitle')}
30
- headerValue={outOfSyncSubscriptionsSummaryResult?.pageInfo.totalItems ?? '-'}
46
+ headerValue={data?.cursor?.total_items ?? '-'}
31
47
  headerStatus={SummaryCardStatus.Error}
32
48
  listTitle={t('listTitle')}
33
- listItems={optionalArrayMapper(
34
- outOfSyncSubscriptionsSummaryResult?.subscriptions,
35
- mapSubscriptionSummaryToSummaryCardListItem,
36
- )}
49
+ listItems={optionalArrayMapper(data?.data, mapSubscriptionSearchResultToSummaryCardListItem)}
37
50
  button={{
38
51
  name: t('buttonText'),
39
52
  url: getUrlWithQueryParams(PATH_SUBSCRIPTIONS, queryParams),
@@ -38,11 +38,6 @@ const SAPS_PATH_INFO = {
38
38
  availablePaths: ['saps.port'],
39
39
  } as unknown as PathInfo;
40
40
 
41
- const PREFILLED_FIELD_OPTIONS = new Map([
42
- ['subscription.insync', ['eq', 'neq']],
43
- ['subscription.status', ['eq', 'neq', 'like']],
44
- ]);
45
-
46
41
  // The mocked backend only knows saps, returned for any prefix of it.
47
42
  const mockAutocomplete = (loading = false) =>
48
43
  jest.mocked(usePathAutocomplete).mockImplementation((prefix) => ({
@@ -56,7 +51,6 @@ const renderFieldSelector = (field: string = defaultPlaceholderFieldName) => {
56
51
  const onFieldSelected = jest.fn();
57
52
  const context: WfoQueryBuilderContext = {
58
53
  onFieldSelected,
59
- prefilledFieldOptions: PREFILLED_FIELD_OPTIONS,
60
54
  fieldPathInfoMap: new Map(),
61
55
  useAdvancedNestedSearch: true,
62
56
  };
@@ -154,16 +148,6 @@ describe('WfoFieldSelector', () => {
154
148
  expect(listedOptions()).toEqual(['startTypingToLoadOptions']);
155
149
  });
156
150
 
157
- it('lists matching prefilled fields alongside the backend paths', () => {
158
- renderFieldSelector();
159
-
160
- typeSearchTerm('ins');
161
- expect(listedOptions()).toEqual(['subscription.insync']);
162
-
163
- typeSearchTerm('s');
164
- expect(listedOptions()).toEqual(['subscription.insync', 'subscription.status', 'saps', 'saps.port']);
165
- });
166
-
167
151
  it('does not offer the raw typed text as an option and reports no match once loaded', () => {
168
152
  renderFieldSelector();
169
153
 
@@ -37,7 +37,7 @@ interface WfoFieldSelectorProps extends Omit<FieldSelectorProps, 'context'> {
37
37
 
38
38
  export const WfoFieldSelector: FC<WfoFieldSelectorProps> = ({ handleOnChange, disabled, rule, context }) => {
39
39
  const { field } = rule;
40
- const { useAdvancedNestedSearch, prefilledFieldOptions, onFieldSelected } = context;
40
+ const { useAdvancedNestedSearch, onFieldSelected } = context;
41
41
  const [autoFocus] = useState(field === defaultPlaceholderFieldName);
42
42
  const selectedField = field === defaultPlaceholderFieldName ? '' : field;
43
43
  const [searchTerm, setSearchTerm] = useState('');
@@ -66,19 +66,13 @@ export const WfoFieldSelector: FC<WfoFieldSelectorProps> = ({ handleOnChange, di
66
66
  error: errorMessage,
67
67
  } = usePathAutocomplete(autocompletePrefix, EntityKind.SUBSCRIPTION);
68
68
 
69
- const matchesPrefix = (path: string) => path.toLowerCase().includes(autocompletePrefix.toLowerCase());
70
-
71
- const prefilledFields = Array.from(prefilledFieldOptions.keys()).filter(matchesPrefix);
72
- const autocompleteOptions = getOptionsFromPathInfo(paths).filter(
73
- (option) => !prefilledFields.includes(option.value ?? ''),
74
- );
69
+ const autocompleteOptions = getOptionsFromPathInfo(paths);
75
70
 
76
71
  const startTypingHintOption: EuiComboBoxOptionOption<string> = {
77
72
  label: t('startTypingToLoadOptions'),
78
73
  disabled: true,
79
74
  };
80
- const options: EuiComboBoxOptionOption<string>[] =
81
- autocompletePrefix ? [...prefilledFields.map(getOption), ...autocompleteOptions] : [startTypingHintOption];
75
+ const options: EuiComboBoxOptionOption<string>[] = autocompletePrefix ? autocompleteOptions : [startTypingHintOption];
82
76
 
83
77
  const renderHintOption = (option: EuiComboBoxOptionOption<string>) => (
84
78
  <EuiText size="xs" color="default">
@@ -103,7 +97,7 @@ export const WfoFieldSelector: FC<WfoFieldSelectorProps> = ({ handleOnChange, di
103
97
  const matchingPath =
104
98
  paths.find((path) => path.path === selectedValue)
105
99
  ?? paths.find((path) => path.availablePaths?.includes(selectedValue));
106
- const operators = matchingPath?.operators ?? prefilledFieldOptions.get(selectedValue) ?? [];
100
+ const operators = matchingPath?.operators ?? [];
107
101
 
108
102
  onFieldSelected(selectedValue, operators, matchingPath);
109
103
  };
@@ -11,10 +11,11 @@ import { useTranslations } from 'next-intl';
11
11
 
12
12
  import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
13
13
 
14
- import { SearchParams, WfoAutoExpandableTextArea, WfoTextAnchor } from '@/components';
14
+ import { SearchParams, WfoAutoExpandableTextArea, WfoErrorWithMessage, WfoTextAnchor } from '@/components';
15
15
  import { WfoApplyFilterButton } from '@/components/WfoTable/WfoStructuredSearchTable/WfoApplyFilterButton';
16
16
  import { WfoCombinatorSelector } from '@/components/WfoTable/WfoStructuredSearchTable/WfoCombinatorSelector';
17
17
  import { useFieldsPathInfo, useWithOrchestratorTheme } from '@/hooks';
18
+ import type { WfoGraphqlError } from '@/rtk';
18
19
  import type { FieldToOperatorMap, PathInfo, WfoQueryBuilderContext } from '@/types';
19
20
  import { EntityKind } from '@/types';
20
21
 
@@ -28,6 +29,7 @@ import { WfoValueEditor } from './WfoValueEditor';
28
29
  import { getWfoStructuredSearchTableStyles } from './styles';
29
30
  import {
30
31
  collectRuleFields,
32
+ hasNestedRuleWithEmptyValue,
31
33
  onAddGroupHandler,
32
34
  operatorsToRQBOperatorOptionsMapper,
33
35
  useSearchWithDebouncedCallback,
@@ -41,8 +43,8 @@ interface WfoFilterBuilderProps {
41
43
  onUpdateQueryBuilder: (ruleGroup: RuleGroupType | false) => void;
42
44
  handleSearch: (searchParams?: SearchParams) => void;
43
45
  onToggleFilterBuilder: (isVisible: boolean) => void;
44
- prefilledFieldOptions: FieldToOperatorMap;
45
46
  useAdvancedNestedSearch?: boolean;
47
+ error?: WfoGraphqlError[];
46
48
  }
47
49
 
48
50
  const initialRuleGroup: RuleGroupType = {
@@ -58,13 +60,13 @@ export const WfoFilterBuilder = ({
58
60
  queryBuilderRuleGroup = initialRuleGroup,
59
61
  onUpdateQueryBuilder,
60
62
  handleSearch,
61
- prefilledFieldOptions,
62
63
  onToggleFilterBuilder,
63
64
  useAdvancedNestedSearch = true,
65
+ error,
64
66
  }: WfoFilterBuilderProps) => {
65
67
  const t = useTranslations('common');
66
68
  const { queryBuilderContainerStyles } = useWithOrchestratorTheme(getWfoStructuredSearchTableStyles);
67
- const [fieldToOperatorMap, setFieldToOperatorMap] = useState<FieldToOperatorMap>(prefilledFieldOptions);
69
+ const [fieldToOperatorMap, setFieldToOperatorMap] = useState<FieldToOperatorMap>(new Map());
68
70
  const [fieldPathInfoMap, setFieldPathInfoMap] = useState<Map<string, PathInfo>>(new Map());
69
71
 
70
72
  // Enter in a value editor commits its value on blur, and that state update has not
@@ -77,6 +79,7 @@ export const WfoFilterBuilder = ({
77
79
  const { handleSubmitSearchOnClick, pendingSearchRun, handleSubmitSearchOnEnter } = useSearchWithDebouncedCallback({
78
80
  filterString,
79
81
  isValidFilterString,
82
+ hasEmptyRuleValue: hasNestedRuleWithEmptyValue(queryBuilderRuleGroup),
80
83
  searchCallback: () => handleSearch({ ruleGroup: latestRuleGroupRef.current }),
81
84
  });
82
85
 
@@ -106,7 +109,6 @@ export const WfoFilterBuilder = ({
106
109
 
107
110
  const queryBuilderContext: WfoQueryBuilderContext = {
108
111
  onFieldSelected: handleFieldSelected,
109
- prefilledFieldOptions,
110
112
  fieldPathInfoMap,
111
113
  useAdvancedNestedSearch,
112
114
  };
@@ -173,7 +175,7 @@ export const WfoFilterBuilder = ({
173
175
  />
174
176
  </EuiFlexItem>
175
177
 
176
- <EuiFlexGroup direction={'rowReverse'} alignItems={'center'}>
178
+ <EuiFlexGroup direction={'rowReverse'} alignItems={'center'} gutterSize={'l'}>
177
179
  <WfoApplyFilterButton
178
180
  isDisabled={!isValidFilterString}
179
181
  pendingSearchRun={pendingSearchRun}
@@ -189,6 +191,7 @@ export const WfoFilterBuilder = ({
189
191
  onToggleFilterBuilder(false);
190
192
  }}
191
193
  />
194
+ <EuiFlexItem>{error && <WfoErrorWithMessage error={error} />}</EuiFlexItem>
192
195
  </EuiFlexGroup>
193
196
  </EuiFlexGroup>
194
197
  </EuiFlexGroup>
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Regression test for the URL-restore render loop: a controlled QueryBuilder wired like
3
- * WfoSearchPocPage + WfoFilterBuilder, restoring `lldp == true` from CEL while the path
3
+ * WfoSubscriptionsListPage + WfoFilterBuilder, restoring `lldp == true` from CEL while the path
4
4
  * info of `lldp` resolves asynchronously (boolean ui type + operator list).
5
5
  *
6
6
  * The rule group is deliberately created with parseCEL directly — without the rule ids
@@ -80,7 +80,7 @@ const Harness = ({ initialCel = 'lldp == true' }: { initialCel?: string }) => {
80
80
  onQueryChange={(ruleGroup: RuleGroupType) => {
81
81
  queryChangeLog.push(JSON.stringify(ruleGroup));
82
82
  if (queryChangeLog.length > 20) return;
83
- // Mirrors WfoSearchPocPage.onUpdateQueryBuilder
83
+ // Mirrors WfoSubscriptionsListPage.onUpdateQueryBuilder
84
84
  setQuery({ ...ruleGroup });
85
85
  setFilterString(formatQuery({ ...ruleGroup }, { format: 'cel', fallbackExpression: '' }));
86
86
  }}
@@ -30,7 +30,7 @@ import {
30
30
  import { useOrchestratorTheme, useWithOrchestratorTheme } from '@/hooks';
31
31
  import { WfoArrowsExpand } from '@/icons';
32
32
  import { WfoGraphqlError } from '@/rtk';
33
- import { FieldToOperatorMap, RetrieverType } from '@/types';
33
+ import { RetrieverType } from '@/types';
34
34
  import { getDefaultTableConfig } from '@/utils';
35
35
 
36
36
  import { ColumnType, WfoTable, WfoTableProps } from '../WfoTable';
@@ -97,7 +97,6 @@ export type WfoStructuredSearchTableProps<T extends object> = Omit<
97
97
  setPageSize: (updatedPageSize: number) => void;
98
98
  totalItems: number | false;
99
99
  hasNextPage: boolean;
100
- prefilledFieldOptions: FieldToOperatorMap;
101
100
  };
102
101
 
103
102
  export const WfoStructuredSearchTable = <T extends object>({
@@ -131,7 +130,6 @@ export const WfoStructuredSearchTable = <T extends object>({
131
130
  hasNextPage,
132
131
  data,
133
132
  isLoading,
134
- prefilledFieldOptions,
135
133
  ...tableProps
136
134
  }: WfoStructuredSearchTableProps<T>) => {
137
135
  const { theme } = useOrchestratorTheme();
@@ -283,13 +281,13 @@ export const WfoStructuredSearchTable = <T extends object>({
283
281
  onUpdateQueryBuilder={onUpdateQueryBuilder}
284
282
  handleSearch={handleSearch}
285
283
  onToggleFilterBuilder={setIsFilterBuilderVisible}
286
- prefilledFieldOptions={prefilledFieldOptions}
287
284
  useAdvancedNestedSearch={advancedNestedSearch}
285
+ error={error}
288
286
  />
289
287
  </>
290
288
  )}
291
289
 
292
- {error && <WfoErrorWithMessage error={error} />}
290
+ {error && !isFilterBuilderVisible && <WfoErrorWithMessage error={error} />}
293
291
 
294
292
  <EuiSpacer size="m" />
295
293
 
@@ -5,11 +5,13 @@ import { act, renderHook } from '@testing-library/react';
5
5
 
6
6
  import { FILTER_CHANGE_DEBOUNCE_DELAY, useSearchWithDebouncedCallback } from './utils';
7
7
 
8
+ type SearchHookProps = { filterString: string; hasEmptyRuleValue?: boolean };
9
+
8
10
  const renderSearchHook = (searchCallback: () => void, initialFilterString = 'subscription.status == "active"') =>
9
11
  renderHook(
10
- ({ filterString }: { filterString: string }) =>
11
- useSearchWithDebouncedCallback({ filterString, isValidFilterString: true, searchCallback }),
12
- { initialProps: { filterString: initialFilterString } },
12
+ ({ filterString, hasEmptyRuleValue = false }: SearchHookProps) =>
13
+ useSearchWithDebouncedCallback({ filterString, isValidFilterString: true, hasEmptyRuleValue, searchCallback }),
14
+ { initialProps: { filterString: initialFilterString } as SearchHookProps },
13
15
  );
14
16
 
15
17
  describe('useSearchWithDebouncedCallback', () => {
@@ -91,4 +93,55 @@ describe('useSearchWithDebouncedCallback', () => {
91
93
  });
92
94
  expect(searchCallback).toHaveBeenCalledTimes(1);
93
95
  });
96
+
97
+ it('does not schedule a search while a value editor is empty', () => {
98
+ const searchCallback = jest.fn();
99
+ const { result, rerender } = renderSearchHook(searchCallback);
100
+
101
+ // Clearing the value editor empties the value out of the filter string.
102
+ rerender({ filterString: 'subscription.status == ""', hasEmptyRuleValue: true });
103
+ expect(result.current.pendingSearchRun).toBeUndefined();
104
+
105
+ act(() => {
106
+ jest.advanceTimersByTime(FILTER_CHANGE_DEBOUNCE_DELAY);
107
+ });
108
+ expect(searchCallback).not.toHaveBeenCalled();
109
+
110
+ // Typing a value again resumes the automatic search.
111
+ rerender({ filterString: 'subscription.status == "a"', hasEmptyRuleValue: false });
112
+ expect(result.current.pendingSearchRun).toBeDefined();
113
+
114
+ act(() => {
115
+ jest.advanceTimersByTime(FILTER_CHANGE_DEBOUNCE_DELAY);
116
+ });
117
+ expect(searchCallback).toHaveBeenCalledTimes(1);
118
+ });
119
+
120
+ it('drops a scheduled search when the value editor is cleared before it runs', () => {
121
+ const searchCallback = jest.fn();
122
+ const { result, rerender } = renderSearchHook(searchCallback);
123
+
124
+ rerender({ filterString: 'subscription.status == "activ"' });
125
+ expect(result.current.pendingSearchRun).toBeDefined();
126
+
127
+ rerender({ filterString: 'subscription.status == ""', hasEmptyRuleValue: true });
128
+ expect(result.current.pendingSearchRun).toBeUndefined();
129
+
130
+ act(() => {
131
+ jest.advanceTimersByTime(FILTER_CHANGE_DEBOUNCE_DELAY);
132
+ });
133
+ expect(searchCallback).not.toHaveBeenCalled();
134
+ });
135
+
136
+ it('still searches on click while a value editor is empty', () => {
137
+ const searchCallback = jest.fn();
138
+ const { result, rerender } = renderSearchHook(searchCallback);
139
+
140
+ rerender({ filterString: 'subscription.status == ""', hasEmptyRuleValue: true });
141
+
142
+ act(() => {
143
+ result.current.handleSubmitSearchOnClick();
144
+ });
145
+ expect(searchCallback).toHaveBeenCalledTimes(1);
146
+ });
94
147
  });
@@ -1,7 +1,7 @@
1
1
  import type { RuleGroupType, RuleType } from 'react-querybuilder';
2
2
  import { formatQuery } from 'react-querybuilder';
3
3
 
4
- import { collectRuleFields, parseCelToRuleGroup } from './utils';
4
+ import { collectRuleFields, hasNestedRuleWithEmptyValue, parseCelToRuleGroup } from './utils';
5
5
 
6
6
  describe('parseCelToRuleGroup', () => {
7
7
  it('assigns ids to the parsed group and rules so rule identity stays stable', () => {
@@ -54,3 +54,62 @@ describe('collectRuleFields', () => {
54
54
  expect(collectRuleFields({ combinator: 'and', rules: [] })).toEqual([]);
55
55
  });
56
56
  });
57
+
58
+ describe('hasRuleWithEmptyValue', () => {
59
+ const groupWith = (rules: RuleGroupType['rules']): RuleGroupType => ({ combinator: 'and', rules });
60
+
61
+ it('reports an empty text value, including whitespace only', () => {
62
+ expect(hasNestedRuleWithEmptyValue(groupWith([{ field: 'subscription.status', operator: '=', value: '' }]))).toBe(
63
+ true,
64
+ );
65
+ expect(hasNestedRuleWithEmptyValue(groupWith([{ field: 'subscription.status', operator: '=', value: ' ' }]))).toBe(
66
+ true,
67
+ );
68
+ expect(
69
+ hasNestedRuleWithEmptyValue(groupWith([{ field: 'subscription.status', operator: '=', value: 'active' }])),
70
+ ).toBe(false);
71
+ });
72
+
73
+ it('treats a missing value as empty and a committed boolean or number as filled', () => {
74
+ expect(hasNestedRuleWithEmptyValue(groupWith([{ field: 'lldp', operator: '=', value: undefined }]))).toBe(true);
75
+ expect(hasNestedRuleWithEmptyValue(groupWith([{ field: 'lldp', operator: '=', value: false }]))).toBe(false);
76
+ expect(hasNestedRuleWithEmptyValue(groupWith([{ field: 'port.speed', operator: '>', value: 0 }]))).toBe(false);
77
+ });
78
+
79
+ it('reports a range with only one side filled in', () => {
80
+ expect(hasNestedRuleWithEmptyValue(groupWith([{ field: 'port.speed', operator: 'between', value: '1000,' }]))).toBe(
81
+ true,
82
+ );
83
+ expect(hasNestedRuleWithEmptyValue(groupWith([{ field: 'port.speed', operator: 'between', value: '1000' }]))).toBe(
84
+ true,
85
+ );
86
+ expect(
87
+ hasNestedRuleWithEmptyValue(groupWith([{ field: 'port.speed', operator: 'between', value: '1000,2000' }])),
88
+ ).toBe(false);
89
+ });
90
+
91
+ it('never reports operators that take no value, whatever their leftover value is', () => {
92
+ expect(hasNestedRuleWithEmptyValue(groupWith([{ field: 'port', operator: 'notNull', value: null }]))).toBe(false);
93
+ expect(hasNestedRuleWithEmptyValue(groupWith([{ field: 'port', operator: 'null', value: null }]))).toBe(false);
94
+ expect(hasNestedRuleWithEmptyValue(groupWith([{ field: 'port', operator: 'notNull', value: '' }]))).toBe(false);
95
+ expect(hasNestedRuleWithEmptyValue(groupWith([{ field: 'port', operator: 'null', value: 'abc' }]))).toBe(false);
96
+ });
97
+
98
+ it('does not hold back a search for a filter restored from CEL that uses has component', () => {
99
+ expect(hasNestedRuleWithEmptyValue(parseCelToRuleGroup('subscription.port != null'))).toBe(false);
100
+ expect(hasNestedRuleWithEmptyValue(parseCelToRuleGroup('subscription.port == null'))).toBe(false);
101
+ });
102
+
103
+ it('looks inside nested groups', () => {
104
+ const ruleGroup = groupWith([
105
+ { field: 'subscription.status', operator: '=', value: 'active' },
106
+ groupWith([{ field: 'subscription.note', operator: 'contains', value: '' }]),
107
+ ]);
108
+
109
+ expect(hasNestedRuleWithEmptyValue(ruleGroup)).toBe(true);
110
+ });
111
+
112
+ it('returns false without a rule group', () => {
113
+ expect(hasNestedRuleWithEmptyValue(undefined)).toBe(false);
114
+ });
115
+ });