diff --git a/web/src/App.jsx b/web/src/App.jsx index bef32b5860..d65379b109 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -29,11 +29,8 @@ 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. * diff --git a/web/src/MainLayout.jsx b/web/src/MainLayout.jsx index e68beb40fe..b830c96a84 100644 --- a/web/src/MainLayout.jsx +++ b/web/src/MainLayout.jsx @@ -19,7 +19,7 @@ * find current contact information at www.suse.com. */ -import React from "react"; +import React, { Suspense } from "react"; import { Outlet, NavLink, useNavigate } from "react-router-dom"; import { Button, @@ -28,7 +28,7 @@ import { Page, PageSidebar, PageSidebarBody, PageToggleButton, Toolbar, ToolbarContent, ToolbarGroup, ToolbarItem } from "@patternfly/react-core"; -import { Icon } from "~/components/layout"; +import { Icon, Loading } from "~/components/layout"; import { About, InstallerOptions, LogsButton } from "~/components/core"; import { _ } from "~/i18n"; import { rootRoutes } from "~/router"; @@ -134,7 +134,9 @@ export default function Root() { header={
} sidebar={} > - + }> + + ); } diff --git a/web/src/components/l10n/KeyboardSelection.jsx b/web/src/components/l10n/KeyboardSelection.jsx index 12dddfe100..fa58525b08 100644 --- a/web/src/components/l10n/KeyboardSelection.jsx +++ b/web/src/components/l10n/KeyboardSelection.jsx @@ -21,10 +21,10 @@ import React, { useState } from "react"; import { Form, FormGroup, Radio, Text } from "@patternfly/react-core"; -import { useLoaderData, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { ListSearch, Page } from "~/components/core"; import { _ } from "~/i18n"; -import { useConfigMutation } from "~/queries/l10n"; +import { useConfigMutation, useL10n } from "~/queries/l10n"; import textStyles from '@patternfly/react-styles/css/utilities/Text/text'; // TODO: Add documentation and typechecking @@ -32,7 +32,7 @@ import textStyles from '@patternfly/react-styles/css/utilities/Text/text'; export default function KeyboardSelection() { const navigate = useNavigate(); const setConfig = useConfigMutation(); - const { keymaps, keymap: currentKeymap } = useLoaderData(); + const { keymaps, selectedKeymap: currentKeymap } = useL10n(); const [selected, setSelected] = useState(currentKeymap.id); const [filteredKeymaps, setFilteredKeymaps] = useState( keymaps.sort((k1, k2) => k1.name > k2.name ? 1 : -1) diff --git a/web/src/components/l10n/KeyboardSelection.test.jsx b/web/src/components/l10n/KeyboardSelection.test.jsx index fd733dde25..b08daf2659 100644 --- a/web/src/components/l10n/KeyboardSelection.test.jsx +++ b/web/src/components/l10n/KeyboardSelection.test.jsx @@ -36,13 +36,13 @@ const mockConfigMutation = { jest.mock("~/queries/l10n", () => ({ ...jest.requireActual("~/queries/l10n"), - useConfigMutation: () => mockConfigMutation + useConfigMutation: () => mockConfigMutation, + useL10n: () => ({ keymaps, selectedKeymap: keymaps[0] }) })); jest.mock("react-router-dom", () => ({ ...jest.requireActual("react-router-dom"), useNavigate: () => mockNavigateFn, - useLoaderData: () => ({ keymaps, keymap: keymaps[0] }) })); it("allows changing the keyboard", async () => { diff --git a/web/src/components/l10n/L10nPage.jsx b/web/src/components/l10n/L10nPage.jsx index 57a0c6b41e..2eaa70babd 100644 --- a/web/src/components/l10n/L10nPage.jsx +++ b/web/src/components/l10n/L10nPage.jsx @@ -25,6 +25,7 @@ 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 "~/queries/l10n"; const Section = ({ label, value, children }) => { return ( @@ -45,7 +46,11 @@ const Section = ({ label, value, children }) => { * @component */ export default function L10nPage() { - const { locale, timezone, keymap } = useLoaderData(); + const { + selectedLocale: locale, + selectedTimezone: timezone, + selectedKeymap: keymap + } = useL10n(); return ( <> diff --git a/web/src/components/l10n/L10nPage.test.jsx b/web/src/components/l10n/L10nPage.test.jsx index 1406d0a99c..6ad324dc6e 100644 --- a/web/src/components/l10n/L10nPage.test.jsx +++ b/web/src/components/l10n/L10nPage.test.jsx @@ -25,18 +25,39 @@ import L10nPage from "~/components/l10n/L10nPage"; let mockLoadedData; +const locales = [ + { id: "en_US.UTF-8", name: "English", territory: "United States" }, + { id: "es_ES.UTF-8", name: "Spanish", territory: "Spain" } +]; + +const keymaps = [ + { id: "us", name: "English" }, + { id: "es", name: "Spanish" } +]; + +const timezones = [ + { id: "Europe/Berlin", parts: ["Europe", "Berlin"] }, + { id: "Europe/Madrid", parts: ["Europe", "Madrid"] } +]; + jest.mock('react-router-dom', () => ({ ...jest.requireActual("react-router-dom"), - useLoaderData: () => mockLoadedData, // TODO: mock the link because it needs a working router. Link: ({ children }) => })); +jest.mock("~/queries/l10n", () => ({ + useL10n: () => mockLoadedData +})); + beforeEach(() => { mockLoadedData = { - locale: { id: "en_US.UTF-8", name: "English", territory: "United States" }, - keymap: { id: "us", name: "English" }, - timezone: { id: "Europe/Berlin", parts: ["Europe", "Berlin"] } + locales, + keymaps, + timezones, + selectedLocale: locales[0], + selectedKeymap: keymaps[0], + selectedTimezone: timezones[0], }; }); @@ -49,7 +70,7 @@ it("renders a section for configuring the language", () => { describe("if there is no selected language", () => { beforeEach(() => { - mockLoadedData.locale = undefined; + mockLoadedData.selectedLocale = undefined; }); it("renders a button for selecting a language", () => { @@ -69,7 +90,7 @@ it("renders a section for configuring the keyboard", () => { describe("if there is no selected keyboard", () => { beforeEach(() => { - mockLoadedData.keymap = undefined; + mockLoadedData.selectedKeymap = undefined; }); it("renders a button for selecting a keyboard", () => { @@ -89,7 +110,7 @@ it("renders a section for configuring the time zone", () => { describe("if there is no selected time zone", () => { beforeEach(() => { - mockLoadedData.timezone = undefined; + mockLoadedData.selectedTimezone = undefined; }); it("renders a button for selecting a time zone", () => { diff --git a/web/src/components/l10n/LocaleSelection.jsx b/web/src/components/l10n/LocaleSelection.jsx index 9f143b76dd..543c18336a 100644 --- a/web/src/components/l10n/LocaleSelection.jsx +++ b/web/src/components/l10n/LocaleSelection.jsx @@ -21,10 +21,10 @@ import React, { useState } from "react"; import { Flex, Form, FormGroup, Radio } from "@patternfly/react-core"; -import { useLoaderData, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { ListSearch, Page } from "~/components/core"; import { _ } from "~/i18n"; -import { useConfigMutation } from "~/queries/l10n"; +import { useConfigMutation, useL10n } from "~/queries/l10n"; import textStyles from '@patternfly/react-styles/css/utilities/Text/text'; // TODO: Add documentation and typechecking @@ -32,7 +32,7 @@ import textStyles from '@patternfly/react-styles/css/utilities/Text/text'; export default function LocaleSelection() { const navigate = useNavigate(); const setConfig = useConfigMutation(); - const { locales, locale: currentLocale } = useLoaderData(); + const { locales, selectedLocale: currentLocale } = useL10n(); const [selected, setSelected] = useState(currentLocale.id); const [filteredLocales, setFilteredLocales] = useState(locales); diff --git a/web/src/components/l10n/LocaleSelection.test.jsx b/web/src/components/l10n/LocaleSelection.test.jsx index 1fc001578f..a104ddcd30 100644 --- a/web/src/components/l10n/LocaleSelection.test.jsx +++ b/web/src/components/l10n/LocaleSelection.test.jsx @@ -36,13 +36,13 @@ const mockConfigMutation = { jest.mock("~/queries/l10n", () => ({ ...jest.requireActual("~/queries/l10n"), + useL10n: () => ({ locales, selectedLocale: locales[0] }), useConfigMutation: () => mockConfigMutation })); jest.mock("react-router-dom", () => ({ ...jest.requireActual("react-router-dom"), - useNavigate: () => mockNavigateFn, - useLoaderData: () => ({ locales, locale: locales[0] }) + useNavigate: () => mockNavigateFn })); it("allows changing the keyboard", async () => { diff --git a/web/src/components/l10n/TimezoneSelection.jsx b/web/src/components/l10n/TimezoneSelection.jsx index 4650bfa52b..a67c65fc5a 100644 --- a/web/src/components/l10n/TimezoneSelection.jsx +++ b/web/src/components/l10n/TimezoneSelection.jsx @@ -21,11 +21,11 @@ import React, { useState } from "react"; import { Divider, Flex, Form, FormGroup, Radio, Text } from "@patternfly/react-core"; -import { useLoaderData, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { ListSearch, Page } from "~/components/core"; import { _ } from "~/i18n"; import { timezoneTime } from "~/utils"; -import { useConfigMutation } from "~/queries/l10n"; +import { useConfigMutation, useL10n } from "~/queries/l10n"; import textStyles from '@patternfly/react-styles/css/utilities/Text/text'; let date; @@ -56,7 +56,7 @@ export default function TimezoneSelection() { date = new Date(); const navigate = useNavigate(); const setConfig = useConfigMutation(); - const { timezones, timezone: currentTimezone } = useLoaderData(); + const { timezones, selectedTimezone: currentTimezone } = useL10n(); const displayTimezones = timezones.map(timezoneWithDetails); const [selected, setSelected] = useState(currentTimezone.id); const [filteredTimezones, setFilteredTimezones] = useState(sortedTimezones(displayTimezones)); diff --git a/web/src/components/l10n/TimezoneSelection.test.jsx b/web/src/components/l10n/TimezoneSelection.test.jsx index efecd089fb..61e882fc8f 100644 --- a/web/src/components/l10n/TimezoneSelection.test.jsx +++ b/web/src/components/l10n/TimezoneSelection.test.jsx @@ -36,13 +36,13 @@ const mockConfigMutation = { jest.mock("~/queries/l10n", () => ({ ...jest.requireActual("~/queries/l10n"), - useConfigMutation: () => mockConfigMutation + useConfigMutation: () => mockConfigMutation, + useL10n: () => ({ timezones, selectedTimezone: timezones[0] }) })); jest.mock("react-router-dom", () => ({ ...jest.requireActual("react-router-dom"), - useNavigate: () => mockNavigateFn, - useLoaderData: () => ({ timezones, timezone: timezones[0] }) + useNavigate: () => mockNavigateFn })); it("allows changing the keyboard", async () => { diff --git a/web/src/components/overview/L10nSection.jsx b/web/src/components/overview/L10nSection.jsx index 1b2c8112dc..b909e79e07 100644 --- a/web/src/components/overview/L10nSection.jsx +++ b/web/src/components/overview/L10nSection.jsx @@ -23,16 +23,10 @@ import React from "react"; import { TextContent, Text, TextVariants } from "@patternfly/react-core"; import { Em } from "~/components/core"; import { _ } from "~/i18n"; -import { localesQuery, configQuery } from "~/queries/l10n"; -import { useQuery } from "@tanstack/react-query"; +import { useL10n } from "~/queries/l10n"; export default function L10nSection() { - const { isPending: isLocalesPending, data: locales } = useQuery(localesQuery()); - const { isPending: isConfigPending, data: config } = useQuery(configQuery()); - - if (isLocalesPending || isConfigPending) return; - - const locale = locales.find((l) => l.id === config?.locales[0]); + const { selectedLocale: locale } = useL10n(); // 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 dad4cde147..a3f29f6945 100644 --- a/web/src/components/overview/L10nSection.test.jsx +++ b/web/src/components/overview/L10nSection.test.jsx @@ -30,14 +30,7 @@ const locales = [ ]; 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"] }) - }) + useL10n: () => ({ locales, selectedLocale: locales[0] }), })); it("displays the selected locale", async () => { diff --git a/web/src/context/app.jsx b/web/src/context/app.jsx index d22ccce51e..3b9a83f0bc 100644 --- a/web/src/context/app.jsx +++ b/web/src/context/app.jsx @@ -52,4 +52,4 @@ function AppProviders({ children }) { ); } -export { AppProviders, queryClient }; +export { AppProviders }; diff --git a/web/src/queries/hooks.js b/web/src/queries/hooks.js deleted file mode 100644 index 1c64660b2f..0000000000 --- a/web/src/queries/hooks.js +++ /dev/null @@ -1,65 +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 { 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 deleted file mode 100644 index 6db7d4b3ee..0000000000 --- a/web/src/queries/hooks.test.js +++ /dev/null @@ -1,49 +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 { 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 index 75d193251b..66d81f851c 100644 --- a/web/src/queries/l10n.js +++ b/web/src/queries/l10n.js @@ -20,9 +20,8 @@ */ import React from "react"; -import { useQueryClient, useMutation } from "@tanstack/react-query"; +import { useQueryClient, useMutation, useSuspenseQueries } from "@tanstack/react-query"; import { useInstallerClient } from "~/context/installer"; -import { useDataInvalidator } from "~/queries/hooks"; import { timezoneUTCOffset } from "~/utils"; /** @@ -30,7 +29,7 @@ import { timezoneUTCOffset } from "~/utils"; */ const configQuery = () => { return { - queryKey: ["l10n", "config"], + queryKey: ["l10n/config"], queryFn: () => fetch("/api/l10n/config").then((res) => res.json()), }; }; @@ -39,7 +38,7 @@ const configQuery = () => { * Returns a query for retrieving the list of known locales */ const localesQuery = () => ({ - queryKey: ["l10n", "locales"], + queryKey: ["l10n/locales"], queryFn: async () => { const response = await fetch("/api/l10n/locales"); const locales = await response.json(); @@ -54,7 +53,7 @@ const localesQuery = () => ({ * Returns a query for retrieving the list of known timezones */ const timezonesQuery = () => ({ - queryKey: ["l10n", "timezones"], + queryKey: ["l10n/timezones"], queryFn: async () => { const response = await fetch("/api/l10n/timezones"); const timezones = await response.json(); @@ -70,7 +69,7 @@ const timezonesQuery = () => ({ * Returns a query for retrieving the list of known keymaps */ const keymapsQuery = () => ({ - queryKey: ["l10n", "keymaps"], + queryKey: ["l10n/keymaps"], queryFn: async () => { const response = await fetch("/api/l10n/keymaps"); const json = await response.json(); @@ -108,7 +107,7 @@ const useConfigMutation = () => { * revalidate its data (executing the loaders again). */ const useL10nConfigChanges = () => { - const dataInvalidator = useDataInvalidator(); + const queryClient = useQueryClient(); const client = useInstallerClient(); React.useEffect(() => { @@ -116,10 +115,35 @@ const useL10nConfigChanges = () => { return client.ws().onEvent(event => { if (event.type === "L10nConfigChanged") { - dataInvalidator({ queryKey: ["l10n", "config"] }); + queryClient.invalidateQueries({ queryKey: ["l10n/config"] }); } }); - }, [client, dataInvalidator]); + }, [client, queryClient]); +}; + +/// Returns the l10n data. +const useL10n = () => { + const [ + { data: config }, + { data: locales }, + { data: keymaps }, + { data: timezones } + ] = useSuspenseQueries({ + queries: [ + configQuery(), + localesQuery(), + keymapsQuery(), + timezonesQuery() + ] + }); + + const selectedLocale = locales.find((l) => l.id === config.locales[0]); + const selectedKeymap = keymaps.find((k) => k.id === config.keymap); + const selectedTimezone = timezones.find((t) => t.id === config.timezone); + + return { + locales, keymaps, timezones, selectedLocale, selectedKeymap, selectedTimezone + }; }; export { @@ -128,5 +152,6 @@ export { localesQuery, timezonesQuery, useConfigMutation, + useL10n, useL10nConfigChanges }; diff --git a/web/src/routes/l10n.js b/web/src/routes/l10n.js index b6d26c10ff..3e0df54833 100644 --- a/web/src/routes/l10n.js +++ b/web/src/routes/l10n.js @@ -36,20 +36,6 @@ 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: , @@ -60,22 +46,18 @@ const routes = { 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: , } ]