@centreon/ui 26.10.8 → 26.10.10
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/package.json +6 -3
- package/public/mockServiceWorker.js +31 -8
- package/src/Dashboard/Dashboard.browser.test.tsx +107 -0
- package/src/Form/CollapsibleGroup.tsx +6 -33
- package/src/Form/Form.browser.test.tsx +553 -0
- package/src/Form/Form.stories.tsx +102 -1
- package/src/Form/Form.tsx +4 -3
- package/src/Form/Inputs/ExclusiveCheckboxGroup.browser.test.tsx +304 -0
- package/src/Form/Inputs/Grid.browser.test.tsx +186 -0
- package/src/Form/Inputs/Grid.tsx +59 -12
- package/src/Form/Inputs/PasswordInput.browser.test.tsx +80 -0
- package/src/Form/Inputs/SegmentedButtons.browser.test.tsx +197 -0
- package/src/Form/Inputs/SegmentedButtons.tsx +38 -29
- package/src/Form/Inputs/Text.tsx +13 -1
- package/src/Form/Inputs/index.tsx +10 -52
- package/src/Form/Inputs/models.ts +6 -0
- package/src/Form/Section/FormSection.browser.test.tsx +174 -0
- package/src/Form/Section/FormSection.tsx +20 -6
- package/src/Form/Section/navigateToSection.ts +26 -8
- package/src/Form/Section/useActiveSection.ts +213 -0
- package/src/InputField/Number/Number.browser.test.tsx +109 -0
- package/src/InputField/Select/Autocomplete/Connected/Multi/MultiConnectedAutocompleteField.browser.test.tsx +250 -0
- package/src/InputField/Select/Autocomplete/Connected/index.test.tsx +37 -0
- package/src/InputField/Select/Autocomplete/Connected/index.tsx +56 -45
- package/src/Listing/Cell/DataCell.tsx +0 -2
- package/src/Listing/Listing.browser.test.tsx +476 -0
- package/src/Module/Module.browser.test.tsx +145 -0
- package/src/Pagination/Pagination.browser.test.tsx +184 -0
- package/src/RichTextEditor/RichTextEditor.browser.test.tsx +567 -0
- package/src/RichTextEditor/plugins/FloatingLinkEditorPlugin.tsx +3 -1
- package/src/TimePeriods/ResolutionTimePeriod.browser.test.tsx +143 -0
- package/src/TimePeriods/TimePeriods.browser.test.tsx +468 -0
- package/src/Typography/FluidTypography/FluidTypography.browser.test.tsx +54 -0
- package/src/api/useMutationQuery/useMutationQuery.browser.test.tsx +154 -0
- package/src/components/CollapsibleItem/CollapsibleItem.browser.test.tsx +120 -0
- package/src/components/CopyCommand/CopyCommand.browser.test.tsx +287 -0
- package/src/components/CrudPage/CrudPage.browser.test.tsx +659 -0
- package/src/components/DataTable/DataTable.browser.test.tsx +257 -0
- package/src/components/Form/AccessRights/AccessRights.browser.test.tsx +527 -0
- package/src/components/Header/PageHeader/PageHeader.stories.tsx +9 -0
- package/src/components/Header/PageHeader/PageHeaderMain.tsx +4 -2
- package/src/components/Header/PageHeader/PageHeaderTitle.tsx +61 -18
- package/src/components/ItemComposition/ItemComposition.browser.test.tsx +172 -0
- package/src/components/Layout/PageLayout.browser.test.tsx +114 -0
- package/src/components/Modal/ConfirmationModal/ConfirmationModal.browser.test.tsx +237 -0
- package/src/components/SortableEntriesList/SortableEntriesList.browser.test.tsx +981 -0
- package/src/components/Tabs/Tabs.browser.test.tsx +115 -0
- package/src/components/Tabs/Tabs.tsx +7 -1
- package/src/utils/useFullscreen/Fullscreen.browser.test.tsx +176 -0
- package/src/utils/useLocale/useLocale.browser.test.tsx +49 -0
- package/src/utils/useLocaleTimezoneDate/{LocaleTimezoneDate.cypress.spec.tsx → LocaleTimezoneDate.browser.test.tsx} +24 -11
- package/src/Dashboard/Dashboard.cypress.spec.tsx +0 -71
- package/src/Form/Form.cypress.spec.tsx +0 -306
- package/src/Form/Inputs/ExclusiveCheckboxGroup.cypress.spec.tsx +0 -236
- package/src/Form/Inputs/SegmentedButtons.cypress.spec.tsx +0 -101
- package/src/InputField/Number/Number.cypress.spec.tsx +0 -89
- package/src/InputField/Select/Autocomplete/Connected/Multi/MultiConnectedAutocompleteField.cypress.spec.tsx +0 -182
- package/src/Listing/Listing.cypress.spec.tsx +0 -304
- package/src/Module/Module.cypress.spec.tsx +0 -127
- package/src/Pagination/Pagination.cypress.spec.tsx +0 -140
- package/src/RichTextEditor/RichTextEditor.cypress.spec.tsx +0 -408
- package/src/TimePeriods/ResolutionTimePeriod.cypress.spec.tsx +0 -74
- package/src/TimePeriods/TimePeriods.cypress.spec.tsx +0 -488
- package/src/Typography/FluidTypography/FluidTypography.cypress.spec.tsx +0 -27
- package/src/api/useMutationQuery/useMutationQuery.cypress.spec.tsx +0 -93
- package/src/components/CollapsibleItem/CollapsibleItem.cypress.spec.tsx +0 -76
- package/src/components/CopyCommand/CopyCommand.cypress.spec.tsx +0 -167
- package/src/components/CrudPage/CrudPage.cypress.spec.tsx +0 -561
- package/src/components/DataTable/DataTable.cypress.spec.tsx +0 -174
- package/src/components/Form/AccessRights/AccessRights.cypress.spec.tsx +0 -376
- package/src/components/ItemComposition/ItemComposition.cypress.spec.tsx +0 -112
- package/src/components/Layout/PageLayout.cypress.spec.tsx +0 -64
- package/src/components/Modal/ConfirmationModal/ConfirmationModal.cypress.spec.tsx +0 -171
- package/src/components/SortableEntriesList/SortableEntriesList.cypress.spec.tsx +0 -792
- package/src/components/Tabs/Tabs.cypress.spec.tsx +0 -69
- package/src/utils/useFullscreen/Fullscreen.cypress.spec.tsx +0 -130
- package/src/utils/useLocale/useLocale.cypress.spec.tsx +0 -40
|
@@ -1,9 +1,27 @@
|
|
|
1
|
-
export const
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
)
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
export const sectionAnchorAttribute = 'data-section-group-form-id';
|
|
2
|
+
|
|
3
|
+
export const getSectionAnchors = (container: HTMLElement): Array<HTMLElement> =>
|
|
4
|
+
Array.from(
|
|
5
|
+
container.querySelectorAll<HTMLElement>(`[${sectionAnchorAttribute}]`)
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
interface NavigateToSectionProps {
|
|
9
|
+
container: HTMLElement | null;
|
|
10
|
+
sectionName: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// The lookup stays inside the form: two forms can share a group name.
|
|
14
|
+
export const navigateToSection = ({
|
|
15
|
+
container,
|
|
16
|
+
sectionName
|
|
17
|
+
}: NavigateToSectionProps): void => {
|
|
18
|
+
if (!container) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const section = getSectionAnchors(container).find(
|
|
23
|
+
(anchor) => anchor.getAttribute(sectionAnchorAttribute) === sectionName
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
section?.scrollIntoView({ behavior: 'smooth' });
|
|
9
27
|
};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type RefObject,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState
|
|
7
|
+
} from 'react';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
getSectionAnchors,
|
|
11
|
+
navigateToSection,
|
|
12
|
+
sectionAnchorAttribute
|
|
13
|
+
} from './navigateToSection';
|
|
14
|
+
|
|
15
|
+
// Distance from the top of the scroll area under which a section is the one in
|
|
16
|
+
// view: the pinned tab bar plus the scroll margin of the group titles.
|
|
17
|
+
const sectionOffset = 64;
|
|
18
|
+
// `scrollend` is missing from older browsers, and is not fired when the
|
|
19
|
+
// clicked section is already in place.
|
|
20
|
+
const scrollEndFallbackDelay = 1000;
|
|
21
|
+
|
|
22
|
+
const getScrollParent = (element: HTMLElement | null): HTMLElement | null => {
|
|
23
|
+
let parent = element?.parentElement ?? null;
|
|
24
|
+
|
|
25
|
+
while (parent && parent !== document.body) {
|
|
26
|
+
// An ancestor that can scroll but has nothing to scroll (no height of its
|
|
27
|
+
// own) is not the one the user moves: keep looking further up.
|
|
28
|
+
if (
|
|
29
|
+
/(auto|scroll)/.test(getComputedStyle(parent).overflowY) &&
|
|
30
|
+
parent.scrollHeight > parent.clientHeight
|
|
31
|
+
) {
|
|
32
|
+
return parent;
|
|
33
|
+
}
|
|
34
|
+
parent = parent.parentElement;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
interface ComputeActiveSectionProps {
|
|
41
|
+
container: HTMLElement;
|
|
42
|
+
scrollParent: HTMLElement | null;
|
|
43
|
+
sectionNames: Array<string>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const computeActiveSection = ({
|
|
47
|
+
container,
|
|
48
|
+
scrollParent,
|
|
49
|
+
sectionNames
|
|
50
|
+
}: ComputeActiveSectionProps): string => {
|
|
51
|
+
const scrollingElement = scrollParent ?? document.documentElement;
|
|
52
|
+
|
|
53
|
+
// A tab can have no section in the form: only the sections displayed count.
|
|
54
|
+
const sections = getSectionAnchors(container)
|
|
55
|
+
.filter((anchor) =>
|
|
56
|
+
sectionNames.includes(anchor.getAttribute(sectionAnchorAttribute) || '')
|
|
57
|
+
)
|
|
58
|
+
.map((anchor) => ({
|
|
59
|
+
name: anchor.getAttribute(sectionAnchorAttribute) as string,
|
|
60
|
+
top: anchor.getBoundingClientRect().top
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
if (!sections.length) {
|
|
64
|
+
return sectionNames[0];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const isScrolledToBottom =
|
|
68
|
+
scrollingElement.scrollTop > 0 &&
|
|
69
|
+
scrollingElement.scrollTop + scrollingElement.clientHeight >=
|
|
70
|
+
scrollingElement.scrollHeight - 1;
|
|
71
|
+
|
|
72
|
+
// The last sections can be too short to ever reach the top.
|
|
73
|
+
if (isScrolledToBottom) {
|
|
74
|
+
return (
|
|
75
|
+
[...sectionNames]
|
|
76
|
+
.reverse()
|
|
77
|
+
.find((name) => sections.some((section) => section.name === name)) ||
|
|
78
|
+
sectionNames[0]
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const line =
|
|
83
|
+
(scrollParent ? scrollParent.getBoundingClientRect().top : 0) +
|
|
84
|
+
sectionOffset;
|
|
85
|
+
|
|
86
|
+
const passedSections = sections.filter(({ top }) => top <= line);
|
|
87
|
+
|
|
88
|
+
if (!passedSections.length) {
|
|
89
|
+
return sectionNames.find((name) =>
|
|
90
|
+
sections.some((section) => section.name === name)
|
|
91
|
+
) as string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return passedSections.reduce((closest, section) =>
|
|
95
|
+
section.top > closest.top ? section : closest
|
|
96
|
+
).name;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
interface UseActiveSectionProps {
|
|
100
|
+
containerRef: RefObject<HTMLElement | null>;
|
|
101
|
+
isEnabled: boolean;
|
|
102
|
+
sectionNames: Array<string>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface UseActiveSection {
|
|
106
|
+
activeSection: string;
|
|
107
|
+
goToSection: (sectionName: string) => void;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const useActiveSection = ({
|
|
111
|
+
containerRef,
|
|
112
|
+
isEnabled,
|
|
113
|
+
sectionNames
|
|
114
|
+
}: UseActiveSectionProps): UseActiveSection => {
|
|
115
|
+
const [activeSection, setActiveSection] = useState(sectionNames[0]);
|
|
116
|
+
|
|
117
|
+
const isScrollingToSection = useRef(false);
|
|
118
|
+
const scrollTargetRef = useRef<HTMLElement | Window | null>(null);
|
|
119
|
+
const fallbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(
|
|
120
|
+
undefined
|
|
121
|
+
);
|
|
122
|
+
const stopWaitingRef = useRef<(() => void) | undefined>(undefined);
|
|
123
|
+
const refreshActiveSectionRef = useRef<(() => void) | undefined>(undefined);
|
|
124
|
+
|
|
125
|
+
const sectionNamesKey = sectionNames.join('\n');
|
|
126
|
+
|
|
127
|
+
const stopWaitingForScrollEnd = useCallback((): void => {
|
|
128
|
+
stopWaitingRef.current?.();
|
|
129
|
+
stopWaitingRef.current = undefined;
|
|
130
|
+
clearTimeout(fallbackTimeoutRef.current);
|
|
131
|
+
isScrollingToSection.current = false;
|
|
132
|
+
}, []);
|
|
133
|
+
|
|
134
|
+
// The scroll events fired while waiting are ignored, and none fires when the
|
|
135
|
+
// clicked section is already in place: the position is read again once done.
|
|
136
|
+
const endScrollWait = useCallback((): void => {
|
|
137
|
+
stopWaitingForScrollEnd();
|
|
138
|
+
refreshActiveSectionRef.current?.();
|
|
139
|
+
}, [stopWaitingForScrollEnd]);
|
|
140
|
+
|
|
141
|
+
const goToSection = useCallback(
|
|
142
|
+
(sectionName: string): void => {
|
|
143
|
+
stopWaitingForScrollEnd();
|
|
144
|
+
|
|
145
|
+
// The clicked tab is the active one until the smooth scroll ends, so the
|
|
146
|
+
// highlight does not run through the sections in between.
|
|
147
|
+
setActiveSection(sectionName);
|
|
148
|
+
isScrollingToSection.current = true;
|
|
149
|
+
|
|
150
|
+
const target = scrollTargetRef.current;
|
|
151
|
+
target?.addEventListener('scrollend', endScrollWait, { once: true });
|
|
152
|
+
stopWaitingRef.current = () =>
|
|
153
|
+
target?.removeEventListener('scrollend', endScrollWait);
|
|
154
|
+
fallbackTimeoutRef.current = setTimeout(
|
|
155
|
+
endScrollWait,
|
|
156
|
+
scrollEndFallbackDelay
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
navigateToSection({ container: containerRef.current, sectionName });
|
|
160
|
+
},
|
|
161
|
+
[containerRef, endScrollWait, stopWaitingForScrollEnd]
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
const container = containerRef.current;
|
|
166
|
+
|
|
167
|
+
if (!isEnabled || !container) {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const scrollParent = getScrollParent(container);
|
|
172
|
+
const scrollTarget = scrollParent ?? window;
|
|
173
|
+
scrollTargetRef.current = scrollTarget;
|
|
174
|
+
|
|
175
|
+
let animationFrame = 0;
|
|
176
|
+
|
|
177
|
+
const refresh = (): void => {
|
|
178
|
+
setActiveSection(
|
|
179
|
+
computeActiveSection({
|
|
180
|
+
container,
|
|
181
|
+
scrollParent,
|
|
182
|
+
sectionNames: sectionNamesKey.split('\n')
|
|
183
|
+
})
|
|
184
|
+
);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const update = (): void => {
|
|
188
|
+
if (isScrollingToSection.current) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
refresh();
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const onScroll = (): void => {
|
|
196
|
+
cancelAnimationFrame(animationFrame);
|
|
197
|
+
animationFrame = requestAnimationFrame(update);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
refreshActiveSectionRef.current = refresh;
|
|
201
|
+
scrollTarget.addEventListener('scroll', onScroll, { passive: true });
|
|
202
|
+
update();
|
|
203
|
+
|
|
204
|
+
return () => {
|
|
205
|
+
scrollTarget.removeEventListener('scroll', onScroll);
|
|
206
|
+
cancelAnimationFrame(animationFrame);
|
|
207
|
+
refreshActiveSectionRef.current = undefined;
|
|
208
|
+
stopWaitingForScrollEnd();
|
|
209
|
+
};
|
|
210
|
+
}, [containerRef, isEnabled, sectionNamesKey, stopWaitingForScrollEnd]);
|
|
211
|
+
|
|
212
|
+
return { activeSection, goToSection };
|
|
213
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { rs } from '@rstest/core';
|
|
2
|
+
import { render } from '@test-setup/render';
|
|
3
|
+
import { waitFor } from '@testing-library/react';
|
|
4
|
+
import userEvent from '@testing-library/user-event';
|
|
5
|
+
|
|
6
|
+
import NumberField, { type NumberProps } from './Number';
|
|
7
|
+
|
|
8
|
+
const initialize = (props: NumberProps): void => {
|
|
9
|
+
render(<NumberField {...props} />);
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const getInput = (): HTMLInputElement =>
|
|
13
|
+
document.querySelector('input') as HTMLInputElement;
|
|
14
|
+
|
|
15
|
+
const getFieldWidth = (): number =>
|
|
16
|
+
(
|
|
17
|
+
getInput().closest('.MuiTextField-root') as HTMLElement
|
|
18
|
+
).getBoundingClientRect().width;
|
|
19
|
+
|
|
20
|
+
describe('Number field', () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
expect.hasAssertions();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('displays the placeholder when the field has no default or fallback values', () => {
|
|
26
|
+
initialize({ dataTestId: 'test', onChange: rs.fn() });
|
|
27
|
+
|
|
28
|
+
expect(getInput()).toHaveValue(null);
|
|
29
|
+
expect(getInput()).toHaveAttribute('placeholder', '0');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('displays the fallback value as placeholder when the field as no default value', () => {
|
|
33
|
+
initialize({ dataTestId: 'test', fallbackValue: 25, onChange: rs.fn() });
|
|
34
|
+
|
|
35
|
+
expect(getInput()).toHaveValue(null);
|
|
36
|
+
expect(getInput()).toHaveAttribute('placeholder', '25');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('displays the default value as placeholder when the field as default value', () => {
|
|
40
|
+
initialize({ dataTestId: 'test', defaultValue: 25, onChange: rs.fn() });
|
|
41
|
+
|
|
42
|
+
expect(getInput()).toHaveValue(25);
|
|
43
|
+
// The value only: no placeholder behind it.
|
|
44
|
+
expect(getInput()).not.toHaveAttribute('placeholder');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('displays the fallback value when the field is cleared out', async () => {
|
|
48
|
+
const onChange = rs.fn();
|
|
49
|
+
|
|
50
|
+
initialize({
|
|
51
|
+
dataTestId: 'test',
|
|
52
|
+
defaultValue: 25,
|
|
53
|
+
fallbackValue: 10,
|
|
54
|
+
onChange
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(getInput()).toHaveValue(25);
|
|
58
|
+
await userEvent.clear(getInput());
|
|
59
|
+
await waitFor(() => {
|
|
60
|
+
expect(getInput()).toHaveValue(null);
|
|
61
|
+
});
|
|
62
|
+
expect(getInput()).toHaveAttribute('placeholder', '10');
|
|
63
|
+
expect(onChange).toHaveBeenLastCalledWith(10);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('displays the field using auto size', async () => {
|
|
67
|
+
initialize({
|
|
68
|
+
autoSize: true,
|
|
69
|
+
dataTestId: 'test',
|
|
70
|
+
defaultValue: 25,
|
|
71
|
+
onChange: rs.fn()
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(getInput()).toHaveValue(25);
|
|
75
|
+
|
|
76
|
+
await userEvent.type(getInput(), '0');
|
|
77
|
+
|
|
78
|
+
await waitFor(() => {
|
|
79
|
+
expect(getInput()).toHaveValue(250);
|
|
80
|
+
});
|
|
81
|
+
// A narrow field sized on its value, where a field without auto size has
|
|
82
|
+
// the 178px default width.
|
|
83
|
+
expect(getFieldWidth()).toBeLessThan(100);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('sets the value to the minimum value configured when a value less than the minimum value is typed in the field', async () => {
|
|
87
|
+
initialize({
|
|
88
|
+
dataTestId: 'test',
|
|
89
|
+
defaultValue: 25,
|
|
90
|
+
onChange: rs.fn(),
|
|
91
|
+
textFieldSlotsAndSlotProps: {
|
|
92
|
+
slotProps: {
|
|
93
|
+
htmlInput: {
|
|
94
|
+
min: 2
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(getInput()).toHaveValue(25);
|
|
101
|
+
|
|
102
|
+
await userEvent.clear(getInput());
|
|
103
|
+
await userEvent.type(getInput(), '-1');
|
|
104
|
+
|
|
105
|
+
await waitFor(() => {
|
|
106
|
+
expect(getInput()).toHaveValue(2);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Method,
|
|
3
|
+
MultiConnectedAutocompleteField,
|
|
4
|
+
type SelectEntry,
|
|
5
|
+
TestQueryProvider
|
|
6
|
+
} from '@centreon/ui';
|
|
7
|
+
|
|
8
|
+
import { interceptAPIRequest, waitForRequest } from '@test-setup/api';
|
|
9
|
+
import { render } from '@test-setup/render';
|
|
10
|
+
import { screen, waitFor, within } from '@testing-library/react';
|
|
11
|
+
import userEvent from '@testing-library/user-event';
|
|
12
|
+
import i18next from 'i18next';
|
|
13
|
+
import { useState } from 'react';
|
|
14
|
+
import { initReactI18next } from 'react-i18next';
|
|
15
|
+
|
|
16
|
+
import { labelSelectAll, labelUnSelectAll } from '../../../../translatedLabels';
|
|
17
|
+
import { baseEndpoint, getEndpoint, label, placeholder } from './utils';
|
|
18
|
+
|
|
19
|
+
const optionOne = 'My Option 1';
|
|
20
|
+
|
|
21
|
+
// The option list and search result the endpoint mocks return.
|
|
22
|
+
const listOptions = {
|
|
23
|
+
meta: { limit: 5, page: 1, total: 5 },
|
|
24
|
+
result: [
|
|
25
|
+
{ id: 0, name: 'My Option 1' },
|
|
26
|
+
{ id: 1, name: 'My Option 2' },
|
|
27
|
+
{ id: 2, name: 'My Option 3' },
|
|
28
|
+
{ id: 3, name: 'My Option 4' },
|
|
29
|
+
{ id: 4, name: 'My Option 5' }
|
|
30
|
+
]
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const searchedOption = {
|
|
34
|
+
meta: { limit: 1, page: 1, total: 1 },
|
|
35
|
+
result: [{ id: 0, name: 'My Option 1' }]
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const optionNames = listOptions.result.map(({ name }) => name);
|
|
39
|
+
|
|
40
|
+
// Passed down to the multi autocomplete, though the connected field's type
|
|
41
|
+
// does not declare it.
|
|
42
|
+
const multiAutocompleteProps: Record<string, unknown> = {
|
|
43
|
+
disableSelectAll: false
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const Component = (): JSX.Element => {
|
|
47
|
+
const [values, setValues] = useState<Array<SelectEntry>>([]);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<TestQueryProvider>
|
|
51
|
+
<div style={{ paddingTop: 20 }}>
|
|
52
|
+
<MultiConnectedAutocompleteField
|
|
53
|
+
{...multiAutocompleteProps}
|
|
54
|
+
field="host.name"
|
|
55
|
+
getEndpoint={getEndpoint}
|
|
56
|
+
label={label}
|
|
57
|
+
onChange={(_, item) => setValues(item as Array<SelectEntry>)}
|
|
58
|
+
placeholder={placeholder}
|
|
59
|
+
value={values}
|
|
60
|
+
/>
|
|
61
|
+
</div>
|
|
62
|
+
</TestQueryProvider>
|
|
63
|
+
);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const getInput = (): HTMLInputElement =>
|
|
67
|
+
document.querySelector(
|
|
68
|
+
'[data-testid="Multi Connected Autocomplete"]'
|
|
69
|
+
) as HTMLInputElement;
|
|
70
|
+
|
|
71
|
+
// The last one: a closing list can still be in the page.
|
|
72
|
+
const findListOptions = async (): Promise<HTMLElement> =>
|
|
73
|
+
(await screen.findAllByRole('presentation')).at(-1) as HTMLElement;
|
|
74
|
+
|
|
75
|
+
const getOptionTexts = (listOptionsElement: HTMLElement): Array<string> =>
|
|
76
|
+
within(listOptionsElement)
|
|
77
|
+
.queryAllByText(/^My Option \d$/)
|
|
78
|
+
.map((option) => option.textContent ?? '');
|
|
79
|
+
|
|
80
|
+
const getOptionCheckboxes = (listOptionsElement: HTMLElement) =>
|
|
81
|
+
within(listOptionsElement).getAllByRole('checkbox');
|
|
82
|
+
|
|
83
|
+
describe('Multi connected autocomplete', () => {
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
expect.hasAssertions();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
i18next.use(initReactI18next).init({
|
|
90
|
+
lng: 'en',
|
|
91
|
+
resources: {}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
interceptAPIRequest({
|
|
95
|
+
alias: 'getListOptions',
|
|
96
|
+
method: Method.GET,
|
|
97
|
+
path: `${baseEndpoint}**`,
|
|
98
|
+
response: listOptions
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
interceptAPIRequest({
|
|
102
|
+
alias: 'getSearchedOption',
|
|
103
|
+
method: Method.GET,
|
|
104
|
+
path: `${baseEndpoint}**`,
|
|
105
|
+
query: {
|
|
106
|
+
name: 'search',
|
|
107
|
+
value: JSON.stringify({
|
|
108
|
+
$and: [{ $and: [{ 'host.name': { $lk: `%${optionOne}%` } }] }]
|
|
109
|
+
})
|
|
110
|
+
},
|
|
111
|
+
response: searchedOption
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
render(<Component />);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('displays and hides the list when the user double-clicks on the input', async () => {
|
|
118
|
+
expect((await screen.findAllByText(label))[0]).toBeVisible();
|
|
119
|
+
|
|
120
|
+
const input = getInput();
|
|
121
|
+
|
|
122
|
+
await userEvent.click(input);
|
|
123
|
+
|
|
124
|
+
await waitForRequest('@getListOptions');
|
|
125
|
+
|
|
126
|
+
expect(input).toHaveAttribute('placeholder', placeholder);
|
|
127
|
+
|
|
128
|
+
const listOptionsElement = await findListOptions();
|
|
129
|
+
|
|
130
|
+
expect(listOptionsElement).toBeVisible();
|
|
131
|
+
|
|
132
|
+
// The list re-renders its options: compared by text, not by element.
|
|
133
|
+
await waitFor(() => {
|
|
134
|
+
expect(getOptionTexts(listOptionsElement)).toEqual(optionNames);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// The count of options, the select all button and the five options
|
|
138
|
+
// unchecked.
|
|
139
|
+
expect(
|
|
140
|
+
within(listOptionsElement).getByText('5 element(s) found')
|
|
141
|
+
).toBeVisible();
|
|
142
|
+
expect(within(listOptionsElement).getByText(labelSelectAll)).toBeVisible();
|
|
143
|
+
getOptionCheckboxes(listOptionsElement).forEach((checkbox) => {
|
|
144
|
+
expect(checkbox).not.toBeChecked();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await userEvent.click(input);
|
|
148
|
+
|
|
149
|
+
await waitFor(() => {
|
|
150
|
+
expect(listOptionsElement).not.toBeInTheDocument();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// No option list, and nothing selected.
|
|
154
|
+
expect(screen.queryByRole('presentation')).not.toBeInTheDocument();
|
|
155
|
+
expect(screen.queryByTestId('CancelIcon')).not.toBeInTheDocument();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('displays all options on the list when the user searches for and selects an option', async () => {
|
|
159
|
+
const input = getInput();
|
|
160
|
+
|
|
161
|
+
await userEvent.click(input);
|
|
162
|
+
|
|
163
|
+
await waitForRequest('@getListOptions');
|
|
164
|
+
|
|
165
|
+
const listOptionsElement = await findListOptions();
|
|
166
|
+
|
|
167
|
+
expect(listOptionsElement).toBeVisible();
|
|
168
|
+
|
|
169
|
+
await userEvent.type(input, optionOne);
|
|
170
|
+
|
|
171
|
+
await waitForRequest('@getSearchedOption');
|
|
172
|
+
|
|
173
|
+
await waitFor(() => {
|
|
174
|
+
expect(listOptionsElement.querySelectorAll('li')).toHaveLength(6);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await userEvent.click(document.querySelectorAll('[type="checkbox"]')[0]);
|
|
178
|
+
|
|
179
|
+
const inputContainer = input.parentElement as HTMLElement;
|
|
180
|
+
|
|
181
|
+
expect(await within(inputContainer).findByText(optionOne)).toBeVisible();
|
|
182
|
+
expect(within(inputContainer).getByTestId('CancelIcon')).toBeVisible();
|
|
183
|
+
|
|
184
|
+
await waitForRequest('@getListOptions');
|
|
185
|
+
|
|
186
|
+
await waitFor(() => {
|
|
187
|
+
expect(listOptionsElement.querySelectorAll('li')).toHaveLength(
|
|
188
|
+
listOptions.result.length + 1
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// The list re-renders its options: compared by text, not by element.
|
|
193
|
+
await waitFor(() => {
|
|
194
|
+
expect(getOptionTexts(listOptionsElement)).toEqual(optionNames);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('checks all options when Select all button is clicked', async () => {
|
|
199
|
+
const input = getInput();
|
|
200
|
+
|
|
201
|
+
await userEvent.click(input);
|
|
202
|
+
|
|
203
|
+
expect(await screen.findByText('5 element(s) found')).toBeInTheDocument();
|
|
204
|
+
|
|
205
|
+
await waitForRequest('@getListOptions');
|
|
206
|
+
|
|
207
|
+
await userEvent.click(await screen.findByText(labelSelectAll));
|
|
208
|
+
|
|
209
|
+
expect(await screen.findByText(labelUnSelectAll)).toBeVisible();
|
|
210
|
+
|
|
211
|
+
await waitFor(() => {
|
|
212
|
+
expect(screen.getAllByTestId('CancelIcon')).toHaveLength(5);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// A chip per option in the field, in order, and every option checked.
|
|
216
|
+
expect(
|
|
217
|
+
within(input.parentElement as HTMLElement)
|
|
218
|
+
.getAllByText(/^My Option \d$/)
|
|
219
|
+
.map((chip) => chip.textContent)
|
|
220
|
+
).toEqual(optionNames);
|
|
221
|
+
getOptionCheckboxes(await findListOptions()).forEach((checkbox) => {
|
|
222
|
+
expect(checkbox).toBeChecked();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('unchecks all options when unSelect all button is clicked', async () => {
|
|
227
|
+
const input = getInput();
|
|
228
|
+
|
|
229
|
+
await userEvent.click(input);
|
|
230
|
+
|
|
231
|
+
expect(await screen.findByText('5 element(s) found')).toBeInTheDocument();
|
|
232
|
+
|
|
233
|
+
await waitForRequest('@getListOptions');
|
|
234
|
+
|
|
235
|
+
await userEvent.click(await screen.findByText(labelSelectAll));
|
|
236
|
+
await userEvent.click(await screen.findByText(labelUnSelectAll));
|
|
237
|
+
|
|
238
|
+
expect(await screen.findByText(labelSelectAll)).toBeVisible();
|
|
239
|
+
|
|
240
|
+
await waitFor(() => {
|
|
241
|
+
expect(screen.queryAllByTestId('CancelIcon')).toHaveLength(0);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// The field is empty and every option unchecked.
|
|
245
|
+
expect(input).toHaveAttribute('placeholder', placeholder);
|
|
246
|
+
getOptionCheckboxes(await findListOptions()).forEach((checkbox) => {
|
|
247
|
+
expect(checkbox).not.toBeChecked();
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
resetMocks,
|
|
9
9
|
waitFor
|
|
10
10
|
} from '../../../../../test/testRenderer';
|
|
11
|
+
import fetchMock from 'jest-fetch-mock';
|
|
12
|
+
|
|
11
13
|
import buildListingEndpoint from '../../../../api/buildListingEndpoint';
|
|
12
14
|
import type {
|
|
13
15
|
BuildListingEndpointParameters,
|
|
@@ -127,4 +129,39 @@ describe(SingleConnectedAutocompleteField, () => {
|
|
|
127
129
|
);
|
|
128
130
|
});
|
|
129
131
|
});
|
|
132
|
+
|
|
133
|
+
it('leaves no request rejected unhandled when it is closed while its options load', async () => {
|
|
134
|
+
const unhandledRejections: Array<unknown> = [];
|
|
135
|
+
const onUnhandledRejection = (reason: unknown): void => {
|
|
136
|
+
unhandledRejections.push(reason);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
process.on('unhandledRejection', onUnhandledRejection);
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
// Options that never arrive: the request is still running at unmount,
|
|
143
|
+
// which cancels it.
|
|
144
|
+
fetchMock.mockResponse(() => new Promise(() => undefined));
|
|
145
|
+
|
|
146
|
+
const { getByLabelText, unmount } =
|
|
147
|
+
renderSingleConnectedAutocompleteField();
|
|
148
|
+
|
|
149
|
+
fireEvent.click(getByLabelText('Open'));
|
|
150
|
+
|
|
151
|
+
await waitFor(() => {
|
|
152
|
+
expect(getFetchCall(0)).toEqual(`${baseEndpoint}?page=1`);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
unmount();
|
|
156
|
+
|
|
157
|
+
// Unhandled rejections are reported once the microtask queue drains.
|
|
158
|
+
await new Promise((resolve) => {
|
|
159
|
+
setTimeout(resolve, 50);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(unhandledRejections).toEqual([]);
|
|
163
|
+
} finally {
|
|
164
|
+
process.off('unhandledRejection', onUnhandledRejection);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
130
167
|
});
|