diff --git a/src/api/api.ts b/src/api/api.ts index f32249b4..d9c6a80c 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -34,7 +34,12 @@ type TFetchProductionResponse = TBasicProductionResponse & { lines: TLine[]; }; -type TListProductionsResponse = TBasicProductionResponse[]; +export type TListProductionsResponse = { + productions: TBasicProductionResponse[]; + offset: 0; + limit: 0; + totalItems: 0; +}; type TOfferAudioSessionOptions = { productionId: number; @@ -77,9 +82,13 @@ export const API = { }), }) ), - listProductions: (): Promise => + listProductions: ({ + searchParams, + }: { + searchParams: string; + }): Promise => handleFetchRequest( - fetch(`${API_URL}production/`, { + fetch(`${API_URL}productionlist?${searchParams}`, { method: "GET", headers: { ...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}), diff --git a/src/assets/icons/chevron_left.svg b/src/assets/icons/chevron_left.svg new file mode 100644 index 00000000..02383e03 --- /dev/null +++ b/src/assets/icons/chevron_left.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/assets/icons/icon.tsx b/src/assets/icons/icon.tsx index 45cf3f28..9c709d36 100644 --- a/src/assets/icons/icon.tsx +++ b/src/assets/icons/icon.tsx @@ -6,6 +6,8 @@ import VolumeOn from "./volume_on.svg?react"; import VolumeOff from "./volume_off.svg?react"; import UserSvg from "./user.svg?react"; import ConfirmSvg from "./done.svg?react"; +import StepLeftSvg from "./chevron_left.svg?react"; +import StepRightSvg from "./navigate_next.svg?react"; export const MicMuted = () => ; @@ -22,3 +24,7 @@ export const SpeakerOn = () => ; export const UserIcon = () => ; export const ConfirmIcon = () => ; + +export const StepLeftIcon = () => ; + +export const StepRightIcon = () => ; diff --git a/src/assets/icons/navigate_next.svg b/src/assets/icons/navigate_next.svg new file mode 100644 index 00000000..e5b7d300 --- /dev/null +++ b/src/assets/icons/navigate_next.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/components/landing-page/create-production.tsx b/src/components/landing-page/create-production.tsx index c8c636a3..1ea1bf2d 100644 --- a/src/components/landing-page/create-production.tsx +++ b/src/components/landing-page/create-production.tsx @@ -92,7 +92,7 @@ export const CreateProduction = () => { lines: [], }); dispatch({ - type: "PRODUCTION_CREATED", + type: "PRODUCTION_UPDATED", }); } }, [createdProductionId, dispatch, reset]); diff --git a/src/components/landing-page/landing-page.tsx b/src/components/landing-page/landing-page.tsx index 2498aa64..fe9ebc05 100644 --- a/src/components/landing-page/landing-page.tsx +++ b/src/components/landing-page/landing-page.tsx @@ -1,6 +1,6 @@ import { JoinProduction } from "./join-production.tsx"; import { CreateProduction } from "./create-production.tsx"; -import { ProductionsList } from "./productions-list.tsx"; +import { ProductionsListContainer } from "./productions-list-container.tsx"; import { useNavigateToProduction } from "./use-navigate-to-production.ts"; import { DisplayContainer, FlexContainer } from "../generic-components.ts"; import { useGlobalState } from "../../global-state/context-provider.tsx"; @@ -23,7 +23,7 @@ export const LandingPage = () => { )} - + ); }; diff --git a/src/components/landing-page/productions-list.tsx b/src/components/landing-page/productions-list-container.tsx similarity index 52% rename from src/components/landing-page/productions-list.tsx rename to src/components/landing-page/productions-list-container.tsx index 561848b7..dd10aa61 100644 --- a/src/components/landing-page/productions-list.tsx +++ b/src/components/landing-page/productions-list-container.tsx @@ -6,45 +6,30 @@ import { useRefreshAnimation } from "./use-refresh-animation.ts"; import { DisplayContainerHeader } from "./display-container-header.tsx"; import { DisplayContainer } from "../generic-components.ts"; import { ManageProductionButton } from "./manage-production-button.tsx"; -import { LocalError } from "../error.tsx"; import { useFetchProductionList } from "./use-fetch-production-list.ts"; - -const ProductionListContainer = styled.div` - display: flex; - padding: 0 0 2rem 2rem; - flex-wrap: wrap; -`; +import { ProductionsList } from "../productions-list.tsx"; const HeaderContainer = styled(DisplayContainer)` padding: 0 2rem 0 2rem; `; -const ProductionItem = styled.div` - flex: 1 0 calc(25% - 2rem); - min-width: 20rem; - border: 1px solid #424242; - border-radius: 0.5rem; - padding: 2rem; - margin: 0 2rem 2rem 0; +const LoaderWrapper = styled.div` + padding-bottom: 1rem; `; -const ProductionName = styled.div` - font-size: 1.4rem; - font-weight: bold; - margin: 0 0 1rem; - word-break: break-word; -`; - -const ProductionId = styled.div` - font-size: 2rem; - color: #9e9e9e; +const ListWrapper = styled.div` + display: flex; + flex-wrap: wrap; + padding: 0 0 0 2rem; `; -export const ProductionsList = () => { +export const ProductionsListContainer = () => { const [{ reloadProductionList }] = useGlobalState(); const { productions, doInitialLoad, error, setIntervalLoad } = - useFetchProductionList(); + useFetchProductionList({ + limit: "10", + }); const showRefreshing = useRefreshAnimation({ reloadProductionList, @@ -63,24 +48,19 @@ export const ProductionsList = () => { return ( <> - + + + Production List - - {error && } - {!error && - productions.map((p) => ( - - {p.name} - {p.productionId} - - ))} - - {!!productions.length && } + + + + {!!productions?.productions.length && } ); }; diff --git a/src/components/landing-page/use-fetch-production-list.ts b/src/components/landing-page/use-fetch-production-list.ts index 44eb2740..283ef67a 100644 --- a/src/components/landing-page/use-fetch-production-list.ts +++ b/src/components/landing-page/use-fetch-production-list.ts @@ -1,10 +1,14 @@ import { useEffect, useState } from "react"; import { useGlobalState } from "../../global-state/context-provider"; -import { API } from "../../api/api.ts"; -import { TBasicProduction } from "../production-line/types.ts"; +import { API, TListProductionsResponse } from "../../api/api.ts"; -export const useFetchProductionList = () => { - const [productions, setProductions] = useState([]); +export type GetProductionListFilter = { + limit?: string; + offset?: string; +}; + +export const useFetchProductionList = (filter?: GetProductionListFilter) => { + const [productions, setProductions] = useState(); const [doInitialLoad, setDoInitialLoad] = useState(true); const [intervalLoad, setIntervalLoad] = useState(false); const [error, setError] = useState(null); @@ -14,16 +18,19 @@ export const useFetchProductionList = () => { useEffect(() => { let aborted = false; - if (reloadProductionList || intervalLoad || doInitialLoad) { - API.listProductions() + if ( + reloadProductionList || + intervalLoad || + doInitialLoad || + filter?.offset !== productions?.offset.toString() + ) { + const searchParams = new URLSearchParams(filter).toString(); + + API.listProductions({ searchParams }) .then((result) => { if (aborted) return; - setProductions( - result - // pick last 10 items and display newest first - .slice(0, 10) - ); + setProductions(result); dispatch({ type: "PRODUCTION_LIST_FETCHED", @@ -45,7 +52,14 @@ export const useFetchProductionList = () => { return () => { aborted = true; }; - }, [dispatch, intervalLoad, reloadProductionList, doInitialLoad]); + }, [ + dispatch, + intervalLoad, + reloadProductionList, + doInitialLoad, + filter, + productions?.offset, + ]); return { productions, diff --git a/src/components/landing-page/use-refresh-animation.ts b/src/components/landing-page/use-refresh-animation.ts index 39d297f6..16f91c27 100644 --- a/src/components/landing-page/use-refresh-animation.ts +++ b/src/components/landing-page/use-refresh-animation.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; type TUseRefreshAnimationOptions = { - reloadProductionList: boolean; + reloadProductionList?: boolean; doInitialLoad: boolean; }; diff --git a/src/components/manage-productions/manage-lines.tsx b/src/components/manage-productions/manage-lines.tsx index 2a32465b..14c1c453 100644 --- a/src/components/manage-productions/manage-lines.tsx +++ b/src/components/manage-productions/manage-lines.tsx @@ -37,7 +37,7 @@ const Container = styled.div` max-width: 45rem; min-width: 35rem; padding: 2rem; - margin-right: 2rem; + margin: 0 2rem 2rem 0; border-radius: 1rem; border: 0.2rem solid #434343; `; diff --git a/src/components/manage-productions/manage-productions.tsx b/src/components/manage-productions/manage-productions.tsx index ab5bad3b..2903f873 100644 --- a/src/components/manage-productions/manage-productions.tsx +++ b/src/components/manage-productions/manage-productions.tsx @@ -5,6 +5,7 @@ import styled from "@emotion/styled"; import { DisplayContainerHeader } from "../landing-page/display-container-header"; import { DecorativeLabel, + PrimaryButton, StyledWarningMessage, } from "../landing-page/form-elements"; import { useFetchProduction } from "../landing-page/use-fetch-production"; @@ -15,23 +16,46 @@ import { FormInputWithLoader } from "../landing-page/form-input-with-loader"; import { RemoveProduction } from "./remove-production"; import { ManageLines } from "./manage-lines"; import { TProduction } from "../production-line/types"; +import { useFetchProductionList } from "../landing-page/use-fetch-production-list"; +import { isMobile } from "../../bowser"; +import { useGlobalState } from "../../global-state/context-provider"; +import { useRefreshAnimation } from "../landing-page/use-refresh-animation"; +import { PaginatedList } from "./paginated-list"; type FormValue = { productionId: string; }; -const SubContainers = styled.div` +type MobileLayout = { + isMobile: boolean; +}; + +const ShowListBtn = styled(PrimaryButton)` + margin-bottom: 2rem; +`; + +const FormInputWrapper = styled.div` + max-width: 45rem; + padding-bottom: 1rem; +`; + +const SubContainers = styled.div` width: 100%; display: flex; - flex-direction: row; margin-bottom: 2rem; + + ${() => (isMobile ? `flex-direction: column;` : `flex-direction: row;`)} `; const Container = styled.form` - max-width: 45rem; padding: 1rem 2rem 0 2rem; `; +const BottomMessagesWrapper = styled.div` + max-width: 45rem; + padding-bottom: 2rem; +`; + const RemoveConfirmation = styled.div` background: #91fa8c; padding: 1rem; @@ -55,6 +79,8 @@ export const ManageProductions = () => { const [showDeleteDoneMessage, setShowDeleteDoneMessage] = useState(false); const [verifyRemove, setVerifyRemove] = useState(false); + const [showProductionsList, setShowProductionsList] = + useState(false); const [delayOnGuideText, setDelayOnGuideText] = useState(false); const [removeId, setRemoveId] = useState(null); const [cachedProduction, setCachedProduction] = useState( @@ -64,6 +90,12 @@ export const ManageProductions = () => { null ); + // Pagination + const [offset, setOffset] = useState("0"); + const limit = "10"; + + const [, dispatch] = useGlobalState(); + const { reset, formState, @@ -78,6 +110,15 @@ export const ManageProductions = () => { min: 1, }); + const { productions, doInitialLoad, error } = useFetchProductionList({ + limit, + offset, + }); + + const showRefreshing = useRefreshAnimation({ + doInitialLoad, + }); + const { error: productionFetchError, production, @@ -103,13 +144,17 @@ export const ManageProductions = () => { if (successfullDelete) { setVerifyRemove(false); setShowDeleteDoneMessage(true); + dispatch({ + type: "PRODUCTION_UPDATED", + }); } - }, [successfullDelete]); + }, [dispatch, successfullDelete]); useEffect(() => { if (production) { setCachedProduction(production); setProductionIdToFetch(null); + setShowProductionsList(false); } }, [production]); @@ -159,46 +204,73 @@ export const ManageProductions = () => { Manage Productions - { - onChange(ev); - const pid = parseInt(ev.target.value, 10); - const confirmedPid = Number.isNaN(pid) ? null : pid; - - setProductionIdToFetch(confirmedPid); - setShowDeleteDoneMessage(false); - }} - label="Production ID" - placeholder="Production ID" - name={name} - inputRef={ref} - onBlur={onBlur} - type="number" - loading={fetchLoader} - /> - {productionFetchError && ( - - The production ID could not be fetched. {productionFetchError.name}{" "} - {productionFetchError.message}. - - )} - {productionDeleteError && ( - - The production ID could not be deleted. {productionDeleteError.name}{" "} - {productionDeleteError.message}. - - )} + + { + onChange(ev); + const pid = parseInt(ev.target.value, 10); + const confirmedPid = Number.isNaN(pid) ? null : pid; + + setProductionIdToFetch(confirmedPid); + setShowDeleteDoneMessage(false); + }} + label="Production ID" + placeholder="Production ID" + name={name} + inputRef={ref} + onBlur={onBlur} + type="number" + loading={fetchLoader} + /> + {delayOnGuideText && ( + + Please enter a production id + + )} + {productionFetchError && ( + + The production ID could not be fetched. {productionFetchError.name}{" "} + {productionFetchError.message}. + + )} + + {cachedProduction && ( + { + setShowProductionsList(!showProductionsList); + }} + > + {showProductionsList ? "Hide" : "Show"} Productions List + + )} + {(!cachedProduction || showProductionsList) && ( + setOffset(input)} + showRefreshing={showRefreshing} + productions={productions} + error={error} + manageProduction={(v: string) => { + setProductionIdToFetch(parseInt(v, 10)); + reset({ + productionId: `${v}`, + }); + setShowProductionsList(false); + setShowDeleteDoneMessage(false); + }} + /> + )} {cachedProduction && ( <> Production name: {cachedProduction.name} - + { reset={() => { setVerifyRemove(false); setRemoveId(null); + setCachedProduction(null); + reset({ + productionId: "", + }); }} /> )} - {delayOnGuideText && ( - - Please enter a production id - - )} - {showDeleteDoneMessage && ( - - The production {production?.name} has been removed - - )} + + {productionDeleteError && ( + + The production ID could not be deleted. {productionDeleteError.name}{" "} + {productionDeleteError.message}. + + )} + {showDeleteDoneMessage && ( + + The production {production?.name} has been removed + + )} + ); }; diff --git a/src/components/manage-productions/paginated-list.tsx b/src/components/manage-productions/paginated-list.tsx new file mode 100644 index 00000000..9dfbad24 --- /dev/null +++ b/src/components/manage-productions/paginated-list.tsx @@ -0,0 +1,140 @@ +import styled from "@emotion/styled"; +import { useEffect, useState } from "react"; +import { ProductionsList } from "../productions-list"; +import { LoaderDots } from "../loader/loader"; +import { TListProductionsResponse } from "../../api/api"; +import { StepLeftIcon, StepRightIcon } from "../../assets/icons/icon"; + +const Container = styled.div` + display: flex; + flex-direction: column; +`; + +const LoaderWrapper = styled.div` + padding-bottom: 1rem; +`; + +const ListWrapper = styled.div` + display: flex; + flex-direction: row; + flex-wrap: wrap; +`; + +const PaginationWrapper = styled.div` + display: flex; + align-items: center; + padding: 0 0 1rem 0; +`; + +const PageStep = styled.button` + cursor: pointer; + border: 1px solid #424242; + border-radius: 0.5rem; + padding: 1rem; + margin: 0 0 1rem 2rem; + width: 4rem; + height: 5rem; + + &.icon { + height: 4rem; + } +`; + +const PageNumber = styled.div` + font-size: 1.4rem; +`; + +type TPaginatedList = { + setProductionPage: (input: string) => void; + showRefreshing: boolean; + productions: TListProductionsResponse | undefined; + error: Error | null; + manageProduction: (v: string) => void; +}; + +export const PaginatedList = ({ + setProductionPage, + showRefreshing, + productions, + error, + manageProduction, +}: TPaginatedList) => { + const [pagesArray, setPagesArray] = useState(); + const totalPages = productions + ? Math.ceil(productions.totalItems / productions.limit) + : 0; + + useEffect(() => { + const result: number[] = []; + // eslint-disable-next-line no-plusplus + for (let i = 1; i <= totalPages; i++) { + result.push(i); + } + return setPagesArray(result); + }, [totalPages]); + + if (!productions) return null; + + const pageIndex = productions.offset / productions.limit; + const currentPage = pageIndex + 1; + + const handleClick = (pageNumber: number) => { + setProductionPage( + (pageNumber * productions.limit - productions.limit).toString() + ); + }; + + return ( + + + + + {productions && ( + <> + + manageProduction(v)} + /> + + {totalPages > 1 && ( + + handleClick(currentPage - 1)} + disabled={currentPage === 1} + > + + + {pagesArray && + pagesArray.map((item: number, index) => ( + handleClick(item)} + disabled={currentPage === item} + > + {index + 1} + + ))} + handleClick(currentPage + 1)} + disabled={currentPage === totalPages} + > + + + + )} + + )} + + ); +}; diff --git a/src/components/manage-productions/remove-production.tsx b/src/components/manage-productions/remove-production.tsx index 52262c1f..b9090b09 100644 --- a/src/components/manage-productions/remove-production.tsx +++ b/src/components/manage-productions/remove-production.tsx @@ -7,6 +7,7 @@ const Container = styled.div` max-width: 45rem; min-width: 35rem; padding: 2rem; + margin: 0 2rem 2rem 0; border-radius: 1rem; border: 0.2rem solid #434343; `; diff --git a/src/components/productions-list.tsx b/src/components/productions-list.tsx new file mode 100644 index 00000000..9f7f52e4 --- /dev/null +++ b/src/components/productions-list.tsx @@ -0,0 +1,68 @@ +import styled from "@emotion/styled"; +import { TBasicProduction } from "./production-line/types.ts"; +import { LocalError } from "./error.tsx"; + +const ProductionItem = styled.button` + text-align: start; + background-color: transparent; + flex: 1 0 calc(25% - 2rem); + justify-content: start; + min-width: 20rem; + border: 1px solid #424242; + border-radius: 0.5rem; + padding: 2rem; + margin: 0 2rem 2rem 0; + + &.clickableList { + cursor: pointer; + } +`; + +const ProductionName = styled.div` + font-size: 1.4rem; + font-weight: bold; + margin: 0 0 1rem; + word-break: break-word; +`; + +const ProductionId = styled.div` + font-size: 2rem; + color: #9e9e9e; +`; + +type TProductionsList = { + productions: TBasicProduction[] | undefined; + className?: string; + error: Error | null; + manageProduction?: (v: string) => void; +}; + +export const ProductionsList = ({ + productions, + className, + error, + manageProduction, +}: TProductionsList) => { + return ( + <> + {error && } + {!error && + productions && + productions.map((p) => ( + + manageProduction + ? manageProduction(p.productionId) + : console.log("") + } + > + {p.name} + {p.productionId} + + ))} + + ); +}; diff --git a/src/global-state/global-state-actions.ts b/src/global-state/global-state-actions.ts index 98f6583e..204ebf0d 100644 --- a/src/global-state/global-state-actions.ts +++ b/src/global-state/global-state-actions.ts @@ -21,7 +21,7 @@ export type TDominantSpeaker = { }; export type TProductionCreated = { - type: "PRODUCTION_CREATED"; + type: "PRODUCTION_UPDATED"; }; export type TProductionListFetched = { diff --git a/src/global-state/global-state-reducer.ts b/src/global-state/global-state-reducer.ts index df948e6d..aaba142d 100644 --- a/src/global-state/global-state-reducer.ts +++ b/src/global-state/global-state-reducer.ts @@ -25,7 +25,7 @@ const globalReducer: Reducer = ( ...state, error: action.payload, }; - case "PRODUCTION_CREATED": + case "PRODUCTION_UPDATED": return { ...state, reloadProductionList: true,