Wizard: Add Languages drop down

This adds a drop down for languages selection. The options are populated with an ouput of `localectl list-locales` and sorted to display results starting with the search term first.

Also added tests.
This commit is contained in:
regexowl 2024-12-06 14:41:04 +01:00 committed by Lucas Garfield
parent 9324a33a74
commit ad02609038
3 changed files with 590 additions and 9 deletions

View file

@ -1,9 +1,175 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { FormGroup } from '@patternfly/react-core';
import {
Button,
Chip,
ChipGroup,
FormGroup,
MenuToggle,
MenuToggleElement,
Select,
SelectList,
SelectOption,
TextInputGroup,
TextInputGroupMain,
TextInputGroupUtilities,
} from '@patternfly/react-core';
import { TimesIcon } from '@patternfly/react-icons';
import { useAppDispatch, useAppSelector } from '../../../../../store/hooks';
import {
addLanguage,
removeLanguage,
selectLanguages,
} from '../../../../../store/wizardSlice';
import { languagesList } from '../languagesList';
const LanguagesDropDown = () => {
return <FormGroup isRequired={false} label="Languages"></FormGroup>;
const languages = useAppSelector(selectLanguages);
const dispatch = useAppDispatch();
const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState<string>('');
const [filterValue, setFilterValue] = useState<string>('');
const [selectOptions, setSelectOptions] = useState<string[]>(languagesList);
useEffect(() => {
let filteredLanguages = languagesList;
if (filterValue) {
filteredLanguages = languagesList.filter((language: string) =>
String(language).toLowerCase().includes(filterValue.toLowerCase())
);
if (!filteredLanguages.length) {
filteredLanguages = [`No results found for "${filterValue}"`];
}
if (!isOpen) {
setIsOpen(true);
}
}
setSelectOptions(filteredLanguages.sort((a, b) => sortfn(a, b)));
// This useEffect hook should run *only* on when the filter value changes.
// eslint's exhaustive-deps rule does not support this use.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filterValue]);
const sortfn = (a: string, b: string) => {
const aLang = a.toLowerCase();
const bLang = b.toLowerCase();
// check for languages that start with the search term
if (aLang.startsWith(filterValue) && !bLang.startsWith(filterValue)) {
return -1;
}
if (bLang.startsWith(filterValue) && !aLang.startsWith(filterValue)) {
return 1;
}
// if both (or neither) start with the search term
// sort alphabetically
if (aLang < bLang) {
return -1;
}
if (bLang < aLang) {
return 1;
}
return 0;
};
const onToggle = (isOpen: boolean) => {
setIsOpen(!isOpen);
};
const onInputClick = () => {
if (!isOpen) {
setIsOpen(true);
} else if (!inputValue) {
setIsOpen(false);
}
};
const onSelect = (_event: React.MouseEvent, value: string) => {
if (value && !value.includes('No results')) {
setInputValue('');
setFilterValue('');
dispatch(addLanguage(value));
setIsOpen(false);
}
};
const onTextInputChange = (_event: React.FormEvent, value: string) => {
setInputValue(value);
setFilterValue(value);
};
const onToggleClick = () => {
setIsOpen(!isOpen);
};
const onClearButtonClick = () => {
setInputValue('');
setFilterValue('');
};
const toggle = (toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle
ref={toggleRef}
variant="typeahead"
onClick={onToggleClick}
isExpanded={isOpen}
isFullWidth
>
<TextInputGroup isPlain>
<TextInputGroupMain
value={inputValue}
onClick={onInputClick}
onChange={onTextInputChange}
autoComplete="off"
placeholder="Select a language"
isExpanded={isOpen}
/>
{inputValue && (
<TextInputGroupUtilities>
<Button
variant="plain"
onClick={onClearButtonClick}
aria-label="Clear input"
>
<TimesIcon />
</Button>
</TextInputGroupUtilities>
)}
</TextInputGroup>
</MenuToggle>
);
return (
<FormGroup isRequired={false} label="Languages">
<Select
isScrollable
isOpen={isOpen}
selected={inputValue}
onSelect={onSelect}
onOpenChange={onToggle}
toggle={toggle}
shouldFocusFirstItemOnOpen={false}
>
<SelectList>
{selectOptions.map((option) => (
<SelectOption key={option} value={option}>
{option}
</SelectOption>
))}
</SelectList>
</Select>
<ChipGroup numChips={5} className="pf-v5-u-mt-sm pf-v5-u-w-100">
{languages?.map((lang) => (
<Chip key={lang} onClick={() => dispatch(removeLanguage(lang))}>
{lang}
</Chip>
))}
</ChipGroup>
</FormGroup>
);
};
export default LanguagesDropDown;

View file

@ -0,0 +1,328 @@
export const languagesList = [
'aa_DJ.UTF-8',
'aa_ER.UTF-8',
'aa_ET.UTF-8',
'af_ZA.UTF-8',
'agr_PE.UTF-8',
'ak_GH.UTF-8',
'am_ET.UTF-8',
'an_ES.UTF-8',
'anp_IN.UTF-8',
'ar_AE.UTF-8',
'ar_BH.UTF-8',
'ar_DZ.UTF-8',
'ar_EG.UTF-8',
'ar_IN.UTF-8',
'ar_IQ.UTF-8',
'ar_JO.UTF-8',
'ar_KW.UTF-8',
'ar_LB.UTF-8',
'ar_LY.UTF-8',
'ar_MA.UTF-8',
'ar_OM.UTF-8',
'ar_QA.UTF-8',
'ar_SA.UTF-8',
'ar_SD.UTF-8',
'ar_SS.UTF-8',
'ar_SY.UTF-8',
'ar_TN.UTF-8',
'ar_YE.UTF-8',
'as_IN.UTF-8',
'ast_ES.UTF-8',
'ayc_PE.UTF-8',
'az_AZ.UTF-8',
'az_IR.UTF-8',
'be_BY.UTF-8',
'be_BY.UTF-8@latin',
'bem_ZM.UTF-8',
'ber_DZ.UTF-8',
'ber_MA.UTF-8',
'bg_BG.UTF-8',
'bhb_IN.UTF-8',
'bho_IN.UTF-8',
'bho_NP.UTF-8',
'bi_VU.UTF-8',
'bn_BD.UTF-8',
'bn_IN.UTF-8',
'bo_CN.UTF-8',
'bo_IN.UTF-8',
'br_FR.UTF-8',
'brx_IN.UTF-8',
'bs_BA.UTF-8',
'byn_ER.UTF-8',
'ca_AD.UTF-8',
'ca_ES.UTF-8',
'ca_ES.UTF-8@valencia',
'ca_FR.UTF-8',
'ca_IT.UTF-8',
'ce_RU.UTF-8',
'chr_US.UTF-8',
'ckb_IQ.UTF-8',
'cmn_TW.UTF-8',
'crh_RU.UTF-8',
'crh_UA.UTF-8',
'csb_PL.UTF-8',
'cs_CZ.UTF-8',
'C.UTF-8',
'cv_RU.UTF-8',
'cy_GB.UTF-8',
'da_DK.UTF-8',
'de_AT.UTF-8',
'de_BE.UTF-8',
'de_CH.UTF-8',
'de_DE.UTF-8',
'de_IT.UTF-8',
'de_LI.UTF-8',
'de_LU.UTF-8',
'doi_IN.UTF-8',
'dsb_DE.UTF-8',
'dv_MV.UTF-8',
'dz_BT.UTF-8',
'el_CY.UTF-8',
'el_GR.UTF-8',
'en_AG.UTF-8',
'en_AU.UTF-8',
'en_BW.UTF-8',
'en_CA.UTF-8',
'en_DK.UTF-8',
'en_GB.UTF-8',
'en_HK.UTF-8',
'en_IE.UTF-8',
'en_IL.UTF-8',
'en_IN.UTF-8',
'en_NG.UTF-8',
'en_NZ.UTF-8',
'en_PH.UTF-8',
'en_SC.UTF-8',
'en_SG.UTF-8',
'en_US.UTF-8',
'en_ZA.UTF-8',
'en_ZM.UTF-8',
'en_ZW.UTF-8',
'eo.UTF-8',
'es_AR.UTF-8',
'es_BO.UTF-8',
'es_CL.UTF-8',
'es_CO.UTF-8',
'es_CR.UTF-8',
'es_CU.UTF-8',
'es_DO.UTF-8',
'es_EC.UTF-8',
'es_ES.UTF-8',
'es_GT.UTF-8',
'es_HN.UTF-8',
'es_MX.UTF-8',
'es_NI.UTF-8',
'es_PA.UTF-8',
'es_PE.UTF-8',
'es_PR.UTF-8',
'es_PY.UTF-8',
'es_SV.UTF-8',
'es_US.UTF-8',
'es_UY.UTF-8',
'es_VE.UTF-8',
'et_EE.UTF-8',
'eu_ES.UTF-8',
'fa_IR.UTF-8',
'ff_SN.UTF-8',
'fi_FI.UTF-8',
'fil_PH.UTF-8',
'fo_FO.UTF-8',
'fr_BE.UTF-8',
'fr_CA.UTF-8',
'fr_CH.UTF-8',
'fr_FR.UTF-8',
'fr_LU.UTF-8',
'fur_IT.UTF-8',
'fy_DE.UTF-8',
'fy_NL.UTF-8',
'ga_IE.UTF-8',
'gbm_IN.UTF-8',
'gd_GB.UTF-8',
'gez_ER.UTF-8',
'gez_ER.UTF-8@abegede',
'gez_ET.UTF-8',
'gez_ET.UTF-8@abegede',
'gl_ES.UTF-8',
'gu_IN.UTF-8',
'gv_GB.UTF-8',
'hak_TW.UTF-8',
'ha_NG.UTF-8',
'he_IL.UTF-8',
'hif_FJ.UTF-8',
'hi_IN.UTF-8',
'hne_IN.UTF-8',
'hr_HR.UTF-8',
'hsb_DE.UTF-8',
'ht_HT.UTF-8',
'hu_HU.UTF-8',
'hy_AM.UTF-8',
'ia_FR.UTF-8',
'id_ID.UTF-8',
'ig_NG.UTF-8',
'ik_CA.UTF-8',
'is_IS.UTF-8',
'it_CH.UTF-8',
'it_IT.UTF-8',
'iu_CA.UTF-8',
'ja_JP.UTF-8',
'kab_DZ.UTF-8',
'ka_GE.UTF-8',
'kk_KZ.UTF-8',
'kl_GL.UTF-8',
'km_KH.UTF-8',
'kn_IN.UTF-8',
'kok_IN.UTF-8',
'ko_KR.UTF-8',
'ks_IN.UTF-8',
'ks_IN.UTF-8@devanagari',
'ku_TR.UTF-8',
'kv_RU.UTF-8',
'kw_GB.UTF-8',
'ky_KG.UTF-8',
'lb_LU.UTF-8',
'lg_UG.UTF-8',
'li_BE.UTF-8',
'lij_IT.UTF-8',
'li_NL.UTF-8',
'ln_CD.UTF-8',
'lo_LA.UTF-8',
'ltg_LV.UTF-8',
'lt_LT.UTF-8',
'lv_LV.UTF-8',
'lzh_TW.UTF-8',
'mag_IN.UTF-8',
'mai_IN.UTF-8',
'mai_NP.UTF-8',
'mdf_RU.UTF-8',
'mfe_MU.UTF-8',
'mg_MG.UTF-8',
'mhr_RU.UTF-8',
'mi_NZ.UTF-8',
'miq_NI.UTF-8',
'mjw_IN.UTF-8',
'mk_MK.UTF-8',
'ml_IN.UTF-8',
'mni_IN.UTF-8',
'mn_MN.UTF-8',
'mnw_MM.UTF-8',
'mr_IN.UTF-8',
'ms_MY.UTF-8',
'mt_MT.UTF-8',
'my_MM.UTF-8',
'nan_TW.UTF-8',
'nan_TW.UTF-8@latin',
'nb_NO.UTF-8',
'nds_DE.UTF-8',
'nds_NL.UTF-8',
'ne_NP.UTF-8',
'nhn_MX.UTF-8',
'niu_NU.UTF-8',
'niu_NZ.UTF-8',
'nl_AW.UTF-8',
'nl_BE.UTF-8',
'nl_NL.UTF-8',
'nn_NO.UTF-8',
'nr_ZA.UTF-8',
'nso_ZA.UTF-8',
'oc_FR.UTF-8',
'om_ET.UTF-8',
'om_KE.UTF-8',
'or_IN.UTF-8',
'os_RU.UTF-8',
'pa_IN.UTF-8',
'pap_AW.UTF-8',
'pap_CW.UTF-8',
'pa_PK.UTF-8',
'pl_PL.UTF-8',
'ps_AF.UTF-8',
'pt_BR.UTF-8',
'pt_PT.UTF-8',
'quz_PE.UTF-8',
'raj_IN.UTF-8',
'rif_MA.UTF-8',
'ro_RO.UTF-8',
'ru_RU.UTF-8',
'ru_UA.UTF-8',
'rw_RW.UTF-8',
'sah_RU.UTF-8',
'sa_IN.UTF-8',
'sat_IN.UTF-8',
'sc_IT.UTF-8',
'scn_IT.UTF-8',
'sd_IN.UTF-8',
'sd_IN.UTF-8@devanagari',
'se_NO.UTF-8',
'sgs_LT.UTF-8',
'shn_MM.UTF-8',
'shs_CA.UTF-8',
'sid_ET.UTF-8',
'si_LK.UTF-8',
'sk_SK.UTF-8',
'sl_SI.UTF-8',
'sm_WS.UTF-8',
'so_DJ.UTF-8',
'so_ET.UTF-8',
'so_KE.UTF-8',
'so_SO.UTF-8',
'sq_AL.UTF-8',
'sq_MK.UTF-8',
'sr_ME.UTF-8',
'sr_RS.UTF-8',
'sr_RS.UTF-8@latin',
'ssy_ER.UTF-8',
'ss_ZA.UTF-8',
'st_ZA.UTF-8',
'su_ID.UTF-8',
'sv_FI.UTF-8',
'sv_SE.UTF-8',
'sw_KE.UTF-8',
'sw_TZ.UTF-8',
'syr.UTF-8',
'szl_PL.UTF-8',
'ta_IN.UTF-8',
'ta_LK.UTF-8',
'tcy_IN.UTF-8',
'te_IN.UTF-8',
'tg_TJ.UTF-8',
'the_NP.UTF-8',
'th_TH.UTF-8',
'ti_ER.UTF-8',
'ti_ET.UTF-8',
'tig_ER.UTF-8',
'tk_TM.UTF-8',
'tl_PH.UTF-8',
'tn_ZA.UTF-8',
'tok.UTF-8',
'to_TO.UTF-8',
'tpi_PG.UTF-8',
'tr_CY.UTF-8',
'tr_TR.UTF-8',
'ts_ZA.UTF-8',
'tt_RU.UTF-8',
'tt_RU.UTF-8@iqtelif',
'ug_CN.UTF-8',
'uk_UA.UTF-8',
'unm_US.UTF-8',
'ur_IN.UTF-8',
'ur_PK.UTF-8',
'uz_UZ.UTF-8',
'uz_UZ.UTF-8@cyrillic',
've_ZA.UTF-8',
'vi_VN.UTF-8',
'wa_BE.UTF-8',
'wae_CH.UTF-8',
'wal_ET.UTF-8',
'wo_SN.UTF-8',
'xh_ZA.UTF-8',
'yi_US.UTF-8',
'yo_NG.UTF-8',
'yue_HK.UTF-8',
'yuw_PG.UTF-8',
'zgh_MA.UTF-8',
'zh_CN.UTF-8',
'zh_HK.UTF-8',
'zh_SG.UTF-8',
'zh_TW.UTF-8',
'zu_ZA.UTF-8',
];

View file

@ -45,6 +45,33 @@ const goToReviewStep = async () => {
await clickNext(); // Review
};
const clearLanguageSearch = async () => {
const user = userEvent.setup();
const languagesDropdown = await screen.findByPlaceholderText(
/select a language/i
);
await waitFor(() => user.clear(languagesDropdown));
};
const searchForLanguage = async (search: string) => {
const user = userEvent.setup();
const languagesDropdown = await screen.findByPlaceholderText(
/select a language/i
);
await waitFor(() => user.type(languagesDropdown, search));
};
const selectLanguages = async () => {
const user = userEvent.setup();
await searchForLanguage('nl');
const nlOption = await screen.findByRole('option', { name: 'nl_NL.UTF-8' });
await waitFor(() => user.click(nlOption));
await searchForLanguage('en');
const enOption = await screen.findByRole('option', { name: 'en_GB.UTF-8' });
await waitFor(() => user.click(enOption));
};
const searchForKeyboard = async () => {
const user = userEvent.setup();
const keyboardDropdown = await screen.findByPlaceholderText(
@ -87,7 +114,24 @@ describe('Step Locale', () => {
await verifyCancelButton(router);
});
test('search results get sorted correctly', async () => {
test('language results get sorted correctly', async () => {
await renderCreateMode();
await goToLocaleStep();
await searchForLanguage('nl');
const nlOptions = await screen.findAllByRole('option');
expect(nlOptions[0]).toHaveTextContent('nl_AW.UTF-8');
expect(nlOptions[1]).toHaveTextContent('nl_BE.UTF-8');
expect(nlOptions[2]).toHaveTextContent('nl_NL.UTF-8');
await clearLanguageSearch();
await searchForLanguage('gb');
const gbOptions = await screen.findAllByRole('option');
expect(gbOptions[0]).toHaveTextContent('gbm_IN.UTF-8');
expect(gbOptions[1]).toHaveTextContent('cy_GB.UTF-8');
expect(gbOptions[2]).toHaveTextContent('en_GB.UTF-8');
});
test('keyboard search results get sorted correctly', async () => {
await renderCreateMode();
await goToLocaleStep();
await searchForKeyboard();
@ -99,11 +143,10 @@ describe('Step Locale', () => {
});
describe('Locale request generated correctly', () => {
test('with keyboard selected', async () => {
test('with languages selected', async () => {
await renderCreateMode();
await goToLocaleStep();
await searchForKeyboard();
await selectKeyboard();
await selectLanguages();
await goToReviewStep();
// informational modal pops up in the first test only as it's tied
// to a 'imageBuilder.saveAndBuildModalSeen' variable in localStorage
@ -114,6 +157,52 @@ describe('Locale request generated correctly', () => {
...blueprintRequest,
customizations: {
locale: {
languages: ['nl_NL.UTF-8', 'en_GB.UTF-8'],
},
},
};
await waitFor(() => {
expect(receivedRequest).toEqual(expectedRequest);
});
});
test('with keyboard selected', async () => {
await renderCreateMode();
await goToLocaleStep();
await searchForKeyboard();
await selectKeyboard();
await goToReviewStep();
const receivedRequest = await interceptBlueprintRequest(CREATE_BLUEPRINT);
const expectedRequest = {
...blueprintRequest,
customizations: {
locale: {
keyboard: 'us',
},
},
};
await waitFor(() => {
expect(receivedRequest).toEqual(expectedRequest);
});
});
test('with languages and keyboard selected', async () => {
await renderCreateMode();
await goToLocaleStep();
await selectLanguages();
await searchForKeyboard();
await selectKeyboard();
await goToReviewStep();
const receivedRequest = await interceptBlueprintRequest(CREATE_BLUEPRINT);
const expectedRequest = {
...blueprintRequest,
customizations: {
locale: {
languages: ['nl_NL.UTF-8', 'en_GB.UTF-8'],
keyboard: 'us',
},
},
@ -143,6 +232,4 @@ describe('Locale edit mode', () => {
});
});
// TO DO 'with languages selected'
// TO DO 'with languages and keyboard selected'
// TO DO 'Step Locale' -> 'revisit step button on Review works'