diff --git a/packages/tupaia-web-server/src/app/createApp.ts b/packages/tupaia-web-server/src/app/createApp.ts index f2516f2b5f..7d76ab264a 100644 --- a/packages/tupaia-web-server/src/app/createApp.ts +++ b/packages/tupaia-web-server/src/app/createApp.ts @@ -65,7 +65,7 @@ export function createApp() { 'requestCountryAccess', handleWith(RequestCountryAccessRoute), ) - .get('entities/:hierarchyName/:rootEntityCode', handleWith(EntitiesRoute)) + .get('entities/:projectCode/:rootEntityCode', handleWith(EntitiesRoute)) // TODO: Stop using get for logout, then delete this .get('logout', handleWith(TempLogoutRoute)) .build(); diff --git a/packages/tupaia-web-server/src/routes/EntitiesRoute.ts b/packages/tupaia-web-server/src/routes/EntitiesRoute.ts index 85cc7b2242..14c010da9f 100644 --- a/packages/tupaia-web-server/src/routes/EntitiesRoute.ts +++ b/packages/tupaia-web-server/src/routes/EntitiesRoute.ts @@ -6,16 +6,9 @@ import { Request } from 'express'; import { Route } from '@tupaia/server-boilerplate'; import camelcaseKeys from 'camelcase-keys'; -import { Entity } from '@tupaia/types'; -import groupBy from 'lodash.groupby'; export type EntitiesRequest = Request; -interface NestedEntity extends Entity { - parent_code?: string | null; - children?: NestedEntity[] | null; -} - const DEFAULT_FILTER = { generational_distance: { comparator: '<=', @@ -27,36 +20,28 @@ const DEFAULT_FIELDS = ['parent_code', 'code', 'name', 'type']; export class EntitiesRoute extends Route { public async buildResponse() { const { params, query, ctx } = this.req; - const { rootEntityCode, hierarchyName } = params; - const { filter, ...restOfQuery } = query; + const { rootEntityCode, projectCode } = params; + + const project = ( + await ctx.services.central.fetchResources('projects', { + filter: { code: projectCode }, + columns: ['entity_hierarchy.name', 'entity_hierarchy.canonical_types', 'config'], + }) + )[0]; + const { + 'entity_hierarchy.name': hierarchyName, + // TODO: Filter entities by canonical_types and config.frontendExcluded + // 'entity_hierarchy.canonical_types': canonicalTypes, + // config: projectConfig, + } = project; - // Apply the generational_distance filter even if the user specifies other filters - // Only override it if the user specifically requests to override const flatEntities = await ctx.services.entity.getDescendantsOfEntity( hierarchyName, rootEntityCode, - { filter: { ...DEFAULT_FILTER, ...filter }, fields: DEFAULT_FIELDS, ...restOfQuery }, - true, + { filter: DEFAULT_FILTER, fields: DEFAULT_FIELDS, ...query }, + query.includeRoot || false, ); - // Entity server returns a flat list of entities - // Convert that to a nested object - const nestChildrenEntities = ( - entitiesByParent: Record, - parentEntity: NestedEntity, - ): NestedEntity => { - const children = entitiesByParent[parentEntity.code] || []; - const nestedChildren = children.map((childEntity: NestedEntity) => - nestChildrenEntities(entitiesByParent, childEntity), - ); - // If the entity has no children, do not attach an empty array - return { ...parentEntity, children: nestedChildren.length ? nestedChildren : undefined }; - }; - - const entitiesByParent = groupBy(flatEntities, (e: NestedEntity) => e.parent_code); - const rootEntity = flatEntities.find((entity: NestedEntity) => entity.code === rootEntityCode); - const nestedEntities = nestChildrenEntities(entitiesByParent, rootEntity); - - return camelcaseKeys(nestedEntities, { deep: true }); + return camelcaseKeys(flatEntities, { deep: true }); } } diff --git a/packages/tupaia-web/src/api/queries/useEntities.ts b/packages/tupaia-web/src/api/queries/useEntities.ts index 0633dc00bf..133e72f312 100644 --- a/packages/tupaia-web/src/api/queries/useEntities.ts +++ b/packages/tupaia-web/src/api/queries/useEntities.ts @@ -17,10 +17,24 @@ export const useEntities = ( queryOptions?.enabled === undefined ? !!projectCode && !!entityCode : queryOptions.enabled; return useQuery( - ['entities', projectCode, entityCode, axiosConfig, queryOptions], - async (): Promise => { - return get(`entities/${projectCode}/${entityCode}`, axiosConfig); - }, + ['entities', projectCode, entityCode, axiosConfig], + (): Promise => + get(`entities/${projectCode}/${entityCode}`, { + params: { + includeRoot: true, + fields: [ + 'parent_code', + 'code', + 'name', + 'type', + 'point', + 'image_url', + 'attributes', + 'child_codes', + ], + }, + ...axiosConfig, + }), { enabled, }, @@ -29,5 +43,20 @@ export const useEntities = ( export const useEntitiesWithLocation = (projectCode?: string, entityCode?: string) => useEntities(projectCode, entityCode, { - params: { fields: ['parent_code', 'code', 'name', 'type', 'bounds', 'region'] }, + params: { + includeRoot: true, + fields: [ + 'parent_code', + 'code', + 'name', + 'type', + 'bounds', + 'region', + 'point', + 'location_type', + 'image_url', + 'attributes', + 'child_codes', + ], + }, }); diff --git a/packages/tupaia-web/src/api/queries/useEntity.ts b/packages/tupaia-web/src/api/queries/useEntity.ts index 91348fbce0..bf189d3a23 100644 --- a/packages/tupaia-web/src/api/queries/useEntity.ts +++ b/packages/tupaia-web/src/api/queries/useEntity.ts @@ -4,34 +4,32 @@ */ import { useQuery } from 'react-query'; -import { sleep } from '@tupaia/utils'; - -const response = { - type: 'Country', - organisationUnitCode: 'TO', - countryCode: 'TO', - name: 'Tonga', - location: { - type: 'area', - point: null, - bounds: [ - [-24.1625706, 180.604802], - [-15.3655722, 186.4704542], - ], - region: [], - }, - photoUrl: - 'https://tupaia.s3-ap-southeast-2.amazonaws.com/uploads/images/1499489588784_647646.png', - countryHierarchy: [], -}; +import { useParams } from 'react-router-dom'; +import { EntityResponse } from '../../types'; +import { get } from '../api'; +import { DEFAULT_BOUNDS } from '../../constants'; export const useEntity = (entityCode?: string) => { + // Todo: use entity endpoint when it's done and remove project code + const { projectCode } = useParams(); + return useQuery( - ['entity', entityCode], - async () => { - await sleep(1000); - return response; + ['entities', projectCode, entityCode], + async (): Promise => { + const entities = await get(`entities/${projectCode}/${entityCode}`, { + params: { + includeRoot: true, + fields: ['parent_code', 'code', 'name', 'type', 'bounds', 'region', 'image_url'], + }, + }); + // @ts-ignore + const entity = entities.find(e => e.code === entityCode); + + if (entity.code === 'explore') { + return { ...entity, bounds: DEFAULT_BOUNDS }; + } + + return entity; }, - { enabled: !!entityCode }, ); }; diff --git a/packages/tupaia-web/src/features/Dashboard/Dashboard.tsx b/packages/tupaia-web/src/features/Dashboard/Dashboard.tsx index bbdca50af2..6c18b78b80 100644 --- a/packages/tupaia-web/src/features/Dashboard/Dashboard.tsx +++ b/packages/tupaia-web/src/features/Dashboard/Dashboard.tsx @@ -13,7 +13,7 @@ import { ExpandButton } from './ExpandButton'; import { Photo } from './Photo'; import { Breadcrumbs } from './Breadcrumbs'; import { StaticMap } from './StaticMap'; -import { useDashboards as useDashboardData, useEntitiesWithLocation } from '../../api/queries'; +import { useDashboards as useDashboardData, useEntity } from '../../api/queries'; import { DashboardMenu } from './DashboardMenu'; import { DashboardItem } from '../DashboardItem'; import { DashboardItemType, DashboardType } from '../../types'; @@ -116,11 +116,11 @@ const useDashboards = () => { }; export const Dashboard = () => { - const { projectCode, entityCode } = useParams(); + const { entityCode } = useParams(); const [isExpanded, setIsExpanded] = useState(false); const { dashboards, activeDashboard } = useDashboards(); - const { data: entityData } = useEntitiesWithLocation(projectCode, entityCode); - const bounds = entityData?.bounds; + const { data: entity } = useEntity(entityCode); + const bounds = entity?.bounds; const toggleExpanded = () => { setIsExpanded(!isExpanded); @@ -133,13 +133,13 @@ export const Dashboard = () => { {bounds ? ( - + ) : ( - + )} - {entityData?.name} + {entity?.name} }>Export diff --git a/packages/tupaia-web/src/features/Dashboard/StaticMap.tsx b/packages/tupaia-web/src/features/Dashboard/StaticMap.tsx index 55bfc066cd..22fca4a4fe 100644 --- a/packages/tupaia-web/src/features/Dashboard/StaticMap.tsx +++ b/packages/tupaia-web/src/features/Dashboard/StaticMap.tsx @@ -64,11 +64,11 @@ const makeStaticMapUrl = (polygonBounds: Position[]) => { return `${MAPBOX_BASE_URL}${boundingBoxPath}/${longitude},${latitude},${zoomLevel}/${size}@2x?access_token=${MAPBOX_TOKEN}&attribution=false`; }; -export const StaticMap = ({ polygonBounds }: { polygonBounds: Position[] }) => { - if (!areBoundsValid(polygonBounds)) { +export const StaticMap = ({ bounds }: { bounds: Position[] }) => { + if (!areBoundsValid(bounds)) { return null; } - const url = makeStaticMapUrl(polygonBounds); + const url = makeStaticMapUrl(bounds); return ; }; diff --git a/packages/tupaia-web/src/features/EntitySearch/EntityMenu.tsx b/packages/tupaia-web/src/features/EntitySearch/EntityMenu.tsx index 288620c934..7bd635971c 100644 --- a/packages/tupaia-web/src/features/EntitySearch/EntityMenu.tsx +++ b/packages/tupaia-web/src/features/EntitySearch/EntityMenu.tsx @@ -5,8 +5,7 @@ import React, { useState } from 'react'; import { Link, useLocation } from 'react-router-dom'; import styled from 'styled-components'; -import { Entity } from '@tupaia/types'; -import { ProjectCode } from '../../types'; +import { EntityResponse, ProjectCode } from '../../types'; import { LocalHospital as HospitalIcon, ExpandMore as ExpandIcon } from '@material-ui/icons'; import { Button, IconButton, List as MuiList, ListItemProps } from '@material-ui/core'; import { useEntities } from '../../api/queries'; @@ -41,12 +40,10 @@ const MenuLink = styled(Button).attrs({ } `; -type EntityWithChildren = Entity & { children?: Entity[] }; - interface EntityMenuProps { projectCode: ProjectCode; - children: EntityWithChildren[]; - isLoading: boolean; + children: EntityResponse[]; + grandChildren: EntityResponse[]; onClose: () => void; } @@ -54,16 +51,16 @@ interface EntityMenuProps { * ExpandedList is a recursive component that renders a list of entities and their children to * display an expandable entity menu. */ -export const EntityMenu = ({ projectCode, children, isLoading, onClose }: EntityMenuProps) => { - const entityList = children.sort((a, b) => a.name.localeCompare(b.name)); +export const EntityMenu = ({ projectCode, children, grandChildren, onClose }: EntityMenuProps) => { + const sortedChildren = children.sort((a, b) => a.name.localeCompare(b.name)); return ( - {entityList.map(entity => ( + {sortedChildren.map(entity => ( child.parentCode === entity.code)} entity={entity} - parentIsLoading={isLoading} onClose={onClose} /> ))} @@ -73,29 +70,27 @@ export const EntityMenu = ({ projectCode, children, isLoading, onClose }: Entity interface EntityMenuItemProps { projectCode: ProjectCode; - entity: EntityWithChildren; - parentIsLoading?: boolean; + entity: EntityResponse; onClose: () => void; + children?: EntityResponse[]; } -const EntityMenuItem = ({ - projectCode, - entity, - parentIsLoading = false, - onClose, -}: EntityMenuItemProps) => { +const EntityMenuItem = ({ projectCode, entity, children, onClose }: EntityMenuItemProps) => { const location = useLocation(); const [isExpanded, setIsExpanded] = useState(false); - const { data, isLoading } = useEntities(projectCode!, entity.code!, {}, { enabled: isExpanded }); + const { data = [] } = useEntities(projectCode!, entity.code!, {}, { enabled: isExpanded }); const toggleExpanded = () => { setIsExpanded(!isExpanded); }; /* - Pre-populate the next layer of the menu with children that came from the previous layer of entity - data then replace them with the children from the API response when it arrives - */ - const nextChildren = data?.children || entity.children; + Pre-populate the next layer of the menu with children that came from the previous layer of entity + data then replace them with the children from the API response when it arrives + */ + const nextChildren = + data?.filter(childEntity => childEntity.parentCode === entity.code) || children; + + const grandChildren = data.filter(childEntity => childEntity.parentCode !== entity.code); const link = { ...location, pathname: `/${projectCode}/${entity.code}` }; @@ -107,7 +102,7 @@ const EntityMenuItem = ({ @@ -116,9 +111,9 @@ const EntityMenuItem = ({ {isExpanded && ( )} diff --git a/packages/tupaia-web/src/features/EntitySearch/EntitySearch.tsx b/packages/tupaia-web/src/features/EntitySearch/EntitySearch.tsx index bd789bac0e..5c0170913c 100644 --- a/packages/tupaia-web/src/features/EntitySearch/EntitySearch.tsx +++ b/packages/tupaia-web/src/features/EntitySearch/EntitySearch.tsx @@ -56,7 +56,7 @@ const isMobile = () => { export const EntitySearch = () => { const { projectCode } = useParams(); const { data: project } = useProject(projectCode!); - const { data: entity, isLoading } = useEntities(projectCode!, project?.entityCode); + const { data: entities = [] } = useEntities(projectCode!, project?.entityCode); const [searchValue, setSearchValue] = useState(''); const [isOpen, setIsOpen] = useState(false); @@ -66,6 +66,9 @@ export const EntitySearch = () => { } }; + const children = entities.filter(entity => entity.parentCode === project?.entityCode); + const grandChildren = entities.filter(entity => entity.parentCode !== project?.entityCode); + return ( setIsOpen(false)}> @@ -77,8 +80,8 @@ export const EntitySearch = () => { ) : ( )} diff --git a/packages/tupaia-web/src/features/Map/Map.tsx b/packages/tupaia-web/src/features/Map/Map.tsx index bbe3bb4c76..3048c31fed 100644 --- a/packages/tupaia-web/src/features/Map/Map.tsx +++ b/packages/tupaia-web/src/features/Map/Map.tsx @@ -12,7 +12,7 @@ import { MapWatermark } from './MapWatermark'; import { MapLegend } from './MapLegend'; import { MapOverlays } from '../MapOverlays'; import { MapOverlaySelector } from './MapOverlaySelector'; -import { useEntitiesWithLocation } from '../../api/queries'; +import { useEntity } from '../../api/queries'; const MapContainer = styled.div` height: 100%; @@ -64,10 +64,13 @@ const TilePickerWrapper = styled.div` // This contains the map controls (legend, overlay selector, etc, so that they can fit within the map appropriately) const MapControlWrapper = styled.div` + position: relative; width: 100%; height: 100%; display: flex; - position: relative; + + // This is to prevent the wrapper div from blocking clicks on the map overlays + pointer-events: none; `; const MapControlColumn = styled.div` @@ -77,10 +80,10 @@ const MapControlColumn = styled.div` `; export const Map = () => { - const { projectCode, entityCode } = useParams(); + const { entityCode } = useParams(); const [activeTileSet, setActiveTileSet] = useState(TILE_SETS[0]); - const { data: entityData } = useEntitiesWithLocation(projectCode, entityCode); + const { data: entity } = useEntity(entityCode); const onTileSetChange = (tileSetKey: string) => { setActiveTileSet(TILE_SETS.find(({ key }) => key === tileSetKey) as typeof TILE_SETS[0]); @@ -88,7 +91,7 @@ export const Map = () => { return ( - + diff --git a/packages/tupaia-web/src/features/Map/MapLegend/DesktopMapLegend.tsx b/packages/tupaia-web/src/features/Map/MapLegend/DesktopMapLegend.tsx index 1982b19101..aa29397965 100644 --- a/packages/tupaia-web/src/features/Map/MapLegend/DesktopMapLegend.tsx +++ b/packages/tupaia-web/src/features/Map/MapLegend/DesktopMapLegend.tsx @@ -9,6 +9,7 @@ import { MOBILE_BREAKPOINT } from '../../../constants'; // Placeholder for legend const Wrapper = styled.div` + pointer-events: auto; display: flex; flex-direction: row; align-items: flex-end; diff --git a/packages/tupaia-web/src/features/Map/MapOverlaySelector/DesktopMapOverlaySelector.tsx b/packages/tupaia-web/src/features/Map/MapOverlaySelector/DesktopMapOverlaySelector.tsx index b42b6f9fbc..bc2fb5da91 100644 --- a/packages/tupaia-web/src/features/Map/MapOverlaySelector/DesktopMapOverlaySelector.tsx +++ b/packages/tupaia-web/src/features/Map/MapOverlaySelector/DesktopMapOverlaySelector.tsx @@ -25,6 +25,7 @@ const MaxHeightContainer = styled.div` `; const Wrapper = styled(MaxHeightContainer)` + pointer-events: auto; max-width: 21.25rem; margin: 0.625rem; @media screen and (max-width: ${MOBILE_BREAKPOINT}) { diff --git a/packages/tupaia-web/src/features/MapOverlays/MapOverlays.tsx b/packages/tupaia-web/src/features/MapOverlays/MapOverlays.tsx index d95aa564eb..25e1060db9 100644 --- a/packages/tupaia-web/src/features/MapOverlays/MapOverlays.tsx +++ b/packages/tupaia-web/src/features/MapOverlays/MapOverlays.tsx @@ -10,7 +10,7 @@ import { useEntitiesWithLocation } from '../../api/queries'; export const MapOverlays = () => { const { projectCode, entityCode } = useParams(); - const { data: entityData } = useEntitiesWithLocation(projectCode, entityCode); + const { data } = useEntitiesWithLocation(projectCode, entityCode); - return ; + return ; }; diff --git a/packages/tupaia-web/src/features/MapOverlays/PolygonLayer.tsx b/packages/tupaia-web/src/features/MapOverlays/PolygonLayer.tsx index 2589ce5f93..80d24518a3 100644 --- a/packages/tupaia-web/src/features/MapOverlays/PolygonLayer.tsx +++ b/packages/tupaia-web/src/features/MapOverlays/PolygonLayer.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { ActivePolygon } from '@tupaia/ui-map-components'; import { useParams } from 'react-router-dom'; -import { EntityResponse } from '../../types'; +import { EntityResponse, EntityCode } from '../../types'; import { InteractivePolygon } from './InteractivePolygon'; import { useEntitiesWithLocation } from '../../api/queries'; @@ -23,18 +23,24 @@ const ChildEntities = ({ entities }: { entities: EntityResponse['children'] }) = ); }; -const SiblingEntities = ({ entity }: { entity: EntityResponse }) => { +const SiblingEntities = ({ + parentEntityCode, + activeEntityCode, +}: { + parentEntityCode: EntityCode; + activeEntityCode: EntityCode; +}) => { const { projectCode } = useParams(); const { data: siblingEntities, isLoading } = useEntitiesWithLocation( projectCode, - entity.parentCode, + parentEntityCode, ); - if (isLoading || !siblingEntities) { + if (isLoading || !siblingEntities || siblingEntities.length === 0) { return null; } - const children = siblingEntities?.children?.filter(e => e.code !== entity.code) || []; + const children = siblingEntities.filter(entity => entity.code !== activeEntityCode); return ( <> @@ -63,16 +69,31 @@ const ActiveEntity = ({ entity }: { entity: EntityResponse }) => { ); }; -export const PolygonLayer = ({ entityData }: { entityData: EntityResponse }) => { - if (!entityData) { +interface PolygonLayerProps { + entities: EntityResponse[]; + entityCode: EntityCode; +} + +export const PolygonLayer = ({ entities, entityCode }: PolygonLayerProps) => { + if (!entities || entities.length === 0) { return null; } + const activeEntity = entities.find(entity => entity.code === entityCode); + const childEntities = entities.filter(entity => entity.parentCode === entityCode); + return ( <> - - - + {activeEntity && ( + <> + + + + )} + ); }; diff --git a/packages/tupaia-web/src/types/types.d.ts b/packages/tupaia-web/src/types/types.d.ts index 2782020e5d..50a2e36844 100644 --- a/packages/tupaia-web/src/types/types.d.ts +++ b/packages/tupaia-web/src/types/types.d.ts @@ -80,6 +80,7 @@ export type Entity = KeysToCamelCase; export type EntityResponse = Entity & { parentCode: Entity['code']; + childCodes: Entity['code'][]; photoUrl?: string; children?: Entity[]; };