diff --git a/static/js/brand-store/brand-store.tsx b/static/js/brand-store/brand-store.tsx index b5410472e..755296e44 100644 --- a/static/js/brand-store/brand-store.tsx +++ b/static/js/brand-store/brand-store.tsx @@ -1,4 +1,5 @@ import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "react-query"; import * as Sentry from "@sentry/react"; import { BrowserTracing } from "@sentry/browser"; import App from "./routes/App"; @@ -13,10 +14,21 @@ Sentry.init({ const container = document.getElementById("root"); const root = createRoot(container as HTMLDivElement); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }, + }, +}); + root.render( - - - + + + + + , ); diff --git a/static/js/brand-store/components/Navigation/Navigation.tsx b/static/js/brand-store/components/Navigation/Navigation.tsx index 42ae25c47..1d7af9745 100644 --- a/static/js/brand-store/components/Navigation/Navigation.tsx +++ b/static/js/brand-store/components/Navigation/Navigation.tsx @@ -1,15 +1,15 @@ import { useState, useEffect, ReactNode } from "react"; -import { useSelector } from "react-redux"; -import { useRecoilState } from "recoil"; +import { useRecoilState, useRecoilValue } from "recoil"; import { useParams, NavLink } from "react-router-dom"; import Logo from "./Logo"; import { publisherState } from "../../state/publisherState"; import { brandIdState } from "../../state/brandStoreState"; -import { brandStoresListSelector } from "../../state/selectors"; import { useBrand, usePublisher } from "../../hooks"; +import { brandStoresState } from "../../state/brandStoreState"; + import type { Store } from "../../types/shared"; function Navigation({ @@ -17,7 +17,7 @@ function Navigation({ }: { sectionName: string | null; }): ReactNode { - const brandStoresList = useSelector(brandStoresListSelector); + const brandStoresList = useRecoilValue(brandStoresState); const { id } = useParams(); const { data: brand } = useBrand(id); const { data: publisherData } = usePublisher(); diff --git a/static/js/brand-store/components/Navigation/__tests__/Navigation.test.tsx b/static/js/brand-store/components/Navigation/__tests__/Navigation.test.tsx deleted file mode 100644 index 907a14765..000000000 --- a/static/js/brand-store/components/Navigation/__tests__/Navigation.test.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import "@testing-library/jest-dom"; -import { Provider } from "react-redux"; -import { QueryClient, QueryClientProvider } from "react-query"; -import { BrowserRouter } from "react-router-dom"; -import { RecoilRoot } from "recoil"; -import { store } from "../../../state/store"; -import Navigation from "../Navigation"; - -const queryClient = new QueryClient(); - -jest.mock("react-query", () => ({ - ...jest.requireActual("react-query"), - useQuery: jest.fn().mockReturnValue({ - data: { - publisher: { - email: "test@example.com", - fullname: "John Doe", - has_stores: true, - indentity_url: "https://example.com", - image: null, - is_canonical: false, - nickname: "johndoe", - }, - }, - isLoading: false, - isSuccess: true, - }), -})); - -const mockRouterReturnValue = { id: "test-id" }; - -jest.mock("react-router-dom", () => ({ - ...jest.requireActual("react-router-dom"), - useParams: () => mockRouterReturnValue, -})); - -jest.mock("react-redux", () => ({ - ...jest.requireActual("react-redux"), - useSelector: jest.fn().mockReturnValue([ - { id: "test-id", name: "Test store", roles: ["admin"] }, - { - id: "non-admin-store", - name: "Non-admin store", - roles: ["review", "view", "access"], - }, - ]), -})); - -const renderComponent = (sectionName: string) => { - render( - - - - - - - - - , - ); -}; - -describe("Navigation", () => { - test("displays logo", () => { - renderComponent("snaps"); - expect(screen.getAllByRole("img", { name: "Snapcraft logo" })).toHaveLength( - 2, - ); - }); - - test("members link is visible if user has admin role", () => { - mockRouterReturnValue.id = "test-id"; - renderComponent("snaps"); - expect(screen.getByRole("link", { name: /Members/ })).toBeInTheDocument(); - }); - - test("members link is not visible if user does not have admin role", () => { - mockRouterReturnValue.id = "non-admin-store"; - renderComponent("snaps"); - expect( - screen.queryByRole("link", { name: /Members/ }), - ).not.toBeInTheDocument(); - }); - - test("settings link is visible if user has admin role", () => { - mockRouterReturnValue.id = "test-id"; - renderComponent("snaps"); - expect(screen.getByRole("link", { name: /Settings/ })).toBeInTheDocument(); - }); - - test("settings link is not visible if user does not have admin role", () => { - mockRouterReturnValue.id = "non-admin-store"; - renderComponent("snaps"); - expect( - screen.queryByRole("link", { name: /Settings/ }), - ).not.toBeInTheDocument(); - }); -}); diff --git a/static/js/brand-store/hooks/index.ts b/static/js/brand-store/hooks/index.ts index 6a9f76ef6..c46bdf75f 100644 --- a/static/js/brand-store/hooks/index.ts +++ b/static/js/brand-store/hooks/index.ts @@ -3,6 +3,8 @@ import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; import type { AppDispatch, RootState } from "../state/store"; import type { Model as ModelType, SigningKey, Policy } from "../types/shared"; +import useBrandStores from "./useBrandStores"; + export const useAppDispatch: () => AppDispatch = useDispatch; export const useAppSelector: TypedUseSelectorHook = useSelector; @@ -127,3 +129,5 @@ export function usePublisher() { return publisherData; }); } + +export { useBrandStores }; diff --git a/static/js/brand-store/hooks/useBrandStores.tsx b/static/js/brand-store/hooks/useBrandStores.tsx new file mode 100644 index 000000000..4357bef13 --- /dev/null +++ b/static/js/brand-store/hooks/useBrandStores.tsx @@ -0,0 +1,24 @@ +import { useQuery } from "react-query"; + +function useBrandStores() { + return useQuery({ + queryKey: ["brandStores"], + queryFn: async () => { + const response = await fetch("/api/stores"); + + if (!response.ok) { + throw new Error("Unable to fetch stores"); + } + + const responseData = await response.json(); + + if (!responseData.success) { + throw new Error(responseData.message); + } + + return responseData.data; + }, + }); +} + +export default useBrandStores; diff --git a/static/js/brand-store/pages/Snaps/Snaps.tsx b/static/js/brand-store/pages/Snaps/Snaps.tsx index bac9f762c..9ca22a8c7 100644 --- a/static/js/brand-store/pages/Snaps/Snaps.tsx +++ b/static/js/brand-store/pages/Snaps/Snaps.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; -import type { ReactNode } from "react"; import { useParams, Link } from "react-router-dom"; +import { useRecoilValue } from "recoil"; import { useSelector } from "react-redux"; import { AsyncThunkAction } from "@reduxjs/toolkit"; import { useAppDispatch } from "../../state/store"; @@ -14,14 +14,12 @@ import { Accordion, } from "@canonical/react-components"; -import { - snapsSelector, - brandStoresListSelector, - membersSelector, -} from "../../state/selectors"; +import { snapsSelector, membersSelector } from "../../state/selectors"; import { fetchSnaps } from "../../state/slices/snapsSlice"; import { fetchMembers } from "../../state/slices/membersSlice"; +import { brandStoresState } from "../../state/brandStoreState"; + import Publisher from "../Publisher"; import Reviewer from "../Reviewer"; import ReviewerAndPublisher from "../ReviewerAndPublisher"; @@ -35,23 +33,19 @@ import IncludedSnapsTable from "./IncludedSnapsTable"; import { setPageTitle } from "../../utils"; import type { - StoresSlice, + Store, Snap, SnapsList, - Store, Member, SnapsSlice, MembersSlice, } from "../../types/shared"; -function Snaps(): ReactNode { - const brandStoresList = useSelector(brandStoresListSelector); +function Snaps() { + const brandStoresList = useRecoilValue(brandStoresState); const snaps = useSelector(snapsSelector); const members = useSelector(membersSelector); const snapsLoading = useSelector((state: SnapsSlice) => state.snaps.loading); - const storesLoading = useSelector( - (state: StoresSlice) => state.brandStores.loading, - ); const membersLoading = useSelector( (state: MembersSlice) => state.members.loading, ); @@ -86,7 +80,7 @@ function Snaps(): ReactNode { useState(false); const [showRemoveSnapsConfirmation, setShowRemoveSnapsConfirmation] = useState(false); - const [globalStore, setGlobalStore] = useState(null); + const [globalStore, setGlobalStore] = useState(); const [fetchSnapsByStoreIdPromise, setFetchSnapsByStoreIdPromise] = useState< ReturnType> | undefined @@ -374,7 +368,7 @@ function Snaps(): ReactNode {
- {snapsLoading && storesLoading && membersLoading ? ( + {snapsLoading && membersLoading ? (
@@ -479,7 +473,7 @@ function Snaps(): ReactNode { content: ( state.brandStores.loading, - ); - const brandStoresList: StoresList = useSelector(brandStoresListSelector); - const dispatch = useDispatch(); - - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }, - }, - }); +function App() { + const { data: brandStoresList, isLoading } = useBrandStores(); const setRecoilBrandStores = useSetRecoilState(brandStoresState); - useEffect(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - dispatch(fetchStores() as any); - }, []); - useEffect(() => { if (brandStoresList) { setRecoilBrandStores(brandStoresList); @@ -56,49 +34,47 @@ function App(): ReactNode { return ( - - - - ) : brandStoresList[0].id === "ubuntu" ? ( - // Don't redirect to the global store by default - - ) : ( - - ) - ) : null - } - /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } - /> - } - /> - } /> - } - /> - - + + + ) : brandStoresList[0].id === "ubuntu" ? ( + // Don't redirect to the global store by default + + ) : ( + + ) + ) : null + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } + /> + } + /> + } /> + } + /> + ); }