diff --git a/ui/webui/src/actions/localization-actions.js b/ui/webui/src/actions/localization-actions.js new file mode 100644 index 00000000000..a7ec46e36ba --- /dev/null +++ b/ui/webui/src/actions/localization-actions.js @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2023 Red Hat, Inc. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with This program; If not, see . + */ + +import { + getCommonLocales, + getLanguages, + getLanguageData, + getLocales, + getLocaleData, +} from "../apis/localization.js"; + +export const getLanguagesAction = () => { + return async function fetchUserThunk (dispatch) { + const languageIds = await getLanguages(); + + dispatch(getCommonLocalesAction()); + return languageIds.map(language => dispatch(getLanguageDataAction({ language }))); + }; +}; + +export const getLanguageDataAction = ({ language }) => { + return async function fetchUserThunk (dispatch) { + const localeIds = await getLocales({ lang: language }); + const languageData = await getLanguageData({ lang: language }); + const locales = await Promise.all(localeIds.map(async locale => await getLocaleData({ locale }))); + + return dispatch({ + type: "GET_LANGUAGE_DATA", + payload: { languageData: { [language]: { languageData, locales } } } + }); + }; +}; + +export const getCommonLocalesAction = () => { + return async function fetchUserThunk (dispatch) { + const commonLocales = await getCommonLocales(); + + return dispatch({ + type: "GET_COMMON_LOCALES", + payload: { commonLocales } + }); + }; +}; diff --git a/ui/webui/src/actions/storage-actions.js b/ui/webui/src/actions/storage-actions.js new file mode 100644 index 00000000000..df5b9cd7b21 --- /dev/null +++ b/ui/webui/src/actions/storage-actions.js @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2023 Red Hat, Inc. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with This program; If not, see . + */ + +import cockpit from "cockpit"; + +import { + getAllDiskSelection, + getDeviceData, + getDevices, + getDiskFreeSpace, + getDiskTotalSpace, + getFormatData, + getUsableDisks, +} from "../apis/storage.js"; + +export const getDevicesAction = () => { + return async function fetchUserThunk (dispatch) { + const devices = await getDevices(); + return devices[0].map(device => dispatch(getDeviceDataAction({ device }))); + }; +}; + +export const getDeviceDataAction = ({ device }) => { + return async function fetchUserThunk (dispatch) { + let devData = {}; + const deviceData = await getDeviceData({ disk: device }) + .then(res => { + devData = res[0]; + return getDiskFreeSpace({ diskNames: [device] }); + }) + .then(free => { + // Since the getDeviceData returns an object with variants as values, + // extend it with variants to keep the format consistent + devData.free = cockpit.variant(String, free[0]); + return getDiskTotalSpace({ diskNames: [device] }); + }) + .then(total => { + devData.total = cockpit.variant(String, total[0]); + return getFormatData({ diskName: device }); + }) + .then(formatData => { + devData.formatData = formatData[0]; + return ({ [device]: devData }); + }) + .catch(console.error); + + return dispatch({ + type: "GET_DEVICE_DATA", + payload: { deviceData } + }); + }; +}; + +export const getDiskSelectionAction = () => { + return async function fetchUserThunk (dispatch) { + const usableDisks = await getUsableDisks(); + const diskSelection = await getAllDiskSelection(); + + return dispatch({ + type: "GET_DISK_SELECTION", + payload: { + diskSelection: { + ignoredDisks: diskSelection[0].IgnoredDisks.v, + selectedDisks: diskSelection[0].SelectedDisks.v, + usableDisks: usableDisks[0], + } + }, + }); + }; +}; diff --git a/ui/webui/src/apis/storage.js b/ui/webui/src/apis/storage.js index e5d4a86bcdd..29adafd04c9 100644 --- a/ui/webui/src/apis/storage.js +++ b/ui/webui/src/apis/storage.js @@ -16,6 +16,8 @@ */ import cockpit from "cockpit"; +import { getDiskSelectionAction } from "../actions/storage-actions.js"; + export class StorageClient { constructor (address) { if (StorageClient.instance) { @@ -284,13 +286,21 @@ export const resetPartitioning = () => { * @returns {Promise} Resolves a DBus path to a task */ export const runStorageTask = ({ task, onSuccess, onFail }) => { + // FIXME: This is a workaround for 'Succeeded' signal being emited twice + let succeededEmitted = false; const taskProxy = new StorageClient().client.proxy( "org.fedoraproject.Anaconda.Task", task ); const addEventListeners = () => { taskProxy.addEventListener("Stopped", () => taskProxy.Finish().catch(onFail)); - taskProxy.addEventListener("Succeeded", onSuccess); + taskProxy.addEventListener("Succeeded", () => { + if (succeededEmitted) { + return; + } + succeededEmitted = true; + onSuccess(); + }); }; taskProxy.wait(() => { addEventListeners(); @@ -390,3 +400,19 @@ export const setSelectedDisks = ({ drives }) => { ] ); }; + +export const startEventMonitorStorage = ({ dispatch }) => { + return new StorageClient().client.subscribe( + { }, + (path, iface, signal, args) => { + switch (signal) { + case "PropertiesChanged": + if (args[0] === "org.fedoraproject.Anaconda.Modules.Storage.DiskSelection") { + dispatch(getDiskSelectionAction()); + } + break; + default: + console.debug(`Unhandled signal on ${path}: ${iface}.${signal}`); + } + }); +}; diff --git a/ui/webui/src/components/AnacondaWizard.jsx b/ui/webui/src/components/AnacondaWizard.jsx index 288e2abf714..32c78228ae3 100644 --- a/ui/webui/src/components/AnacondaWizard.jsx +++ b/ui/webui/src/components/AnacondaWizard.jsx @@ -40,11 +40,13 @@ import { InstallationProgress } from "./installation/InstallationProgress.jsx"; import { ReviewConfiguration, ReviewConfigurationConfirmModal } from "./review/ReviewConfiguration.jsx"; import { exitGui } from "../helpers/exit.js"; import { usePageLocation } from "hooks"; -import { resetPartitioning } from "../apis/storage.js"; +import { + resetPartitioning +} from "../apis/storage.js"; const _ = cockpit.gettext; -export const AnacondaWizard = ({ onAddErrorNotification, toggleContextHelp, hideContextHelp, title, conf }) => { +export const AnacondaWizard = ({ dispatch, storageData, localizationData, onAddErrorNotification, toggleContextHelp, hideContextHelp, title, conf }) => { const [isFormValid, setIsFormValid] = useState(true); const [stepNotification, setStepNotification] = useState(); const [isInProgress, setIsInProgress] = useState(false); @@ -55,6 +57,7 @@ export const AnacondaWizard = ({ onAddErrorNotification, toggleContextHelp, hide const stepsOrder = [ { component: InstallationLanguage, + data: { dispatch, languages: localizationData.languages, commonLocales: localizationData.commonLocales }, id: "installation-language", label: _("Welcome"), }, @@ -64,6 +67,7 @@ export const AnacondaWizard = ({ onAddErrorNotification, toggleContextHelp, hide label: _("Installation destination"), steps: [{ component: InstallationDestination, + data: { deviceData: storageData.devices, diskSelection: storageData.diskSelection, dispatch }, id: "storage-devices", label: _("Storage devices") }, { @@ -78,6 +82,7 @@ export const AnacondaWizard = ({ onAddErrorNotification, toggleContextHelp, hide }, { component: ReviewConfiguration, + data: { deviceData: storageData.devices, diskSelection: storageData.diskSelection }, id: "installation-review", label: _("Review and install"), }, @@ -144,6 +149,7 @@ export const AnacondaWizard = ({ onAddErrorNotification, toggleContextHelp, hide window.sessionStorage.setItem("storage-scenario-id", scenarioId); setStorageScenarioId(scenarioId); }} + {...s.data} /> ), }); diff --git a/ui/webui/src/components/app.jsx b/ui/webui/src/components/app.jsx index 2f5f841511a..b97ba6d1314 100644 --- a/ui/webui/src/components/app.jsx +++ b/ui/webui/src/components/app.jsx @@ -32,11 +32,12 @@ import { HelpDrawer } from "./HelpDrawer.jsx"; import { BossClient } from "../apis/boss.js"; import { LocalizationClient } from "../apis/localization.js"; -import { StorageClient } from "../apis/storage.js"; +import { StorageClient, startEventMonitorStorage } from "../apis/storage.js"; import { PayloadsClient } from "../apis/payloads"; import { readBuildstamp, getIsFinal } from "../helpers/betanag.js"; import { readConf } from "../helpers/conf.js"; +import { useReducerWithThunk, reducer, initialState } from "../reducer.js"; export const Application = () => { const [address, setAddress] = useState(); @@ -47,6 +48,7 @@ export const Application = () => { const [isHelpExpanded, setIsHelpExpanded] = useState(false); const [helpContent, setHelpContent] = useState(""); const [prettyName, setPrettyName] = useState(""); + const [state, dispatch] = useReducerWithThunk(reducer, initialState); useEffect(() => { cockpit.file("/run/anaconda/bus.address").watch(address => { @@ -59,6 +61,8 @@ export const Application = () => { clients.forEach(c => c.init()); setAddress(address); + + startEventMonitorStorage({ dispatch }); }); readConf().then( @@ -72,7 +76,7 @@ export const Application = () => { ); readOsRelease().then(osRelease => setPrettyName(osRelease.PRETTY_NAME)); - }, []); + }, [dispatch]); const onAddNotification = (notificationProps) => { setNotifications({ @@ -139,6 +143,9 @@ export const Application = () => { toggleContextHelp={toggleContextHelp} hideContextHelp={() => setIsHelpExpanded(false)} title={title} + storageData={state.storage} + localizationData={state.localization} + dispatch={dispatch} conf={conf} /> diff --git a/ui/webui/src/components/localization/InstallationLanguage.jsx b/ui/webui/src/components/localization/InstallationLanguage.jsx index 149ea5456cf..cee2d4518d3 100644 --- a/ui/webui/src/components/localization/InstallationLanguage.jsx +++ b/ui/webui/src/components/localization/InstallationLanguage.jsx @@ -40,12 +40,7 @@ import { setLocale } from "../../apis/boss.js"; import { getLanguage, - getLanguages, - getLanguageData, - getLocales, - getLocaleData, setLanguage, - getCommonLocales, } from "../../apis/localization.js"; import { @@ -54,6 +49,7 @@ import { setLangCookie } from "../../helpers/language.js"; import { AnacondaPage } from "../AnacondaPage.jsx"; +import { getLanguagesAction } from "../../actions/localization-actions.js"; import "./InstallationLanguage.scss"; @@ -69,9 +65,6 @@ class LanguageSelector extends React.Component { constructor (props) { super(props); this.state = { - languages: [], - locales: [], - commonLocales: [], search: "", lang: "", }; @@ -93,28 +86,14 @@ class LanguageSelector extends React.Component { } catch (e) { this.props.onAddErrorNotification(e); } - - const languageIds = await getLanguages(); - // Create the languages state object - this.setState({ languages: await Promise.all(languageIds.map(async lang => await getLanguageData({ lang }))) }); - // Create the locales state object - const localeIds = await Promise.all(languageIds.map(async lang => await getLocales({ lang }))); - const locales = await Promise.all(localeIds.map(async localeId => { - return await Promise.all(localeId.map(async locale => await getLocaleData({ locale }))); - })); - this.setState({ locales }, this.updateNativeName); - - // Create a list of common locales. - this.setState({ commonLocales: await getCommonLocales() }); } - async updateNativeName (localeData) { - localeData = localeData || await getLocaleData({ locale: this.state.lang }); - this.props.getNativeName(getLocaleNativeName(localeData)); + async updateNativeName (localeItem) { + this.props.setNativeName(getLocaleNativeName(localeItem)); } renderOptions (filter) { - const { languages, locales } = this.state; + const { languages } = this.props; const idPrefix = this.props.idPrefix; const filterLow = filter.toLowerCase(); @@ -124,10 +103,11 @@ class LanguageSelector extends React.Component { let foundSelected = false; // Returns a locale with a given code. const findLocaleWithId = (localeCode) => { - for (const locale of this.state.locales) { - for (const subLocale of locale) { - if (getLocaleId(subLocale) === localeCode) { - return subLocale; + for (const languageId in languages) { + const languageItem = languages[languageId]; + for (const locale of languageItem.locales) { + if (getLocaleId(locale) === localeCode) { + return locale; } } } @@ -180,7 +160,7 @@ class LanguageSelector extends React.Component { key="group-common-languages" > { - this.state.commonLocales + this.props.commonLocales .map(findLocaleWithId) .filter(locale => locale) .map(locale => createMenuItem(locale, "option-common-")) @@ -192,20 +172,20 @@ class LanguageSelector extends React.Component { } // List alphabetically. - for (const langLocales of locales) { - const currentLang = languages.find(lang => getLanguageId(lang) === getLanguageId(langLocales[0])); - - const label = cockpit.format("$0 ($1)", getLanguageNativeName(currentLang), getLanguageEnglishName(currentLang)); + const languagesIds = Object.keys(languages).sort(); + for (const languageId of languagesIds) { + const languageItem = languages[languageId]; + const label = cockpit.format("$0 ($1)", getLanguageNativeName(languageItem.languageData), getLanguageEnglishName(languageItem.languageData)); if (!filter || label.toLowerCase().indexOf(filterLow) !== -1) { filtered.push( - {langLocales.map(locale => createMenuItem(locale, "option-alpha-"))} + {languageItem.locales.map(locale => createMenuItem(locale, "option-alpha-"))} ); } @@ -227,11 +207,12 @@ class LanguageSelector extends React.Component { } render () { - const { languages, locales, lang, commonLocales } = this.state; + const { lang } = this.state; + const { languages, commonLocales } = this.props; + const handleOnSelect = (_event, item) => { - const { locales } = this.state; - for (const locale of locales) { - for (const localeItem of locale) { + for (const languageItem in languages) { + for (const localeItem of languages[languageItem].locales) { if (getLocaleId(localeItem) === item) { setLangCookie({ cockpitLang: convertToCockpitLang({ lang: getLocaleId(localeItem) }) }); setLanguage({ lang: getLocaleId(localeItem) }) @@ -261,7 +242,7 @@ class LanguageSelector extends React.Component { } }; - const isLoading = languages.length === 0 || languages.length !== locales.length || commonLocales.length === 0; + const isLoading = languages.length === 0 || commonLocales.length === 0; if (isLoading) { return ; @@ -315,14 +296,15 @@ class LanguageSelector extends React.Component { } LanguageSelector.contextType = AddressContext; -export const InstallationLanguage = ({ idPrefix, setIsFormValid, onAddErrorNotification }) => { +export const InstallationLanguage = ({ idPrefix, languages, commonLocales, dispatch, setIsFormValid, onAddErrorNotification }) => { const [nativeName, setNativeName] = React.useState(false); const { setLanguage } = React.useContext(LanguageContext); const [distributionName, setDistributionName] = useState(""); useEffect(() => { readOsRelease().then(osRelease => setDistributionName(osRelease.NAME)); - }, []); + dispatch(getLanguagesAction()); + }, [dispatch]); return ( @@ -347,9 +329,11 @@ export const InstallationLanguage = ({ idPrefix, setIsFormValid, onAddErrorNotif diff --git a/ui/webui/src/components/review/ReviewConfiguration.jsx b/ui/webui/src/components/review/ReviewConfiguration.jsx index 012da15209b..02b1a4e5883 100644 --- a/ui/webui/src/components/review/ReviewConfiguration.jsx +++ b/ui/webui/src/components/review/ReviewConfiguration.jsx @@ -35,8 +35,6 @@ import { } from "@patternfly/react-core"; import { - getSelectedDisks, - getDeviceData, getAppliedPartitioning, getPartitioningRequest, } from "../../apis/storage.js"; @@ -100,9 +98,7 @@ const DeviceRow = ({ name, data }) => { ); }; -export const ReviewConfiguration = ({ idPrefix, storageScenarioId }) => { - const [deviceData, setDeviceData] = useState({}); - const [selectedDisks, setSelectedDisks] = useState(); +export const ReviewConfiguration = ({ deviceData, diskSelection, idPrefix, storageScenarioId }) => { const [systemLanguage, setSystemLanguage] = useState(); const [encrypt, setEncrypt] = useState(); const [showLanguageSection, setShowLanguageSection] = useState(true); @@ -114,26 +110,17 @@ export const ReviewConfiguration = ({ idPrefix, storageScenarioId }) => { const langData = await getLanguageData({ lang }).catch(console.error); setSystemLanguage(langData["native-name"].v); }; - const initializeDisks = async () => { - const selDisks = await getSelectedDisks().catch(console.error); - setSelectedDisks(selDisks); - for (const disk of selDisks) { - const devData = await getDeviceData({ disk }).catch(console.error); - setDeviceData(d => ({ ...d, [disk]: devData[0] })); - } - }; const initializeEncrypt = async () => { const partitioning = await getAppliedPartitioning().catch(console.error); const request = await getPartitioningRequest({ partitioning }).catch(console.error); setEncrypt(request.encrypted.v); }; initializeLanguage(); - initializeDisks(); initializeEncrypt(); }, []); // handle case of disks not (yet) loaded - if (!selectedDisks || !systemLanguage) { + if (!systemLanguage) { return null; } @@ -191,7 +178,7 @@ export const ReviewConfiguration = ({ idPrefix, storageScenarioId }) => { {_("Storage devices and configurations")} - {Object.keys(deviceData).map(deviceName => + {diskSelection.selectedDisks.map(deviceName => )} diff --git a/ui/webui/src/components/storage/InstallationDestination.jsx b/ui/webui/src/components/storage/InstallationDestination.jsx index d1c8dda0679..096381a9ec5 100644 --- a/ui/webui/src/components/storage/InstallationDestination.jsx +++ b/ui/webui/src/components/storage/InstallationDestination.jsx @@ -15,7 +15,7 @@ * along with This program; If not, see . */ import cockpit from "cockpit"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { Alert, @@ -53,14 +53,7 @@ import { helpStorageOptions } from "./HelpStorageOptions.jsx"; import { applyPartitioning, createPartitioning, - getAllDiskSelection, - getDevices, - getDeviceData, - getDiskFreeSpace, - getDiskTotalSpace, - getFormatData, getRequiredDeviceSize, - getUsableDisks, partitioningConfigureWithTask, resetPartitioning, runStorageTask, @@ -75,6 +68,7 @@ import { import { getRequiredSpace, } from "../../apis/payloads"; +import { getDevicesAction, getDiskSelectionAction } from "../../actions/storage-actions.js"; import { sleep, @@ -109,14 +103,10 @@ const selectDefaultDisks = ({ ignoredDisks, selectedDisks, usableDisks }) => { } }; -const setSelectionForAllDisks = ({ disks, value }) => { - return (Object.keys(disks).reduce((acc, cur) => ({ ...acc, [cur]: value }), {})); -}; - const containEqualDisks = (disks1, disks2) => { - const disks1Str = Object.keys(disks1).sort() + const disks1Str = disks1.sort() .join(); - const disks2Str = Object.keys(disks2).sort() + const disks2Str = disks2.sort() .join(); return disks1Str === disks2Str; }; @@ -195,81 +185,54 @@ const DropdownBulkSelect = ({ ); }; -const LocalStandardDisks = ({ idPrefix, setIsFormValid, onAddErrorNotification }) => { - const [deviceData, setDeviceData] = useState({}); - const [disks, setDisks] = useState({}); - const [refreshCnt, setRefreshCnt] = useState(0); +const LocalStandardDisks = ({ deviceData, diskSelection, dispatch, idPrefix, setIsFormValid, onAddErrorNotification }) => { const [isRescanningDisks, setIsRescanningDisks] = useState(false); - const [lastRescanDisks, setLastRescanDisks] = useState({}); const [equalDisksNotify, setEqualDisksNotify] = useState(false); + const refUsableDisks = useRef(); useEffect(() => { - let usableDisks; - let devices; - getDevices() - .then(ret => { - devices = ret[0]; - return getUsableDisks(); - }) - .then(res => { - usableDisks = res[0]; - return getAllDiskSelection(); - }) - .then(props => { - // Select default disks for the partitioning - const defaultDisks = selectDefaultDisks({ - ignoredDisks: props[0].IgnoredDisks.v, - selectedDisks: props[0].SelectedDisks.v, - usableDisks, - }); - setDisks(usableDisks.reduce((acc, cur) => ({ ...acc, [cur]: defaultDisks.includes(cur) }), {})); - - // Show disks data - devices.forEach(disk => { - let deviceData = {}; - const diskNames = [disk]; - - getDeviceData({ disk }) - .then(res => { - deviceData = res[0]; - return getDiskFreeSpace({ diskNames }); - }, console.error) - .then(free => { - // Since the getDeviceData returns an object with variants as values, - // extend it with variants to keep the format consistent - deviceData.free = cockpit.variant(String, free[0]); - return getDiskTotalSpace({ diskNames }); - }, console.error) - .then(total => { - deviceData.total = cockpit.variant(String, total[0]); - return getFormatData({ diskName: disk }); - }, console.error) - .then(formatData => { - deviceData.formatData = formatData[0]; - setDeviceData(d => ({ ...d, [disk]: deviceData })); - }, console.error); - }); - }, console.error); - }, [refreshCnt]); + if (isRescanningDisks) { + refUsableDisks.current = diskSelection.usableDisks; + setEqualDisksNotify(true); + } + }, [isRescanningDisks, diskSelection.usableDisks]); + + useEffect(() => { + // Select default disks for the partitioning on component mount + if (refUsableDisks.current !== undefined) { + return; + } + + const defaultDisks = selectDefaultDisks({ + ignoredDisks: diskSelection.ignoredDisks, + selectedDisks: diskSelection.selectedDisks, + usableDisks: diskSelection.usableDisks, + }); + + if (!containEqualDisks(diskSelection.selectedDisks, defaultDisks)) { + refUsableDisks.current = diskSelection.usableDisks; + setSelectedDisks({ drives: defaultDisks }); + } + }, [diskSelection]); + + useEffect(() => { + dispatch(getDevicesAction()); + dispatch(getDiskSelectionAction()); + }, [dispatch]); - const totalDisksCnt = Object.keys(disks).length; - const selectedDisksCnt = Object.keys(disks).filter(disk => !!disks[disk]).length; + const totalDisksCnt = diskSelection.usableDisks.length; + const selectedDisksCnt = diskSelection.selectedDisks.length; // When the selected disks change in the UI, update in the backend as well useEffect(() => { // Do not update on the inital value, wait for initialization by the other effect - if (Object.keys(disks).length === 0) { + if (refUsableDisks.current === undefined) { return; } setIsFormValid(selectedDisksCnt > 0); + }, [selectedDisksCnt, setIsFormValid]); - const selected = Object.keys(disks).filter(disk => disks[disk]); - console.log("Updating storage backend with selected disks:", selected.join(",")); - - setSelectedDisks({ drives: selected }).catch(onAddErrorNotification); - }, [disks, onAddErrorNotification, selectedDisksCnt, setIsFormValid]); - - const loading = Object.keys(disks).some(disk => !deviceData[disk]); + const loading = !deviceData || diskSelection.usableDisks.some(disk => !deviceData[disk]); if (loading) { return ; } @@ -297,17 +260,19 @@ const LocalStandardDisks = ({ idPrefix, setIsFormValid, onAddErrorNotification } variant="secondary" onClick={() => { setIsRescanningDisks(true); - setLastRescanDisks({ ...disks }); - setDisks(setSelectionForAllDisks({ disks, value: false })); + setSelectedDisks({ drives: [] }); scanDevicesWithTask() .then(res => { runStorageTask({ task: res[0], - onSuccess: () => resetPartitioning().then(() => setRefreshCnt(refreshCnt + 1), onAddErrorNotification), + onSuccess: () => resetPartitioning().then(() => { + dispatch(getDevicesAction()); + dispatch(getDiskSelectionAction()); + }, onAddErrorNotification), onFail: onAddErrorNotification }); }) - .finally(() => { setIsRescanningDisks(false); setEqualDisksNotify(true) }); + .finally(() => setIsRescanningDisks(false)); }} > ); - const localDisksRows = Object.keys(disks).map(disk => { + const localDisksRows = diskSelection.usableDisks.map(disk => { const hasPartitions = deviceData[disk]?.children?.v.length && deviceData[disk]?.children?.v.every(partition => deviceData[partition]); return ({ - selected: !!disks[disk], + selected: !!diskSelection.selectedDisks.includes(disk), hasPadding: true, props: { key: disk, id: disk }, columns: [ @@ -395,7 +360,7 @@ const LocalStandardDisks = ({ idPrefix, setIsFormValid, onAddErrorNotification } ] ); - const rescanningDisksRows = Object.keys(disks).map(disk => ( + const rescanningDisksRows = diskSelection.usableDisks.map(disk => ( { columns: rescanningDisksRow } @@ -403,9 +368,15 @@ const LocalStandardDisks = ({ idPrefix, setIsFormValid, onAddErrorNotification } const dropdownBulkSelect = ( setDisks(setSelectionForAllDisks({ disks, value: true }))} - onSelectNone={() => setDisks(setSelectionForAllDisks({ disks, value: false }))} - onChange={(checked) => setDisks(setSelectionForAllDisks({ disks, value: checked }))} + onSelectAll={() => setSelectedDisks({ drives: diskSelection.usableDisks })} + onSelectNone={() => setSelectedDisks({ drives: [] })} + onChange={(checked) => { + if (checked) { + setSelectedDisks({ drives: diskSelection.usableDisks }); + } else { + setSelectedDisks({ drives: [] }); + } + }} selectedCnt={selectedDisksCnt} totalCnt={totalDisksCnt} isDisabled={isRescanningDisks} @@ -440,7 +411,15 @@ const LocalStandardDisks = ({ idPrefix, setIsFormValid, onAddErrorNotification } } onSelect={ !isRescanningDisks - ? (_, isSelected, diskId) => setDisks({ ...disks, [Object.keys(disks)[diskId]]: isSelected }) + ? (_, isSelected, diskId) => { + const newDisk = diskSelection.usableDisks[diskId]; + + if (isSelected) { + setSelectedDisks({ drives: [...diskSelection.selectedDisks, newDisk] }); + } else { + setSelectedDisks({ drives: diskSelection.selectedDisks.filter(disk => disk !== newDisk) }); + } + } : () => {} } rows={ @@ -451,6 +430,8 @@ const LocalStandardDisks = ({ idPrefix, setIsFormValid, onAddErrorNotification } /> ); + const equalDisks = refUsableDisks.current && containEqualDisks(refUsableDisks.current, diskSelection.usableDisks); + return (
- {equalDisksNotify && containEqualDisks(disks, lastRescanDisks) && + {equalDisksNotify && equalDisks && setEqualDisksNotify(false)} />} + actionClose={ { setEqualDisksNotify(false) }} />} />} {localDisksToolbar} {localDisksTable} @@ -478,7 +459,7 @@ const LocalStandardDisks = ({ idPrefix, setIsFormValid, onAddErrorNotification } ); }; -export const InstallationDestination = ({ idPrefix, setIsFormValid, onAddErrorNotification, toggleContextHelp, stepNotification, isInProgress }) => { +export const InstallationDestination = ({ deviceData, diskSelection, dispatch, idPrefix, setIsFormValid, onAddErrorNotification, toggleContextHelp, stepNotification, isInProgress }) => { const [requiredSize, setRequiredSize] = useState(0); const toggleHelpStorageOptions = () => { @@ -516,6 +497,9 @@ export const InstallationDestination = ({ idPrefix, setIsFormValid, onAddErrorNo variant="danger" />} . + */ + +import { useReducer, useCallback } from "react"; + +/* Initial state for the storeage store substate */ +export const storageInitialState = { + devices: {}, + diskSelection: { + usableDisks: [], + selectedDisks: [], + ignoredDisks: [] + }, +}; + +/* Initial state for the localization store substate */ +export const localizationInitialState = { + languages: {}, + commonLocales: [] +}; + +/* Initial state for the global store */ +export const initialState = { + localization: localizationInitialState, + storage: storageInitialState, +}; + +/* Custom hook to use the reducer with async actions */ +export const useReducerWithThunk = (reducer, initialState) => { + const [state, dispatch] = useReducer(reducer, initialState); + + function customDispatch (action) { + if (typeof action === "function") { + return action(customDispatch); + } else { + dispatch(action); + } + } + + // Memoize so you can include it in the dependency array without causing infinite loops + // eslint-disable-next-line react-hooks/exhaustive-deps + const stableDispatch = useCallback(customDispatch, [dispatch]); + + return [state, stableDispatch]; +}; + +export const reducer = (state, action) => { + return ({ + localization: localizationReducer(state.localization, action), + storage: storageReducer(state.storage, action), + }); +}; + +export const storageReducer = (state = storageInitialState, action) => { + if (action.type === "GET_DEVICE_DATA") { + return { ...state, devices: { ...action.payload.deviceData, ...state.devices } }; + } else if (action.type === "GET_DISK_SELECTION") { + return { ...state, diskSelection: action.payload.diskSelection }; + } else { + return state; + } +}; + +export const localizationReducer = (state = localizationInitialState, action) => { + if (action.type === "GET_LANGUAGE_DATA") { + return { ...state, languages: { ...action.payload.languageData, ...state.languages } }; + } else if (action.type === "GET_COMMON_LOCALES") { + return { ...state, commonLocales: action.payload.commonLocales }; + } else { + return state; + } +}; diff --git a/ui/webui/test/check-storage b/ui/webui/test/check-storage index 3f0f73bb90e..b6ead85e88a 100755 --- a/ui/webui/test/check-storage +++ b/ui/webui/test/check-storage @@ -311,7 +311,7 @@ class TestStorageExtraDisks(anacondalib.VirtInstallMachineCase): s.check_disk_partition("vdb", "/dev/vdb1", "Encrypted (LUKS)", "429 MB") s.check_disk_partition("vdb", "/dev/vdb2", "ext4", "859 MB") - s.check_disk_selected("vda", True) + s.check_disk_selected("vda", False) s.check_disk_selected("vdb", False) b.assert_pixels( diff --git a/ui/webui/test/helpers/storage.py b/ui/webui/test/helpers/storage.py index b3eb0edc7c1..0211115404a 100644 --- a/ui/webui/test/helpers/storage.py +++ b/ui/webui/test/helpers/storage.py @@ -71,7 +71,11 @@ def click_checkbox_and_check_all_disks(self, disks, selected): @log_step(snapshot_before=True) def check_disk_selected(self, disk, selected=True): - assert self.browser.get_checked(f"#{disk} input") == selected + if selected: + self.browser.wait_visible(f"#{disk} input:checked") + else: + self.browser.wait_visible(f"#{disk} input:not(:checked)") + @log_step() def wait_no_disks(self): diff --git a/ui/webui/test/reference b/ui/webui/test/reference index 27104458d69..f8a5a93c5e5 160000 --- a/ui/webui/test/reference +++ b/ui/webui/test/reference @@ -1 +1 @@ -Subproject commit 27104458d69da4fb346d47f0a0bdb58c11fffefc +Subproject commit f8a5a93c5e560e79f49b02f88246d3f80c848508