Skip to content

Commit

Permalink
Implement network calls to the backend (#9)
Browse files Browse the repository at this point in the history
* feat: server-side requests

* feat: add client side hooks

* docs: readme update
  • Loading branch information
Tschonti authored Nov 4, 2024
1 parent bc32d22 commit 996c0ee
Show file tree
Hide file tree
Showing 21 changed files with 231 additions and 38 deletions.
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ This is the repository for the World Food Program's HungerMap.
- [next-themes](https://github.com/pacocoursey/next-themes)
- [leaflet](https://leafletjs.com/), [react-leaflet](https://react-leaflet.js.org/)
- [Highcharts](https://www.highcharts.com/), [highcharts-react](https://github.com/highcharts/highcharts-react)
- [Tanstack Query](https://tanstack.com/query/latest)

## How to Use

Expand Down Expand Up @@ -65,7 +66,5 @@ What this means for a React application, is that instead of using one global sta

The point of DI is that the dependencies of services should not be hardcoded, instead they should receive them as parameters, where the type of the parameter is an interface. This makes testing easier and the code more reusable, since the same service can be used with different implementations of the same interface (for example mock implementations for testing).

For a React application, the most common depeendency that needs mocking is the one that makes the HTTP call to the API. Thus contexts or hooks that call the backend should receive the HTTP library implementation as a prop or from a higher-order context.

- Example of how to use the container to resolve a dependency can be found in `src/app/elements/page.tsx`.
- A list of all dependencies and their implementations can be found in `src/container.tsx`.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@nextui-org/theme": "2.2.11",
"@react-aria/ssr": "3.9.4",
"@react-aria/visually-hidden": "3.8.12",
"@tanstack/react-query": "^5.59.17",
"clsx": "2.1.1",
"framer-motion": "~11.1.1",
"highcharts": "^11.4.8",
Expand Down
10 changes: 8 additions & 2 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import MapLoader from '@/components/Map/MapLoader';
import container from '@/container';
import { GlobalDataRepository } from '@/domain/repositories/GlobalDataRepository';

export default function Home() {
return <MapLoader />;
export default async function Home() {
const globalRepo = container.resolve<GlobalDataRepository>('GlobalDataRepository');
const countryMapDataPromise = globalRepo.getMapDataForCountries();
const disputedAreasPromise = globalRepo.getDisputedAreas();
const [countryMapData, disputedAreas] = await Promise.all([countryMapDataPromise, disputedAreasPromise]);
return <MapLoader countries={countryMapData} disputedAreas={disputedAreas} />;
}
21 changes: 21 additions & 0 deletions src/components/Map/CountryPolygon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useState } from 'react';
import { Polygon } from 'react-leaflet';

import { CountryMapData } from '@/domain/entities/country/CountryMapData';

import { CountryPopup } from './CountryPopup';

export function CountryPolygon({ country }: { country: CountryMapData }) {
const [active, setActive] = useState(false);

return (
<>
<Polygon
pathOptions={{ color: 'purple' }}
positions={country.geometry.coordinates}
eventHandlers={{ click: () => setActive(true) }}
/>
{active && <CountryPopup country={country} onClose={() => setActive(false)} />}
</>
);
}
20 changes: 20 additions & 0 deletions src/components/Map/CountryPopup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Popup } from 'react-leaflet';

import { CountryMapData } from '@/domain/entities/country/CountryMapData';
import { useCountryDataQuery } from '@/domain/hooks/countryHooks';

/**
* This is just an example of how the sidebars could fetch the data when a country is clicked
*/
export function CountryPopup({ country, onClose }: { country: CountryMapData; onClose: () => void }) {
const { data, isPending } = useCountryDataQuery(country.properties.adm0_id);

return (
<Popup
position={{ lat: country.properties.centroid.latitude, lng: country.properties.centroid.longitude }}
eventHandlers={{ remove: onClose }}
>
FCS: {isPending || !data ? 'Loading...' : data.fcs}
</Popup>
);
}
12 changes: 11 additions & 1 deletion src/components/Map/Map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,23 @@ import 'leaflet/dist/leaflet.css';

import { MapContainer, TileLayer } from 'react-leaflet';

export default function Map() {
import { MapProps } from '@/domain/props/MapProps';

import { CountryPolygon } from './CountryPolygon';

export default function Map({ countries }: MapProps) {
return (
<MapContainer center={[21.505, -0.09]} zoom={4} style={{ height: '100%', width: '100%' }}>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{countries.features
.filter((c) => c.properties.interactive)
.map((c) => (
// TODO fix the layout, this is just an example
<CountryPolygon country={c} key={c.properties.adm0_id} />
))}
</MapContainer>
);
}
5 changes: 3 additions & 2 deletions src/components/Map/MapLoader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
import dynamic from 'next/dynamic';

import MapSkeleton from '@/components/Map/MapSkeleton';
import { MapProps } from '@/domain/props/MapProps';

const LazyMap = dynamic(() => import('@/components/Map/Map'), {
ssr: false,
loading: () => <MapSkeleton />,
});

export default function MapLoader() {
return <LazyMap />;
export default function MapLoader(props: MapProps) {
return <LazyMap {...props} />;
}
15 changes: 15 additions & 0 deletions src/config/queryClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { QueryClient } from '@tanstack/react-query';

/**
* Only refetches data if it's more than 1 hour old
*/
export const cachedQueryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
gcTime: 1000 * 60 * 60,
},
},
});
4 changes: 3 additions & 1 deletion src/domain/entities/common/Geometry.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { LatLngExpression } from 'leaflet';

export interface Geometry {
type: string;
coordinates: number[][][]; // Maybe a common type is not best idea here, the coordinate arrays seem to have different dephts.
coordinates: LatLngExpression[][][]; // Maybe a common type is not best idea here, the coordinate arrays seem to have different dephts.
}
2 changes: 1 addition & 1 deletion src/domain/entities/common/ResponseWrapper.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export interface ResponseWrapper<T> {
statusCode: string;
body: T[];
body: T;
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import { Coordinate } from './common/Coordinate';
import { Feature } from './common/Feature';
import { Coordinate } from '../common/Coordinate';
import { Feature } from '../common/Feature';

/**
* Not really sure what this represents...
*/
export type Adm0Data = Feature<{
export type CountryMapData = Feature<{
OBJECTID: number;
adm0_name: string;
map_lab: string;
Expand Down Expand Up @@ -39,3 +36,7 @@ export type Adm0Data = Feature<{
centroid: Coordinate;
dataType?: string;
}>;
export interface CountryMapDataWrapper {
type: string;
features: CountryMapData[];
}
28 changes: 28 additions & 0 deletions src/domain/hooks/alertHooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useQuery } from '@tanstack/react-query';

import { cachedQueryClient } from '@/config/queryClient';
import container from '@/container';

import { Conflict } from '../entities/alerts/Conflict';
import { Hazard } from '../entities/alerts/Hazard';
import { AlertRepository } from '../repositories/AlertRepository';

const alertsRepo = container.resolve<AlertRepository>('AlertRepository');

export const useConflictQuery = () =>
useQuery<Conflict[]>(
{
queryKey: ['fetchConflicts'],
queryFn: alertsRepo.getConflictData,
},
cachedQueryClient
);

export const useHazardQuery = () =>
useQuery<Hazard[]>(
{
queryKey: ['fetchHazards'],
queryFn: alertsRepo.getHazardData,
},
cachedQueryClient
);
48 changes: 48 additions & 0 deletions src/domain/hooks/countryHooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { useQuery } from '@tanstack/react-query';

import { cachedQueryClient } from '@/config/queryClient';
import container from '@/container';

import { AdditionalCountryData } from '../entities/country/AdditionalCountryData';
import { CountryData } from '../entities/country/CountryData';
import { CountryIso3Data } from '../entities/country/CountryIso3Data';
import { RegionIpc } from '../entities/region/RegionIpc';
import CountryRepository from '../repositories/CountryRepository';

const countryRepo = container.resolve<CountryRepository>('CountryRepository');

export const useCountryDataQuery = (countryId: number) =>
useQuery<CountryData, Error>(
{
queryKey: ['fetchCountryData', countryId],
queryFn: async () => countryRepo.getCountryData(countryId),
},
cachedQueryClient
);

export const useRegionDataQuery = (countryId: number) =>
useQuery<AdditionalCountryData, Error>(
{
queryKey: ['fetchRegionData', countryId],
queryFn: async () => countryRepo.getRegionData(countryId),
},
cachedQueryClient
);

export const useCountryIso3DataQuery = (countryCode: string) =>
useQuery<CountryIso3Data, Error>(
{
queryKey: ['fetchCountryIso3Data', countryCode],
queryFn: async () => countryRepo.getCountryIso3Data(countryCode),
},
cachedQueryClient
);

export const useRegionIpcDataQuery = (countryId: number) =>
useQuery<RegionIpc, Error>(
{
queryKey: ['fetchRegionIpcData', countryId],
queryFn: async () => countryRepo.getRegionIpcData(countryId),
},
cachedQueryClient
);
18 changes: 18 additions & 0 deletions src/domain/hooks/globalHooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/react-query';

import { cachedQueryClient } from '@/config/queryClient';
import container from '@/container';

import { CountryIpcData } from '../entities/country/CountryIpcData';
import { GlobalDataRepository } from '../repositories/GlobalDataRepository';

const globalRepo = container.resolve<GlobalDataRepository>('GlobalDataRepository');

export const useIpcQuery = () =>
useQuery<CountryIpcData[]>(
{
queryKey: ['fetchIpcData'],
queryFn: globalRepo.getIpcData,
},
cachedQueryClient
);
7 changes: 7 additions & 0 deletions src/domain/props/MapProps.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { CountryMapDataWrapper } from '../entities/country/CountryMapData';
import { DisputedAreas } from '../entities/DisputedAreas';

export interface MapProps {
countries: CountryMapDataWrapper;
disputedAreas: DisputedAreas;
}
6 changes: 0 additions & 6 deletions src/domain/repositories/CountryRepository.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
import { AdditionalCountryData } from '../entities/country/AdditionalCountryData';
import { CountryCodesData } from '../entities/country/CountryCodesData';
import { CountryData } from '../entities/country/CountryData';
import { CountryIso3Data } from '../entities/country/CountryIso3Data';
import { CountryIso3Notes } from '../entities/country/CountryIso3Notes';
import { CountryMimiData } from '../entities/country/CountryMimiData';
import { RegionIpc } from '../entities/region/RegionIpc';

export default interface CountryRepository {
/**
* Returns the ID and ISO codes of all the countries, plus a summary report for each.
*/
getCountryCodes(): Promise<CountryCodesData[]>;

/**
* Returns the population, FCS and RCS data and news about a country
* @param countryId
Expand Down
12 changes: 9 additions & 3 deletions src/domain/repositories/GlobalDataRepository.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
import { Adm0Data } from '../entities/Adm0Data';
import { ChangeLogItem } from '../entities/ChangeLogItem';
import { CountryCodesData } from '../entities/country/CountryCodesData';
import { CountryIpcData } from '../entities/country/CountryIpcData';
import { CountryMapDataWrapper } from '../entities/country/CountryMapData';
import { DisputedAreas } from '../entities/DisputedAreas';
import { YearInReview } from '../entities/YearInReview';

export interface GlobalDataRepository {
/**
* Returns the ID and ISO codes of all the countries, plus a summary report for each.
*/
getCountryCodes(): Promise<CountryCodesData[]>;
/**
* Returns the national IPC data of all the countries
*/
getIpcData(): Promise<CountryIpcData[]>;

/**
* Not sure what this returns :/
* Returns the polygons for the countries and whether any alerts are active in the country
* (The alerts shown on the Current Food Consuption map, e.g. countries with >=1 fatality...)
*/
getAdm0Data(): Promise<Adm0Data[]>;
getMapDataForCountries(): Promise<CountryMapDataWrapper>;

/**
* Returns all the disputed areas around the world
Expand Down
2 changes: 1 addition & 1 deletion src/infrastructure/repositories/AlertRepositoryImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { AlertRepository } from '@/domain/repositories/AlertRepository';
export class AlertRepositoryImpl implements AlertRepository {
async getHazardData(): Promise<Hazard[]> {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/pdc.json`);
const data: ResponseWrapper<Hazard> = await response.json();
const data: ResponseWrapper<Hazard[]> = await response.json();
return data.body;
}

Expand Down
6 changes: 0 additions & 6 deletions src/infrastructure/repositories/CountryRepositoryImpl.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { AdditionalCountryData } from '@/domain/entities/country/AdditionalCountryData';
import { CountryCodesData } from '@/domain/entities/country/CountryCodesData';
import { CountryData } from '@/domain/entities/country/CountryData';
import { CountryIso3Data } from '@/domain/entities/country/CountryIso3Data';
import { CountryIso3Notes } from '@/domain/entities/country/CountryIso3Notes';
Expand All @@ -8,11 +7,6 @@ import { RegionIpc } from '@/domain/entities/region/RegionIpc';
import CountryRepository from '@/domain/repositories/CountryRepository';

export default class CountryRepositoryImpl implements CountryRepository {
async getCountryCodes(): Promise<CountryCodesData[]> {
const response = await fetch(`https://static.hungermapdata.org/insight-reports/latest/country.json`);
return response.json();
}

async getCountryData(countryId: number): Promise<CountryData> {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/adm0/${countryId}/countryData.json`);
return response.json();
Expand Down
Loading

0 comments on commit 996c0ee

Please sign in to comment.