From d18b38f17edc6ffb5b22bc54a55f53d695a27ca8 Mon Sep 17 00:00:00 2001 From: acdunham <129009580+acdunham@users.noreply.github.com> Date: Wed, 28 Jun 2023 16:56:08 +1200 Subject: [PATCH] Waitp 1224 overlay selector desktop (#4685) * WAITP-1224 initial working selector * WAITP-1244 Tidy up files * Update border radius * Update border radius logic * Tidy ups --- packages/tupaia-web/src/api/queries/index.ts | 2 + .../src/api/queries/useMapOverlayData.ts | 31 +++ .../src/api/queries/useMapOverlays.ts | 77 +++++++ packages/tupaia-web/src/constants/url.ts | 1 + packages/tupaia-web/src/features/Map/Map.tsx | 36 ++- .../Map/MapLegend/DesktopMapLegend.tsx | 23 +- .../DesktopMapOverlaySelector.tsx | 213 +++++++++++++++++- .../Map/MapOverlaySelector/MapOverlayList.tsx | 115 ++++++++++ .../MapOverlaySelector/MapOverlaySelector.tsx | 18 +- packages/tupaia-web/src/theme/theme.ts | 1 + packages/tupaia-web/src/types/helpers.ts | 2 + packages/tupaia-web/src/types/types.d.ts | 24 +- 12 files changed, 510 insertions(+), 33 deletions(-) create mode 100644 packages/tupaia-web/src/api/queries/useMapOverlayData.ts create mode 100644 packages/tupaia-web/src/api/queries/useMapOverlays.ts create mode 100644 packages/tupaia-web/src/features/Map/MapOverlaySelector/MapOverlayList.tsx diff --git a/packages/tupaia-web/src/api/queries/index.ts b/packages/tupaia-web/src/api/queries/index.ts index 212060794e..10817d64db 100644 --- a/packages/tupaia-web/src/api/queries/index.ts +++ b/packages/tupaia-web/src/api/queries/index.ts @@ -13,3 +13,5 @@ export { useEntities, useEntitiesWithLocation } from './useEntities'; export { useEmailVerification } from './useEmailVerification'; export { useDashboards } from './useDashboards'; export { useReport } from './useReport'; +export { useMapOverlays } from './useMapOverlays'; +export { useMapOverlayData } from './useMapOverlayData'; diff --git a/packages/tupaia-web/src/api/queries/useMapOverlayData.ts b/packages/tupaia-web/src/api/queries/useMapOverlayData.ts new file mode 100644 index 0000000000..5ce9ce4a95 --- /dev/null +++ b/packages/tupaia-web/src/api/queries/useMapOverlayData.ts @@ -0,0 +1,31 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +import { useQuery } from 'react-query'; +import { get } from '../api'; +import { EntityCode, ProjectCode, SingleMapOverlayItem } from '../../types'; + +export const useMapOverlayData = ( + projectCode?: ProjectCode, + entityCode?: EntityCode, + mapOverlayCode?: SingleMapOverlayItem['code'], +) => { + return useQuery( + ['mapOverlayData', projectCode, entityCode, mapOverlayCode], + async () => { + return get('measureData', { + params: { + mapOverlayCode, + organisationUnitCode: entityCode, + projectCode, + shouldShowAllParentCountryResults: projectCode !== entityCode, // TODO: figure out the logic here for shouldShowAllParentCountryResults + }, + }); + }, + { + enabled: !!projectCode && !!entityCode && !!mapOverlayCode, + }, + ); +}; diff --git a/packages/tupaia-web/src/api/queries/useMapOverlays.ts b/packages/tupaia-web/src/api/queries/useMapOverlays.ts new file mode 100644 index 0000000000..4685f8dcf5 --- /dev/null +++ b/packages/tupaia-web/src/api/queries/useMapOverlays.ts @@ -0,0 +1,77 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ +import { ChangeEvent } from 'react'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { useQuery, UseQueryResult } from 'react-query'; +import { get } from '../api'; +import { MapOverlayGroup, SingleMapOverlayItem } from '../../types'; +import { URL_SEARCH_PARAMS } from '../../constants'; + +const mapOverlayByCode = ( + mapOverlayGroups: MapOverlayGroup[] = [], +): Record => { + return mapOverlayGroups.reduce( + ( + result: Record, + mapOverlay: MapOverlayGroup | SingleMapOverlayItem, + ) => { + if (mapOverlay.children) { + return { ...result, ...mapOverlayByCode(mapOverlay.children) }; + } + return { + ...result, + [mapOverlay.code]: mapOverlay, + }; + }, + {}, + ); +}; + +interface UseMapOverlaysResult { + hasMapOverlays: boolean; + mapOverlayGroups: MapOverlayGroup[]; + isLoadingMapOverlays: boolean; + errorLoadingMapOverlays: UseQueryResult['error']; + selectedOverlayCode: string | null; + selectedOverlay?: SingleMapOverlayItem; + updateSelectedMapOverlay: (e: ChangeEvent) => void; +} + +/** + * Gets the map overlays and returns useful utils and values associated with these + */ +export const useMapOverlays = (): UseMapOverlaysResult => { + const [urlSearchParams, setUrlParams] = useSearchParams(); + const { projectCode, entityCode } = useParams(); + const { data, isLoading, error } = useQuery( + ['mapOverlays', projectCode, entityCode], + async () => { + return get(`mapOverlays/${projectCode}/${entityCode}`); + }, + { + enabled: !!projectCode && !!entityCode, + }, + ); + + const selectedOverlayCode = urlSearchParams.get(URL_SEARCH_PARAMS.MAP_OVERLAY); + const codedOverlays = mapOverlayByCode(data?.mapOverlays); + + const selectedOverlay = codedOverlays[selectedOverlayCode!]; + + const updateSelectedMapOverlay = (e: ChangeEvent) => { + urlSearchParams.set(URL_SEARCH_PARAMS.MAP_OVERLAY, e.target.value); + setUrlParams(urlSearchParams); + }; + + return { + hasMapOverlays: !!data?.mapOverlays?.length, + mapOverlayGroups: data?.mapOverlays, + isLoadingMapOverlays: isLoading, + errorLoadingMapOverlays: error, + selectedOverlayCode, + selectedOverlay, + updateSelectedMapOverlay, + }; +}; diff --git a/packages/tupaia-web/src/constants/url.ts b/packages/tupaia-web/src/constants/url.ts index 33a4862ae8..40cdbfea74 100644 --- a/packages/tupaia-web/src/constants/url.ts +++ b/packages/tupaia-web/src/constants/url.ts @@ -6,6 +6,7 @@ export const URL_SEARCH_PARAMS = { PROJECT: 'project', TAB: 'tab', PASSWORD_RESET_TOKEN: 'passwordResetToken', + MAP_OVERLAY: 'overlay', }; export enum MODAL_ROUTES { diff --git a/packages/tupaia-web/src/features/Map/Map.tsx b/packages/tupaia-web/src/features/Map/Map.tsx index 625ba4248d..bbe3bb4c76 100644 --- a/packages/tupaia-web/src/features/Map/Map.tsx +++ b/packages/tupaia-web/src/features/Map/Map.tsx @@ -57,15 +57,25 @@ const StyledMap = styled(LeafletMap)` `; // Position this absolutely so it can be placed over the map const TilePickerWrapper = styled.div` - position: absolute; - right: 0; - bottom: 0; - height: 100%; @media screen and (max-width: ${MOBILE_BREAKPOINT}) { display: none; } `; +// This contains the map controls (legend, overlay selector, etc, so that they can fit within the map appropriately) +const MapControlWrapper = styled.div` + width: 100%; + height: 100%; + display: flex; + position: relative; +`; + +const MapControlColumn = styled.div` + display: flex; + flex-direction: column; + flex: 1; +`; + export const Map = () => { const { projectCode, entityCode } = useParams(); const [activeTileSet, setActiveTileSet] = useState(TILE_SETS[0]); @@ -82,13 +92,21 @@ export const Map = () => { - + + + + + + + + + - - - - ); }; diff --git a/packages/tupaia-web/src/features/Map/MapLegend/DesktopMapLegend.tsx b/packages/tupaia-web/src/features/Map/MapLegend/DesktopMapLegend.tsx index 0bb410bf76..1982b19101 100644 --- a/packages/tupaia-web/src/features/Map/MapLegend/DesktopMapLegend.tsx +++ b/packages/tupaia-web/src/features/Map/MapLegend/DesktopMapLegend.tsx @@ -13,19 +13,24 @@ const Wrapper = styled.div` flex-direction: row; align-items: flex-end; justify-content: center; - position: absolute; - background-color: grey; - width: 300px; - height: 50px; - bottom: 1em; - left: 50%; - transform: translateX(-50%); - border-radius: 5px; + width: 100%; + padding: 1rem; @media screen and (max-width: ${MOBILE_BREAKPOINT}) { display: none; } `; +const Legend = styled.div` + height: 50px; + width: 300px; + background-color: grey; + border-radius: 5px; +`; + export const DesktopMapLegend = () => { - return ; + return ( + + + + ); }; diff --git a/packages/tupaia-web/src/features/Map/MapOverlaySelector/DesktopMapOverlaySelector.tsx b/packages/tupaia-web/src/features/Map/MapOverlaySelector/DesktopMapOverlaySelector.tsx index 55c895f7cd..b42b6f9fbc 100644 --- a/packages/tupaia-web/src/features/Map/MapOverlaySelector/DesktopMapOverlaySelector.tsx +++ b/packages/tupaia-web/src/features/Map/MapOverlaySelector/DesktopMapOverlaySelector.tsx @@ -5,23 +5,212 @@ import React from 'react'; import styled from 'styled-components'; +import { useParams } from 'react-router'; +import { Accordion, Typography, AccordionSummary, AccordionDetails } from '@material-ui/core'; +import { ExpandMore, Layers } from '@material-ui/icons'; +import { Skeleton as MuiSkeleton } from '@material-ui/lab'; +import { periodToMoment } from '@tupaia/utils'; import { MOBILE_BREAKPOINT } from '../../../constants'; +import { Entity } from '../../../types'; +import { useMapOverlayData, useMapOverlays } from '../../../api/queries'; -// Placeholder for MapOverlaySelector component -const Wrapper = styled.div` - width: 18.75rem; - margin: 1em; - height: 2.5rem; - border-radius: 5px; - background-color: ${({ theme }) => theme.palette.secondary.main}; - opacity: 0.6; - position: absolute; - top: 0; +import { MapOverlayList } from './MapOverlayList'; + +const MaxHeightContainer = styled.div` + flex: 1; + max-height: 100%; + overflow: hidden; + display: flex; + flex-direction: column; +`; + +const Wrapper = styled(MaxHeightContainer)` + max-width: 21.25rem; + margin: 0.625rem; @media screen and (max-width: ${MOBILE_BREAKPOINT}) { display: none; } `; -export const DesktopMapOverlaySelector = () => { - return ; +const Header = styled.div` + padding: 0.9rem 1rem; + background-color: ${({ theme }) => theme.palette.secondary.main}; + border-radius: 5px 5px 0 0; +`; + +const Heading = styled(Typography).attrs({ + variant: 'h2', +})` + font-size: 0.75rem; + text-transform: uppercase; + font-weight: ${({ theme }) => theme.typography.fontWeightMedium}; +`; + +const Container = styled(MaxHeightContainer)` + border-radius: 0 0 5px 5px; +`; + +const MapOverlayNameContainer = styled.div<{ + $hasMapOverlays: boolean; +}>` + padding: 1.3rem 1rem 1rem 1.125rem; + background-color: ${({ theme }) => theme.overlaySelector.overlayNameBackground}; + border-radius: ${({ $hasMapOverlays }) => ($hasMapOverlays ? '0' : '0 0 5px 5px')}; +`; + +const MapOverlayName = styled.span` + font-weight: ${({ theme }) => theme.typography.fontWeightMedium}; +`; + +const Skeleton = styled(MuiSkeleton)` + transform: scale(1, 1); + ::after { + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); + } +`; + +const OverlayLibraryAccordion = styled(Accordion)` + display: flex; + flex-direction: column; + margin: 0 !important; + background-color: ${({ theme }) => theme.overlaySelector.menuBackground}; + border-radius: 0 0 5px 5px; + &:before { + display: none; + } + &.MuiPaper-root.Mui-expanded { + height: 100%; + overflow: hidden; // make the accordion conform to the max-height of the parent container, regardless of how much content is present + > .MuiCollapse-container.MuiCollapse-entered { + max-height: 100%; + overflow-y: auto; // scrollable content when accordion is expanded; + } + } +`; + +const OverlayLibraryIcon = styled(Layers)` + margin-right: 0.5rem; + .Mui-expanded & { + fill: ${({ theme }) => theme.palette.secondary.main}; + } +`; + +const OverlayLibraryTitle = styled(Typography)` + font-size: 0.75rem; + text-transform: uppercase; + font-weight: ${({ theme }) => theme.typography.fontWeightMedium}; +`; + +const OverlayLibraryHeader = styled(AccordionSummary)` + margin: 0 !important; + min-height: unset !important; + color: rgba(255, 255, 255, 0.6); + .MuiAccordionSummary-content { + margin: 0 !important; + align-items: center; + } + + &:hover, + &.Mui-expanded, + &:focus-visible { + color: ${({ theme }) => theme.palette.text.primary}; + } +`; + +const OverlayLibraryContentWrapper = styled(AccordionDetails)` + padding: 0 1rem 1rem; +`; + +const OverlayLibraryContentContainer = styled.div` + border-top: 1px solid rgba(255, 255, 255, 0.12); + width: 100%; + padding-top: 1rem; +`; + +const LatestDataContainer = styled.div` + background-color: rgba(0, 0, 0, 0.3); + border-radius: 5px; + padding: 0.3rem 0.5rem 0.2rem; + margin-top: 0.5rem; +`; + +const LatestDataText = styled(Typography)` + font-size: 0.875rem; + line-height: 1.3; +`; + +interface DesktopMapOverlaySelectorProps { + entityName?: Entity['name']; + overlayLibraryOpen: boolean; + toggleOverlayLibrary: () => void; +} + +export const DesktopMapOverlaySelector = ({ + entityName, + overlayLibraryOpen, + toggleOverlayLibrary, +}: DesktopMapOverlaySelectorProps) => { + const { + hasMapOverlays, + isLoadingMapOverlays, + selectedOverlayCode, + selectedOverlay, + } = useMapOverlays(); + + const { projectCode, entityCode } = useParams(); + const { data: mapOverlayData } = useMapOverlayData(projectCode, entityCode, selectedOverlayCode); + + return ( + +
+ Map Overlays +
+ + + {isLoadingMapOverlays ? ( + + ) : ( + + {hasMapOverlays ? ( + {selectedOverlay?.name} + ) : ( + `Select an area with valid data. ${ + entityName && `${entityName} has no map overlays available.` + }` + )} + + )} + + {hasMapOverlays && ( + + } + aria-controls="overlay-library-content" + id="overlay-library-header" + > + + Overlay library + + + + + + + + )} + {mapOverlayData?.period?.latestAvailable && ( + + + Latest overlay data:{' '} + {periodToMoment(mapOverlayData?.period?.latestAvailable).format('DD/MM/YYYY')} + + + )} + +
+ ); }; diff --git a/packages/tupaia-web/src/features/Map/MapOverlaySelector/MapOverlayList.tsx b/packages/tupaia-web/src/features/Map/MapOverlaySelector/MapOverlayList.tsx new file mode 100644 index 0000000000..0cb03375ee --- /dev/null +++ b/packages/tupaia-web/src/features/Map/MapOverlaySelector/MapOverlayList.tsx @@ -0,0 +1,115 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + */ + +import React, { useState } from 'react'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + FormControlLabel, + Radio, + RadioGroup, +} from '@material-ui/core'; +import { KeyboardArrowRight } from '@material-ui/icons'; +import styled from 'styled-components'; +import { MapOverlayGroup } from '../../../types'; +import { useMapOverlays } from '../../../api/queries'; + +const AccordionWrapper = styled(Accordion)` + background-color: transparent; + box-shadow: none; + &:before { + display: none; + } + &.MuiAccordion-root.Mui-expanded { + margin: 0; + } +`; +const AccordionHeader = styled(AccordionSummary)` + &.MuiAccordionSummary-root { + min-height: unset; + padding: 0; + flex-direction: row-reverse; + } + .MuiAccordionSummary-expandIcon { + padding: 0rem; + &.Mui-expanded { + transform: rotate(90deg); + } + } + .MuiAccordionSummary-content { + margin: 0; + padding: 0.5rem 0.5rem 0.5rem 1rem; + font-size: 1rem; + } +`; + +const AccordionContent = styled(AccordionDetails)` + display: flex; + flex-direction: column; + + &.MuiAccordionDetails-root { + padding: 0 0 1rem 2rem; + } + .MuiSvgIcon-root { + width: 1.2rem; + height: 1.2rem; + color: white; + } + .MuiButtonBase-root { + padding: 0; + margin-right: 0.5rem; + } + .MuiFormControlLabel-root { + padding: 0.4rem 0; + } +`; + +/** + * This is a recursive component that renders a list of map overlays in an accordion + */ +const MapOverlayAccordion = ({ mapOverlayGroup }: { mapOverlayGroup: MapOverlayGroup }) => { + const [expanded, setExpanded] = useState(false); + const toggleExpanded = () => { + setExpanded(!expanded); + }; + return ( + + }>{mapOverlayGroup.name} + + {/** Map through the children, and if there are more nested children, render another accordion, otherwise render radio input for the overlay */} + {mapOverlayGroup.children.map(mapOverlay => + mapOverlay.children ? ( + + ) : ( + } label={mapOverlay.name} /> + ), + )} + + + ); +}; + +/** + * This is the parent list of all the map overlays available to pick from + */ +export const MapOverlayList = () => { + const { mapOverlayGroups, selectedOverlayCode, updateSelectedMapOverlay } = useMapOverlays(); + + return ( + + {mapOverlayGroups + .filter(item => item.name) + .map(group => ( + + ))} + + ); +}; diff --git a/packages/tupaia-web/src/features/Map/MapOverlaySelector/MapOverlaySelector.tsx b/packages/tupaia-web/src/features/Map/MapOverlaySelector/MapOverlaySelector.tsx index 2cd7a41d20..789b1276e9 100644 --- a/packages/tupaia-web/src/features/Map/MapOverlaySelector/MapOverlaySelector.tsx +++ b/packages/tupaia-web/src/features/Map/MapOverlaySelector/MapOverlaySelector.tsx @@ -3,15 +3,29 @@ * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd */ -import React from 'react'; +import React, { useState } from 'react'; +import { useParams } from 'react-router-dom'; import { DesktopMapOverlaySelector } from './DesktopMapOverlaySelector'; import { MobileMapOverlaySelector } from './MobileMapOverlaySelector'; +import { useEntity } from '../../../api/queries'; export const MapOverlaySelector = () => { + const [overlayLibraryOpen, setOverlayLibraryOpen] = useState(false); + const { entityCode } = useParams(); + const { data: entity } = useEntity(entityCode); + + const toggleOverlayLibrary = () => { + setOverlayLibraryOpen(!overlayLibraryOpen); + }; + return ( <> - + ); }; diff --git a/packages/tupaia-web/src/theme/theme.ts b/packages/tupaia-web/src/theme/theme.ts index 1c70db1d41..4a02af93ac 100644 --- a/packages/tupaia-web/src/theme/theme.ts +++ b/packages/tupaia-web/src/theme/theme.ts @@ -41,6 +41,7 @@ export const theme = createMuiTheme( background: '#202124', }, overlaySelector: { + overlayNameBackground: '#072849', menuBackground: '#203e5c', // Dark blue used for button and header background in mobile overlay selector, as well as the background of the menu list on desktop }, }, diff --git a/packages/tupaia-web/src/types/helpers.ts b/packages/tupaia-web/src/types/helpers.ts index 5db517454b..1bc0eb9f64 100644 --- a/packages/tupaia-web/src/types/helpers.ts +++ b/packages/tupaia-web/src/types/helpers.ts @@ -16,3 +16,5 @@ export type KeysToCamelCase = { ? KeysToCamelCase[] : ObjectToCamel; }; + +export type ValueOf = T[keyof T]; diff --git a/packages/tupaia-web/src/types/types.d.ts b/packages/tupaia-web/src/types/types.d.ts index df417fec54..2782020e5d 100644 --- a/packages/tupaia-web/src/types/types.d.ts +++ b/packages/tupaia-web/src/types/types.d.ts @@ -2,10 +2,13 @@ import { LandingPage, Project, Country, - Entity, + Entity as BaseEntity, Dashboard as BaseDashboard, DashboardItem as BaseDashboardItem, DashboardItemConfig, + MapOverlay, + MapOverlayGroupRelation, + EntityType, } from '@tupaia/types'; import { ViewContent } from '@tupaia/ui-chart-components'; import { KeysToCamelCase } from './helpers'; @@ -53,6 +56,25 @@ export type TupaiaUrlParams = { export type DashboardItemDisplayProps = ViewContent & DashboardItemType; export type DashboardName = BaseDashboard['name']; +export type SingleMapOverlayItem = KeysToCamelCase< + Pick +> & { + measureLevel?: string; + displayType: string; +}; + +export type MapOverlayGroup = { + name: MapOverlay['name']; + children: SingleMapOverlayItem[] | MapOverlayGroup[]; +}; +export type MapOverlays = { + entityCode: EntityCode; + entityType: EntityType; + name: string; + mapOverlays: MapOverlayGroup[]; +}; + +export type Entity = KeysToCamelCase; /* Response Types */ // Todo: replace with types from @tupaia/types