diff --git a/apps/frontend/ui/src/navigation/partials/OptionConcepts/func.tsx b/apps/frontend/ui/src/navigation/partials/OptionConcepts/func.tsx index 499db14..2b69db1 100644 --- a/apps/frontend/ui/src/navigation/partials/OptionConcepts/func.tsx +++ b/apps/frontend/ui/src/navigation/partials/OptionConcepts/func.tsx @@ -1,4 +1,4 @@ -import type { JSX } from "react"; +import type { JSX, Dispatch, SetStateAction } from "react"; import { MenuList } from "@lib-components"; @@ -9,23 +9,34 @@ import { useSelectionState } from "@app-ui/navigation/partials/OptionConcepts/ho type TProps = { concepts: string[]; - onSearch: (value: string) => void; + /** must be initialized with { concept: [] }, only then inner functions apply state correctly */ + checkedValuesState: Record; + setCheckedValuesState: Dispatch>>; + onSearch: () => void; + isReqestingConcepts: boolean; + disableButton: boolean; }; export default function OptionConcepts({ concepts, onSearch, + isReqestingConcepts, + checkedValuesState, + setCheckedValuesState, + disableButton, }: TProps): JSX.Element { const classes = useOptionConceptsClasses(); - const { checkedValues, onChange } = useSelectionState(); + const { checkedValues, onChange } = useSelectionState( + checkedValuesState, + setCheckedValuesState, + ); return ( { - onSearch(checkedValues.concept[0]); - }} - disabledSearch={checkedValues.concept.length === 0} + subtitle="Choose one from the given list below" + onClick={onSearch} + disableClick={disableButton} + isLoading={isReqestingConcepts} > >({ - concept: [], - }); +function useSelectionState( + checkedValues: Record, + setCheckedValues: Dispatch>>, +) { const onChange: TMenuProps["onCheckedValueChange"] = ( _, { name, checkedItems }, diff --git a/apps/frontend/ui/src/navigation/partials/OptionConcepts/stories.ts b/apps/frontend/ui/src/navigation/partials/OptionConcepts/stories.ts deleted file mode 100644 index 28cd5bd..0000000 --- a/apps/frontend/ui/src/navigation/partials/OptionConcepts/stories.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; - -import OptionConcepts from "@app-ui/navigation/partials/OptionConcepts"; - -const meta: Meta = { - title: "App/UI/Navigation/Partials/OptionConcepts", - component: OptionConcepts, - args: { - concepts: ["Partiality", "Signaling", "Connectivity", "Transformativity"], - onSearch: () => {}, - }, -}; - -export default meta; - -type Story = StoryObj; - -export const Index: Story = {}; - -export const WithOverflow: Story = { - args: { - concepts: [ - "Partiality", - "Signaling", - "Connectivity", - "Transformativity", - "Adaptability", - "Inclusivity", - "Interactivity", - "Reactivity", - "Sustainability", - ], - }, -}; diff --git a/apps/frontend/ui/src/navigation/partials/OptionConcepts/stories.tsx b/apps/frontend/ui/src/navigation/partials/OptionConcepts/stories.tsx new file mode 100644 index 0000000..d82cced --- /dev/null +++ b/apps/frontend/ui/src/navigation/partials/OptionConcepts/stories.tsx @@ -0,0 +1,114 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; + +import OptionConcepts from "@app-ui/navigation/partials/OptionConcepts"; + +const meta: Meta = { + title: "App/UI/Navigation/Partials/OptionConcepts", + component: OptionConcepts, +}; + +export default meta; + +type Story = StoryObj; + +export const Index: Story = { + argTypes: { + concepts: { control: false }, + onSearch: { control: false }, + checkedValuesState: { control: false }, + setCheckedValuesState: { control: false }, + }, + render: (props) => { + const [checkedValues, setCheckedValues] = useState< + Record + >({ + concept: [], + }); + + return ( + {}} + isReqestingConcepts={props.isReqestingConcepts} + disableButton={props.disableButton} + checkedValuesState={checkedValues} + setCheckedValuesState={setCheckedValues} + /> + ); + }, +}; + +export const FlowWithoutOverflow: Story = { + render: () => { + const [checkedValues, setCheckedValues] = useState< + Record + >({ + concept: [], + }); + const [isFetching, setIsFetching] = useState(false); + + return ( + { + setIsFetching(true); + setTimeout(() => { + setIsFetching(false); + }, 2000); + }} + isReqestingConcepts={isFetching} + disableButton={checkedValues.concept.length === 0 || isFetching} + checkedValuesState={checkedValues} + setCheckedValuesState={setCheckedValues} + /> + ); + }, +}; + +export const FlowWithOverflow: Story = { + render: () => { + const [checkedValues, setCheckedValues] = useState< + Record + >({ + concept: [], + }); + const [isFetching, setIsFetching] = useState(false); + + return ( + { + setIsFetching(true); + setTimeout(() => { + setIsFetching(false); + }, 2000); + }} + isReqestingConcepts={isFetching} + disableButton={checkedValues.concept.length === 0 || isFetching} + checkedValuesState={checkedValues} + setCheckedValuesState={setCheckedValues} + /> + ); + }, +}; diff --git a/apps/frontend/ui/src/navigation/partials/OptionConcepts/tests.tsx b/apps/frontend/ui/src/navigation/partials/OptionConcepts/tests.tsx index 6257313..8286614 100644 --- a/apps/frontend/ui/src/navigation/partials/OptionConcepts/tests.tsx +++ b/apps/frontend/ui/src/navigation/partials/OptionConcepts/tests.tsx @@ -1,18 +1,36 @@ +import { useState } from "react"; + import { render, screen, fireEvent } from "@tests-unit-browser"; import "@testing-library/jest-dom"; import OptionConcepts from "@app-ui/navigation/partials/OptionConcepts"; +function Wrapper() { + const [checkedValues, setCheckedValues] = useState>({ + concept: [], + }); + const [isFetching, setIsFetching] = useState(false); + + return ( + { + setIsFetching(true); + setTimeout(() => { + setIsFetching(false); + }, 3000); + }} + isReqestingConcepts={isFetching} + disableButton={checkedValues.concept.length === 0 || isFetching} + checkedValuesState={checkedValues} + setCheckedValuesState={setCheckedValues} + /> + ); +} + describe("OptionConcepts", () => { it("should render with given concepts", () => { - const onSearch = jest.fn((value: string) => value); - - render( - , - ); + render(); const menuList = screen.getByRole("menu"); expect(menuList).toBeInTheDocument(); @@ -26,40 +44,45 @@ describe("OptionConcepts", () => { }); it("should be able to select different concepts", () => { - const onSearch = jest.fn((value: string) => value); - - render( - , - ); + render(); const concept1 = screen.getByText("concept1"); - expect(concept1).toBeInTheDocument(); const concept2 = screen.getByText("concept2"); - expect(concept2).toBeInTheDocument(); const concept3 = screen.getByText("concept3"); - expect(concept3).toBeInTheDocument(); const searchButton = screen.getByRole("button", { name: "Search" }); expect(searchButton).toBeInTheDocument(); expect(searchButton).toBeDisabled(); fireEvent.click(concept1); + // has aria-checked attribute, when checked + expect(concept1.parentElement).toHaveAttribute("aria-checked", "true"); expect(searchButton).toBeEnabled(); - fireEvent.click(searchButton); - expect(onSearch).toHaveBeenCalledWith("concept1"); - fireEvent.click(concept2); - fireEvent.click(searchButton); - expect(onSearch).toHaveBeenCalledWith("concept2"); + expect(concept2.parentElement).toHaveAttribute("aria-checked", "true"); + // concept1 should be unchecked -> exclusitivity + expect(concept1.parentElement).toHaveAttribute("aria-checked", "false"); fireEvent.click(concept3); + expect(concept3.parentElement).toHaveAttribute("aria-checked", "true"); + expect(concept2.parentElement).toHaveAttribute("aria-checked", "false"); + }); + + it("should show loading state and disable search button", () => { + render(); + + const concept1 = screen.getByText("concept1"); + const searchButton = screen.getByRole("button", { name: "Search" }); + + fireEvent.click(concept1); + expect(searchButton).toBeEnabled(); + fireEvent.click(searchButton); - expect(onSearch).toHaveBeenCalledWith("concept3"); + expect(searchButton).toBeDisabled(); - expect(onSearch).toHaveBeenCalledTimes(3); + // role progressbar is used for loading state + const loading = screen.getByRole("progressbar"); + expect(loading).toBeInTheDocument(); }); }); diff --git a/apps/frontend/ui/src/navigation/partials/OptionEntireCatalogue/func.tsx b/apps/frontend/ui/src/navigation/partials/OptionEntireCatalogue/func.tsx new file mode 100644 index 0000000..7687993 --- /dev/null +++ b/apps/frontend/ui/src/navigation/partials/OptionEntireCatalogue/func.tsx @@ -0,0 +1,24 @@ +import type { JSX } from "react"; + +import { OptionLayoutTemplate } from "@app-ui/navigation/templates"; + +type TProps = { + onRequestCatalogue: () => void; + isRequestingCatalogue: boolean; + disableButton: boolean; +}; + +export default function OptionEntireCatalogue({ + onRequestCatalogue, + isRequestingCatalogue, + disableButton, +}: TProps): JSX.Element { + return ( + + ); +} diff --git a/apps/frontend/ui/src/navigation/partials/OptionEntireCatalogue/index.ts b/apps/frontend/ui/src/navigation/partials/OptionEntireCatalogue/index.ts new file mode 100644 index 0000000..06cbde1 --- /dev/null +++ b/apps/frontend/ui/src/navigation/partials/OptionEntireCatalogue/index.ts @@ -0,0 +1,3 @@ +import OptionEntireCatalogue from "./func"; + +export default OptionEntireCatalogue; diff --git a/apps/frontend/ui/src/navigation/partials/OptionEntireCatalogue/stories.tsx b/apps/frontend/ui/src/navigation/partials/OptionEntireCatalogue/stories.tsx new file mode 100644 index 0000000..27a1943 --- /dev/null +++ b/apps/frontend/ui/src/navigation/partials/OptionEntireCatalogue/stories.tsx @@ -0,0 +1,40 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; + +import OptionEntireCatalogue from "./func"; + +const meta: Meta = { + title: "app/ui/navigation/partials/OptionEntireCatalogue", + component: OptionEntireCatalogue, + args: {}, +}; + +export default meta; + +type Story = StoryObj; + +export const Index: Story = { + args: { + isRequestingCatalogue: false, + disableButton: false, + }, +}; + +export const Flow: Story = { + render: () => { + const [isRequestingCatalogue, setIsRequestingCatalogue] = useState(false); + + return ( + { + setIsRequestingCatalogue(true); + setTimeout(() => { + setIsRequestingCatalogue(false); + }, 3000); + }} + isRequestingCatalogue={isRequestingCatalogue} + disableButton={isRequestingCatalogue} + /> + ); + }, +}; diff --git a/apps/frontend/ui/src/navigation/partials/OptionEntireCatalogue/tests.tsx b/apps/frontend/ui/src/navigation/partials/OptionEntireCatalogue/tests.tsx new file mode 100644 index 0000000..2f0dc5e --- /dev/null +++ b/apps/frontend/ui/src/navigation/partials/OptionEntireCatalogue/tests.tsx @@ -0,0 +1,36 @@ +import { useState } from "react"; + +import { render, screen, fireEvent } from "@tests-unit-browser"; +import "@testing-library/jest-dom"; + +import OptionEntireCatalogue from "./func"; + +describe("OptionEntireCatalogue", () => { + it("should apply loading and disabling logic when clicked", () => { + function Wrapper() { + const [isRequestingCatalogue, setIsRequestingCatalogue] = useState(false); + + return ( + { + setIsRequestingCatalogue(true); + setTimeout(() => { + setIsRequestingCatalogue(false); + }, 3000); + }} + isRequestingCatalogue={isRequestingCatalogue} + disableButton={isRequestingCatalogue} + /> + ); + } + + render(); + const button = screen.getByRole("button"); + expect(button).toBeInTheDocument(); + expect(button).not.toBeDisabled(); + + fireEvent.click(button); + expect(button).toBeDisabled(); + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/ui/src/navigation/templates/OptionLayout/func.tsx b/apps/frontend/ui/src/navigation/templates/OptionLayout/func.tsx index 2ed218b..4fc0b1d 100644 --- a/apps/frontend/ui/src/navigation/templates/OptionLayout/func.tsx +++ b/apps/frontend/ui/src/navigation/templates/OptionLayout/func.tsx @@ -1,35 +1,37 @@ import type { JSX, ReactNode } from "react"; -import { Flex, Button } from "@lib-components"; +import { Flex, Button, Spinner } from "@lib-components"; import { Subtitle2, Caption2 } from "@lib-theme"; import useOptionLayoutClasses from "@app-ui/navigation/templates/OptionLayout/styles"; type TProps = { header: string; - subtitle: string; + onClick: () => void; + disableClick?: boolean; + isLoading?: boolean; + subtitle?: string; children?: ReactNode; - onSearch: () => void; - disabledSearch: boolean; }; export default function OptionLayout({ header, - subtitle, + onClick, + subtitle = undefined, children = undefined, - onSearch, - disabledSearch, + disableClick = false, + isLoading = false, }: TProps): JSX.Element { const classes = useOptionLayoutClasses(); return ( {header} - {subtitle} + {subtitle && {subtitle}} {children} - ); diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index 295896e..449766b 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -4,6 +4,7 @@ export { TabList } from "@fluentui/react-components"; export { Button } from "@fluentui/react-components"; export { MenuList } from "@fluentui/react-components"; export { MenuItemRadio } from "@fluentui/react-components"; +export { Spinner } from "@fluentui/react-components"; export { Collapse } from "@fluentui/react-motion-components-preview";