diff --git a/service/test/agama/software/manager_test.rb b/service/test/agama/software/manager_test.rb index 0b24c2b8a5..8643608c20 100644 --- a/service/test/agama/software/manager_test.rb +++ b/service/test/agama/software/manager_test.rb @@ -253,7 +253,8 @@ expect(products).to all(be_a(Agama::Software::Product)) expect(products).to contain_exactly( an_object_having_attributes(id: "Tumbleweed"), - an_object_having_attributes(id: "MicroOS") + an_object_having_attributes(id: "MicroOS"), + an_object_having_attributes(id: "Leap_16.0") ) end end @@ -335,7 +336,7 @@ expect(proposal).to receive(:set_resolvables) .with("agama", :pattern, [], { optional: true }) expect(proposal).to receive(:set_resolvables) - .with("agama", :package, ["NetworkManager", "openSUSE-repos"]) + .with("agama", :package, ["NetworkManager", "openSUSE-repos-Tumbleweed"]) expect(proposal).to receive(:set_resolvables) .with("agama", :package, [], { optional: true }) subject.propose diff --git a/web/package-lock.json b/web/package-lock.json index e8e79ec4ce..32878954fa 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,6 +12,7 @@ "@patternfly/patternfly": "^5.1.0", "@patternfly/react-core": "^5.1.1", "@patternfly/react-table": "^5.1.1", + "@tanstack/react-query": "^5.49.2", "fast-sort": "^3.4.0", "ipaddr.js": "^2.1.0", "react": "^18.2.0", @@ -4210,6 +4211,30 @@ "url": "https://github.com/sponsors/gregberge" } }, + "node_modules/@tanstack/query-core": { + "version": "5.49.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.49.1.tgz", + "integrity": "sha512-JnC9ndmD1KKS01Rt/ovRUB1tmwO7zkyXAyIxN9mznuJrcNtOrkmOnQqdJF2ib9oHzc2VxHomnEG7xyfo54Npkw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.49.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.49.2.tgz", + "integrity": "sha512-6rfwXDK9BvmHISbNFuGd+wY3P44lyW7lWiA9vIFGT/T0P9aHD1VkjTvcM4SDAIbAQ9ygEZZoLt7dlU1o3NjMVA==", + "dependencies": { + "@tanstack/query-core": "5.49.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, "node_modules/@testing-library/dom": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.1.0.tgz", diff --git a/web/package.json b/web/package.json index 6678916e35..a033858d1f 100644 --- a/web/package.json +++ b/web/package.json @@ -103,6 +103,7 @@ "@patternfly/patternfly": "^5.1.0", "@patternfly/react-core": "^5.1.1", "@patternfly/react-table": "^5.1.1", + "@tanstack/react-query": "^5.49.2", "fast-sort": "^3.4.0", "ipaddr.js": "^2.1.0", "react": "^18.2.0", diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index ee19817591..9dda2dcb85 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,11 @@ +------------------------------------------------------------------- +Mon Jul 8 10:56:14 UTC 2024 - Imobach Gonzalez Sosa + +- Introduce TanStack Query for data fetching and state management + (gh#openSUSE/agama#1439). +- Replace the l10n context with a solution based on TanStack + Query. + ------------------------------------------------------------------- Wed Jul 3 07:17:55 UTC 2024 - Imobach Gonzalez Sosa diff --git a/web/src/App.jsx b/web/src/App.jsx index 9cbad27b3e..bef32b5860 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -29,6 +29,10 @@ import { useInstallerClientStatus } from "~/context/installer"; import { useProduct } from "./context/product"; import { CONFIG, INSTALL, STARTUP } from "~/client/phase"; import { BUSY } from "~/client/status"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useL10nConfigChanges } from "~/queries/l10n"; + +const queryClient = new QueryClient(); /** * Main application component. @@ -42,6 +46,7 @@ function App() { const { connected, error, phase, status } = useInstallerClientStatus(); const { selectedProduct, products } = useProduct(); const { language } = useInstallerL10n(); + useL10nConfigChanges(); const Content = () => { if (error) return ; diff --git a/web/src/App.test.jsx b/web/src/App.test.jsx index 303891708f..d3fe46be4b 100644 --- a/web/src/App.test.jsx +++ b/web/src/App.test.jsx @@ -20,13 +20,14 @@ */ import React from "react"; -import { act, screen } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; import App from "./App"; import { createClient } from "~/client"; import { STARTUP, CONFIG, INSTALL } from "~/client/phase"; import { IDLE, BUSY } from "~/client/status"; +import { useL10nConfigChanges } from "./queries/l10n"; jest.mock("~/client"); @@ -44,6 +45,11 @@ jest.mock("~/context/product", () => ({ } })); +jest.mock("~/queries/l10n", () => ({ + ...jest.requireActual("~/queries/l10n"), + useL10nConfigChanges: () => jest.fn() +})); + const mockClientStatus = { connected: true, error: false, @@ -70,18 +76,9 @@ describe("App", () => { createClient.mockImplementation(() => { return { l10n: { - locales: jest.fn().mockResolvedValue([["en_us", "English", "United States"]]), - getLocales: jest.fn().mockResolvedValue(["en_us"]), - timezones: jest.fn().mockResolvedValue([]), - getTimezone: jest.fn().mockResolvedValue("Europe/Berlin"), - keymaps: jest.fn().mockResolvedValue([]), - getKeymap: jest.fn().mockResolvedValue(undefined), getUIKeymap: jest.fn().mockResolvedValue("en"), getUILocale: jest.fn().mockResolvedValue("en_us"), setUILocale: jest.fn().mockResolvedValue("en_us"), - onTimezoneChange: jest.fn(), - onLocalesChange: jest.fn(), - onKeymapChange: jest.fn() } }; }); diff --git a/web/src/client/index.js b/web/src/client/index.js index 8488197714..e1a7dbf8bd 100644 --- a/web/src/client/index.js +++ b/web/src/client/index.js @@ -129,6 +129,7 @@ const createClient = url => { const isConnected = () => client.ws?.isConnected() || false; const isRecoverable = () => !!client.ws?.isRecoverable(); + const ws = () => client.ws; return { l10n, @@ -145,7 +146,8 @@ const createClient = url => { isConnected, isRecoverable, onConnect: handler => client.ws.onOpen(handler), - onDisconnect: handler => client.ws.onClose(handler) + onDisconnect: handler => client.ws.onClose(handler), + ws: () => client.ws }; }; diff --git a/web/src/client/l10n.js b/web/src/client/l10n.js index 2cdb210969..29e5344f80 100644 --- a/web/src/client/l10n.js +++ b/web/src/client/l10n.js @@ -20,28 +20,6 @@ */ // @ts-check -import { timezoneUTCOffset } from "~/utils"; - -/** - * @typedef {object} Timezone - * @property {string} id - Timezone id (e.g., "Atlantic/Canary"). - * @property {Array} parts - Name of the timezone parts (e.g., ["Atlantic", "Canary"]). - * @property {string} country - Name of the country associated to the zone or empty string (e.g., "Spain"). - * @property {number} utcOffset - UTC offset. - */ - -/** - * @typedef {object} Locale - * @property {string} id - Language id (e.g., "en_US.UTF-8"). - * @property {string} name - Language name (e.g., "English"). - * @property {string} territory - Territory name (e.g., "United States"). - */ - -/** - * @typedef {object} Keymap - * @property {string} id - Keyboard id (e.g., "us"). - * @property {string} name - Keyboard name (e.g., "English (US)"). - */ /** * Manages localization. @@ -107,225 +85,6 @@ class L10nClient { async setUIKeymap(id) { return this.client.patch("/l10n/config", { uiKeymap: id }); } - - /** - * All possible timezones for the target system. - * - * @return {Promise>} - */ - async timezones() { - const response = await this.client.get("/l10n/timezones"); - if (!response.ok) { - console.error("Failed to get localization config: ", response); - return []; - } - const timezones = await response.json(); - return timezones.map(this.buildTimezone); - } - - /** - * Timezone selected for the target system. - * - * @return {Promise} Id of the timezone. - */ - async getTimezone() { - const config = await this.getConfig(); - return config.timezone; - } - - /** - * Sets the timezone for the target system. - * - * @param {string} id - Id of the timezone. - * @return {Promise} - */ - async setTimezone(id) { - this.setConfig({ timezone: id }); - } - - /** - * Available locales to install in the target system. - * - * TODO: find a better name because it is rather confusing (e.g., 'locales' and 'getLocales'). - * - * @return {Promise>} - */ - async locales() { - const response = await this.client.get("/l10n/locales"); - if (!response.ok) { - console.error("Failed to get localization config: ", response); - return []; - } - const locales = await response.json(); - return locales.map(this.buildLocale); - } - - /** - * Locales selected to install in the target system. - * - * @return {Promise>} Ids of the locales. - */ - async getLocales() { - const config = await this.getConfig(); - return config.locales; - } - - /** - * Sets the locales to install in the target system. - * - * @param {Array} ids - Ids of the locales. - * @return {Promise} - */ - async setLocales(ids) { - this.setConfig({ locales: ids }); - } - - /** - * Available keymaps to install in the target system. - * - * Note that name is localized to the current selected UI language: - * { id: "es", name: "Spanish (ES)" } - * - * @return {Promise>} - */ - async keymaps() { - const response = await this.client.get("/l10n/keymaps"); - if (!response.ok) { - console.error("Failed to get localization config: ", response); - return []; - } - const keymaps = await response.json(); - return keymaps.map(this.buildKeymap); - } - - /** - * Keymap selected to install in the target system. - * - * @return {Promise} Id of the keymap. - */ - async getKeymap() { - const config = await this.getConfig(); - return config.keymap; - } - - /** - * Sets the keymap to install in the target system. - * - * @param {string} id - Id of the keymap. - * @return {Promise} - */ - async setKeymap(id) { - this.setConfig({ keymap: id }); - } - - /** - * Register a callback to run when the timezone configuration changes. - * - * @param {(timezone: string) => void} handler - Function to call when Timezone changes. - * @return {import ("./http").RemoveFn} Function to disable the callback. - */ - onTimezoneChange(handler) { - return this.client.onEvent("L10nConfigChanged", ({ timezone }) => { - if (timezone) { - handler(timezone); - } - }); - } - - /** - * Register a callback to run when the locales configuration changes. - * - * @param {(locales: string[]) => void} handler - Function to call when Locales changes. - * @return {import ("./http").RemoveFn} Function to disable the callback. - */ - onLocalesChange(handler) { - return this.client.onEvent("L10nConfigChanged", ({ locales }) => { - if (locales) { - handler(locales); - } - }); - } - - /** - * Register a callback to run when the keymap configuration changes. - * - * @param {(keymap: string) => void} handler - Function to call when Keymap changes. - * @return {import ("./http").RemoveFn} Function to disable the callback. - */ - onKeymapChange(handler) { - return this.client.onEvent("L10nConfigChanged", ({ keymap }) => { - if (keymap) { - handler(keymap); - } - }); - } - - /** - * @private - * Convenience method to get l10n the configuration. - * - * @return {Promise} Localization configuration. - */ - async getConfig() { - const response = await this.client.get("/l10n/config"); - if (!response.ok) { - console.error("Failed to get localization config: ", response); - return {}; - } - return await response.json(); - } - - /** - * @private - * - * Convenience method to set l10n the configuration. - * - * @param {object} data - Configuration to update. It can just part of the configuration. - * @return {Promise} - */ - async setConfig(data) { - return this.client.patch("/l10n/config", data); - } - - /** - * @private - * - * @param {object} timezone - Timezone data. - * @param {string} timezone.code - Timezone identifier. - * @param {Array} timezone.parts - Localized parts of the timezone identifier. - * @param {string} timezone.country - Timezone country. - * @return {Timezone} - */ - buildTimezone({ code, parts, country }) { - const utcOffset = timezoneUTCOffset(code); - - return ({ id: code, parts, country, utcOffset }); - } - - /** - * @private - * - * @param {object} locale - Locale data. - * @param {string} locale.id - Identifier. - * @param {string} locale.language - Name. - * @param {string} locale.territory - Territory. - * @return {Locale} - */ - buildLocale({ id, language, territory }) { - return ({ id, name: language, territory }); - } - - /** - * @private - * - * @param {object} keymap - Keymap data - * @param {string} keymap.id - Id (e.g., "us"). - * @param {string} keymap.description - Keymap description (e.g., "English (US)"). - * @return {Keymap} - */ - buildKeymap({ id, description }) { - return ({ id, name: description }); - } } export { L10nClient }; diff --git a/web/src/client/l10n.test.js b/web/src/client/l10n.test.js deleted file mode 100644 index e24dbb7192..0000000000 --- a/web/src/client/l10n.test.js +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright (c) [2022-2023] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * 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 General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -// @ts-check - -import { HTTPClient } from "./http"; -import { L10nClient } from "./l10n"; - -jest.mock("./dbus"); - -const mockJsonFn = jest.fn(); -const mockGetFn = jest.fn().mockImplementation(() => { - return { ok: true, json: mockJsonFn }; -}); -const mockPatchFn = jest.fn().mockImplementation(() => { - return { ok: true }; -}); - -jest.mock("./http", () => { - return { - HTTPClient: jest.fn().mockImplementation(() => { - return { - get: mockGetFn, - patch: mockPatchFn, - }; - }), - }; -}); - -let client; - -const locales = [ - { - id: "en_US.UTF-8", - language: "English", - territory: "United States", - }, - { - id: "es_ES.UTF-8", - language: "Spanish", - territory: "Spain", - }, -]; - -const config = { - locales: [ - "en_US.UTF-8", - ], - keymap: "us", - timezone: "Europe/Berlin", - uiLocale: "en_US.UTF-8", - uiKeymap: "us", -}; - -beforeEach(() => { - client = new L10nClient(new HTTPClient(new URL("http://localhost"))); -}); - -describe("#locales", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue(locales); - }); - - it("returns the list of available locales", async () => { - const locales = await client.locales(); - - expect(locales).toEqual([ - { id: "en_US.UTF-8", name: "English", territory: "United States" }, - { id: "es_ES.UTF-8", name: "Spanish", territory: "Spain" }, - ]); - expect(mockGetFn).toHaveBeenCalledWith("/l10n/locales"); - }); -}); - -describe("#getConfig", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue(config); - }); - - it("returns the list of selected locales", async () => { - const l10nConfig = await client.getConfig(); - - expect(l10nConfig).toEqual(config); - expect(mockGetFn).toHaveBeenCalledWith("/l10n/config"); - }); -}); - -describe("#setConfig", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue(config); - }); - - it("updates the l10n configuration", async () => { - await client.setConfig(config); - client.setConfig(config); - expect(mockPatchFn).toHaveBeenCalledWith("/l10n/config", config); - }); -}); - -describe("#getLocales", () => { - beforeEach(() => { - mockJsonFn.mockResolvedValue(config); - }); - - it("returns the list of selected locales", async () => { - const locales = await client.getLocales(); - - expect(locales).toEqual(["en_US.UTF-8"]); - expect(mockGetFn).toHaveBeenCalledWith("/l10n/config"); - }); -}); diff --git a/web/src/components/core/CardField.jsx b/web/src/components/core/CardField.jsx index 5a2ca1b7c0..e38356a010 100644 --- a/web/src/components/core/CardField.jsx +++ b/web/src/components/core/CardField.jsx @@ -48,8 +48,9 @@ const CardField = ({ cardDescriptionProps = {} }) => { + // TODO: replace aria-label with the proper aria-labelledby return ( - + diff --git a/web/src/components/core/InstallerOptions.jsx b/web/src/components/core/InstallerOptions.jsx index ba241b968d..440ad04545 100644 --- a/web/src/components/core/InstallerOptions.jsx +++ b/web/src/components/core/InstallerOptions.jsx @@ -30,7 +30,8 @@ import { _ } from "~/i18n"; import { localConnection } from "~/utils"; import { useInstallerL10n } from "~/context/installerL10n"; import supportedLanguages from "~/languages.json"; -import { useL10n } from "~/context/l10n"; +import { useQuery } from "@tanstack/react-query"; +import { keymapsQuery } from "~/queries/l10n"; /** * @typedef {import("@patternfly/react-core").ButtonProps} ButtonProps @@ -50,13 +51,14 @@ export default function InstallerOptions() { } = useInstallerL10n(); const [language, setLanguage] = useState(initialLanguage); const [keymap, setKeymap] = useState(initialKeymap); - const { keymaps } = useL10n(); + const { isPending, data: keymaps } = useQuery(keymapsQuery()); const location = useLocation(); const [isOpen, setIsOpen] = useState(false); const [inProgress, setInProgress] = useState(false); // FIXME: Installer options should be available in the login too. if (["/login", "/products/progress"].includes(location.pathname)) return; + if (isPending) return; const open = () => setIsOpen(true); const close = () => { diff --git a/web/src/components/l10n/KeyboardSelection.jsx b/web/src/components/l10n/KeyboardSelection.jsx index ca9e18bf67..12dddfe100 100644 --- a/web/src/components/l10n/KeyboardSelection.jsx +++ b/web/src/components/l10n/KeyboardSelection.jsx @@ -19,63 +19,49 @@ * find current contact information at www.suse.com. */ -import React, { useEffect, useState } from "react"; -import { - Form, FormGroup, - Radio, - Text -} from "@patternfly/react-core"; -import { useNavigate } from "react-router-dom"; -import { _ } from "~/i18n"; -import { useL10n } from "~/context/l10n"; -import { useInstallerClient } from "~/context/installer"; +import React, { useState } from "react"; +import { Form, FormGroup, Radio, Text } from "@patternfly/react-core"; +import { useLoaderData, useNavigate } from "react-router-dom"; import { ListSearch, Page } from "~/components/core"; +import { _ } from "~/i18n"; +import { useConfigMutation } from "~/queries/l10n"; import textStyles from '@patternfly/react-styles/css/utilities/Text/text'; // TODO: Add documentation and typechecking // TODO: Evaluate if worth it extracting the selector export default function KeyboardSelection() { - const { l10n } = useInstallerClient(); - const { keymaps, selectedKeymap: currentKeymap } = useL10n(); - const [selected, setSelected] = useState(currentKeymap); - const [filteredKeymaps, setFilteredKeymaps] = useState(keymaps); const navigate = useNavigate(); + const setConfig = useConfigMutation(); + const { keymaps, keymap: currentKeymap } = useLoaderData(); + const [selected, setSelected] = useState(currentKeymap.id); + const [filteredKeymaps, setFilteredKeymaps] = useState( + keymaps.sort((k1, k2) => k1.name > k2.name ? 1 : -1) + ); - const sortedKeymaps = keymaps.sort((k1, k2) => k1.name > k2.name ? 1 : -1); const searchHelp = _("Filter by description or keymap code"); - useEffect(() => { - setFilteredKeymaps(sortedKeymaps); - }, [sortedKeymaps, setFilteredKeymaps]); - const onSubmit = async (e) => { e.preventDefault(); - const dataForm = new FormData(e.target); - const nextKeymapId = JSON.parse(dataForm.get("keymap"))?.id; - - if (nextKeymapId !== currentKeymap?.id) { - await l10n.setKeymap(nextKeymapId); - } - - navigate(".."); + setConfig.mutate({ keymap: selected }); + navigate(-1); }; - let keymapsList = filteredKeymaps.map((keymap) => { + let keymapsList = filteredKeymaps.map(({ id, name }) => { return ( setSelected(keymap)} + onChange={() => setSelected(id)} label={ <> - {keymap.name} - {keymap.id} + {name} + {id} } - value={JSON.stringify(keymap)} - defaultChecked={keymap === selected} + value={id} + isChecked={id === selected} /> ); }); diff --git a/web/src/components/l10n/KeyboardSelection.test.jsx b/web/src/components/l10n/KeyboardSelection.test.jsx new file mode 100644 index 0000000000..fd733dde25 --- /dev/null +++ b/web/src/components/l10n/KeyboardSelection.test.jsx @@ -0,0 +1,57 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * 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 General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import KeyboardSelection from "./KeyboardSelection"; +import userEvent from "@testing-library/user-event"; +import { screen } from "@testing-library/react"; +import { mockNavigateFn, plainRender } from "~/test-utils"; + +const keymaps = [ + { id: "us", name: "English" }, + { id: "es", name: "Spanish" } +]; + +const mockConfigMutation = { + mutate: jest.fn() +}; + +jest.mock("~/queries/l10n", () => ({ + ...jest.requireActual("~/queries/l10n"), + useConfigMutation: () => mockConfigMutation +})); + +jest.mock("react-router-dom", () => ({ + ...jest.requireActual("react-router-dom"), + useNavigate: () => mockNavigateFn, + useLoaderData: () => ({ keymaps, keymap: keymaps[0] }) +})); + +it("allows changing the keyboard", async () => { + plainRender(); + + const option = await screen.findByText("Spanish"); + await userEvent.click(option); + const button = await screen.findByRole("button", { name: "Select" }); + await userEvent.click(button); + expect(mockConfigMutation.mutate).toHaveBeenCalledWith({ keymap: "es" }); + expect(mockNavigateFn).toHaveBeenCalledWith(-1); +}); diff --git a/web/src/components/l10n/L10nPage.jsx b/web/src/components/l10n/L10nPage.jsx index 5c1ee7dd14..57a0c6b41e 100644 --- a/web/src/components/l10n/L10nPage.jsx +++ b/web/src/components/l10n/L10nPage.jsx @@ -20,14 +20,11 @@ */ import React from "react"; -import { - Gallery, GalleryItem, -} from "@patternfly/react-core"; -import { Link } from "react-router-dom"; +import { Gallery, GalleryItem, } from "@patternfly/react-core"; +import { useLoaderData } from "react-router-dom"; import { ButtonLink, CardField, Page } from "~/components/core"; +import { LOCALE_SELECTION_PATH, KEYMAP_SELECTION_PATH, TIMEZONE_SELECTION_PATH } from "~/routes/l10n"; import { _ } from "~/i18n"; -import { useL10n } from "~/context/l10n"; -import buttonStyles from '@patternfly/react-styles/css/components/Button/button'; const Section = ({ label, value, children }) => { return ( @@ -48,11 +45,7 @@ const Section = ({ label, value, children }) => { * @component */ export default function L10nPage() { - const { - selectedKeymap: keymap, - selectedTimezone: timezone, - selectedLocales: [locale] - } = useL10n(); + const { locale, timezone, keymap } = useLoaderData(); return ( <> @@ -67,7 +60,7 @@ export default function L10nPage() { label={_("Language")} value={locale ? `${locale.name} - ${locale.territory}` : _("Not selected yet")} > - + {locale ? _("Change") : _("Select")} @@ -78,7 +71,7 @@ export default function L10nPage() { label={_("Keyboard")} value={keymap ? keymap.name : _("Not selected yet")} > - + {keymap ? _("Change") : _("Select")} @@ -89,7 +82,7 @@ export default function L10nPage() { label={_("Time zone")} value={timezone ? (timezone.parts || []).join(' - ') : _("Not selected yet")} > - + {timezone ? _("Change") : _("Select")} diff --git a/web/src/components/l10n/L10nPage.test.jsx b/web/src/components/l10n/L10nPage.test.jsx index a4c675c510..1406d0a99c 100644 --- a/web/src/components/l10n/L10nPage.test.jsx +++ b/web/src/components/l10n/L10nPage.test.jsx @@ -20,384 +20,82 @@ */ import React from "react"; -import { screen, waitFor, within } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; +import L10nPage from "~/components/l10n/L10nPage"; -import { installerRender, plainRender } from "~/test-utils"; -import { L10nPage } from "~/components/l10n"; -import { createClient } from "~/client"; +let mockLoadedData; -const locales = [ - { id: "de_DE.UTF8", name: "German", territory: "Germany" }, - { id: "en_US.UTF8", name: "English", territory: "United States" }, - { id: "es_ES.UTF8", name: "Spanish", territory: "Spain" } -]; - -const keymaps = [ - { id: "de", name: "German" }, - { id: "us", name: "English" }, - { id: "es", name: "Spanish" } -]; - -const timezones = [ - { id: "asia/bangkok", parts: ["Asia", "Bangkok"] }, - { id: "atlantic/canary", parts: ["Atlantic", "Canary"] }, - { id: "america/new_york", parts: ["Americas", "New York"] } -]; - -let mockL10nClient; -let mockSelectedLocales; -let mockSelectedKeymap; -let mockSelectedTimezone; - -jest.mock("~/client"); - -jest.mock("~/context/l10n", () => ({ - ...jest.requireActual("~/context/l10n"), - useL10n: () => ({ - locales, - selectedLocales: mockSelectedLocales, - keymaps, - selectedKeymap: mockSelectedKeymap, - timezones, - selectedTimezone: mockSelectedTimezone - }) -})); - -jest.mock("~/context/product", () => ({ - ...jest.requireActual("~/context/product"), - useProduct: () => ({ - selectedProduct : { name: "Test" } - }) +jest.mock('react-router-dom', () => ({ + ...jest.requireActual("react-router-dom"), + useLoaderData: () => mockLoadedData, + // TODO: mock the link because it needs a working router. + Link: ({ children }) => })); -createClient.mockImplementation(() => ( - { - l10n: mockL10nClient - } -)); - beforeEach(() => { - mockL10nClient = { - setLocales: jest.fn().mockResolvedValue(), - setKeymap: jest.fn().mockResolvedValue(), - setTimezone: jest.fn().mockResolvedValue() + mockLoadedData = { + locale: { id: "en_US.UTF-8", name: "English", territory: "United States" }, + keymap: { id: "us", name: "English" }, + timezone: { id: "Europe/Berlin", parts: ["Europe", "Berlin"] } }; - - mockSelectedLocales = []; - mockSelectedKeymap = undefined; - mockSelectedTimezone = undefined; }); -it.skip("renders a section for configuring the language", () => { - plainRender(); - screen.getByText("Language"); +it("renders a section for configuring the language", () => { + render(); + const region = screen.getByRole("region", { name: "Language" }); + within(region).getByText("English - United States"); + within(region).getByText("Change"); }); -describe.skip("if there is no selected language", () => { +describe("if there is no selected language", () => { beforeEach(() => { - mockSelectedLocales = []; + mockLoadedData.locale = undefined; }); it("renders a button for selecting a language", () => { - plainRender(); - screen.getByText("Language not selected yet"); - screen.getByRole("button", { name: "Select language" }); + render(); + const region = screen.getByRole("region", { name: "Language" }); + within(region).getByText("Not selected yet"); + within(region).getByText("Select"); }); }); -describe.skip("if there is a selected language", () => { - beforeEach(() => { - mockSelectedLocales = [{ id: "es_ES.UTF8", name: "Spanish", territory: "Spain" }]; - }); - - it("renders a button for changing the language", () => { - plainRender(); - screen.getByText("Spanish - Spain"); - screen.getByRole("button", { name: "Change language" }); - }); +it("renders a section for configuring the keyboard", () => { + render(); + const region = screen.getByRole("region", { name: "Keyboard" }); + within(region).getByText("English"); + within(region).getByText("Change"); }); -describe.skip("when the button for changing the language is clicked", () => { +describe("if there is no selected keyboard", () => { beforeEach(() => { - mockSelectedLocales = [{ id: "es_ES.UTF8", name: "Spanish", territory: "Spain" }]; - }); - - it("opens a popup for selecting the language", async () => { - const { user } = installerRender(); - - const button = screen.getByRole("button", { name: "Change language" }); - await user.click(button); - - const popup = await screen.findByRole("dialog"); - within(popup).getByText("Select language"); - within(popup).getByRole("row", { name: /German/ }); - within(popup).getByRole("row", { name: /English/ }); - within(popup).getByRole("row", { name: /Spanish/, selected: true }); - }); - - it("allows filtering languages", async () => { - const { user } = installerRender(); - - const button = screen.getByRole("button", { name: "Change language" }); - await user.click(button); - - const popup = await screen.findByRole("dialog"); - const searchInput = within(popup).getByRole("search"); - - await user.type(searchInput, "ish"); - - await waitFor(() => ( - expect(within(popup).queryByRole("row", { name: /German/ })).not.toBeInTheDocument()) - ); - within(popup).getByRole("row", { name: /English/ }); - within(popup).getByRole("row", { name: /Spanish/ }); - }); - - describe("if the popup is canceled", () => { - it("closes the popup without selecting a new language", async () => { - const { user } = installerRender(); - - const button = screen.getByRole("button", { name: "Change language" }); - await user.click(button); - - const popup = await screen.findByRole("dialog"); - const option = within(popup).getByRole("row", { name: /English/ }); - - await user.click(option); - const cancel = within(popup).getByRole("button", { name: "Cancel" }); - await user.click(cancel); - - expect(mockL10nClient.setLocales).not.toHaveBeenCalled(); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - }); - }); - - describe("if the popup is accepted", () => { - it("closes the popup selecting the new language", async () => { - const { user } = installerRender(); - - const button = screen.getByRole("button", { name: "Change language" }); - await user.click(button); - - const popup = await screen.findByRole("dialog"); - const option = within(popup).getByRole("row", { name: /English/ }); - - await user.click(option); - const accept = within(popup).getByRole("button", { name: "Accept" }); - await user.click(accept); - - expect(mockL10nClient.setLocales).toHaveBeenCalledWith(["en_US.UTF8"]); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - }); - }); -}); - -it.skip("renders a section for configuring the keyboard", () => { - plainRender(); - screen.getByText("Keyboard"); -}); - -describe.skip("if there is no selected keyboard", () => { - beforeEach(() => { - mockSelectedKeymap = undefined; + mockLoadedData.keymap = undefined; }); it("renders a button for selecting a keyboard", () => { - plainRender(); - screen.getByText("Keyboard not selected yet"); - screen.getByRole("button", { name: "Select keyboard" }); + render(); + const region = screen.getByRole("region", { name: "Keyboard" }); + within(region).getByText("Not selected yet"); + within(region).getByText("Select"); }); }); -describe.skip("if there is a selected keyboard", () => { - beforeEach(() => { - mockSelectedKeymap = { id: "es", name: "Spanish" }; - }); - - it("renders a button for changing the keyboard", () => { - plainRender(); - screen.getByText("Spanish"); - screen.getByRole("button", { name: "Change keyboard" }); - }); +it("renders a section for configuring the time zone", () => { + render(); + const region = screen.getByRole("region", { name: "Time zone" }); + within(region).getByText("Europe - Berlin"); + within(region).getByText("Change"); }); -describe.skip("when the button for changing the keyboard is clicked", () => { +describe("if there is no selected time zone", () => { beforeEach(() => { - mockSelectedKeymap = { id: "es", name: "Spanish" }; - }); - - it("opens a popup for selecting the keyboard", async () => { - const { user } = installerRender(); - - const button = screen.getByRole("button", { name: "Change keyboard" }); - await user.click(button); - - const popup = await screen.findByRole("dialog"); - within(popup).getByText("Select keyboard"); - within(popup).getByRole("row", { name: /German/ }); - within(popup).getByRole("row", { name: /English/ }); - within(popup).getByRole("row", { name: /Spanish/, selected: true }); - }); - - it("allows filtering keyboards", async () => { - const { user } = installerRender(); - - const button = screen.getByRole("button", { name: "Change keyboard" }); - await user.click(button); - - const popup = await screen.findByRole("dialog"); - const searchInput = within(popup).getByRole("search"); - - await user.type(searchInput, "ish"); - - await waitFor(() => ( - expect(within(popup).queryByRole("row", { name: /German/ })).not.toBeInTheDocument()) - ); - within(popup).getByRole("row", { name: /English/ }); - within(popup).getByRole("row", { name: /Spanish/ }); - }); - - describe("if the popup is canceled", () => { - it("closes the popup without selecting a new keyboard", async () => { - const { user } = installerRender(); - - const button = screen.getByRole("button", { name: "Change keyboard" }); - await user.click(button); - - const popup = await screen.findByRole("dialog"); - const option = within(popup).getByRole("row", { name: /English/ }); - - await user.click(option); - const cancel = within(popup).getByRole("button", { name: "Cancel" }); - await user.click(cancel); - - expect(mockL10nClient.setKeymap).not.toHaveBeenCalled(); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - }); - }); - - describe("if the popup is accepted", () => { - it("closes the popup selecting the new keyboard", async () => { - const { user } = installerRender(); - - const button = screen.getByRole("button", { name: "Change keyboard" }); - await user.click(button); - - const popup = await screen.findByRole("dialog"); - const option = within(popup).getByRole("row", { name: /English/ }); - - await user.click(option); - const accept = within(popup).getByRole("button", { name: "Accept" }); - await user.click(accept); - - expect(mockL10nClient.setKeymap).toHaveBeenCalledWith("us"); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - }); - }); -}); - -it.skip("renders a section for configuring the time zone", () => { - plainRender(); - screen.getByText("Time zone"); -}); - -describe.skip("if there is no selected time zone", () => { - beforeEach(() => { - mockSelectedTimezone = undefined; + mockLoadedData.timezone = undefined; }); it("renders a button for selecting a time zone", () => { - plainRender(); - screen.getByText("Time zone not selected yet"); - screen.getByRole("button", { name: "Select time zone" }); - }); -}); - -describe.skip("if there is a selected time zone", () => { - beforeEach(() => { - mockSelectedTimezone = { id: "atlantic/canary", parts: ["Atlantic", "Canary"] }; - }); - - it("renders a button for changing the time zone", () => { - plainRender(); - screen.getByText("Atlantic - Canary"); - screen.getByRole("button", { name: "Change time zone" }); - }); -}); - -describe.skip("when the button for changing the time zone is clicked", () => { - beforeEach(() => { - mockSelectedTimezone = { id: "atlantic/canary", parts: ["Atlantic", "Canary"] }; - }); - - it("opens a popup for selecting the time zone", async () => { - const { user } = installerRender(); - - const button = screen.getByRole("button", { name: "Change time zone" }); - await user.click(button); - - const popup = await screen.findByRole("dialog"); - within(popup).getByText("Select time zone"); - within(popup).getByRole("row", { name: /Bangkok/ }); - within(popup).getByRole("row", { name: /Canary/, selected: true }); - within(popup).getByRole("row", { name: /New York/ }); - }); - - it("allows filtering time zones", async () => { - const { user } = installerRender(); - - const button = screen.getByRole("button", { name: "Change time zone" }); - await user.click(button); - - const popup = await screen.findByRole("dialog"); - const searchInput = within(popup).getByRole("search"); - - await user.type(searchInput, "new"); - - await waitFor(() => ( - expect(within(popup).queryByRole("row", { name: /Bangkok/ })).not.toBeInTheDocument()) - ); - await waitFor(() => ( - expect(within(popup).queryByRole("row", { name: /Canary/ })).not.toBeInTheDocument()) - ); - within(popup).getByRole("row", { name: /New York/ }); - }); - - describe("if the popup is canceled", () => { - it("closes the popup without selecting a new time zone", async () => { - const { user } = installerRender(); - - const button = screen.getByRole("button", { name: "Change time zone" }); - await user.click(button); - - const popup = await screen.findByRole("dialog"); - const option = within(popup).getByRole("row", { name: /New York/ }); - - await user.click(option); - const cancel = within(popup).getByRole("button", { name: "Cancel" }); - await user.click(cancel); - - expect(mockL10nClient.setTimezone).not.toHaveBeenCalled(); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - }); - }); - - describe("if the popup is accepted", () => { - it("closes the popup selecting the new time zone", async () => { - const { user } = installerRender(); - - const button = screen.getByRole("button", { name: "Change time zone" }); - await user.click(button); - - const popup = await screen.findByRole("dialog"); - const option = within(popup).getByRole("row", { name: /Bangkok/ }); - - await user.click(option); - const accept = within(popup).getByRole("button", { name: "Accept" }); - await user.click(accept); - - expect(mockL10nClient.setTimezone).toHaveBeenCalledWith("asia/bangkok"); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - }); + render(); + const region = screen.getByRole("region", { name: "Time zone" }); + within(region).getByText("Not selected yet"); + within(region).getByText("Select"); }); }); diff --git a/web/src/components/l10n/LocaleSelection.jsx b/web/src/components/l10n/LocaleSelection.jsx index 676e28e5e5..9f143b76dd 100644 --- a/web/src/components/l10n/LocaleSelection.jsx +++ b/web/src/components/l10n/LocaleSelection.jsx @@ -19,62 +19,47 @@ * find current contact information at www.suse.com. */ -import React, { useEffect, useState } from "react"; -import { - Flex, - Form, FormGroup, - Radio, -} from "@patternfly/react-core"; -import { useNavigate } from "react-router-dom"; -import { _ } from "~/i18n"; -import { useL10n } from "~/context/l10n"; -import { useInstallerClient } from "~/context/installer"; +import React, { useState } from "react"; +import { Flex, Form, FormGroup, Radio } from "@patternfly/react-core"; +import { useLoaderData, useNavigate } from "react-router-dom"; import { ListSearch, Page } from "~/components/core"; +import { _ } from "~/i18n"; +import { useConfigMutation } from "~/queries/l10n"; import textStyles from '@patternfly/react-styles/css/utilities/Text/text'; // TODO: Add documentation and typechecking // TODO: Evaluate if worth it extracting the selector export default function LocaleSelection() { - const { l10n } = useInstallerClient(); - const { locales, selectedLocales } = useL10n(); - const [selected, setSelected] = useState(selectedLocales[0]); - const [filteredLocales, setFilteredLocales] = useState(locales); const navigate = useNavigate(); + const setConfig = useConfigMutation(); + const { locales, locale: currentLocale } = useLoaderData(); + const [selected, setSelected] = useState(currentLocale.id); + const [filteredLocales, setFilteredLocales] = useState(locales); const searchHelp = _("Filter by language, territory or locale code"); - useEffect(() => { - setFilteredLocales(locales); - }, [locales, setFilteredLocales]); - const onSubmit = async (e) => { e.preventDefault(); - const dataForm = new FormData(e.target); - const nextLocaleId = JSON.parse(dataForm.get("locale"))?.id; - - if (nextLocaleId !== selectedLocales[0]?.id) { - await l10n.setLocales([nextLocaleId]); - } - - navigate(".."); + setConfig.mutate({ locales: [selected] }); + navigate(-1); }; - let localesList = filteredLocales.map((locale) => { + let localesList = filteredLocales.map(({ id, name, territory }) => { return ( setSelected(locale)} + onChange={() => setSelected(id)} label={ - {locale.name} - {locale.territory} - {locale.id} + {name} + {territory} + {id} } - value={JSON.stringify(locale)} - checked={locale === selected} + value={id} + isChecked={id === selected} /> ); }); diff --git a/web/src/components/l10n/LocaleSelection.test.jsx b/web/src/components/l10n/LocaleSelection.test.jsx new file mode 100644 index 0000000000..1fc001578f --- /dev/null +++ b/web/src/components/l10n/LocaleSelection.test.jsx @@ -0,0 +1,57 @@ +/* + * Copyright (c) [2023-2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * 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 General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import LocaleSelection from "./LocaleSelection"; +import userEvent from "@testing-library/user-event"; +import { screen } from "@testing-library/react"; +import { mockNavigateFn, plainRender } from "~/test-utils"; + +const locales = [ + { id: "en_US.UTF-8", name: "English", territory: "United States" }, + { id: "es_ES.UTF-8", name: "Spanish", territory: "Spain" } +]; + +const mockConfigMutation = { + mutate: jest.fn() +}; + +jest.mock("~/queries/l10n", () => ({ + ...jest.requireActual("~/queries/l10n"), + useConfigMutation: () => mockConfigMutation +})); + +jest.mock("react-router-dom", () => ({ + ...jest.requireActual("react-router-dom"), + useNavigate: () => mockNavigateFn, + useLoaderData: () => ({ locales, locale: locales[0] }) +})); + +it("allows changing the keyboard", async () => { + plainRender(); + + const option = await screen.findByText("Spanish"); + await userEvent.click(option); + const button = await screen.findByRole("button", { name: "Select" }); + await userEvent.click(button); + expect(mockConfigMutation.mutate).toHaveBeenCalledWith({ locales: ["es_ES.UTF-8"] }); + expect(mockNavigateFn).toHaveBeenCalledWith(-1); +}); diff --git a/web/src/components/l10n/TimezoneSelection.jsx b/web/src/components/l10n/TimezoneSelection.jsx index 1b0fcf4b8b..4650bfa52b 100644 --- a/web/src/components/l10n/TimezoneSelection.jsx +++ b/web/src/components/l10n/TimezoneSelection.jsx @@ -19,20 +19,13 @@ * find current contact information at www.suse.com. */ -import React, { useEffect, useState } from "react"; -import { - Divider, - Flex, - Form, FormGroup, - Radio, - Text -} from "@patternfly/react-core"; +import React, { useState } from "react"; +import { Divider, Flex, Form, FormGroup, Radio, Text } from "@patternfly/react-core"; +import { useLoaderData, useNavigate } from "react-router-dom"; import { ListSearch, Page } from "~/components/core"; -import { useNavigate } from "react-router-dom"; import { _ } from "~/i18n"; import { timezoneTime } from "~/utils"; -import { useL10n } from "~/context/l10n"; -import { useInstallerClient } from "~/context/installer"; +import { useConfigMutation } from "~/queries/l10n"; import textStyles from '@patternfly/react-styles/css/utilities/Text/text'; let date; @@ -40,7 +33,7 @@ let date; const timezoneWithDetails = (timezone) => { const offset = timezone.utcOffset; - if (offset === undefined) return timezone.id; + if (offset === undefined) return { ...timezone, details: timezone.id }; let utc = "UTC"; if (offset > 0) utc += `+${offset}`; @@ -61,58 +54,44 @@ const sortedTimezones = (timezones) => { // TODO: Refactor timezones/extendedTimezones thingy export default function TimezoneSelection() { date = new Date(); - const { l10n } = useInstallerClient(); - const { timezones, selectedTimezone: currentTimezone } = useL10n(); - const [displayTimezones, setDisplayTimezones] = useState([]); - const [selected, setSelected] = useState(currentTimezone); - const [filteredTimezones, setFilteredTimezones] = useState([]); const navigate = useNavigate(); + const setConfig = useConfigMutation(); + const { timezones, timezone: currentTimezone } = useLoaderData(); + const displayTimezones = timezones.map(timezoneWithDetails); + const [selected, setSelected] = useState(currentTimezone.id); + const [filteredTimezones, setFilteredTimezones] = useState(sortedTimezones(displayTimezones)); const searchHelp = _("Filter by territory, time zone code or UTC offset"); - useEffect(() => { - setDisplayTimezones(timezones.map(timezoneWithDetails)); - }, [setDisplayTimezones, timezones]); - - useEffect(() => { - setFilteredTimezones(sortedTimezones(displayTimezones)); - }, [setFilteredTimezones, displayTimezones]); - const onSubmit = async (e) => { e.preventDefault(); - const dataForm = new FormData(e.target); - const nextTimezoneId = JSON.parse(dataForm.get("timezone"))?.id; - - if (nextTimezoneId !== currentTimezone?.id) { - await l10n.setTimezone(nextTimezoneId); - } - - navigate(".."); + setConfig.mutate({ timezone: selected }); + navigate(-1); }; - let timezonesList = filteredTimezones.map((timezone) => { + let timezonesList = filteredTimezones.map(({ id, country, details, parts }) => { return ( setSelected(timezone)} + onChange={() => setSelected(id)} label={ <> - {timezone.parts.join('-')} - {timezone.country} + {parts.join('-')} + {country} } description={ - {timezoneTime(timezone.id, { date }) || ""} + {timezoneTime(id, { date }) || ""} -
{timezone.details}
+
{details}
} - value={JSON.stringify(timezone)} - defaultChecked={timezone === selected} + value={id} + isChecked={id === selected} /> ); }); diff --git a/web/src/components/l10n/TimezoneSelection.test.jsx b/web/src/components/l10n/TimezoneSelection.test.jsx new file mode 100644 index 0000000000..efecd089fb --- /dev/null +++ b/web/src/components/l10n/TimezoneSelection.test.jsx @@ -0,0 +1,57 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * 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 General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import TimezoneSelection from "./TimezoneSelection"; +import userEvent from "@testing-library/user-event"; +import { screen } from "@testing-library/react"; +import { mockNavigateFn, plainRender } from "~/test-utils"; + +const timezones = [ + { id: "Europe/Berlin", parts: ["Europe", "Berlin"], country: "Germany", utcOffset: 1 }, + { id: "Europe/Madrid", parts: ["Europe", "Madrid"], country: "Spain", utfOffset: 1 } +]; + +const mockConfigMutation = { + mutate: jest.fn() +}; + +jest.mock("~/queries/l10n", () => ({ + ...jest.requireActual("~/queries/l10n"), + useConfigMutation: () => mockConfigMutation +})); + +jest.mock("react-router-dom", () => ({ + ...jest.requireActual("react-router-dom"), + useNavigate: () => mockNavigateFn, + useLoaderData: () => ({ timezones, timezone: timezones[0] }) +})); + +it("allows changing the keyboard", async () => { + plainRender(); + + const option = await screen.findByText("Europe-Madrid"); + await userEvent.click(option); + const button = await screen.findByRole("button", { name: "Select" }); + await userEvent.click(button); + expect(mockConfigMutation.mutate).toHaveBeenCalledWith({ timezone: "Europe/Madrid" }); + expect(mockNavigateFn).toHaveBeenCalledWith(-1); +}); diff --git a/web/src/components/l10n/routes.js b/web/src/components/l10n/routes.js deleted file mode 100644 index 1746dd782b..0000000000 --- a/web/src/components/l10n/routes.js +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (c) [2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * 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 General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React from "react"; -import { Page } from "~/components/core"; -import L10nPage from "./L10nPage"; -import LocaleSelection from "./LocaleSelection"; -import KeymapSelection from "./KeyboardSelection"; -import TimezoneSelection from "./TimezoneSelection"; -import { N_ } from "~/i18n"; - -const routes = { - path: "/l10n", - element: , - handle: { - name: N_("Localization"), - icon: "globe" - }, - children: [ - { - index: true, - element: - }, - { - path: "language/select", - element: , - }, - { - path: "keymap/select", - element: , - }, - { - path: "timezone/select", - element: , - } - ] -}; - -export default routes; diff --git a/web/src/components/overview/L10nSection.jsx b/web/src/components/overview/L10nSection.jsx index 2f7955ac43..1b2c8112dc 100644 --- a/web/src/components/overview/L10nSection.jsx +++ b/web/src/components/overview/L10nSection.jsx @@ -23,15 +23,16 @@ import React from "react"; import { TextContent, Text, TextVariants } from "@patternfly/react-core"; import { Em } from "~/components/core"; import { _ } from "~/i18n"; -import { useL10n } from "~/context/l10n"; +import { localesQuery, configQuery } from "~/queries/l10n"; +import { useQuery } from "@tanstack/react-query"; export default function L10nSection() { - const { selectedLocales } = useL10n(); + const { isPending: isLocalesPending, data: locales } = useQuery(localesQuery()); + const { isPending: isConfigPending, data: config } = useQuery(configQuery()); - const locale = selectedLocales[0]; - if (locale === undefined) { - return; - } + if (isLocalesPending || isConfigPending) return; + + const locale = locales.find((l) => l.id === config?.locales[0]); // TRANSLATORS: %s will be replaced by a language name and territory, example: // "English (United States)". diff --git a/web/src/components/overview/L10nSection.test.jsx b/web/src/components/overview/L10nSection.test.jsx index 4a813225bb..dad4cde147 100644 --- a/web/src/components/overview/L10nSection.test.jsx +++ b/web/src/components/overview/L10nSection.test.jsx @@ -20,58 +20,28 @@ */ import React from "react"; -import { act, screen } from "@testing-library/react"; -import { createCallbackMock, installerRender } from "~/test-utils"; +import { screen } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; import { L10nSection } from "~/components/overview"; -import { createClient } from "~/client"; - -jest.mock("~/client"); const locales = [ - { id: "en_US", name: "English", territory: "United States" }, - { id: "de_DE", name: "German", territory: "Germany" } + { id: "en_US.UTF-8", name: "English", territory: "United States" }, + { id: "de_DE.UTF-8", name: "German", territory: "Germany" } ]; -const l10nClientMock = { - locales: jest.fn().mockResolvedValue(locales), - getLocales: jest.fn().mockResolvedValue(["en_US"]), - getUILocale: jest.fn().mockResolvedValue("en_US"), - getUIKeymap: jest.fn().mockResolvedValue("en"), - keymaps: jest.fn().mockResolvedValue([]), - getKeymap: jest.fn().mockResolvedValue(undefined), - timezones: jest.fn().mockResolvedValue([]), - getTimezone: jest.fn().mockResolvedValue(undefined), - onLocalesChange: jest.fn(), - onKeymapChange: jest.fn(), - onTimezoneChange: jest.fn() -}; - -beforeEach(() => { - // if defined outside, the mock is cleared automatically - createClient.mockImplementation(() => { - return { - l10n: l10nClientMock - }; - }); -}); +jest.mock("~/queries/l10n", () => ({ + localesQuery: () => ({ + queryKey: ["l10n", "locales"], + queryFn: jest.fn().mockResolvedValue(locales) + }), + configQuery: () => ({ + queryKey: ["l10n", "config"], + queryFn: jest.fn().mockResolvedValue({ locales: ["en_US.UTF-8"] }) + }) +})); it("displays the selected locale", async () => { - installerRender(, { withL10n: true }); + plainRender(, { withL10n: true }); await screen.findByText("English (United States)"); }); - -describe("when the selected locales change", () => { - it("updates the proposal", async () => { - const [mockFunction, callbacks] = createCallbackMock(); - l10nClientMock.onLocalesChange = mockFunction; - - installerRender(, { withL10n: true }); - await screen.findByText("English (United States)"); - - const [cb] = callbacks; - act(() => cb(["de_DE"])); - - await screen.findByText("German (Germany)"); - }); -}); diff --git a/web/src/context/app.jsx b/web/src/context/app.jsx index d92e9d662e..d22ccce51e 100644 --- a/web/src/context/app.jsx +++ b/web/src/context/app.jsx @@ -24,9 +24,11 @@ import React from "react"; import { InstallerClientProvider } from "./installer"; import { InstallerL10nProvider } from "./installerL10n"; -import { L10nProvider } from "./l10n"; import { ProductProvider } from "./product"; import { IssuesProvider } from "./issues"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const queryClient = new QueryClient(); /** * Combines all application providers. @@ -37,17 +39,17 @@ import { IssuesProvider } from "./issues"; function AppProviders({ children }) { return ( - - + + {children} - - + + ); } -export { AppProviders }; +export { AppProviders, queryClient }; diff --git a/web/src/context/l10n.jsx b/web/src/context/l10n.jsx deleted file mode 100644 index 52b3e5d87b..0000000000 --- a/web/src/context/l10n.jsx +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright (c) [2023] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * 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 General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React, { useContext, useEffect, useState } from "react"; -import { useCancellablePromise } from "~/utils"; -import { useInstallerClient } from "./installer"; - -/** - * @typedef {import ("~/client/l10n").Locale} Locale - * @typedef {import ("~/client/l10n").Keymap} Keymap - * @typedef {import ("~/client/l10n").Timezone} Timezone - */ - -const L10nContext = React.createContext({}); - -function L10nProvider({ children }) { - const client = useInstallerClient(); - const { cancellablePromise } = useCancellablePromise(); - const [timezones, setTimezones] = useState(); - const [selectedTimezone, setSelectedTimezone] = useState(); - const [locales, setLocales] = useState(); - const [selectedLocales, setSelectedLocales] = useState(); - const [keymaps, setKeymaps] = useState(); - const [selectedKeymap, setSelectedKeymap] = useState(); - - useEffect(() => { - const load = async () => { - const timezones = await cancellablePromise(client.l10n.timezones()); - const selectedTimezone = await cancellablePromise(client.l10n.getTimezone()); - const locales = await cancellablePromise(client.l10n.locales()); - const selectedLocales = await cancellablePromise(client.l10n.getLocales()); - const keymaps = await cancellablePromise(client.l10n.keymaps()); - const selectedKeymap = await cancellablePromise(client.l10n.getKeymap()); - setTimezones(timezones); - setSelectedTimezone(selectedTimezone); - setLocales(locales); - setSelectedLocales(selectedLocales); - setKeymaps(keymaps); - setSelectedKeymap(selectedKeymap); - }; - - if (client) { - load().catch(console.error); - } - }, [cancellablePromise, client, setKeymaps, setLocales, setSelectedKeymap, setSelectedLocales, setSelectedTimezone, setTimezones]); - - useEffect(() => { - if (!client) return; - - return client.l10n.onTimezoneChange(setSelectedTimezone); - }, [client, setSelectedTimezone]); - - useEffect(() => { - if (!client) return; - - return client.l10n.onLocalesChange(setSelectedLocales); - }, [client, setSelectedLocales]); - - useEffect(() => { - if (!client) return; - - return client.l10n.onKeymapChange(setSelectedKeymap); - }, [client, setSelectedKeymap]); - - const value = { timezones, selectedTimezone, locales, selectedLocales, keymaps, selectedKeymap }; - return {children}; -} - -/** - * Localization context. - * @function - * - * @typedef {object} L10nContext - * @property {Locale[]} locales - * @property {Keymap[]} keymaps - * @property {Timezone[]} timezones - * @property {Locale[]} selectedLocales - * @property {Keymap|undefined} selectedKeymap - * @property {Timezone|undefined} selectedTimezone - * - * @returns {L10nContext} - */ -function useL10n() { - const context = useContext(L10nContext); - - if (!context) { - throw new Error("useL10n must be used within a L10nProvider"); - } - - const { - timezones = [], - selectedTimezone: selectedTimezoneId, - locales = [], - selectedLocales: selectedLocalesId = [], - keymaps = [], - selectedKeymap: selectedKeymapId - } = context; - - const selectedTimezone = timezones.find(t => t.id === selectedTimezoneId); - const selectedLocales = selectedLocalesId.map(id => locales.find(l => l.id === id)); - const selectedKeymap = keymaps.find(k => k.id === selectedKeymapId); - - return { timezones, selectedTimezone, locales, selectedLocales, keymaps, selectedKeymap }; -} - -export { L10nProvider, useL10n }; diff --git a/web/src/queries/hooks.js b/web/src/queries/hooks.js new file mode 100644 index 0000000000..1c64660b2f --- /dev/null +++ b/web/src/queries/hooks.js @@ -0,0 +1,65 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * 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 General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { useRevalidator } from "react-router-dom"; +import { useQueryClient } from "@tanstack/react-query"; + +/** + * Allows invalidating cached data + * + * This hook is useful for marking data as outdated and retrieve it again. To do so, it performs two important steps + * - ask @tanstack/react-query to invalidate query matching given key + * - ask react-router-dom for a revalidation of loaded data + * + * TODO: rethink the revalidation; we may decide to keep the outdated data + * instead, but warning the user about it (as Github does when reviewing a PR, + * for example) + * + * TODO: allow to specify more than one queryKey + * + * To know more, please visit the documentation of these dependencies + * + * - https://tanstack.com/query/v5/docs/framework/react/guides/query-invalidation + * - https://reactrouter.com/en/main/hooks/use-revalidator#userevalidator + * + * @example + * + * const dataInvalidator = useDataInvalidator(); + * + * useEffect(() => { + * dataInvalidator({ queryKey: ["user", "auth"] }) + * }, [dataInvalidator]); + */ +const useDataInvalidator = () => { + const queryClient = useQueryClient(); + const revalidator = useRevalidator(); + + const dataInvalidator = ({ queryKey }) => { + if (queryKey) queryClient.invalidateQueries({ queryKey }); + revalidator.revalidate(); + }; + + return dataInvalidator; +}; + +export { + useDataInvalidator +}; diff --git a/web/src/queries/hooks.test.js b/web/src/queries/hooks.test.js new file mode 100644 index 0000000000..6db7d4b3ee --- /dev/null +++ b/web/src/queries/hooks.test.js @@ -0,0 +1,49 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * 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 General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { renderHook } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useDataInvalidator } from "~/queries/hooks.js"; + +const mockRevalidateFn = jest.fn(); +jest.mock("react-router-dom", () => ({ + ...jest.requireActual("react-router-dom"), + useRevalidator: () => ({ revalidate: mockRevalidateFn }) +})); + +const queryClient = new QueryClient(); +jest.spyOn(queryClient, "invalidateQueries"); +const wrapper = ({ children }) => ( + + {children} + +); + +describe("useDataInvalidator", () => { + it("forces a data/cache refresh", () => { + const { result } = renderHook(() => useDataInvalidator(), { wrapper }); + const { current: dataInvalidator } = result; + dataInvalidator({ queryKey: "fakeQuery" }); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ queryKey: "fakeQuery" }); + expect(mockRevalidateFn).toHaveBeenCalled(); + }); +}); diff --git a/web/src/queries/l10n.js b/web/src/queries/l10n.js new file mode 100644 index 0000000000..75d193251b --- /dev/null +++ b/web/src/queries/l10n.js @@ -0,0 +1,132 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * 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 General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { useQueryClient, useMutation } from "@tanstack/react-query"; +import { useInstallerClient } from "~/context/installer"; +import { useDataInvalidator } from "~/queries/hooks"; +import { timezoneUTCOffset } from "~/utils"; + +/** + * Returns a query for retrieving the localization configuration + */ +const configQuery = () => { + return { + queryKey: ["l10n", "config"], + queryFn: () => fetch("/api/l10n/config").then((res) => res.json()), + }; +}; + +/** + * Returns a query for retrieving the list of known locales + */ +const localesQuery = () => ({ + queryKey: ["l10n", "locales"], + queryFn: async () => { + const response = await fetch("/api/l10n/locales"); + const locales = await response.json(); + return locales.map(({ id, language, territory }) => { + return { id, name: language, territory }; + }); + }, + staleTime: Infinity +}); + +/** + * Returns a query for retrieving the list of known timezones + */ +const timezonesQuery = () => ({ + queryKey: ["l10n", "timezones"], + queryFn: async () => { + const response = await fetch("/api/l10n/timezones"); + const timezones = await response.json(); + return timezones.map(({ code, parts, country }) => { + const offset = timezoneUTCOffset(code); + return { id: code, parts, country, utcOffset: offset }; + }); + }, + staleTime: Infinity +}); + +/** + * Returns a query for retrieving the list of known keymaps + */ +const keymapsQuery = () => ({ + queryKey: ["l10n", "keymaps"], + queryFn: async () => { + const response = await fetch("/api/l10n/keymaps"); + const json = await response.json(); + const keymaps = json.map(({ id, description }) => { + return { id, name: description }; + }); + return keymaps.sort((a, b) => (a.name < b.name) ? -1 : 1); + }, + staleTime: Infinity +}); + +/** + * Hook that builds a mutation to update the l10n configuration + * + * It does not require to call `useMutation`. + */ +const useConfigMutation = () => { + const query = { + mutationFn: (newConfig) => + fetch("/api/l10n/config", { + method: "PATCH", + body: JSON.stringify(newConfig), + headers: { + "Content-Type": "application/json", + }, + }) + }; + return useMutation(query); +}; + +/** + * Hook that returns a useEffect to listen for L10nConfigChanged events + * + * When the configuration changes, it invalidates the config query and forces the router to + * revalidate its data (executing the loaders again). + */ +const useL10nConfigChanges = () => { + const dataInvalidator = useDataInvalidator(); + const client = useInstallerClient(); + + React.useEffect(() => { + if (!client) return; + + return client.ws().onEvent(event => { + if (event.type === "L10nConfigChanged") { + dataInvalidator({ queryKey: ["l10n", "config"] }); + } + }); + }, [client, dataInvalidator]); +}; + +export { + configQuery, + keymapsQuery, + localesQuery, + timezonesQuery, + useConfigMutation, + useL10nConfigChanges +}; diff --git a/web/src/router.js b/web/src/router.js index 31628d73d1..5b4596ddd5 100644 --- a/web/src/router.js +++ b/web/src/router.js @@ -29,7 +29,7 @@ import { LoginPage } from "~/components/core"; import { OverviewPage } from "~/components/overview"; import { _ } from "~/i18n"; import overviewRoutes from "~/components/overview/routes"; -import l10nRoutes from "~/components/l10n/routes"; +import l10nRoutes from "~/routes/l10n"; import networkRoutes from "~/components/network/routes"; import { productsRoute } from "~/components/product/routes"; import storageRoutes from "~/components/storage/routes"; diff --git a/web/src/routes/l10n.js b/web/src/routes/l10n.js new file mode 100644 index 0000000000..b6d26c10ff --- /dev/null +++ b/web/src/routes/l10n.js @@ -0,0 +1,90 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * 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 General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { Page } from "~/components/core"; +import { L10nPage, LocaleSelection, KeymapSelection, TimezoneSelection } from "~/components/l10n"; +import { queryClient } from "~/context/app"; +import { + configQuery, + localesQuery, + keymapsQuery, + timezonesQuery, +} from "~/queries/l10n"; +import { N_ } from "~/i18n"; + +const L10N_PATH = "/l10n"; +const LOCALE_SELECTION_PATH = "locale/select"; +const KEYMAP_SELECTION_PATH = "keymap/select"; +const TIMEZONE_SELECTION_PATH = "timezone/select"; + +const l10nLoader = async () => { + const config = await queryClient.fetchQuery(configQuery()); + const locales = await queryClient.fetchQuery(localesQuery()); + const keymaps = await queryClient.fetchQuery(keymapsQuery()); + const timezones = await queryClient.fetchQuery(timezonesQuery()); + + const { locales: [localeId], keymap: keymapId, timezone: timezoneId } = config; + const locale = locales.find((l) => l.id === localeId); + const keymap = keymaps.find((k) => k.id === keymapId); + const timezone = timezones.find((t) => t.id === timezoneId); + + return { locales, locale, keymaps, keymap, timezones, timezone }; +}; + +const routes = { + path: L10N_PATH, + element: , + handle: { + name: N_("Localization"), + icon: "globe" + }, + children: [ + { + index: true, + loader: l10nLoader, + element: + }, + { + path: LOCALE_SELECTION_PATH, + loader: l10nLoader, + element: , + }, + { + path: KEYMAP_SELECTION_PATH, + loader: l10nLoader, + element: , + }, + { + path: TIMEZONE_SELECTION_PATH, + loader: l10nLoader, + element: , + } + ] +}; + +export default routes; +export { + L10N_PATH, + LOCALE_SELECTION_PATH, + KEYMAP_SELECTION_PATH, + TIMEZONE_SELECTION_PATH +}; diff --git a/web/src/test-utils.js b/web/src/test-utils.js index 7f64aa3602..7aaa06f560 100644 --- a/web/src/test-utils.js +++ b/web/src/test-utils.js @@ -35,7 +35,7 @@ import { InstallerClientProvider } from "~/context/installer"; import { noop, isObject } from "./utils"; import cockpit from "./lib/cockpit"; import { InstallerL10nProvider } from "./context/installerL10n"; -import { L10nProvider } from "./context/l10n"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; /** * Internal mock for manipulating routes, using ["/"] by default @@ -51,6 +51,14 @@ const initialRoutes = jest.fn().mockReturnValue(["/"]); */ const mockNavigateFn = jest.fn(); +/** + * Allows checking when the useRevalidator function has been called + * + * @example + * expect(mockUseRevalidator).toHaveBeenCalled() + */ +const mockUseRevalidator = jest.fn(); + /** * Allows manipulating MemoryRouter routes for testing purpose * @@ -68,7 +76,8 @@ jest.mock('react-router-dom', () => ({ ...jest.requireActual("react-router-dom"), useNavigate: () => mockNavigateFn, Navigate: ({ to: route }) => <>Navigating to {route}, - Outlet: () => <>Outlet Content + Outlet: () => <>Outlet Content, + useRevalidator: () => mockUseRevalidator })); const Providers = ({ children, withL10n }) => { @@ -100,9 +109,7 @@ const Providers = ({ children, withL10n }) => { return ( - - {children} - + {children} ); @@ -122,10 +129,14 @@ const Providers = ({ children, withL10n }) => { * @see #plainRender for rendering without installer providers */ const installerRender = (ui, options = {}) => { + const queryClient = new QueryClient({}); + const Wrapper = ({ children }) => ( - {children} + + {children} + ); @@ -151,10 +162,17 @@ const installerRender = (ui, options = {}) => { * core/Sidebar as part of the layout. */ const plainRender = (ui, options = {}) => { + const queryClient = new QueryClient({}); + + const Wrapper = ({ children }) => ( + + {children} + + ); return ( { user: userEvent.setup(), - ...render(ui, options) + ...render(ui, { wrapper: Wrapper, ...options }) } ); }; @@ -215,5 +233,6 @@ export { mockGettext, mockNavigateFn, mockRoutes, + mockUseRevalidator, resetLocalStorage }; diff --git a/web/src/utils.js b/web/src/utils.js index d8315dda1b..938e6af327 100644 --- a/web/src/utils.js +++ b/web/src/utils.js @@ -47,6 +47,11 @@ const isObject = (value) => ( */ const noop = () => undefined; +/** + * @return {function} identity function + */ +const identity = (i) => i; + /** * Returns a new array with a given collection split into two groups, the first holding elements * satisfying the filter and the second with those which do not. @@ -373,6 +378,7 @@ const timezoneUTCOffset = (timezone) => { export { noop, + identity, isObject, partition, compact,