Skip to content

Commit 5c57b39

Browse files
committed
Wizard: Revamp timezone step
Replace the typeahead Select with a Menu-based inline search dropdown for timezone selection, following the PatternFly custom menu pattern. Update step description and add helper text to the Timezone and NTP servers fields.
1 parent 7b71d33 commit 5c57b39

File tree

7 files changed

+185
-161
lines changed

7 files changed

+185
-161
lines changed

playwright/Customizations/Timezone.spec.ts

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,18 @@ test('Create a blueprint with Timezone customization', async ({
4545
await frame.getByRole('button', { name: 'Timezone' }).click();
4646
});
4747

48+
await test.step('Select the Timezone', async () => {
49+
await expect(
50+
frame.getByText('Select a timezone and define NTP servers'),
51+
).toBeVisible();
52+
await frame.getByTestId('timezone-toggle').click();
53+
await frame.getByLabel('Filter timezone').fill('Canada');
54+
await frame.getByRole('menuitem', { name: 'Canada/Saskatchewan' }).click();
55+
await frame.getByText('Canada/Saskatchewan', { exact: true }).click();
56+
await frame.getByLabel('Filter timezone').fill('Europe');
57+
await frame.getByRole('menuitem', { name: 'Europe/Stockholm' }).click();
58+
});
59+
4860
await test.step('Shows all NTP chips when 4 or fewer', async () => {
4961
const ntpInput = frame.getByPlaceholder('Add NTP servers');
5062
for (const server of [
@@ -122,13 +134,7 @@ test('Create a blueprint with Timezone customization', async ({
122134
.click();
123135
});
124136

125-
await test.step('Select and fill the Timezone step', async () => {
126-
await frame.getByPlaceholder('Select a timezone').fill('Canada');
127-
await frame.getByRole('option', { name: 'Canada/Saskatchewan' }).click();
128-
await frame.getByPlaceholder('Select a timezone').fill('');
129-
await frame.getByPlaceholder('Select a timezone').fill('Europe');
130-
await frame.getByRole('option', { name: 'Europe/Stockholm' }).click();
131-
137+
await test.step('Fill NTP servers with validation', async () => {
132138
await frame.getByPlaceholder('Add NTP servers').fill('0.nl.pool.ntp.org');
133139
await frame.getByPlaceholder('Add NTP servers').press('Enter');
134140
await expect(frame.getByText('0.nl.pool.ntp.org')).toBeVisible();
@@ -167,14 +173,13 @@ test('Create a blueprint with Timezone customization', async ({
167173
await frame.getByRole('button', { name: 'Edit blueprint' }).click();
168174
await frame.getByLabel('Revisit Timezone step').click();
169175
await expect(frame.getByText('Canada/Saskatchewan')).toBeHidden();
170-
await expect(frame.getByPlaceholder('Select a timezone')).toHaveValue(
171-
'Europe/Stockholm',
172-
);
173-
await frame.getByPlaceholder('Select a timezone').fill('Europe');
174-
await frame.getByRole('option', { name: 'Europe/Oslo' }).click();
175-
await expect(frame.getByPlaceholder('Select a timezone')).toHaveValue(
176-
'Europe/Oslo',
177-
);
176+
await expect(
177+
frame.getByText('Europe/Stockholm', { exact: true }),
178+
).toBeVisible();
179+
await frame.getByText('Europe/Stockholm', { exact: true }).click();
180+
await frame.getByLabel('Filter timezone').fill('Europe');
181+
await frame.getByRole('menuitem', { name: 'Europe/Oslo' }).click();
182+
await expect(frame.getByText('Europe/Oslo', { exact: true })).toBeVisible();
178183
await expect(frame.getByText('0.nl.pool.ntp.org')).toBeVisible();
179184
await expect(frame.getByText('0.de.pool.ntp.org')).toBeVisible();
180185
await expect(frame.getByText('0.cz.pool.ntp.org')).toBeHidden();
@@ -208,9 +213,7 @@ test('Create a blueprint with Timezone customization', async ({
208213
await test.step('Review imported BP', async () => {
209214
await fillInImageOutputGuest(frame);
210215
await frame.getByRole('button', { name: 'Timezone' }).click();
211-
await expect(frame.getByPlaceholder('Select a timezone')).toHaveValue(
212-
'Europe/Oslo',
213-
);
216+
await expect(frame.getByText('Europe/Oslo', { exact: true })).toBeVisible();
214217
await expect(frame.getByText('0.nl.pool.ntp.org')).toBeVisible();
215218
await expect(frame.getByText('0.de.pool.ntp.org')).toBeVisible();
216219
await expect(frame.getByText('0.cz.pool.ntp.org')).toBeHidden();

src/Components/CreateImageWizard/steps/Timezone/components/NtpServersInput.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from 'react';
22

3-
import { FormGroup } from '@patternfly/react-core';
3+
import { FormGroup, HelperText, HelperTextItem } from '@patternfly/react-core';
44

55
import {
66
addNtpServer,
@@ -31,6 +31,12 @@ const NtpServersInput = () => {
3131
stepValidation={stepValidation}
3232
fieldName='ntpServers'
3333
/>
34+
<HelperText className='pf-v6-u-pt-sm'>
35+
<HelperTextItem>
36+
Specify NTP servers by hostname or IP address. Examples:
37+
server.example.com, 172.16.254.1
38+
</HelperTextItem>
39+
</HelperText>
3440
</FormGroup>
3541
);
3642
};

src/Components/CreateImageWizard/steps/Timezone/components/TimezoneDropDown.tsx

Lines changed: 101 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
1-
import React, { useEffect, useState } from 'react';
1+
import React, { useRef, useState } from 'react';
22

33
import {
4+
Divider,
45
FormGroup,
56
HelperText,
67
HelperTextItem,
78
Label,
9+
Menu,
10+
MenuContainer,
11+
MenuContent,
12+
MenuList,
13+
MenuSearch,
14+
MenuSearchInput,
815
MenuToggle,
9-
MenuToggleElement,
10-
Select,
11-
SelectList,
16+
SearchInput,
1217
SelectOption,
13-
TextInputGroup,
14-
TextInputGroupMain,
1518
} from '@patternfly/react-core';
1619

1720
import { changeTimezone, selectTimezone } from '@/store/slices/wizard';
@@ -29,127 +32,121 @@ const TimezoneDropDown = () => {
2932

3033
const [errorText, setErrorText] = useState(stepValidation.errors['timezone']);
3134
const [isOpen, setIsOpen] = useState(false);
32-
const [inputValue, setInputValue] = useState<string>('');
33-
const [filterValue, setFilterValue] = useState<string>('');
34-
const [selectOptions, setSelectOptions] = useState<string[]>(timezones);
35-
36-
useEffect(() => {
37-
let filteredTimezones = timezones;
38-
39-
if (filterValue) {
40-
const normalizedFilter = filterValue
41-
.toLowerCase()
42-
.replace(/[_/]/g, ' ')
43-
.replace(/\s+/g, ' ')
44-
.trim();
45-
46-
filteredTimezones = timezones.filter((timezone: string) => {
47-
const normalizedTimezone = timezone
48-
.toLowerCase()
49-
.replace(/[_/]/g, ' ')
50-
.replace(/\s+/g, ' ');
51-
return normalizedTimezone.includes(normalizedFilter);
52-
});
53-
54-
if (!isOpen) {
55-
setIsOpen(true);
56-
}
57-
}
58-
59-
const sortedTimezones = [...filteredTimezones].sort((a, b) => {
60-
if (a === DEFAULT_TIMEZONE) return -1;
61-
if (b === DEFAULT_TIMEZONE) return 1;
62-
return 0;
63-
});
35+
const [searchValue, setSearchValue] = useState('');
6436

65-
setSelectOptions(sortedTimezones);
37+
const toggleRef = useRef<HTMLButtonElement>(null);
38+
const menuRef = useRef<HTMLDivElement>(null);
6639

67-
// This useEffect hook should run *only* on when the filter value changes.
68-
// eslint's exhaustive-deps rule does not support this use.
69-
// eslint-disable-next-line react-hooks/exhaustive-deps
70-
}, [filterValue]);
71-
72-
const onInputClick = () => {
40+
const handleSearchChange = (value: string) => {
7341
if (!isOpen) {
7442
setIsOpen(true);
75-
} else if (!inputValue) {
76-
setIsOpen(false);
7743
}
44+
setSearchValue(value);
7845
};
7946

80-
const onSelect = (_event?: React.MouseEvent, value?: string | number) => {
81-
if (value && typeof value === 'string') {
82-
setInputValue(value);
83-
setFilterValue('');
47+
const onSelect = (
48+
_event: React.MouseEvent | undefined,
49+
itemId: string | number | undefined,
50+
) => {
51+
if (itemId && typeof itemId === 'string') {
52+
dispatch(changeTimezone(itemId));
8453
setErrorText('');
85-
dispatch(changeTimezone(value));
54+
setSearchValue('');
8655
setIsOpen(false);
8756
}
8857
};
8958

90-
const onTextInputChange = (_event: React.FormEvent, value: string) => {
91-
setInputValue(value);
92-
setFilterValue(value);
93-
94-
if (value !== timezone) {
95-
dispatch(changeTimezone(''));
96-
}
97-
};
98-
99-
const onToggleClick = () => {
100-
setIsOpen(!isOpen);
101-
};
102-
103-
const toggle = (toggleRef: React.Ref<MenuToggleElement>) => (
59+
const normalizeTimezoneString = (value: string): string =>
60+
value.toLowerCase().replace(/[_/]/g, ' ').replace(/\s+/g, ' ').trim();
61+
62+
const normalizedFilter = normalizeTimezoneString(searchValue);
63+
64+
const filteredTimezones = normalizedFilter
65+
? timezones.filter((tz) =>
66+
normalizeTimezoneString(tz).includes(normalizedFilter),
67+
)
68+
: timezones;
69+
70+
const sortedTimezones = [...filteredTimezones].sort((a, b) => {
71+
if (a === DEFAULT_TIMEZONE) return -1;
72+
if (b === DEFAULT_TIMEZONE) return 1;
73+
return 0;
74+
});
75+
76+
const menuItems = sortedTimezones.map((option) => (
77+
<SelectOption key={option} itemId={option}>
78+
{option}{' '}
79+
{option === DEFAULT_TIMEZONE && (
80+
<Label color='blue' isCompact>
81+
Default
82+
</Label>
83+
)}
84+
</SelectOption>
85+
));
86+
87+
if (searchValue && menuItems.length === 0) {
88+
menuItems.push(
89+
<SelectOption isDisabled key='no-results'>
90+
{`No results found for "${searchValue}"`}
91+
</SelectOption>,
92+
);
93+
}
94+
95+
const toggle = (
10496
<MenuToggle
10597
ref={toggleRef}
106-
variant='typeahead'
107-
onClick={onToggleClick}
98+
onClick={() => setIsOpen(!isOpen)}
10899
isExpanded={isOpen}
100+
isFullWidth
101+
data-testid='timezone-toggle'
109102
>
110-
<TextInputGroup isPlain>
111-
<TextInputGroupMain
112-
value={timezone ? timezone : inputValue}
113-
onClick={onInputClick}
114-
onChange={onTextInputChange}
115-
autoComplete='off'
116-
placeholder='Select a timezone'
117-
isExpanded={isOpen}
118-
/>
119-
</TextInputGroup>
103+
{timezone || 'Select a timezone'}
120104
</MenuToggle>
121105
);
122106

107+
const menu = (
108+
<Menu
109+
ref={menuRef}
110+
onSelect={onSelect}
111+
activeItemId={timezone || ''}
112+
isScrollable
113+
>
114+
<MenuSearch>
115+
<MenuSearchInput>
116+
<SearchInput
117+
value={searchValue}
118+
aria-label='Filter timezone'
119+
onChange={(_event, value) => handleSearchChange(value)}
120+
onClear={(event) => {
121+
event.stopPropagation();
122+
handleSearchChange('');
123+
}}
124+
/>
125+
</MenuSearchInput>
126+
</MenuSearch>
127+
<Divider />
128+
<MenuContent maxMenuHeight='300px'>
129+
<MenuList>{menuItems}</MenuList>
130+
</MenuContent>
131+
</Menu>
132+
);
133+
123134
return (
124135
<FormGroup isRequired={false} label='Timezone'>
125-
<Select
126-
isScrollable
136+
<MenuContainer
137+
menu={menu}
138+
menuRef={menuRef}
139+
toggle={toggle}
140+
toggleRef={toggleRef}
127141
isOpen={isOpen}
128142
onOpenChange={(isOpen) => setIsOpen(isOpen)}
129-
selected={timezone}
130-
onSelect={onSelect}
131-
toggle={toggle}
132-
shouldFocusFirstItemOnOpen={false}
133-
>
134-
<SelectList>
135-
{selectOptions.length > 0 ? (
136-
selectOptions.map((option) => (
137-
<SelectOption key={option} value={option}>
138-
{option}{' '}
139-
{option === DEFAULT_TIMEZONE && (
140-
<Label color='blue' isCompact>
141-
Default
142-
</Label>
143-
)}
144-
</SelectOption>
145-
))
146-
) : (
147-
<SelectOption isDisabled>
148-
{`No results found for "${filterValue}"`}
149-
</SelectOption>
150-
)}
151-
</SelectList>
152-
</Select>
143+
onOpenChangeKeys={['Escape']}
144+
/>
145+
<HelperText className='pf-v6-u-pt-sm'>
146+
<HelperTextItem>
147+
Network time servers for system clock synchronization
148+
</HelperTextItem>
149+
</HelperText>
153150
{errorText && (
154151
<HelperText>
155152
<HelperTextItem variant={'error'}>{errorText}</HelperTextItem>

src/Components/CreateImageWizard/steps/Timezone/index.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { Content, Form, Title } from '@patternfly/react-core';
55
import NtpServersInput from './components/NtpServersInput';
66
import TimezoneDropDown from './components/TimezoneDropDown';
77

8-
import { DEFAULT_TIMEZONE } from '../../../../constants';
98
import { CustomizationLabels } from '../../../sharedComponents/CustomizationLabels';
109

1110
const TimezoneStep = () => {
@@ -16,9 +15,8 @@ const TimezoneStep = () => {
1615
Timezone
1716
</Title>
1817
<Content>
19-
Select a timezone for your image. The default timezone is &apos;
20-
{DEFAULT_TIMEZONE}&apos;. You can also configure NTP servers - multiple
21-
servers can be added.
18+
Select a timezone and define NTP servers to ensure your image maintains
19+
accurate system time upon deployment.
2220
</Content>
2321
<TimezoneDropDown />
2422
<NtpServersInput />

0 commit comments

Comments
 (0)