@orchestrator-ui/orchestrator-ui-components 8.9.2 → 8.9.3
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.
- package/.turbo/turbo-build.log +7 -7
- package/.turbo/turbo-lint.log +1 -1
- package/.turbo/turbo-test.log +294 -18
- package/CHANGELOG.md +9 -0
- package/dist/index.d.ts +17 -4
- package/dist/index.js +1637 -1450
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/WfoKeyValueTable/styles.ts +24 -0
- package/src/components/WfoPydanticForm/fields/WfoLabel.tsx +5 -1
- package/src/components/WfoPydanticForm/fields/styles.ts +12 -3
- package/src/components/WfoSettingsModal/WfoInformationModal.tsx +3 -2
- package/src/components/WfoTable/WfoAdvancedTable/WfoAdvancedTable.tsx +5 -1
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoApplyFilterButton.tsx +37 -0
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoDebounceCountdown.tsx +58 -0
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoFieldSelector.spec.tsx +196 -0
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoFieldSelector.tsx +71 -50
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoFilterBuilder.tsx +24 -92
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoRangeEditor.tsx +0 -1
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoStructuredSearchTable.tsx +2 -2
- package/src/components/WfoTable/WfoStructuredSearchTable/WfoValueEditor.tsx +9 -13
- package/src/components/WfoTable/WfoStructuredSearchTable/styles.ts +7 -0
- package/src/components/WfoTable/WfoStructuredSearchTable/useSearchWithDebouncedCallback.spec.tsx +94 -0
- package/src/components/WfoTable/WfoStructuredSearchTable/utils.ts +109 -1
- package/src/configuration/version.ts +1 -1
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useDebouncedCallback.ts +48 -0
- package/src/hooks/usePathAutoComplete.spec.tsx +94 -9
- package/src/hooks/usePathAutoComplete.ts +10 -15
- package/src/hooks/useSearchPagination.ts +1 -1
- package/src/messages/en-GB.json +3 -1
- package/src/messages/nl-NL.json +3 -1
- package/src/types/search.ts +1 -2
package/package.json
CHANGED
|
@@ -63,8 +63,32 @@ export const getStyles = ({ theme }: WfoThemeHelpers) => {
|
|
|
63
63
|
color: theme.colors.textParagraph,
|
|
64
64
|
display: 'flex',
|
|
65
65
|
alignItems: 'center',
|
|
66
|
+
flexWrap: 'wrap',
|
|
67
|
+
gap: theme.size.s,
|
|
66
68
|
overflowWrap: 'anywhere',
|
|
67
69
|
whiteSpace: 'pre-wrap',
|
|
70
|
+
|
|
71
|
+
'& > *': {
|
|
72
|
+
maxWidth: '100%',
|
|
73
|
+
flexGrow: 1,
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
'& > .euiBadge + .euiBadge': {
|
|
77
|
+
marginInlineStart: 0,
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
'&:has(.euiInlineEdit), & > :has(.euiInlineEdit)': {
|
|
81
|
+
flexGrow: 1,
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
'.euiInlineEditText .euiButtonEmpty': {
|
|
85
|
+
blockSize: 'auto',
|
|
86
|
+
whiteSpace: 'normal',
|
|
87
|
+
textAlign: 'start',
|
|
88
|
+
},
|
|
89
|
+
'.euiInlineEditText .eui-textTruncate': {
|
|
90
|
+
whiteSpace: 'pre-wrap !important',
|
|
91
|
+
},
|
|
68
92
|
});
|
|
69
93
|
|
|
70
94
|
const clipboardIconStyle = css({
|
|
@@ -2,6 +2,8 @@ import React from 'react';
|
|
|
2
2
|
|
|
3
3
|
import { PydanticFormElement } from 'pydantic-forms';
|
|
4
4
|
|
|
5
|
+
import { euiFontSizeFromScale } from '@elastic/eui';
|
|
6
|
+
|
|
5
7
|
import { useOrchestratorTheme } from '@/hooks';
|
|
6
8
|
|
|
7
9
|
export const WfoLabel: PydanticFormElement = ({ pydanticFormField }) => {
|
|
@@ -11,7 +13,9 @@ export const WfoLabel: PydanticFormElement = ({ pydanticFormField }) => {
|
|
|
11
13
|
<div data-testid={pydanticFormField.id}>
|
|
12
14
|
<label
|
|
13
15
|
css={{
|
|
14
|
-
|
|
16
|
+
fontSize: euiFontSizeFromScale('m', theme),
|
|
17
|
+
fontWeight: theme.font.weight.semiBold,
|
|
18
|
+
color: theme.colors.link,
|
|
15
19
|
display: 'block',
|
|
16
20
|
}}
|
|
17
21
|
id={pydanticFormField.id}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { euiFontSizeFromScale, euiLineHeightFromBaseline } from '@elastic/eui';
|
|
1
2
|
import { css } from '@emotion/react';
|
|
2
3
|
|
|
3
4
|
import type { WfoThemeHelpers } from '@/hooks';
|
|
@@ -114,15 +115,23 @@ export const getCommonFormFieldStyles = ({ theme }: WfoThemeHelpers) => {
|
|
|
114
115
|
color: theme.colors.textParagraph,
|
|
115
116
|
},
|
|
116
117
|
'.euiFormLabel': {
|
|
117
|
-
|
|
118
|
+
fontSize: euiFontSizeFromScale('m', theme),
|
|
119
|
+
lineHeight: euiLineHeightFromBaseline('m', theme),
|
|
120
|
+
color: theme.colors.link,
|
|
118
121
|
cursor: 'text',
|
|
119
|
-
'&.euiFormLabel-
|
|
120
|
-
color: theme.colors.
|
|
122
|
+
'&.euiFormLabel-isInvalid': {
|
|
123
|
+
color: theme.colors.textDanger,
|
|
121
124
|
},
|
|
122
125
|
},
|
|
123
126
|
'.euiFormRow__labelWrapper': {
|
|
124
127
|
display: 'flex',
|
|
125
128
|
flexDirection: 'column',
|
|
129
|
+
'> .euiText': {
|
|
130
|
+
fontSize: euiFontSizeFromScale('s', theme),
|
|
131
|
+
lineHeight: euiLineHeightFromBaseline('s', theme),
|
|
132
|
+
fontWeight: theme.font.weight.regular,
|
|
133
|
+
color: theme.colors.textParagraph,
|
|
134
|
+
},
|
|
126
135
|
},
|
|
127
136
|
});
|
|
128
137
|
|
|
@@ -14,11 +14,12 @@ export type WfoInformationModalProps = {
|
|
|
14
14
|
title: string;
|
|
15
15
|
onClose: () => void;
|
|
16
16
|
children: ReactNode;
|
|
17
|
+
maxWidth?: number;
|
|
17
18
|
};
|
|
18
19
|
|
|
19
|
-
export const WfoInformationModal: FC<WfoInformationModalProps> = ({ title, onClose, children }) => {
|
|
20
|
+
export const WfoInformationModal: FC<WfoInformationModalProps> = ({ title, onClose, children, maxWidth }) => {
|
|
20
21
|
return (
|
|
21
|
-
<EuiModal onClose={onClose}>
|
|
22
|
+
<EuiModal onClose={onClose} maxWidth={maxWidth}>
|
|
22
23
|
<EuiModalHeader>
|
|
23
24
|
<EuiModalHeaderTitle size="xs">{title}</EuiModalHeaderTitle>
|
|
24
25
|
</EuiModalHeader>
|
|
@@ -190,7 +190,11 @@ export const WfoAdvancedTable = <T extends object>({
|
|
|
190
190
|
)}
|
|
191
191
|
|
|
192
192
|
{rowDetailData && (
|
|
193
|
-
<WfoInformationModal
|
|
193
|
+
<WfoInformationModal
|
|
194
|
+
title={detailModalTitle}
|
|
195
|
+
onClose={() => setSelectedDataForDetailModal(undefined)}
|
|
196
|
+
maxWidth={theme.breakpoint.m}
|
|
197
|
+
>
|
|
194
198
|
<WfoKeyValueTable keyValues={rowDetailData} showCopyToClipboardIcon />
|
|
195
199
|
</WfoInformationModal>
|
|
196
200
|
)}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from 'next-intl';
|
|
4
|
+
|
|
5
|
+
import { EuiButton } from '@elastic/eui';
|
|
6
|
+
|
|
7
|
+
import { WfoDebounceCountdown } from '@/components/WfoTable/WfoStructuredSearchTable/WfoDebounceCountdown';
|
|
8
|
+
import { getWfoStructuredSearchTableStyles } from '@/components/WfoTable/WfoStructuredSearchTable/styles';
|
|
9
|
+
import { DebouncedPendingRun, useWithOrchestratorTheme } from '@/hooks';
|
|
10
|
+
|
|
11
|
+
interface ApplyFilterButtonProps {
|
|
12
|
+
isDisabled: boolean;
|
|
13
|
+
pendingSearchRun?: DebouncedPendingRun;
|
|
14
|
+
onClick: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const WfoApplyFilterButton = ({ isDisabled, pendingSearchRun, onClick }: ApplyFilterButtonProps) => {
|
|
18
|
+
const t = useTranslations('common');
|
|
19
|
+
const { applyFilterContentStyles } = useWithOrchestratorTheme(getWfoStructuredSearchTableStyles);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<EuiButton
|
|
23
|
+
onClick={onClick}
|
|
24
|
+
id={'button-apply-filter'}
|
|
25
|
+
data-test-id={'button-apply-filter'}
|
|
26
|
+
fill
|
|
27
|
+
type="submit"
|
|
28
|
+
aria-label={t('applyFilter')}
|
|
29
|
+
disabled={isDisabled}
|
|
30
|
+
>
|
|
31
|
+
<span css={applyFilterContentStyles}>
|
|
32
|
+
{pendingSearchRun && <WfoDebounceCountdown key={pendingSearchRun.id} durationMs={pendingSearchRun.delay} />}
|
|
33
|
+
{t('applyFilter')}
|
|
34
|
+
</span>
|
|
35
|
+
</EuiButton>
|
|
36
|
+
);
|
|
37
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import { keyframes } from '@emotion/react';
|
|
4
|
+
|
|
5
|
+
const SIZE = 14;
|
|
6
|
+
const STROKE_WIDTH = 2;
|
|
7
|
+
const CENTER = SIZE / 2;
|
|
8
|
+
const RADIUS = (SIZE - STROKE_WIDTH) / 2;
|
|
9
|
+
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
|
10
|
+
|
|
11
|
+
const sweep = keyframes({
|
|
12
|
+
from: { strokeDashoffset: CIRCUMFERENCE },
|
|
13
|
+
to: { strokeDashoffset: 0 },
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
interface WfoDebounceCountdownProps {
|
|
17
|
+
durationMs: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Ring that fills over `durationMs`, showing how long is left before the pending search runs.
|
|
22
|
+
* Render it with a key that changes per scheduled run, so the sweep restarts from empty every
|
|
23
|
+
* time an edit pushes the debounce back.
|
|
24
|
+
*/
|
|
25
|
+
export const WfoDebounceCountdown = ({ durationMs }: WfoDebounceCountdownProps) => (
|
|
26
|
+
<svg width={SIZE} height={SIZE} viewBox={`0 0 ${SIZE} ${SIZE}`} aria-hidden="true" css={{ flex: 'none' }}>
|
|
27
|
+
<circle
|
|
28
|
+
cx={CENTER}
|
|
29
|
+
cy={CENTER}
|
|
30
|
+
r={RADIUS}
|
|
31
|
+
fill="none"
|
|
32
|
+
stroke="currentColor"
|
|
33
|
+
strokeWidth={STROKE_WIDTH}
|
|
34
|
+
opacity={0.3}
|
|
35
|
+
/>
|
|
36
|
+
<circle
|
|
37
|
+
cx={CENTER}
|
|
38
|
+
cy={CENTER}
|
|
39
|
+
r={RADIUS}
|
|
40
|
+
fill="none"
|
|
41
|
+
stroke="currentColor"
|
|
42
|
+
strokeWidth={STROKE_WIDTH}
|
|
43
|
+
strokeLinecap="round"
|
|
44
|
+
strokeDasharray={CIRCUMFERENCE}
|
|
45
|
+
// Start the sweep at 12 o'clock instead of 3 o'clock.
|
|
46
|
+
transform={`rotate(-90 ${CENTER} ${CENTER})`}
|
|
47
|
+
css={{
|
|
48
|
+
strokeDashoffset: CIRCUMFERENCE,
|
|
49
|
+
animation: `${sweep} ${durationMs}ms linear forwards`,
|
|
50
|
+
// A ring that jumps straight to full is still an honest "a search is queued" marker.
|
|
51
|
+
'@media (prefers-reduced-motion: reduce)': {
|
|
52
|
+
animation: 'none',
|
|
53
|
+
strokeDashoffset: 0,
|
|
54
|
+
},
|
|
55
|
+
}}
|
|
56
|
+
/>
|
|
57
|
+
</svg>
|
|
58
|
+
);
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders the filter builder's field selector on its own with the autocomplete hook mocked,
|
|
3
|
+
* so the tests control which paths the "backend" returns for a typed prefix.
|
|
4
|
+
*/
|
|
5
|
+
import React, { type ComponentProps } from 'react';
|
|
6
|
+
import { defaultPlaceholderFieldName } from 'react-querybuilder';
|
|
7
|
+
|
|
8
|
+
import '@testing-library/jest-dom';
|
|
9
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
10
|
+
|
|
11
|
+
import { usePathAutocomplete } from '@/hooks';
|
|
12
|
+
import type { PathInfo, WfoQueryBuilderContext } from '@/types';
|
|
13
|
+
|
|
14
|
+
import { WfoFieldSelector } from './WfoFieldSelector';
|
|
15
|
+
|
|
16
|
+
jest.mock('@/hooks', () => ({
|
|
17
|
+
usePathAutocomplete: jest.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
jest.mock('next-intl', () => ({
|
|
21
|
+
useTranslations: () => (key: string) => key,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
// EuiComboBox sizes its search input by measuring text on a canvas, which jsdom does not implement.
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
jest
|
|
27
|
+
.spyOn(HTMLCanvasElement.prototype, 'getContext')
|
|
28
|
+
.mockReturnValue({ font: '', measureText: () => ({ width: 0 }) } as unknown as CanvasRenderingContext2D);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const SAPS_PATH_INFO = {
|
|
32
|
+
path: 'saps',
|
|
33
|
+
type: 'component',
|
|
34
|
+
operators: ['has_component', 'not_has_component'],
|
|
35
|
+
value_schema: {},
|
|
36
|
+
group: 'component',
|
|
37
|
+
ui_types: ['component'],
|
|
38
|
+
availablePaths: ['saps.port'],
|
|
39
|
+
} as unknown as PathInfo;
|
|
40
|
+
|
|
41
|
+
const PREFILLED_FIELD_OPTIONS = new Map([
|
|
42
|
+
['subscription.insync', ['eq', 'neq']],
|
|
43
|
+
['subscription.status', ['eq', 'neq', 'like']],
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
// The mocked backend only knows saps, returned for any prefix of it.
|
|
47
|
+
const mockAutocomplete = (loading = false) =>
|
|
48
|
+
jest.mocked(usePathAutocomplete).mockImplementation((prefix) => ({
|
|
49
|
+
paths: prefix && 'saps'.startsWith(prefix) ? [SAPS_PATH_INFO] : [],
|
|
50
|
+
loading,
|
|
51
|
+
error: null,
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
const renderFieldSelector = (field: string = defaultPlaceholderFieldName) => {
|
|
55
|
+
const handleOnChange = jest.fn();
|
|
56
|
+
const onFieldSelected = jest.fn();
|
|
57
|
+
const context: WfoQueryBuilderContext = {
|
|
58
|
+
onFieldSelected,
|
|
59
|
+
prefilledFieldOptions: PREFILLED_FIELD_OPTIONS,
|
|
60
|
+
fieldPathInfoMap: new Map(),
|
|
61
|
+
useAdvancedNestedSearch: true,
|
|
62
|
+
};
|
|
63
|
+
const props = {
|
|
64
|
+
handleOnChange,
|
|
65
|
+
rule: { field, operator: '=', value: '' },
|
|
66
|
+
context,
|
|
67
|
+
} as unknown as ComponentProps<typeof WfoFieldSelector>;
|
|
68
|
+
|
|
69
|
+
render(<WfoFieldSelector {...props} />);
|
|
70
|
+
return { handleOnChange, onFieldSelected };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const getSearchInput = () => screen.getByRole('combobox');
|
|
74
|
+
const typeSearchTerm = (searchTerm: string) => fireEvent.change(getSearchInput(), { target: { value: searchTerm } });
|
|
75
|
+
const requestedPrefixes = () => jest.mocked(usePathAutocomplete).mock.calls.map(([prefix]) => prefix);
|
|
76
|
+
const listedOptions = () => screen.queryAllByRole('option').map((option) => option.textContent);
|
|
77
|
+
|
|
78
|
+
describe('WfoFieldSelector', () => {
|
|
79
|
+
beforeEach(() => mockAutocomplete());
|
|
80
|
+
|
|
81
|
+
it('does not look up the placeholder field and only shows a typing hint before typing', () => {
|
|
82
|
+
renderFieldSelector();
|
|
83
|
+
|
|
84
|
+
fireEvent.focus(getSearchInput());
|
|
85
|
+
|
|
86
|
+
expect(requestedPrefixes().every((prefix) => prefix === '')).toBe(true);
|
|
87
|
+
expect(listedOptions()).toEqual(['startTypingToLoadOptions']);
|
|
88
|
+
expect(screen.getByRole('option', { name: 'startTypingToLoadOptions' })).toHaveAttribute('aria-disabled', 'true');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('shows the loading state instead of a no-match message while nothing is listed yet', () => {
|
|
92
|
+
mockAutocomplete(true);
|
|
93
|
+
renderFieldSelector();
|
|
94
|
+
|
|
95
|
+
typeSearchTerm('unknown');
|
|
96
|
+
|
|
97
|
+
expect(screen.getByText('Loading options')).toBeInTheDocument();
|
|
98
|
+
expect(listedOptions()).toEqual([]);
|
|
99
|
+
expect(screen.queryByText(/doesn't match any options/)).not.toBeInTheDocument();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('shows the loading state when the previous paths do not match the new text', () => {
|
|
103
|
+
// The hook keeps the previous lookup's paths until the new ones arrive.
|
|
104
|
+
jest
|
|
105
|
+
.mocked(usePathAutocomplete)
|
|
106
|
+
.mockImplementation(() => ({ paths: [SAPS_PATH_INFO], loading: true, error: null }));
|
|
107
|
+
renderFieldSelector();
|
|
108
|
+
|
|
109
|
+
typeSearchTerm('sax');
|
|
110
|
+
|
|
111
|
+
expect(listedOptions()).toEqual([]);
|
|
112
|
+
expect(screen.getByText('Loading options')).toBeInTheDocument();
|
|
113
|
+
expect(screen.queryByText(/doesn't match any options/)).not.toBeInTheDocument();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('keeps listed options visible while the next lookup runs and spins inside the field', () => {
|
|
117
|
+
mockAutocomplete(true);
|
|
118
|
+
renderFieldSelector();
|
|
119
|
+
|
|
120
|
+
typeSearchTerm('sa');
|
|
121
|
+
|
|
122
|
+
expect(listedOptions()).toEqual(['saps', 'saps.port']);
|
|
123
|
+
expect(screen.queryByText('Loading options')).not.toBeInTheDocument();
|
|
124
|
+
expect(document.querySelector('.euiFormControlLayoutIcons [role="progressbar"]')).toBeInTheDocument();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('looks up the typed term and lists the returned paths', () => {
|
|
128
|
+
renderFieldSelector();
|
|
129
|
+
|
|
130
|
+
typeSearchTerm('sa');
|
|
131
|
+
|
|
132
|
+
expect(requestedPrefixes()).toContain('sa');
|
|
133
|
+
expect(listedOptions()).toEqual(['saps', 'saps.port']);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('selects a listed path and reports its operators and path info', () => {
|
|
137
|
+
const { handleOnChange, onFieldSelected } = renderFieldSelector();
|
|
138
|
+
|
|
139
|
+
typeSearchTerm('sa');
|
|
140
|
+
fireEvent.click(screen.getByRole('option', { name: 'saps' }));
|
|
141
|
+
|
|
142
|
+
expect(handleOnChange).toHaveBeenCalledWith('saps');
|
|
143
|
+
expect(onFieldSelected).toHaveBeenCalledWith('saps', SAPS_PATH_INFO.operators, SAPS_PATH_INFO);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('drops the options again as soon as the input is cleared', () => {
|
|
147
|
+
renderFieldSelector();
|
|
148
|
+
|
|
149
|
+
typeSearchTerm('sa');
|
|
150
|
+
expect(listedOptions()).toEqual(['saps', 'saps.port']);
|
|
151
|
+
|
|
152
|
+
typeSearchTerm('');
|
|
153
|
+
|
|
154
|
+
expect(listedOptions()).toEqual(['startTypingToLoadOptions']);
|
|
155
|
+
});
|
|
156
|
+
|
|
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
|
+
it('does not offer the raw typed text as an option and reports no match once loaded', () => {
|
|
168
|
+
renderFieldSelector();
|
|
169
|
+
|
|
170
|
+
typeSearchTerm('unknown');
|
|
171
|
+
|
|
172
|
+
expect(listedOptions()).toEqual([]);
|
|
173
|
+
expect(screen.getByText(/doesn't match any options/)).toBeInTheDocument();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('shows a restored field as selected without an autocomplete request', () => {
|
|
177
|
+
renderFieldSelector('subscription.insync');
|
|
178
|
+
|
|
179
|
+
// A plain-text single selection is shown as the value of the search input.
|
|
180
|
+
expect(getSearchInput()).toHaveValue('subscription.insync');
|
|
181
|
+
expect(requestedPrefixes().every((prefix) => prefix === '')).toBe(true);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('lists options for the selected field as soon as the selector is opened', () => {
|
|
185
|
+
renderFieldSelector('saps');
|
|
186
|
+
|
|
187
|
+
fireEvent.focus(getSearchInput());
|
|
188
|
+
|
|
189
|
+
expect(requestedPrefixes()).toContain('saps');
|
|
190
|
+
expect(listedOptions()).toEqual(['saps', 'saps.port']);
|
|
191
|
+
|
|
192
|
+
fireEvent.blur(getSearchInput());
|
|
193
|
+
|
|
194
|
+
expect(jest.mocked(usePathAutocomplete).mock.lastCall?.[0]).toBe('');
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import React, { FC, useEffect, useRef, useState } from 'react';
|
|
2
|
-
import {
|
|
1
|
+
import React, { FC, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { FieldSelectorProps, defaultPlaceholderFieldName } from 'react-querybuilder';
|
|
3
4
|
|
|
4
5
|
import { useTranslations } from 'next-intl';
|
|
5
6
|
|
|
6
7
|
import type { EuiComboBoxOptionOption } from '@elastic/eui';
|
|
7
|
-
import { EuiComboBox } from '@elastic/eui';
|
|
8
|
+
import { EuiComboBox, EuiLoadingSpinner, EuiText } from '@elastic/eui';
|
|
8
9
|
|
|
9
10
|
import { usePathAutocomplete } from '@/hooks';
|
|
10
11
|
import { EntityKind, PathInfo, WfoQueryBuilderContext } from '@/types';
|
|
@@ -37,7 +38,10 @@ interface WfoFieldSelectorProps extends Omit<FieldSelectorProps, 'context'> {
|
|
|
37
38
|
export const WfoFieldSelector: FC<WfoFieldSelectorProps> = ({ handleOnChange, disabled, rule, context }) => {
|
|
38
39
|
const { field } = rule;
|
|
39
40
|
const { useAdvancedNestedSearch, prefilledFieldOptions, onFieldSelected } = context;
|
|
40
|
-
const [
|
|
41
|
+
const [autoFocus] = useState(field === defaultPlaceholderFieldName);
|
|
42
|
+
const selectedField = field === defaultPlaceholderFieldName ? '' : field;
|
|
43
|
+
const [searchTerm, setSearchTerm] = useState('');
|
|
44
|
+
const [hasFocus, setHasFocus] = useState(false);
|
|
41
45
|
const [searchInput, setSearchInput] = useState<HTMLInputElement | null>(null);
|
|
42
46
|
const optionsRef = useRef<EuiComboBoxOptionOption<string>[]>([]);
|
|
43
47
|
const handleFieldSelectionRef = useRef<(selected: EuiComboBoxOptionOption<string>[]) => void>(() => {});
|
|
@@ -49,39 +53,51 @@ export const WfoFieldSelector: FC<WfoFieldSelectorProps> = ({ handleOnChange, di
|
|
|
49
53
|
|
|
50
54
|
const isSelectablePath = (path: string) => useAdvancedNestedSearch || !path.includes('.');
|
|
51
55
|
|
|
52
|
-
const getOptionsFromPathInfo = (pathInfos: PathInfo[]): EuiComboBoxOptionOption<string>[] =>
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
pathInfos.forEach((pathInfo) => {
|
|
56
|
-
[pathInfo.path, ...(pathInfo.availablePaths ?? [])].filter(isSelectablePath).forEach((path) => {
|
|
57
|
-
pathOptions.push(getOption(path));
|
|
58
|
-
});
|
|
59
|
-
});
|
|
60
|
-
return (
|
|
61
|
-
pathOptions.length > 0 ? pathOptions
|
|
62
|
-
: selectedValue ? [getOption(selectedValue)]
|
|
63
|
-
: []
|
|
56
|
+
const getOptionsFromPathInfo = (pathInfos: PathInfo[]): EuiComboBoxOptionOption<string>[] =>
|
|
57
|
+
pathInfos.flatMap((pathInfo) =>
|
|
58
|
+
[pathInfo.path, ...(pathInfo.availablePaths ?? [])].filter(isSelectablePath).map(getOption),
|
|
64
59
|
);
|
|
65
|
-
};
|
|
66
60
|
|
|
61
|
+
const trimmedSearchTerm = searchTerm.trim();
|
|
62
|
+
const autocompletePrefix = trimmedSearchTerm || (hasFocus ? selectedField : '');
|
|
67
63
|
const {
|
|
68
64
|
paths,
|
|
69
65
|
loading: isLoading,
|
|
70
66
|
error: errorMessage,
|
|
71
|
-
} = usePathAutocomplete(
|
|
67
|
+
} = usePathAutocomplete(autocompletePrefix, EntityKind.SUBSCRIPTION);
|
|
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
|
+
);
|
|
72
75
|
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
const placeholderOption: EuiComboBoxOptionOption<string> = {
|
|
76
|
-
label: '──────',
|
|
76
|
+
const startTypingHintOption: EuiComboBoxOptionOption<string> = {
|
|
77
|
+
label: t('startTypingToLoadOptions'),
|
|
77
78
|
disabled: true,
|
|
78
79
|
};
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
const options: EuiComboBoxOptionOption<string>[] =
|
|
81
|
+
autocompletePrefix ? [...prefilledFields.map(getOption), ...autocompleteOptions] : [startTypingHintOption];
|
|
82
|
+
|
|
83
|
+
const renderHintOption = (option: EuiComboBoxOptionOption<string>) => (
|
|
84
|
+
<EuiText size="xs" color="default">
|
|
85
|
+
{option.label}
|
|
86
|
+
</EuiText>
|
|
87
|
+
);
|
|
88
|
+
const normalizedSearchTerm = trimmedSearchTerm.toLowerCase();
|
|
89
|
+
const hasListedOptions =
|
|
90
|
+
autocompletePrefix !== ''
|
|
91
|
+
&& options.some(
|
|
92
|
+
(option) =>
|
|
93
|
+
!option.disabled && option.value !== selectedField && option.label.toLowerCase().includes(normalizedSearchTerm),
|
|
94
|
+
);
|
|
95
|
+
const showLoadingState = isLoading && !hasListedOptions;
|
|
96
|
+
const showFieldSpinner = isLoading && hasListedOptions;
|
|
97
|
+
const fieldIconsContainer = useMemo(
|
|
98
|
+
() => searchInput?.closest('.euiFormControlLayout')?.querySelector('.euiFormControlLayoutIcons') ?? null,
|
|
99
|
+
[searchInput],
|
|
100
|
+
);
|
|
85
101
|
|
|
86
102
|
const storeFieldOperators = (selectedValue: string) => {
|
|
87
103
|
const matchingPath =
|
|
@@ -95,7 +111,7 @@ export const WfoFieldSelector: FC<WfoFieldSelectorProps> = ({ handleOnChange, di
|
|
|
95
111
|
const handleFieldSelection = (selectedOptions: EuiComboBoxOptionOption<string>[]) => {
|
|
96
112
|
const selectedOption = selectedOptions[0];
|
|
97
113
|
const selectedValue = selectedOption?.value || '';
|
|
98
|
-
|
|
114
|
+
setSearchTerm('');
|
|
99
115
|
storeFieldOperators(selectedValue);
|
|
100
116
|
|
|
101
117
|
handleOnChange(selectedValue);
|
|
@@ -143,26 +159,31 @@ export const WfoFieldSelector: FC<WfoFieldSelectorProps> = ({ handleOnChange, di
|
|
|
143
159
|
}, [searchInput]);
|
|
144
160
|
|
|
145
161
|
return (
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
162
|
+
<>
|
|
163
|
+
{showFieldSpinner
|
|
164
|
+
&& fieldIconsContainer
|
|
165
|
+
&& createPortal(<EuiLoadingSpinner size="m" aria-label={t('loadingOptions')} />, fieldIconsContainer)}
|
|
166
|
+
<EuiComboBox
|
|
167
|
+
placeholder={t('searchFieldsPlaceholder')}
|
|
168
|
+
options={options}
|
|
169
|
+
renderOption={autocompletePrefix ? undefined : renderHintOption}
|
|
170
|
+
fullWidth={true}
|
|
171
|
+
selectedOptions={selectedField ? [getOption(selectedField)] : []}
|
|
172
|
+
onChange={(selectedOptions) => {
|
|
173
|
+
handleFieldSelection(selectedOptions);
|
|
174
|
+
}}
|
|
175
|
+
onSearchChange={setSearchTerm}
|
|
176
|
+
onFocus={() => setHasFocus(true)}
|
|
177
|
+
onBlur={() => setHasFocus(false)}
|
|
178
|
+
inputRef={setSearchInput}
|
|
179
|
+
autoFocus={autoFocus}
|
|
180
|
+
singleSelection={{ asPlainText: true }}
|
|
181
|
+
isLoading={showLoadingState}
|
|
182
|
+
isClearable
|
|
183
|
+
isInvalid={!!errorMessage}
|
|
184
|
+
isDisabled={disabled}
|
|
185
|
+
rowHeight={30}
|
|
186
|
+
/>
|
|
187
|
+
</>
|
|
167
188
|
);
|
|
168
189
|
};
|