@orchestrator-ui/orchestrator-ui-components 8.6.0 → 8.7.1

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 (56) hide show
  1. package/.turbo/turbo-build.log +8 -8
  2. package/.turbo/turbo-lint.log +1 -1
  3. package/.turbo/turbo-test.log +15 -12
  4. package/CHANGELOG.md +18 -0
  5. package/dist/index.d.ts +416 -1183
  6. package/dist/index.js +3457 -3072
  7. package/dist/index.js.map +1 -1
  8. package/package.json +3 -1
  9. package/src/components/WfoPydanticForm/fields/WfoCron.spec.ts +88 -0
  10. package/src/components/WfoPydanticForm/fields/WfoCron.tsx +233 -0
  11. package/src/components/WfoPydanticForm/fields/WfoSummary.tsx +42 -7
  12. package/src/components/WfoPydanticForm/fields/index.ts +1 -0
  13. package/src/components/WfoPydanticForm/fields/styles.ts +92 -0
  14. package/src/components/WfoSearchPage/WfoFilterGroup/WfoFilterGroup.tsx +1 -1
  15. package/src/components/WfoSearchPage/WfoSearchResults/WfoSearchResultItem.tsx +2 -1
  16. package/src/components/WfoSearchPage/utils.ts +37 -1
  17. package/src/components/WfoSubscription/WfoInUseByRelations.tsx +6 -2
  18. package/src/components/WfoSubscription/WfoSubscriptionActions/WfoSubscriptionActionExpandableMenuItem.tsx +19 -14
  19. package/src/components/WfoSubscription/WfoSubscriptionActions/WfoSubscriptionActions.tsx +13 -1
  20. package/src/components/WfoSubscription/WfoSubscriptionActions/WfoSubscriptionActionsMenuItem.tsx +3 -0
  21. package/src/components/WfoSubscription/WfoSubscriptionProductBlock/WfoSubscriptionProductBlock.tsx +1 -1
  22. package/src/components/WfoSubscriptionsList/WfoSubscriptionsList.tsx +5 -0
  23. package/src/components/WfoSubscriptionsList/subscriptionResultMappers.ts +3 -1
  24. package/src/components/WfoTable/WfoAdvancedTable/WfoAdvancedTable.tsx +2 -12
  25. package/src/components/WfoTable/WfoStructuredSearchTable/WfoExpandingSearchRow.tsx +21 -14
  26. package/src/components/WfoTable/WfoStructuredSearchTable/WfoFieldSelector.tsx +27 -1
  27. package/src/components/WfoTable/WfoStructuredSearchTable/WfoFilterBuilder.tsx +97 -91
  28. package/src/components/WfoTable/WfoStructuredSearchTable/WfoOperatorSelector.tsx +32 -7
  29. package/src/components/WfoTable/WfoStructuredSearchTable/WfoRuleGroup.tsx +5 -4
  30. package/src/components/WfoTable/WfoStructuredSearchTable/WfoStructuredSearchTable.tsx +32 -30
  31. package/src/components/WfoTable/WfoStructuredSearchTable/WfoValueEditor.tsx +41 -11
  32. package/src/components/WfoTable/WfoStructuredSearchTable/styles.ts +22 -5
  33. package/src/components/WfoTable/WfoTable/WfoTable.tsx +1 -0
  34. package/src/components/WfoTable/WfoTable/WfoTableDataRows.tsx +16 -4
  35. package/src/components/WfoTable/WfoTable/styles.ts +8 -0
  36. package/src/components/WfoTable/WfoTable/utils.spec.ts +62 -1
  37. package/src/components/WfoTable/WfoTable/utils.ts +4 -1
  38. package/src/components/WfoTable/WfoTableSettingsModal/WfoTableSettingsModal.spec.ts +45 -0
  39. package/src/components/WfoTable/WfoTableSettingsModal/index.ts +1 -0
  40. package/src/components/WfoTable/WfoTableSettingsModal/utils.ts +17 -0
  41. package/src/configuration/version.ts +1 -1
  42. package/src/hooks/index.ts +1 -0
  43. package/src/hooks/useGetPydanticFormsConfig.tsx +12 -0
  44. package/src/hooks/useLanguageCode.ts +11 -0
  45. package/src/messages/en-GB.json +442 -549
  46. package/src/messages/getTranslationMessages.spec.ts +47 -30
  47. package/src/messages/nl-NL.json +450 -411
  48. package/src/pages/WfoSearchPocPage.tsx +14 -5
  49. package/src/rtk/endpoints/subscriptionList.ts +1 -0
  50. package/src/theme/baseStyles/formFieldsBaseStyle.ts +0 -1
  51. package/src/types/search.ts +1 -1
  52. package/src/types/types.ts +1 -1
  53. package/src/utils/getDefaultTableConfig.ts +6 -1
  54. package/src/utils/index.ts +1 -0
  55. package/src/utils/integer.spec.ts +20 -0
  56. package/src/utils/integer.ts +3 -0
@@ -15,12 +15,14 @@ export type WfoSubscriptionActionExpandableMenuItemProps = {
15
15
  subscriptionAction: SubscriptionAction;
16
16
  onClickLockedRelation: (relation: SubscriptionRelation) => void;
17
17
  children: React.ReactNode;
18
+ subscriptionPath?: string;
18
19
  };
19
20
 
20
21
  export const WfoSubscriptionActionExpandableMenuItem: FC<WfoSubscriptionActionExpandableMenuItemProps> = ({
21
22
  subscriptionAction,
22
23
  onClickLockedRelation,
23
24
  children,
25
+ subscriptionPath = PATH_SUBSCRIPTIONS,
24
26
  }) => {
25
27
  const t = useTranslations('subscriptions.detail.actions');
26
28
 
@@ -29,18 +31,21 @@ export const WfoSubscriptionActionExpandableMenuItem: FC<WfoSubscriptionActionEx
29
31
  const [isExpanded, setIsExpanded] = useState(false);
30
32
 
31
33
  // TODO: remove lockedRelationsUuids fallback when orchestrator-core 6.0.0 is released and only use the _detail variant
32
- const lockedRelationsDetail = subscriptionAction.locked_relations_detail;
33
- const lockedRelationsUuids = subscriptionAction.locked_relations;
34
- const hasLockedRelations = (lockedRelationsDetail?.length ?? 0) > 0 || (lockedRelationsUuids?.length ?? 0) > 0;
34
+ const relationsDetail = [
35
+ ...(subscriptionAction.locked_relations_detail ?? []),
36
+ ...(subscriptionAction.unterminated_in_use_by_subscriptions_detail ?? []),
37
+ ];
38
+ const relationsUuids = [
39
+ ...(subscriptionAction.locked_relations ?? []),
40
+ ...(subscriptionAction.unterminated_in_use_by_subscriptions ?? []),
41
+ ];
42
+ const hasRelations = relationsDetail.length > 0 || relationsUuids.length > 0;
35
43
 
36
44
  return (
37
45
  <div>
38
- <div
39
- css={[expandableMenuItemStyle, hasLockedRelations && clickableStyle]}
40
- onClick={() => setIsExpanded(!isExpanded)}
41
- >
46
+ <div css={[expandableMenuItemStyle, hasRelations && clickableStyle]} onClick={() => setIsExpanded(!isExpanded)}>
42
47
  <div>{children}</div>
43
- {hasLockedRelations && (
48
+ {hasRelations && (
44
49
  <EuiButtonIcon
45
50
  css={expandButtonStyle}
46
51
  iconType={isExpanded ? 'arrowDown' : 'arrowRight'}
@@ -49,17 +54,17 @@ export const WfoSubscriptionActionExpandableMenuItem: FC<WfoSubscriptionActionEx
49
54
  />
50
55
  )}
51
56
  </div>
52
- {hasLockedRelations && isExpanded && (
57
+ {hasRelations && isExpanded && (
53
58
  <div css={expandedContentStyle}>
54
59
  <EuiText size="xs">{t('lockedBySubscriptions')}</EuiText>
55
60
  <ul css={{ margin: 0, paddingLeft: 16, listStyleType: 'disc' }}>
56
- {lockedRelationsDetail ?
57
- lockedRelationsDetail.map((relation) => (
61
+ {relationsDetail.length > 0 ?
62
+ relationsDetail.map((relation) => (
58
63
  <li key={relation.subscription_id}>
59
64
  <EuiText size="xs">
60
65
  <Link
61
66
  css={linkStyle}
62
- href={`${PATH_SUBSCRIPTIONS}/${relation.subscription_id}`}
67
+ href={`${subscriptionPath}/${relation.subscription_id}`}
63
68
  target="_blank"
64
69
  onClick={() => onClickLockedRelation(relation)}
65
70
  >
@@ -68,10 +73,10 @@ export const WfoSubscriptionActionExpandableMenuItem: FC<WfoSubscriptionActionEx
68
73
  </EuiText>
69
74
  </li>
70
75
  ))
71
- : lockedRelationsUuids?.map((id) => (
76
+ : relationsUuids.map((id) => (
72
77
  <li key={id}>
73
78
  <EuiText size="xs">
74
- <Link css={linkStyle} href={`${PATH_SUBSCRIPTIONS}/${id}`} target="_blank">
79
+ <Link css={linkStyle} href={`${subscriptionPath}/${id}`} target="_blank">
75
80
  {id}
76
81
  </Link>
77
82
  </EuiText>
@@ -5,7 +5,13 @@ import { useRouter } from 'next/router';
5
5
 
6
6
  import { EuiButton, EuiButtonIcon, EuiLoadingSpinner, EuiTitle } from '@elastic/eui';
7
7
 
8
- import { PATH_START_NEW_TASK, PATH_START_NEW_WORKFLOW, WfoInSyncField, WfoPopover } from '@/components';
8
+ import {
9
+ PATH_START_NEW_TASK,
10
+ PATH_START_NEW_WORKFLOW,
11
+ PATH_SUBSCRIPTIONS,
12
+ WfoInSyncField,
13
+ WfoPopover,
14
+ } from '@/components';
9
15
  import { WfoSubscriptionActionsMenuItem } from '@/components/WfoSubscription/WfoSubscriptionActions/WfoSubscriptionActionsMenuItem';
10
16
  import { useActiveProcess } from '@/components/WfoSubscription/WfoSubscriptionActions/utils';
11
17
  import { PolicyResource } from '@/configuration/policy-resources';
@@ -27,12 +33,14 @@ export type WfoSubscriptionActionsProps = {
27
33
  subscriptionId: string;
28
34
  isLoading?: boolean;
29
35
  compactMode?: boolean;
36
+ subscriptionPath?: string;
30
37
  };
31
38
 
32
39
  export const WfoSubscriptionActions: FC<WfoSubscriptionActionsProps> = ({
33
40
  subscriptionId,
34
41
  isLoading,
35
42
  compactMode = false,
43
+ subscriptionPath = PATH_SUBSCRIPTIONS,
36
44
  }) => {
37
45
  const t = useTranslations('subscriptions.detail.actions');
38
46
  const { theme } = useOrchestratorTheme();
@@ -132,6 +140,7 @@ export const WfoSubscriptionActions: FC<WfoSubscriptionActionsProps> = ({
132
140
  subscriptionAction={subscriptionAction}
133
141
  target={WorkflowTarget.VALIDATE}
134
142
  setPopover={setPopover}
143
+ subscriptionPath={subscriptionPath}
135
144
  onClick={() => handleActionClick(subscriptionAction.name, compactMode, true)}
136
145
  isLoading={buttonIsLoading}
137
146
  />
@@ -152,6 +161,7 @@ export const WfoSubscriptionActions: FC<WfoSubscriptionActionsProps> = ({
152
161
  }
153
162
  target={WorkflowTarget.RECONCILE}
154
163
  setPopover={setPopover}
164
+ subscriptionPath={subscriptionPath}
155
165
  onClick={() => handleActionClick(subscriptionAction.name, compactMode, false)}
156
166
  isLoading={buttonIsLoading}
157
167
  />
@@ -182,6 +192,7 @@ export const WfoSubscriptionActions: FC<WfoSubscriptionActionsProps> = ({
182
192
  subscriptionAction={subscriptionAction}
183
193
  target={WorkflowTarget.MODIFY}
184
194
  setPopover={setPopover}
195
+ subscriptionPath={subscriptionPath}
185
196
  onClick={() => {
186
197
  redirectToUrl(subscriptionAction.name);
187
198
  }}
@@ -199,6 +210,7 @@ export const WfoSubscriptionActions: FC<WfoSubscriptionActionsProps> = ({
199
210
  subscriptionAction={subscriptionAction}
200
211
  target={WorkflowTarget.TERMINATE}
201
212
  setPopover={setPopover}
213
+ subscriptionPath={subscriptionPath}
202
214
  onClick={() => {
203
215
  redirectToUrl(subscriptionAction.name);
204
216
  }}
@@ -19,6 +19,7 @@ interface MenuItemProps {
19
19
  setPopover: (isOpen: boolean) => void;
20
20
  onClick: () => void;
21
21
  isLoading?: boolean;
22
+ subscriptionPath?: string;
22
23
  }
23
24
 
24
25
  export const WfoSubscriptionActionsMenuItem: FC<MenuItemProps> = ({
@@ -27,6 +28,7 @@ export const WfoSubscriptionActionsMenuItem: FC<MenuItemProps> = ({
27
28
  target,
28
29
  setPopover,
29
30
  isLoading = false,
31
+ subscriptionPath,
30
32
  }) => {
31
33
  const { linkMenuItemStyle, tooltipMenuItemStyle, disabledIconStyle, iconStyle, secondaryIconStyle } =
32
34
  useWithOrchestratorTheme(getSubscriptionActionStyles);
@@ -102,6 +104,7 @@ export const WfoSubscriptionActionsMenuItem: FC<MenuItemProps> = ({
102
104
  <WfoSubscriptionActionExpandableMenuItem
103
105
  subscriptionAction={subscriptionAction}
104
106
  onClickLockedRelation={() => setPopover(false)}
107
+ subscriptionPath={subscriptionPath}
105
108
  >
106
109
  {actionItem}
107
110
  </WfoSubscriptionActionExpandableMenuItem>
@@ -147,7 +147,7 @@ export const WfoSubscriptionProductBlock = ({
147
147
  </td>
148
148
  <td>
149
149
  {(inUseByRelations.length === 0 && 'None') || (
150
- <WfoInUseByRelations inUseByRelations={inUseByRelations} />
150
+ <WfoInUseByRelations inUseByRelations={inUseByRelations} subscriptionPath={subscriptionPath} />
151
151
  )}
152
152
  </td>
153
153
  </tr>
@@ -122,6 +122,11 @@ export const WfoSubscriptionsList: FC<WfoSubscriptionsListProps> = ({
122
122
  label: t('tag'),
123
123
  width: '100px',
124
124
  },
125
+ customerId: {
126
+ columnType: ColumnType.DATA,
127
+ label: t('customerId'),
128
+ width: '100px',
129
+ },
125
130
  customerFullname: {
126
131
  columnType: ColumnType.DATA,
127
132
  label: t('customerFullname'),
@@ -12,6 +12,7 @@ export type SubscriptionListItem = Pick<
12
12
  tag: string | null;
13
13
  customerFullname: string;
14
14
  customerShortcode: string;
15
+ customerId: string;
15
16
  metadata: object | null;
16
17
  };
17
18
 
@@ -27,7 +28,7 @@ export const mapGraphQlSubscriptionsResultToSubscriptionListItems = (
27
28
  subscription;
28
29
 
29
30
  const { name: productName, tag } = product;
30
- const { fullname: customerFullname, shortcode: customerShortcode } = customer;
31
+ const { fullname: customerFullname, shortcode: customerShortcode, customerId } = customer;
31
32
 
32
33
  return {
33
34
  subscriptionId,
@@ -41,6 +42,7 @@ export const mapGraphQlSubscriptionsResultToSubscriptionListItems = (
41
42
  tag,
42
43
  customerFullname,
43
44
  customerShortcode,
45
+ customerId,
44
46
  metadata: Object.keys(metadata).length > 0 ? metadata : null,
45
47
  };
46
48
  });
@@ -8,7 +8,6 @@ import {
8
8
  DEFAULT_PAGE_SIZE,
9
9
  DEFAULT_PAGE_SIZES,
10
10
  TableColumnKeys,
11
- TableSettingsColumnConfig,
12
11
  TableSettingsConfig,
13
12
  TableSettingsModal,
14
13
  WfoErrorWithMessage,
@@ -18,6 +17,7 @@ import {
18
17
  WfoSearchField,
19
18
  WfoToolTip,
20
19
  clearTableConfigFromLocalStorage,
20
+ getTableSettingsColumns,
21
21
  setTableConfigToLocalStorage,
22
22
  } from '@/components';
23
23
  import { getRowDetailData } from '@/components/WfoTable/WfoAdvancedTable/getRowDetailData';
@@ -96,17 +96,7 @@ export const WfoAdvancedTable = <T extends object>({
96
96
  ...tableColumnConfig,
97
97
  };
98
98
 
99
- const tableSettingsColumns: TableSettingsColumnConfig<T>[] = Object.entries(tableColumnConfig).map(
100
- ([key, { label }]): TableSettingsColumnConfig<T> => {
101
- const field = key as keyof T;
102
-
103
- return {
104
- field,
105
- name: label,
106
- isVisible: hiddenColumns.indexOf(field) === -1,
107
- };
108
- },
109
- );
99
+ const tableSettingsColumns = getTableSettingsColumns(tableColumnConfig, hiddenColumns);
110
100
 
111
101
  const rowDetailData: WfoKeyValueTableDataType[] | undefined =
112
102
  selectedDataForDetailModal && getRowDetailData(selectedDataForDetailModal, tableColumnConfig);
@@ -1,36 +1,43 @@
1
1
  import React from 'react';
2
2
 
3
+ import { WfoHighlightedText } from '@/components/WfoSearchPage/WfoSearchResults/WfoHighlightedText';
3
4
  import { useWithOrchestratorTheme } from '@/hooks';
4
5
  import type { MatchingField } from '@/types';
6
+ import { toPercentage } from '@/utils';
5
7
 
6
8
  import { getWfoStructuredSearchTableStyles } from './styles';
7
9
 
8
10
  interface WfoExpandingSearchRowProps {
9
11
  score?: number;
10
12
  perfectMatch?: number;
11
- matchingField?: MatchingField | null;
13
+ matchingFields?: MatchingField[] | null;
12
14
  }
13
15
 
14
- export const WfoExpandingSearchRow = ({ score, matchingField }: WfoExpandingSearchRowProps) => {
15
- const { expandingSearchRowStyles, expandingRowBodyStyles } = useWithOrchestratorTheme(
16
- getWfoStructuredSearchTableStyles,
17
- );
18
-
19
- if (!matchingField) return null;
16
+ export const WfoExpandingSearchRow = ({ score, matchingFields }: WfoExpandingSearchRowProps) => {
17
+ const { expandingSearchRowStyles, expandingRowBodyStyles, hideExpandedRowStyle, dotStyles, expandingRowFieldStyles } =
18
+ useWithOrchestratorTheme(getWfoStructuredSearchTableStyles);
20
19
 
21
- const { path, text } = matchingField;
20
+ if (!matchingFields || matchingFields.length === 0) return null;
22
21
 
22
+ // Note: The row is shown on hover by the row above is as configured in the table component expandableRow config
23
23
  return (
24
- <tr>
24
+ <tr css={hideExpandedRowStyle}>
25
25
  <td colSpan={999} css={expandingSearchRowStyles}>
26
26
  <div css={expandingRowBodyStyles}>
27
27
  <div>
28
- <span>{path}</span> {text}
29
- </div>
30
- <span>•</span>
31
- <div>
32
- <span>score</span> {score !== undefined && `${(score * 100).toFixed(1)}%`}
28
+ {matchingFields.map((field) => {
29
+ return (
30
+ <div key={`${field.path}:${field.text}`} css={expandingRowFieldStyles}>
31
+ <div>
32
+ <span css={dotStyles}>•</span>
33
+ {field.path}: <WfoHighlightedText text={field.text} highlight_indices={field.highlight_indices} />
34
+ </div>
35
+ </div>
36
+ );
37
+ })}
33
38
  </div>
39
+
40
+ {score && <div css={dotStyles}>confidence: {toPercentage(score)}</div>}
34
41
  </div>
35
42
  </td>
36
43
  </tr>
@@ -9,6 +9,27 @@ import { EuiComboBox } from '@elastic/eui';
9
9
  import { usePathAutocomplete } from '@/hooks';
10
10
  import { EntityKind, FieldToOperatorMap, PathInfo } from '@/types';
11
11
 
12
+ // react-querybuilder applies the `.rule` class to the rule container and `.rule-value` to
13
+ // the value-editor cell (see `standardClassnames` in @react-querybuilder/core). We hop from
14
+ // the EuiComboBox search input up to the enclosing rule and focus the value editor's first
15
+ // focusable child on the next frame, once react-querybuilder has re-rendered the rule with
16
+ // the editor type for the new field.
17
+ const focusValueEditorAfterRender = (searchInput: HTMLInputElement | null) => {
18
+ if (!searchInput) return;
19
+ requestAnimationFrame(() => {
20
+ const valueCell = searchInput.closest('.rule')?.querySelector('.rule-value');
21
+ const focusable = valueCell?.querySelector<HTMLElement>(
22
+ 'input:not([disabled]), textarea:not([disabled]), button:not([disabled])',
23
+ );
24
+
25
+ if (!focusable) return;
26
+ focusable.focus();
27
+ if (focusable instanceof HTMLInputElement && (focusable.type === 'text' || focusable.type === 'number')) {
28
+ focusable.select();
29
+ }
30
+ });
31
+ };
32
+
12
33
  export const WfoFieldSelector = ({ handleOnChange, disabled, rule, context }: FieldSelectorProps) => {
13
34
  const { field } = rule;
14
35
  const prefilledFieldOptions: FieldToOperatorMap = context.prefilledFieldOptions;
@@ -74,6 +95,8 @@ export const WfoFieldSelector = ({ handleOnChange, disabled, rule, context }: Fi
74
95
  storeFieldOperators(selectedValue);
75
96
 
76
97
  handleOnChange(selectedValue);
98
+
99
+ if (selectedValue) focusValueEditorAfterRender(searchInput);
77
100
  };
78
101
 
79
102
  // EuiComboBox only auto-selects on Enter/Tab when exactly one option matches the typed
@@ -95,7 +118,10 @@ export const WfoFieldSelector = ({ handleOnChange, disabled, rule, context }: Fi
95
118
  (option) => !option.disabled && option.label.toLowerCase() === typed.toLowerCase(),
96
119
  );
97
120
  if (!exactMatch) return;
98
- if (event.key === 'Enter') event.preventDefault();
121
+ // Prevent both Enter (form submit / EUI's no-op fallback) and Tab (native focus
122
+ // shift to the operator selector) — handleFieldSelection moves focus to the value
123
+ // editor itself, and a native Tab in the meantime causes a visible focus flash.
124
+ event.preventDefault();
99
125
  handleFieldSelectionRef.current([exactMatch]);
100
126
 
101
127
  // EuiComboBox keeps its own internal `searchValue` and `isListOpen` state. Clearing
@@ -1,4 +1,4 @@
1
- import React, { useState } from 'react';
1
+ import React, { useRef, useState } from 'react';
2
2
  import { FullOperator, QueryBuilder, type RuleGroupType, generateID } from 'react-querybuilder';
3
3
  import 'react-querybuilder/dist/query-builder.css';
4
4
 
@@ -58,7 +58,6 @@ interface WfoFilterBuilderProps {
58
58
  queryBuilderRuleGroup?: RuleGroupType;
59
59
  onUpdateQueryBuilder: (ruleGroup: RuleGroupType | false) => void;
60
60
  handleSearch: (searchParams?: SearchParams) => void;
61
- isFilterBuilderVisible: boolean;
62
61
  onToggleFilterBuilder: (isVisible: boolean) => void;
63
62
  prefilledFieldOptions: FieldToOperatorMap;
64
63
  }
@@ -82,7 +81,6 @@ export const WfoFilterBuilder = ({
82
81
  onUpdateQueryBuilder,
83
82
  handleSearch,
84
83
  prefilledFieldOptions,
85
- isFilterBuilderVisible,
86
84
  onToggleFilterBuilder,
87
85
  }: WfoFilterBuilderProps) => {
88
86
  const mapOperatorsToRQBOperatorOptions = (operators?: string[]): FullOperator[] => {
@@ -94,11 +92,20 @@ export const WfoFilterBuilder = ({
94
92
  };
95
93
 
96
94
  const t = useTranslations('common');
97
- const { queryBuilderContainerStyles, toggleButtonStyles } = useWithOrchestratorTheme(
98
- getWfoStructuredSearchTableStyles,
99
- );
95
+ const { queryBuilderContainerStyles } = useWithOrchestratorTheme(getWfoStructuredSearchTableStyles);
100
96
  const [fieldToOperatorMap, setFieldToOperatorMap] = useState<FieldToOperatorMap>(prefilledFieldOptions);
101
97
 
98
+ // Enter in a value editor commits its value on blur, and that state update has not
99
+ // flushed yet when the search runs in the same keydown. onQueryChange fires
100
+ // synchronously during the blur, so this ref always holds the freshest rule group,
101
+ // which is passed to handleSearch explicitly (same pattern as the remove-filter link).
102
+ const latestRuleGroupRef = useRef<RuleGroupType | undefined>(queryBuilderRuleGroup);
103
+ latestRuleGroupRef.current = queryBuilderRuleGroup;
104
+
105
+ const handleValueEditorEnter = () => {
106
+ handleSearch({ ruleGroup: latestRuleGroupRef.current });
107
+ };
108
+
102
109
  const handleFieldSelected = (field: string, operators: string[]) => {
103
110
  setFieldToOperatorMap((previousMap) => {
104
111
  return new Map(previousMap).set(field, operators);
@@ -106,93 +113,92 @@ export const WfoFilterBuilder = ({
106
113
  };
107
114
 
108
115
  return (
109
- <EuiFlexGroup css={isFilterBuilderVisible ? queryBuilderContainerStyles : undefined}>
110
- {(isFilterBuilderVisible && (
111
- <EuiFlexGroup direction={'column'}>
112
- <EuiFlexItem>
113
- <QueryBuilder
114
- query={queryBuilderRuleGroup}
115
- enableMountQueryChange={false}
116
- onQueryChange={(ruleGroup: RuleGroupType) => {
117
- onUpdateQueryBuilder(ruleGroup);
118
- }}
119
- context={{ onFieldSelected: handleFieldSelected, prefilledFieldOptions }}
120
- getOperators={(field) => {
121
- const operators = fieldToOperatorMap.get(field);
122
- return mapOperatorsToRQBOperatorOptions(operators);
123
- }}
124
- controlElements={{
125
- fieldSelector: WfoFieldSelector,
126
- operatorSelector: WfoOperatorSelector,
127
- valueEditor: WfoValueEditor,
128
- ruleGroup: WfoRuleGroup,
129
- rule: WfoRule,
130
- combinatorSelector: WfoCombinatorSelector,
131
- inlineCombinator: WfoInlineCombinator,
132
- addRuleAction: null,
133
- addGroupAction: null,
134
- removeGroupAction: null,
135
- removeRuleAction: WfoRemoveRuleAction,
136
- }}
137
- addRuleToNewGroups
138
- onAddGroup={onAddGroupHandler}
139
- maxLevels={5}
140
- showCombinatorsBetweenRules
141
- />
142
- </EuiFlexItem>
143
- <EuiFlexItem>
144
- <WfoAutoExpandableTextArea
145
- id={'searchbox-textarea'}
146
- value={filterString ?? ''}
147
- onChange={(e) => {
148
- const filterString = e.target.value;
149
- onUpdateFilterString(filterString);
150
- }}
151
- isInvalid={!isValidFilterString}
152
- />
153
- </EuiFlexItem>
154
-
155
- <EuiFlexGroup direction={'rowReverse'} alignItems={'center'}>
156
- <EuiButton
157
- onClick={() => {
116
+ <EuiFlexGroup css={queryBuilderContainerStyles}>
117
+ <EuiFlexGroup direction={'column'}>
118
+ <EuiFlexItem>
119
+ <QueryBuilder
120
+ query={queryBuilderRuleGroup}
121
+ enableMountQueryChange={false}
122
+ onQueryChange={(ruleGroup: RuleGroupType) => {
123
+ latestRuleGroupRef.current = ruleGroup;
124
+ onUpdateQueryBuilder(ruleGroup);
125
+ }}
126
+ context={{
127
+ onFieldSelected: handleFieldSelected,
128
+ prefilledFieldOptions,
129
+ onValueEditorEnter: handleValueEditorEnter,
130
+ }}
131
+ getOperators={(field) => {
132
+ const operators = fieldToOperatorMap.get(field);
133
+ return mapOperatorsToRQBOperatorOptions(operators);
134
+ }}
135
+ controlElements={{
136
+ fieldSelector: WfoFieldSelector,
137
+ operatorSelector: WfoOperatorSelector,
138
+ valueEditor: WfoValueEditor,
139
+ ruleGroup: WfoRuleGroup,
140
+ rule: WfoRule,
141
+ combinatorSelector: WfoCombinatorSelector,
142
+ inlineCombinator: WfoInlineCombinator,
143
+ addRuleAction: null,
144
+ addGroupAction: null,
145
+ removeGroupAction: null,
146
+ removeRuleAction: WfoRemoveRuleAction,
147
+ }}
148
+ addRuleToNewGroups
149
+ onAddGroup={onAddGroupHandler}
150
+ maxLevels={5}
151
+ showCombinatorsBetweenRules
152
+ resetOnFieldChange={false}
153
+ />
154
+ </EuiFlexItem>
155
+ <EuiFlexItem>
156
+ <WfoAutoExpandableTextArea
157
+ id={'searchbox-textarea'}
158
+ value={filterString ?? ''}
159
+ onChange={(e) => {
160
+ const filterString = e.target.value;
161
+ onUpdateFilterString(filterString);
162
+ }}
163
+ onKeyDown={(event) => {
164
+ // Enter applies the filter like the Apply button (and like it, does nothing
165
+ // while the filter string is invalid); Shift+Enter inserts a newline.
166
+ if (event.key !== 'Enter' || event.shiftKey) return;
167
+ event.preventDefault();
168
+ if (isValidFilterString) {
158
169
  handleSearch();
159
- }}
160
- id={'button-apply-filter'}
161
- data-test-id={'button-apply-filter'}
162
- fill
163
- type="submit"
164
- aria-label={t('applyFilter')}
165
- disabled={!isValidFilterString}
166
- >
167
- {t('applyFilter')}
168
- </EuiButton>
169
- <WfoTextAnchor
170
- text={t('removeFilter')}
171
- onClick={() => {
172
- onUpdateQueryBuilder(false);
173
- // we call with ruleGroup: false explictly to
174
- // avoid state not having caught up yet when searching
175
- handleSearch({ ruleGroup: false });
176
- onToggleFilterBuilder(false);
177
- }}
178
- />
179
- </EuiFlexGroup>
170
+ }
171
+ }}
172
+ isInvalid={!isValidFilterString}
173
+ />
174
+ </EuiFlexItem>
175
+
176
+ <EuiFlexGroup direction={'rowReverse'} alignItems={'center'}>
177
+ <EuiButton
178
+ onClick={() => {
179
+ handleSearch();
180
+ }}
181
+ id={'button-apply-filter'}
182
+ data-test-id={'button-apply-filter'}
183
+ fill
184
+ type="submit"
185
+ aria-label={t('applyFilter')}
186
+ disabled={!isValidFilterString}
187
+ >
188
+ {t('applyFilter')}
189
+ </EuiButton>
190
+ <WfoTextAnchor
191
+ text={t('removeFilter')}
192
+ onClick={() => {
193
+ onUpdateQueryBuilder(false);
194
+ // we call with ruleGroup: false explictly to
195
+ // avoid state not having caught up yet when searching
196
+ handleSearch({ ruleGroup: false });
197
+ onToggleFilterBuilder(false);
198
+ }}
199
+ />
180
200
  </EuiFlexGroup>
181
- )) || (
182
- <EuiButton
183
- css={toggleButtonStyles}
184
- onClick={() => onToggleFilterBuilder(true)}
185
- id={'button-toggle-filter-builder'}
186
- data-test-id={'button-toggle-filter-builder'}
187
- fill
188
- type="submit"
189
- iconType="filter"
190
- iconSide="left"
191
- aria-label={t('createFilter')}
192
- >
193
- {t('createFilter')}
194
- </EuiButton>
195
- )}
201
+ </EuiFlexGroup>
196
202
  </EuiFlexGroup>
197
203
  );
198
204
  };
@@ -1,5 +1,6 @@
1
- import React, { useEffect, useMemo } from 'react';
1
+ import React, { useEffect, useMemo, useRef } from 'react';
2
2
  import type { FullOperator, OperatorSelectorProps, OptionGroup } from 'react-querybuilder';
3
+ import { defaultOperators } from 'react-querybuilder';
3
4
 
4
5
  import { EuiSelect } from '@elastic/eui';
5
6
 
@@ -7,6 +8,8 @@ const isOptionGroup = (operator: FullOperator | OptionGroup<FullOperator>): oper
7
8
  'options' in operator;
8
9
 
9
10
  export const WfoOperatorSelector = (props: OperatorSelectorProps) => {
11
+ const { value, handleOnChange } = props;
12
+
10
13
  const flatOptions = (props.options as Array<FullOperator | OptionGroup<FullOperator>>).flatMap((option) =>
11
14
  isOptionGroup(option) ? option.options : [option],
12
15
  );
@@ -16,17 +19,39 @@ export const WfoOperatorSelector = (props: OperatorSelectorProps) => {
16
19
  [flatOptions],
17
20
  );
18
21
 
22
+ const optionsKey = selectOptions.map((option) => option.value).join('|');
23
+ const previousOptionsKeyRef = useRef(optionsKey);
24
+
19
25
  useEffect(() => {
20
- if (props.options && !props.value && selectOptions.length > 0) {
21
- props.handleOnChange(selectOptions[0].value);
26
+ const optionsChanged = previousOptionsKeyRef.current !== optionsKey;
27
+ previousOptionsKeyRef.current = optionsKey;
28
+
29
+ // Reset to the first option only when the field's operator list changes — i.e. the
30
+ // user picked a (different) field, and resetOnFieldChange=false on QueryBuilder
31
+ // preserved an operator that is not valid for it. The `optionsChanged` guard keeps
32
+ // rules restored from CEL (URL or textarea) intact on mount: parseCEL can produce
33
+ // operators outside the prefilled operator lists (e.g. beginsWith), and resetting
34
+ // those here would silently rewrite the user's filter string.
35
+ if (!optionsChanged || selectOptions.length === 0) return;
36
+ const currentValueIsValid = selectOptions.some((option) => option.value === value);
37
+ if (!currentValueIsValid) {
38
+ handleOnChange(selectOptions[0].value);
22
39
  }
23
- }, [props, props.options, props.value, selectOptions]);
40
+ }, [optionsKey, selectOptions, value, handleOnChange]);
41
+
42
+ // A restored operator that falls outside the field's list must still be visible in
43
+ // the dropdown; without it EuiSelect renders an empty selection for the rule.
44
+ const currentValueIsListed = !value || selectOptions.some((option) => option.value === value);
45
+ const displayOptions =
46
+ currentValueIsListed ? selectOptions : (
47
+ [...selectOptions, { value, text: defaultOperators.find((operator) => operator.name === value)?.label ?? value }]
48
+ );
24
49
 
25
50
  return (
26
51
  <EuiSelect
27
- options={selectOptions}
28
- value={props.value}
29
- onChange={(e) => props.handleOnChange(e.target.value)}
52
+ options={displayOptions}
53
+ value={value}
54
+ onChange={(e) => handleOnChange(e.target.value)}
30
55
  disabled={props.disabled}
31
56
  />
32
57
  );