From d7f5f53b9f82354f62b98aefcb7f86f949b7720d Mon Sep 17 00:00:00 2001 From: frozenhelium Date: Wed, 18 Sep 2024 13:48:55 +0545 Subject: [PATCH 01/59] Improve cyclone footprint visualization in imminent events - Visualize storm position, forecast uncertainty, track line and exposed area differently - Add option to toggle visibility of different layers - Update styling for event list items - Update styling for event details --- .changeset/bright-mayflies-punch.md | 10 ++ .changeset/little-adults-sin.md | 8 + app/package.json | 2 +- .../LayerOptions/index.tsx | 139 ++++++++++++++++ .../LayerOptions/styles.module.css | 21 +++ .../domain/RiskImminentEventMap/index.tsx | 149 ++++++++++++++++-- .../domain/RiskImminentEventMap/mapStyles.ts | 145 +++++++++++++++-- .../RiskImminentEventMap/styles.module.css | 21 ++- .../domain/RiskImminentEventMap/utils.ts | 52 ++++++ .../Gdacs/EventDetails/index.tsx | 48 ++++-- .../Gdacs/EventDetails/styles.module.css | 19 --- .../Gdacs/EventListItem/index.tsx | 14 +- .../domain/RiskImminentEvents/Gdacs/index.tsx | 109 +++++++++++-- .../MeteoSwiss/EventListItem/index.tsx | 10 +- .../RiskImminentEvents/MeteoSwiss/index.tsx | 40 ++++- .../Pdc/EventDetails/i18n.json | 2 +- .../Pdc/EventDetails/index.tsx | 146 ++++++++--------- .../Pdc/EventDetails/styles.module.css | 23 --- .../Pdc/EventListItem/index.tsx | 12 +- .../domain/RiskImminentEvents/Pdc/index.tsx | 42 ++++- .../WfpAdam/EventDetails/index.tsx | 29 ++-- .../WfpAdam/EventListItem/index.tsx | 12 +- .../RiskImminentEvents/WfpAdam/index.tsx | 58 ++++++- .../RiskImminentEvents/styles.module.css | 3 +- app/src/hooks/useSetFieldValue.ts | 25 +++ app/src/utils/constants.ts | 1 + app/src/utils/domain/risk.ts | 14 +- packages/ui/src/components/Checkbox/index.tsx | 3 + .../src/components/Checkbox/styles.module.css | 9 +- .../ui/src/components/Container/index.tsx | 14 +- .../components/Container/styles.module.css | 10 ++ packages/ui/src/components/Legend/index.tsx | 8 +- packages/ui/src/components/Switch/index.tsx | 21 ++- .../src/components/Switch/styles.module.css | 10 +- .../ui/src/components/TextOutput/index.tsx | 10 +- .../components/TextOutput/styles.module.css | 5 + yarn.lock | 8 +- 37 files changed, 1006 insertions(+), 246 deletions(-) create mode 100644 .changeset/bright-mayflies-punch.md create mode 100644 .changeset/little-adults-sin.md create mode 100644 app/src/components/domain/RiskImminentEventMap/LayerOptions/index.tsx create mode 100644 app/src/components/domain/RiskImminentEventMap/LayerOptions/styles.module.css create mode 100644 app/src/components/domain/RiskImminentEventMap/utils.ts delete mode 100644 app/src/components/domain/RiskImminentEvents/Gdacs/EventDetails/styles.module.css delete mode 100644 app/src/components/domain/RiskImminentEvents/Pdc/EventDetails/styles.module.css create mode 100644 app/src/hooks/useSetFieldValue.ts diff --git a/.changeset/bright-mayflies-punch.md b/.changeset/bright-mayflies-punch.md new file mode 100644 index 0000000000..ec661494fb --- /dev/null +++ b/.changeset/bright-mayflies-punch.md @@ -0,0 +1,10 @@ +--- +"go-web-app": patch +--- + +Revamp risk imminent events for cyclone + - Visualize storm position, forecast uncertainty, track line and exposed area differently + - Add option to toggle visibility of these different layers + - Add severity legend for exposure + - Update styling for items in event list + - Update styling for event details page diff --git a/.changeset/little-adults-sin.md b/.changeset/little-adults-sin.md new file mode 100644 index 0000000000..8f4bd0f9ef --- /dev/null +++ b/.changeset/little-adults-sin.md @@ -0,0 +1,8 @@ +--- +"@ifrc-go/ui": patch +--- + +- Add support for background in Checkbox, TextOutput +- Add support for inverted view in Switch +- Add new view withBorderAndHeaderBackground in Container +- Add option to set className for label and list container in Legend diff --git a/app/package.json b/app/package.json index 7c49203e2b..9e056d7f47 100644 --- a/app/package.json +++ b/app/package.json @@ -49,7 +49,7 @@ "@togglecorp/fujs": "^2.1.1", "@togglecorp/re-map": "^0.2.0-beta-6", "@togglecorp/toggle-form": "^2.0.4", - "@togglecorp/toggle-request": "^1.0.0-beta.2", + "@togglecorp/toggle-request": "^1.0.0-beta.3", "@turf/bbox": "^6.5.0", "@turf/buffer": "^6.5.0", "exceljs": "^4.3.0", diff --git a/app/src/components/domain/RiskImminentEventMap/LayerOptions/index.tsx b/app/src/components/domain/RiskImminentEventMap/LayerOptions/index.tsx new file mode 100644 index 0000000000..45bc46dbed --- /dev/null +++ b/app/src/components/domain/RiskImminentEventMap/LayerOptions/index.tsx @@ -0,0 +1,139 @@ +import { useMemo } from 'react'; +import { + Container, + Legend, + Switch, +} from '@ifrc-go/ui'; + +import useSetFieldValue from '#hooks/useSetFieldValue'; +import { + COLOR_DARK_GREY, + COLOR_GREEN, + COLOR_ORANGE, + COLOR_RED, +} from '#utils/constants'; + +import { RiskLayerSeverity } from '../utils'; + +import styles from './styles.module.css'; + +export interface LayerOptionsValue { + showStormPosition: boolean; + showForecastUncertainty: boolean; + showTrackLine: boolean; + showExposedArea: boolean; +} + +interface SeverityLegendItem { + severity: RiskLayerSeverity; + label: string; + color: string; +} + +function severitySelector(item: SeverityLegendItem) { + return item.severity; +} +function labelSelector(item: SeverityLegendItem) { + return item.label; +} +function colorSelector(item: SeverityLegendItem) { + return item.color; +} + +interface Props { + value: LayerOptionsValue; + onChange: React.Dispatch>; +} + +function LayerOptions(props: Props) { + const { + value, + onChange, + } = props; + + const setFieldValue = useSetFieldValue(onChange); + + // FIXME: use strings + const severityLegendItems = useMemo(() => ([ + { + severity: 'green', + label: 'Green', + color: COLOR_GREEN, + }, + { + severity: 'orange', + label: 'Orange', + color: COLOR_ORANGE, + }, + { + severity: 'red', + label: 'Red', + color: COLOR_RED, + }, + { + severity: 'unknown', + label: 'Unknown', + color: COLOR_DARK_GREY, + }, + ]), []); + + return ( + + +
+ + {value.showExposedArea && ( + + )} +
+ + +
+ ); +} + +export default LayerOptions; diff --git a/app/src/components/domain/RiskImminentEventMap/LayerOptions/styles.module.css b/app/src/components/domain/RiskImminentEventMap/LayerOptions/styles.module.css new file mode 100644 index 0000000000..3de1362016 --- /dev/null +++ b/app/src/components/domain/RiskImminentEventMap/LayerOptions/styles.module.css @@ -0,0 +1,21 @@ +.layer-options { + .exposed-area-input-wrapper { + display: flex; + gap: var(--go-ui-spacing-sm); + flex-direction: column; + background-color: var(--go-ui-color-background); + padding: var(--go-ui-spacing-sm); + + .exposed-area-legend { + display: flex; + flex-direction: column; + gap: unset; + + .legend-label { + color: var(--go-ui-color-text-light); + font-size: var(--go-ui-font-size-sm); + font-weight: var(--go-ui-font-weight-medium); + } + } + } +} diff --git a/app/src/components/domain/RiskImminentEventMap/index.tsx b/app/src/components/domain/RiskImminentEventMap/index.tsx index 2f66326ff8..e36eca74d0 100644 --- a/app/src/components/domain/RiskImminentEventMap/index.tsx +++ b/app/src/components/domain/RiskImminentEventMap/index.tsx @@ -16,10 +16,13 @@ import { mapToList, } from '@togglecorp/fujs'; import { + getLayerName, MapBounds, MapImage, MapLayer, + MapOrder, MapSource, + MapState, } from '@togglecorp/re-map'; import getBbox from '@turf/bbox'; import getBuffer from '@turf/buffer'; @@ -38,17 +41,27 @@ import { DURATION_MAP_ZOOM, } from '#utils/constants'; +import LayerOptions, { LayerOptionsValue } from './LayerOptions'; import { + activeHazardPointLayer, exposureFillLayer, + exposureFillOutlineLayer, geojsonSourceOptions, hazardKeyToIconmap, hazardPointIconLayout, hazardPointLayer, + invisibleCircleLayer, + invisibleFillLayer, invisibleLayout, + invisibleLineLayer, + invisibleSymbolLayer, trackArrowLayer, - trackOutlineLayer, + trackLineLayer, trackPointLayer, + trackPointOuterCircleLayer, + uncertaintyConeLayer, } from './mapStyles'; +import { RiskLayerProperties } from './utils'; import i18n from './i18n.json'; import styles from './styles.module.css'; @@ -73,27 +86,30 @@ type EventPointProperties = { export type EventPointFeature = GeoJSON.Feature; -interface EventItemProps { +export interface RiskEventListItemProps { data: EVENT; onExpandClick: (eventId: number | string) => void; + className?: string; } -interface EventDetailProps { +export interface RiskEventDetailProps { data: EVENT; exposure: EXPOSURE | undefined; pending: boolean; + children?: React.ReactNode; } -type Footprint = GeoJSON.FeatureCollection | undefined; +type Footprint = GeoJSON.FeatureCollection | undefined; interface Props { events: EVENT[] | undefined; keySelector: (event: EVENT) => KEY; + hazardTypeSelector: (event: EVENT) => HazardType | '' | undefined; pointFeatureSelector: (event: EVENT) => EventPointFeature | undefined; footprintSelector: (activeEventExposure: EXPOSURE | undefined) => Footprint | undefined; activeEventExposure: EXPOSURE | undefined; - listItemRenderer: React.ComponentType>; - detailRenderer: React.ComponentType>; + listItemRenderer: React.ComponentType>; + detailRenderer: React.ComponentType>; pending: boolean; sidePanelHeading: React.ReactNode; bbox: LngLatBoundsLike | undefined; @@ -114,6 +130,7 @@ function RiskImminentEventMap< detailRenderer, pending, activeEventExposure, + hazardTypeSelector, footprintSelector, sidePanelHeading, bbox, @@ -124,6 +141,12 @@ function RiskImminentEventMap< const strings = useTranslation(i18n); const [activeEventId, setActiveEventId] = useState(undefined); + const [layerOptions, setLayerOptions] = useState({ + showStormPosition: true, + showForecastUncertainty: true, + showTrackLine: true, + showExposedArea: true, + }); const activeEvent = useMemo( () => { if (isNotDefined(activeEventId)) { @@ -137,6 +160,18 @@ function RiskImminentEventMap< [activeEventId, keySelector, events], ); + const eventVisibilityAttributes = useMemo( + () => events?.map((event) => { + const key = keySelector(event); + + return { + id: key, + value: isNotDefined(activeEventId) || activeEventId === key, + }; + }), + [events, activeEventId, keySelector], + ); + const activeEventFootprint = useMemo( () => { if (isNotDefined(activeEventId) || activeEventExposurePending) { @@ -184,10 +219,21 @@ function RiskImminentEventMap< () => ({ type: 'FeatureCollection' as const, features: events?.map( - pointFeatureSelector, + (event) => { + const feature = pointFeatureSelector(event); + + if (isNotDefined(feature)) { + return undefined; + } + + return { + ...feature, + id: keySelector(event), + }; + }, ).filter(isDefined) ?? [], }), - [events, pointFeatureSelector], + [events, pointFeatureSelector, keySelector], ); const setActiveEventIdSafe = useCallback( @@ -210,9 +256,10 @@ function RiskImminentEventMap< ); const eventListRendererParams = useCallback( - (_: string | number, event: EVENT): EventItemProps => ({ + (_: string | number, event: EVENT): RiskEventListItemProps => ({ data: event, onExpandClick: setActiveEventIdSafe, + className: styles.riskEventListItem, }), [setActiveEventIdSafe], ); @@ -242,7 +289,18 @@ function RiskImminentEventMap< const hazardPointIconLayer = useMemo>( () => ({ type: 'symbol', - paint: { 'icon-color': COLOR_WHITE }, + paint: { + 'icon-color': COLOR_WHITE, + 'icon-opacity': [ + 'case', + ['boolean', ['feature-state', 'eventVisible'], true], + 1, + 0, + ], + 'icon-opacity-transition': { + duration: 200, + }, + }, layout: allIconsLoaded ? hazardPointIconLayout : invisibleLayout, }), [allIconsLoaded], @@ -274,7 +332,6 @@ function RiskImminentEventMap< /> ); })} - {/* FIXME: footprint layer should always be the bottom layer */} {activeEventFootprint && ( + + + + )} @@ -313,7 +400,27 @@ function RiskImminentEventMap< layerKey="hazard-points-icon" layerOptions={hazardPointIconLayer} /> + + {boundsSafe && ( + > + {hazardTypeSelector(activeEvent) === 'TC' && ( + + )} + )} diff --git a/app/src/components/domain/RiskImminentEventMap/mapStyles.ts b/app/src/components/domain/RiskImminentEventMap/mapStyles.ts index 206d758d77..b537f1a060 100644 --- a/app/src/components/domain/RiskImminentEventMap/mapStyles.ts +++ b/app/src/components/domain/RiskImminentEventMap/mapStyles.ts @@ -5,6 +5,7 @@ import { import type { CircleLayer, CirclePaint, + Expression, FillLayer, Layout, LineLayer, @@ -20,10 +21,19 @@ import wildfireIcon from '#assets/icons/risk/wildfire.png'; import { type components } from '#generated/riskTypes'; import { COLOR_BLACK, - COLOR_PRIMARY_BLUE, + COLOR_DARK_GREY, + COLOR_GREEN, + COLOR_ORANGE, + COLOR_RED, + COLOR_WHITE, } from '#utils/constants'; import { hazardTypeToColorMap } from '#utils/domain/risk'; +import { + type RiskLayerSeverity, + type RiskLayerTypes, +} from './utils'; + type HazardType = components<'read'>['schemas']['HazardTypeEnum']; export const hazardKeyToIconmap: Record = { @@ -51,6 +61,18 @@ const iconImage: SymbolLayout['icon-image'] = [ '', ]; +const severityColorStyle: Expression = [ + 'match', + ['get', 'severity'], + 'red' satisfies RiskLayerSeverity, + COLOR_RED, + 'orange' satisfies RiskLayerSeverity, + COLOR_ORANGE, + 'green' satisfies RiskLayerSeverity, + COLOR_GREEN, + COLOR_DARK_GREY, +]; + export const geojsonSourceOptions: mapboxgl.GeoJSONSourceRaw = { type: 'geojson' }; export const hazardTypeColorPaint: CirclePaint['circle-color'] = [ 'match', @@ -59,19 +81,68 @@ export const hazardTypeColorPaint: CirclePaint['circle-color'] = [ COLOR_BLACK, ]; -export const hazardPointLayer: Omit = { +export const activeHazardPointLayer: Omit = { type: 'circle', + filter: [ + '==', + ['get', 'type'], + 'hazard-point' satisfies RiskLayerTypes, + ], paint: { 'circle-radius': 12, - 'circle-color': hazardTypeColorPaint, + 'circle-color': severityColorStyle, 'circle-opacity': 1, }, }; +export const hazardPointLayer: Omit = { + type: 'circle', + paint: { + 'circle-radius': [ + 'case', + ['boolean', ['feature-state', 'eventVisible'], true], + 12, + 0, + ], + 'circle-radius-transition': { + delay: 200, + duration: 200, + }, + 'circle-color': hazardTypeColorPaint, + 'circle-opacity': [ + 'case', + ['boolean', ['feature-state', 'eventVisible'], true], + 1, + 0, + ], + 'circle-opacity-transition': { duration: 200 }, + }, +}; + export const invisibleLayout: Layout = { visibility: 'none', }; +export const invisibleFillLayer: Omit = { + type: 'fill', + layout: invisibleLayout, +}; + +export const invisibleLineLayer: Omit = { + type: 'line', + layout: invisibleLayout, +}; + +export const invisibleSymbolLayer: Omit = { + type: 'symbol', + layout: invisibleLayout, +}; + +export const invisibleCircleLayer: Omit = { + type: 'circle', + layout: invisibleLayout, +}; + export const hazardPointIconLayout: SymbolLayout = { visibility: 'visible', 'icon-image': iconImage, @@ -84,25 +155,58 @@ export const exposureFillLayer: Omit = { filter: [ '==', ['get', 'type'], - 'exposure', + 'exposure' satisfies RiskLayerTypes, ], paint: { - 'fill-color': COLOR_PRIMARY_BLUE, - 'fill-opacity': 0.3, + 'fill-color': severityColorStyle, + 'fill-opacity': 0.4, }, + layout: { visibility: 'visible' }, }; -export const trackOutlineLayer: Omit = { +export const exposureFillOutlineLayer: Omit = { type: 'line', filter: [ '==', ['get', 'type'], - 'track', + 'exposure' satisfies RiskLayerTypes, + ], + paint: { + 'line-color': COLOR_WHITE, + 'line-width': 1, + 'line-opacity': 1, + }, + layout: { visibility: 'visible' }, +}; + +export const uncertaintyConeLayer: Omit = { + type: 'line', + filter: [ + '==', + ['get', 'type'], + 'uncertainty-cone' satisfies RiskLayerTypes, + ], + paint: { + 'line-color': COLOR_BLACK, + 'line-opacity': 1, + 'line-width': 1, + 'line-dasharray': [5, 7], + }, + layout: { visibility: 'visible' }, +}; + +export const trackLineLayer: Omit = { + type: 'line', + filter: [ + '==', + ['get', 'type'], + 'track-linestring' satisfies RiskLayerTypes, ], paint: { 'line-color': COLOR_BLACK, 'line-opacity': 0.5, }, + layout: { visibility: 'visible' }, }; export const trackArrowLayer: Omit = { @@ -110,13 +214,14 @@ export const trackArrowLayer: Omit = { filter: [ '==', ['get', 'type'], - 'track', + 'track-linestring' satisfies RiskLayerTypes, ], paint: { 'icon-color': COLOR_BLACK, 'icon-opacity': 0.6, }, layout: { + visibility: 'visible', 'icon-allow-overlap': true, 'symbol-placement': 'line', 'icon-image': 'triangle-11', @@ -130,11 +235,27 @@ export const trackPointLayer: Omit = { filter: [ '==', ['get', 'type'], - 'track-point', + 'track-point' satisfies RiskLayerTypes, + ], + paint: { + 'circle-radius': 3, + 'circle-color': COLOR_BLACK, + 'circle-opacity': 1, + }, + layout: { visibility: 'visible' }, +}; + +export const trackPointOuterCircleLayer: Omit = { + type: 'circle', + filter: [ + '==', + ['get', 'type'], + 'track-point' satisfies RiskLayerTypes, ], paint: { - 'circle-radius': 4, + 'circle-radius': 7, 'circle-color': COLOR_BLACK, - 'circle-opacity': 0.5, + 'circle-opacity': 0.3, }, + layout: { visibility: 'visible' }, }; diff --git a/app/src/components/domain/RiskImminentEventMap/styles.module.css b/app/src/components/domain/RiskImminentEventMap/styles.module.css index 95628f04ab..15a3d78a63 100644 --- a/app/src/components/domain/RiskImminentEventMap/styles.module.css +++ b/app/src/components/domain/RiskImminentEventMap/styles.module.css @@ -6,7 +6,9 @@ .side-panel { flex-basis: calc(14vw + 16rem); - background-color: var(--go-ui-color-background); + margin: var(--go-ui-spacing-sm) var(--go-ui-spacing-sm) var(--go-ui-spacing-sm) 0; + border-radius: var(--go-ui-border-radius-md); + box-shadow: var(--go-ui-box-shadow-sm); .icon { font-size: var(--go-ui-height-icon-multiplier); @@ -18,7 +20,17 @@ .event-list { display: flex; flex-direction: column; - gap: var(--go-ui-spacing-md); + gap: var(--go-ui-spacing-xs); + + .risk-event-list-item { + border: var(--go-ui-width-separator-sm) solid transparent; + background-color: var(--go-ui-color-background); + padding: var(--go-ui-spacing-sm); + + &:hover { + border-color: var(--go-ui-color-separator); + } + } } } } @@ -63,6 +75,11 @@ .side-panel { flex-basis: unset; + margin: unset; + border-radius: unset; + box-shadow: unset; + max-height: 70vh; + overflow: auto; } .map-container { diff --git a/app/src/components/domain/RiskImminentEventMap/utils.ts b/app/src/components/domain/RiskImminentEventMap/utils.ts new file mode 100644 index 0000000000..899425110e --- /dev/null +++ b/app/src/components/domain/RiskImminentEventMap/utils.ts @@ -0,0 +1,52 @@ +export type RiskLayerTypes = 'hazard-point' +| 'track-point' +| 'track-point-boundary' +| 'track-linestring' +| 'uncertainty-cone' +| 'exposure' +| 'unknown'; + +export type RiskLayerSeverity = 'red' | 'orange' | 'green' | 'unknown'; + +interface BaseLayerProperties { + type: RiskLayerTypes; +} + +export interface HazardPointLayerProperties extends BaseLayerProperties { + type: 'hazard-point'; + severity: RiskLayerSeverity; +} + +export interface TrackPointLayerProperties extends BaseLayerProperties { + type: 'track-point'; +} + +export interface TrackPointBoundaryLayerProperties extends BaseLayerProperties { + type: 'track-point-boundary'; +} + +export interface TrackLinestringLayerProperties extends BaseLayerProperties { + type: 'track-linestring'; +} + +export interface UncertaintyConeLayerProperties extends BaseLayerProperties { + type: 'uncertainty-cone'; + forecastDays: number | undefined; +} + +export interface ExposureLayerProperties extends BaseLayerProperties { + type: 'exposure'; + severity: RiskLayerSeverity; +} + +export interface UnknownRiskLayerProperties extends BaseLayerProperties { + type: 'unknown'; +} + +export type RiskLayerProperties = HazardPointLayerProperties +| TrackPointLayerProperties +| TrackPointBoundaryLayerProperties +| TrackLinestringLayerProperties +| UncertaintyConeLayerProperties +| ExposureLayerProperties +| UnknownRiskLayerProperties; diff --git a/app/src/components/domain/RiskImminentEvents/Gdacs/EventDetails/index.tsx b/app/src/components/domain/RiskImminentEvents/Gdacs/EventDetails/index.tsx index aa6906e026..e4b3906b1d 100644 --- a/app/src/components/domain/RiskImminentEvents/Gdacs/EventDetails/index.tsx +++ b/app/src/components/domain/RiskImminentEvents/Gdacs/EventDetails/index.tsx @@ -1,16 +1,15 @@ import { - BlockLoading, Container, TextOutput, } from '@ifrc-go/ui'; import { useTranslation } from '@ifrc-go/ui/hooks'; import { isDefined } from '@togglecorp/fujs'; +import { type RiskEventDetailProps } from '#components/domain/RiskImminentEventMap'; import Link from '#components/Link'; import { type RiskApiResponse } from '#utils/restRequest'; import i18n from './i18n.json'; -import styles from './styles.module.css'; type GdacsResponse = RiskApiResponse<'/api/v1/gdacs/'>; type GdacsItem = NonNullable[number]; @@ -57,6 +56,7 @@ interface GdacsEventDetails { geometry?: string; }, } + interface GdacsPopulationExposure { death?: number; displaced?: number; @@ -65,11 +65,7 @@ interface GdacsPopulationExposure { impact?: string; } -interface Props { - data: GdacsItem; - exposure: GdacsExposure | undefined; - pending: boolean; -} +type Props = RiskEventDetailProps; function EventDetails(props: Props) { const { @@ -80,6 +76,7 @@ function EventDetails(props: Props) { }, exposure, pending, + children, } = props; const strings = useTranslation(i18n); @@ -89,26 +86,30 @@ function EventDetails(props: Props) { return ( )} + withBorderAndHeaderBackground + pending={pending} > - {pending && } -
+ {isDefined(eventDetails?.source) && ( )} {isDefined(populationExposure?.death) && ( @@ -118,6 +119,8 @@ function EventDetails(props: Props) { maximumFractionDigits={2} compact valueType="number" + strongValue + withBackground /> )} {isDefined(populationExposure?.displaced) && ( @@ -127,24 +130,32 @@ function EventDetails(props: Props) { maximumFractionDigits={2} compact valueType="number" + strongValue + withBackground /> )} {isDefined(populationExposure?.exposed_population) && ( )} {isDefined(populationExposure?.people_affected) && ( )} {isDefined(populationExposure?.impact) && ( )} {isDefined(eventDetails?.severitydata) @@ -152,15 +163,19 @@ function EventDetails(props: Props) { )} {isDefined(eventDetails?.alertlevel) && ( )} -
+
{isDefined(eventDetails) && isDefined(eventDetails.url) && isDefined(eventDetails.url.report) @@ -173,6 +188,9 @@ function EventDetails(props: Props) { {strings.eventMoreDetailsLink} )} + {/* NOTE: Intentional additional div to maintain gap */} + {children &&
} + {children} ); } diff --git a/app/src/components/domain/RiskImminentEvents/Gdacs/EventDetails/styles.module.css b/app/src/components/domain/RiskImminentEvents/Gdacs/EventDetails/styles.module.css deleted file mode 100644 index 5f9eeca4f6..0000000000 --- a/app/src/components/domain/RiskImminentEvents/Gdacs/EventDetails/styles.module.css +++ /dev/null @@ -1,19 +0,0 @@ -.event-details { - .content { - display: flex; - flex-direction: column; - gap: var(--go-ui-spacing-lg); - - .useful-links-content { - display: flex; - flex-wrap: wrap; - gap: var(--go-ui-spacing-xs) var(--go-ui-spacing-md); - } - - .event-details { - display: flex; - flex-direction: column; - gap: var(--go-ui-spacing-xs); - } - } -} diff --git a/app/src/components/domain/RiskImminentEvents/Gdacs/EventListItem/index.tsx b/app/src/components/domain/RiskImminentEvents/Gdacs/EventListItem/index.tsx index 0a4f257ddf..c20a9d799b 100644 --- a/app/src/components/domain/RiskImminentEvents/Gdacs/EventListItem/index.tsx +++ b/app/src/components/domain/RiskImminentEvents/Gdacs/EventListItem/index.tsx @@ -5,19 +5,18 @@ import { TextOutput, } from '@ifrc-go/ui'; import { useTranslation } from '@ifrc-go/ui/hooks'; +import { _cs } from '@togglecorp/fujs'; +import { RiskEventListItemProps } from '#components/domain/RiskImminentEventMap'; import { type RiskApiResponse } from '#utils/restRequest'; import i18n from './i18n.json'; import styles from './styles.module.css'; type ImminentEventResponse = RiskApiResponse<'/api/v1/gdacs/'>; -type EventItem = NonNullable[number]; +type GdacsItem = NonNullable[number]; -interface Props { - data: EventItem; - onExpandClick: (eventId: string | number) => void; -} +type Props = RiskEventListItemProps; function EventListItem(props: Props) { const { @@ -27,13 +26,14 @@ function EventListItem(props: Props) { start_date, }, onExpandClick, + className, } = props; const strings = useTranslation(i18n); return (
)} - spacing="condensed" + spacing="cozy" > ; type EventItem = NonNullable[number]; -function getLayerType(geometryType: GeoJSON.Geometry['type']) { - if (geometryType === 'Point' || geometryType === 'MultiPoint') { - return 'track-point'; +function hazardTypeSelector(item: EventItem) { + return item.hazard_type; +} + +interface CommonFeatureProperties { + Class: string; +} + +type FeatureAlertLevel = 'Green' | 'Red' | 'Orange'; + +interface HazardPointFeatureProperties extends CommonFeatureProperties { + alertlevel: FeatureAlertLevel, +} + +const severityMapping: Record = { + Red: 'red', + Orange: 'orange', + Green: 'green', +}; + +// Currently observed classes for TC are +// Point_ are points +// Poly_ are polygons +// Line_ are linestrings +// Point_0 is track point +// Poly_Green is exposure polygon +// Poly_Polygon_Point_0 is circle around the Point_0 +// Line_Line_0 is a line from Point_0 to Point_1 +// Poly_Cones is cone of uncertainty +function getLayerProperties( + feature: GeoJSON.Feature, +): RiskLayerProperties { + if (isNotDefined(feature.properties) || !('Class' in feature.properties)) { + return { + type: 'unknown', + }; + } + + const { + Class: featureClass, + } = feature.properties; + + const splits = featureClass.split('_'); + + if (splits[0] === 'Point') { + if (splits[1] === 'Centroid') { + const severityStr = (feature.properties as HazardPointFeatureProperties).alertlevel; + + return { + type: 'hazard-point', + severity: severityMapping[severityStr] ?? 'unknown', + }; + } + + return { + type: 'track-point', + }; } - if (geometryType === 'LineString' || geometryType === 'MultiLineString') { - return 'track'; + if (splits[0] === 'Line') { + return { + type: 'track-linestring', + }; + } + + if (splits[0] === 'Poly') { + if (splits[1] === 'Cones') { + return { + type: 'uncertainty-cone', + forecastDays: undefined, + }; + } + + if (splits[1] === 'Red' || splits[1] === 'Orange' || splits[1] === 'Green') { + return { + type: 'exposure', + severity: severityMapping[splits[1]] ?? 'unknown', + }; + } + + if (splits[1] === 'Polygon' && splits[2] === 'Point') { + return { + type: 'track-point-boundary', + }; + } } - return 'exposure'; + return { + type: 'unknown', + }; } type BaseProps = { @@ -76,7 +160,7 @@ function Gdacs(props: Props) { const { response: exposureResponse, pending: exposureResponsePending, - trigger: getFootprint, + trigger: getExposureDetails, } = useRiskLazyRequest<'/api/v1/gdacs/{id}/exposure/', { eventId: number | string, }>({ @@ -135,7 +219,7 @@ function Gdacs(props: Props) { ? footprint_geojson : undefined; - const geoJson: GeoJSON.FeatureCollection = { + const geoJson: GeoJSON.FeatureCollection = { type: 'FeatureCollection' as const, features: [ ...footprint?.features?.map( @@ -143,7 +227,7 @@ function Gdacs(props: Props) { ...feature, properties: { ...feature.properties, - type: getLayerType(feature.geometry.type), + ...getLayerProperties(feature), }, }), ) ?? [], @@ -158,12 +242,12 @@ function Gdacs(props: Props) { const handleActiveEventChange = useCallback( (eventId: number | undefined) => { if (isDefined(eventId)) { - getFootprint({ eventId }); + getExposureDetails({ eventId }); } else { - getFootprint(undefined); + getExposureDetails(undefined); } }, - [getFootprint], + [getExposureDetails], ); return ( @@ -171,6 +255,7 @@ function Gdacs(props: Props) { events={countryRiskResponse?.results} pointFeatureSelector={pointFeatureSelector} keySelector={numericIdSelector} + hazardTypeSelector={hazardTypeSelector} listItemRenderer={EventListItem} detailRenderer={EventDetails} pending={pendingCountryRiskResponse} diff --git a/app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventListItem/index.tsx b/app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventListItem/index.tsx index f06a13dc1a..51b5f05ca3 100644 --- a/app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventListItem/index.tsx +++ b/app/src/components/domain/RiskImminentEvents/MeteoSwiss/EventListItem/index.tsx @@ -5,7 +5,9 @@ import { TextOutput, } from '@ifrc-go/ui'; import { useTranslation } from '@ifrc-go/ui/hooks'; +import { _cs } from '@togglecorp/fujs'; +import { RiskEventListItemProps } from '#components/domain/RiskImminentEventMap'; import { type RiskApiResponse } from '#utils/restRequest'; import i18n from './i18n.json'; @@ -14,10 +16,7 @@ import styles from './styles.module.css'; type ImminentEventResponse = RiskApiResponse<'/api/v1/meteoswiss/'>; type EventItem = NonNullable[number]; -interface Props { - data: EventItem; - onExpandClick: (eventId: string | number) => void; -} +type Props = RiskEventListItemProps; function EventListItem(props: Props) { const { @@ -29,6 +28,7 @@ function EventListItem(props: Props) { hazard_name, }, onExpandClick, + className, } = props; const strings = useTranslation(i18n); @@ -37,7 +37,7 @@ function EventListItem(props: Props) { return (
; type EventItem = NonNullable[number]; -function getLayerType(geometryType: GeoJSON.Geometry['type']) { +function hazardTypeSelector(item: EventItem) { + return item.hazard_type; +} + +function getLayerProperties( + feature: GeoJSON.Feature, +): RiskLayerProperties { + if (isNotDefined(feature) + || isNotDefined(feature.properties) + || isNotDefined(feature.geometry) + ) { + return { + type: 'unknown', + }; + } + + const geometryType = feature.geometry.type; + if (geometryType === 'Point' || geometryType === 'MultiPoint') { - return 'track-point'; + return { type: 'track-point' }; } if (geometryType === 'LineString' || geometryType === 'MultiLineString') { - return 'track'; + return { type: 'track-linestring' }; + } + + if (geometryType === 'Polygon' || geometryType === 'MultiPolygon') { + return { + type: 'exposure', + severity: 'unknown', + }; } - return 'exposure'; + return { + type: 'unknown', + }; } type BaseProps = { @@ -137,7 +164,7 @@ function MeteoSwiss(props: Props) { ? footprint_geojson : undefined; - const geoJson: GeoJSON.FeatureCollection = { + const geoJson: GeoJSON.FeatureCollection = { type: 'FeatureCollection' as const, features: [ ...footprint?.features?.map( @@ -155,7 +182,7 @@ function MeteoSwiss(props: Props) { ...feature, properties: { ...feature.properties, - type: getLayerType(feature.geometry.type), + ...getLayerProperties(feature), }, }; }, @@ -184,6 +211,7 @@ function MeteoSwiss(props: Props) { events={countryRiskResponse?.results} pointFeatureSelector={pointFeatureSelector} keySelector={numericIdSelector} + hazardTypeSelector={hazardTypeSelector} listItemRenderer={EventListItem} detailRenderer={EventDetails} pending={pendingCountryRiskResponse} diff --git a/app/src/components/domain/RiskImminentEvents/Pdc/EventDetails/i18n.json b/app/src/components/domain/RiskImminentEvents/Pdc/EventDetails/i18n.json index c5338c0b61..9ac8a9b2b4 100644 --- a/app/src/components/domain/RiskImminentEvents/Pdc/EventDetails/i18n.json +++ b/app/src/components/domain/RiskImminentEvents/Pdc/EventDetails/i18n.json @@ -1,7 +1,7 @@ { "namespace": "common", "strings": { - "eventDetailsViewDetails": "View Details", + "eventDetailsStartedOn": "Started on", "eventDetailsCreatedOn": "Created on", "eventDetailsUpdatedOn": "Updated on", "eventDetailsPeopleExposed": "People exposed / Potentially affected", diff --git a/app/src/components/domain/RiskImminentEvents/Pdc/EventDetails/index.tsx b/app/src/components/domain/RiskImminentEvents/Pdc/EventDetails/index.tsx index 475a2770f7..2ef85f22bf 100644 --- a/app/src/components/domain/RiskImminentEvents/Pdc/EventDetails/index.tsx +++ b/app/src/components/domain/RiskImminentEvents/Pdc/EventDetails/index.tsx @@ -1,24 +1,19 @@ import { - BlockLoading, Container, TextOutput, } from '@ifrc-go/ui'; import { useTranslation } from '@ifrc-go/ui/hooks'; +import { type RiskEventDetailProps } from '#components/domain/RiskImminentEventMap'; import { type RiskApiResponse } from '#utils/restRequest'; import i18n from './i18n.json'; -import styles from './styles.module.css'; type PdcResponse = RiskApiResponse<'/api/v1/pdc/'>; type PdcEventItem = NonNullable[number]; type PdcExposure = RiskApiResponse<'/api/v1/pdc/{id}/exposure/'>; -interface Props { - data: PdcEventItem; - exposure: PdcExposure | undefined; - pending: boolean; -} +type Props = RiskEventDetailProps; function EventDetails(props: Props) { const { @@ -31,6 +26,7 @@ function EventDetails(props: Props) { }, exposure, pending, + children, } = props; const strings = useTranslation(i18n); @@ -56,75 +52,81 @@ function EventDetails(props: Props) { return ( - - - - + )} + withBorderAndHeaderBackground + pending={pending} > - {pending && } - {!pending && ( - <> -
- - - - - - -
-
- {description} -
- - )} + + + + + + + + + + + + {children}
); } diff --git a/app/src/components/domain/RiskImminentEvents/Pdc/EventDetails/styles.module.css b/app/src/components/domain/RiskImminentEvents/Pdc/EventDetails/styles.module.css deleted file mode 100644 index 955ff6ee0f..0000000000 --- a/app/src/components/domain/RiskImminentEvents/Pdc/EventDetails/styles.module.css +++ /dev/null @@ -1,23 +0,0 @@ -.event-details { - .content { - display: flex; - flex-direction: column; - gap: var(--go-ui-spacing-lg); - - .event-meta { - display: flex; - flex-direction: column; - gap: var(--go-ui-spacing-2xs); - } - - .exposure-details { - display: flex; - flex-direction: column; - gap: var(--go-ui-spacing-2xs); - } - - .description { - color: var(--go-ui-color-text-light); - } - } -} diff --git a/app/src/components/domain/RiskImminentEvents/Pdc/EventListItem/index.tsx b/app/src/components/domain/RiskImminentEvents/Pdc/EventListItem/index.tsx index a2fcbf897e..e797243af2 100644 --- a/app/src/components/domain/RiskImminentEvents/Pdc/EventListItem/index.tsx +++ b/app/src/components/domain/RiskImminentEvents/Pdc/EventListItem/index.tsx @@ -5,7 +5,9 @@ import { TextOutput, } from '@ifrc-go/ui'; import { useTranslation } from '@ifrc-go/ui/hooks'; +import { _cs } from '@togglecorp/fujs'; +import { RiskEventListItemProps } from '#components/domain/RiskImminentEventMap'; import { type RiskApiResponse } from '#utils/restRequest'; import i18n from './i18n.json'; @@ -14,10 +16,7 @@ import styles from './styles.module.css'; type ImminentEventResponse = RiskApiResponse<'/api/v1/pdc/'>; type EventItem = NonNullable[number]; -interface Props { - data: EventItem; - onExpandClick: (eventId: string | number) => void; -} +type Props = RiskEventListItemProps; function EventListItem(props: Props) { const { @@ -27,13 +26,14 @@ function EventListItem(props: Props) { start_date, }, onExpandClick, + className, } = props; const strings = useTranslation(i18n); return (
)} - spacing="condensed" + spacing="cozy" > ; type EventItem = NonNullable[number]; +function hazardTypeSelector(item: EventItem) { + return item.hazard_type; +} + type BaseProps = { title: React.ReactNode; bbox: LngLatBoundsLike | undefined; @@ -119,6 +124,8 @@ function Pdc(props: Props) { const { footprint_geojson, storm_position_geojson, + // cyclone_five_days_cou, + cyclone_three_days_cou, } = exposure; if (isNotDefined(footprint_geojson) && isNotDefined(storm_position_geojson)) { @@ -130,20 +137,46 @@ function Pdc(props: Props) { const stormPositions = (storm_position_geojson as unknown as unknown[] | undefined) ?.filter(isValidPointFeature); + // FIXME: fix typing in server (low priority) + const forecastUncertainty = isValidFeature(cyclone_three_days_cou?.[0]) + ? cyclone_three_days_cou[0] + : undefined; + + // severity + // WARNING: Adverse or significant impacts to population are imminent or occuring. + // WATCH: Conditions are possible for adverse or significant impacts to population. + // ADVISORY: Conditions are possible for limited or minor impacts to population + // INFORMATION: Conditions are possible for limited or minor impacts to population + + // advisory_date: "28-Sep-2024" + // advisory_number: 4 + // advisory_time: "0000Z" + // hazard_name: "Super Typhoon - Krathon" + // // forecast_date_time : "2023 SEP 04, 00:00Z" // severity : "WARNING" // storm_name : "HAIKUI" // track_heading : "WNW" // wind_speed_mph : 75 + // track_speed_mph: xx - const geoJson: GeoJSON.FeatureCollection = { + const geoJson: GeoJSON.FeatureCollection = { type: 'FeatureCollection' as const, features: [ footprint ? { ...footprint, properties: { ...footprint.properties, - type: 'exposure', + type: 'exposure' as const, + severity: 'unknown' as const, + }, + } : undefined, + forecastUncertainty ? { + ...forecastUncertainty, + properties: { + ...forecastUncertainty.properties, + type: 'uncertainty-cone' as const, + forecastDays: 3, }, } : undefined, stormPositions ? { @@ -157,7 +190,7 @@ function Pdc(props: Props) { ), }, properties: { - type: 'track', + type: 'track-linestring' as const, }, } : undefined, ...stormPositions?.map( @@ -165,7 +198,7 @@ function Pdc(props: Props) { ...pointFeature, properties: { ...pointFeature.properties, - type: 'track-point', + type: 'track-point' as const, }, }), ) ?? [], @@ -193,6 +226,7 @@ function Pdc(props: Props) { events={countryRiskResponse?.results} pointFeatureSelector={pointFeatureSelector} keySelector={numericIdSelector} + hazardTypeSelector={hazardTypeSelector} listItemRenderer={EventListItem} detailRenderer={EventDetails} pending={pendingCountryRiskResponse} diff --git a/app/src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/index.tsx b/app/src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/index.tsx index 40c2365130..a39a3159b5 100644 --- a/app/src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/index.tsx +++ b/app/src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/index.tsx @@ -1,6 +1,5 @@ import { useMemo } from 'react'; import { - BlockLoading, Container, TextOutput, Tooltip, @@ -17,8 +16,10 @@ import { isDefined, isFalsyString, isNotDefined, + unique, } from '@togglecorp/fujs'; +import { type RiskEventDetailProps } from '#components/domain/RiskImminentEventMap'; import Link from '#components/Link'; import { isValidFeatureCollection, @@ -78,11 +79,7 @@ interface WfpAdamEventDetails { sitrep?: string; } -interface Props { - data: WfpAdamItem; - exposure: WfpAdamExposure | undefined; - pending: boolean; -} +type Props = RiskEventDetailProps; function EventDetails(props: Props) { const { @@ -93,6 +90,7 @@ function EventDetails(props: Props) { }, exposure, pending, + children, } = props; const strings = useTranslation(i18n); @@ -130,16 +128,16 @@ function EventDetails(props: Props) { const date = new Date(track_date); return { - id: date.getTime(), + id: track_date, windSpeed: wind_speed, date, }; }, ).filter(isDefined).sort( (a, b) => compareDate(a.date, b.date), - ); + ) ?? []; - return points; + return unique(points, (point) => point.id); }, [exposure], ); @@ -156,15 +154,17 @@ function EventDetails(props: Props) { stormPoints?.map(({ windSpeed }) => windSpeed), ); - // TODO: add exposure details + // TODO: Add exposure details + // TODO: Update stylings return ( )} + pending={pending} > - {pending && } {stormPoints && stormPoints.length > 0 && isDefined(maxWindSpeed) && ( /* TODO: use proper svg charts */
@@ -387,6 +387,7 @@ function EventDetails(props: Props) { /> )}
+ {children}
); } diff --git a/app/src/components/domain/RiskImminentEvents/WfpAdam/EventListItem/index.tsx b/app/src/components/domain/RiskImminentEvents/WfpAdam/EventListItem/index.tsx index 1596c34de3..1becd6c66c 100644 --- a/app/src/components/domain/RiskImminentEvents/WfpAdam/EventListItem/index.tsx +++ b/app/src/components/domain/RiskImminentEvents/WfpAdam/EventListItem/index.tsx @@ -5,7 +5,9 @@ import { TextOutput, } from '@ifrc-go/ui'; import { useTranslation } from '@ifrc-go/ui/hooks'; +import { _cs } from '@togglecorp/fujs'; +import { RiskEventListItemProps } from '#components/domain/RiskImminentEventMap'; import { type RiskApiResponse } from '#utils/restRequest'; import i18n from './i18n.json'; @@ -14,10 +16,7 @@ import styles from './styles.module.css'; type ImminentEventResponse = RiskApiResponse<'/api/v1/adam-exposure/'>; type EventItem = NonNullable[number]; -interface Props { - data: EventItem; - onExpandClick: (eventId: string | number) => void; -} +type Props = RiskEventListItemProps; function EventListItem(props: Props) { const { @@ -27,13 +26,14 @@ function EventListItem(props: Props) { title, }, onExpandClick, + className, } = props; const strings = useTranslation(i18n); return (
)} - spacing="condensed" + spacing="cozy" > ; type EventItem = NonNullable[number]; -function getLayerType(geometryType: GeoJSON.Geometry['type']) { +function hazardTypeSelector(item: EventItem) { + return item.hazard_type; +} + +function getLayerProperties( + feature: GeoJSON.Feature, +): RiskLayerProperties { + if (isNotDefined(feature) + || isNotDefined(feature.properties) + || isNotDefined(feature.geometry) + ) { + return { + type: 'unknown', + }; + } + + const geometryType = feature.geometry.type; + if (geometryType === 'Point' || geometryType === 'MultiPoint') { - return 'track-point'; + return { type: 'track-point' }; } if (geometryType === 'LineString' || geometryType === 'MultiLineString') { - return 'track'; + return { type: 'track-linestring' }; + } + + if (geometryType === 'Polygon' || geometryType === 'MultiPolygon') { + const alertLevel = feature.properties.alert_level; + + if (alertLevel === 'Cones') { + return { + type: 'uncertainty-cone', + forecastDays: undefined, + }; + } + + const severityMapping: Record = { + Red: 'red', + Orange: 'orange', + Green: 'green', + }; + + return { + type: 'exposure', + severity: severityMapping[alertLevel] ?? 'unknown', + }; } - return 'exposure'; + return { + type: 'unknown', + }; } type BaseProps = { @@ -137,7 +182,7 @@ function WfpAdam(props: Props) { ? storm_position_geojson : undefined; - const geoJson: GeoJSON.FeatureCollection = { + const geoJson: GeoJSON.FeatureCollection = { type: 'FeatureCollection' as const, features: [ ...stormPositions?.features?.map( @@ -145,7 +190,7 @@ function WfpAdam(props: Props) { ...feature, properties: { ...feature.properties, - type: getLayerType(feature.geometry.type), + ...getLayerProperties(feature), }, }), ) ?? [], @@ -173,6 +218,7 @@ function WfpAdam(props: Props) { events={countryRiskResponse?.results} pointFeatureSelector={pointFeatureSelector} keySelector={numericIdSelector} + hazardTypeSelector={hazardTypeSelector} listItemRenderer={EventListItem} detailRenderer={EventDetails} pending={pendingCountryRiskResponse} diff --git a/app/src/components/domain/RiskImminentEvents/styles.module.css b/app/src/components/domain/RiskImminentEvents/styles.module.css index cdf236a75a..75ccddae01 100644 --- a/app/src/components/domain/RiskImminentEvents/styles.module.css +++ b/app/src/components/domain/RiskImminentEvents/styles.module.css @@ -34,13 +34,14 @@ .footer-actions { flex-shrink: unset; + flex-wrap: wrap; } } .popup { .description { - display: inline; + .description-content { display: flex; flex-direction: column; diff --git a/app/src/hooks/useSetFieldValue.ts b/app/src/hooks/useSetFieldValue.ts new file mode 100644 index 0000000000..cd920c7552 --- /dev/null +++ b/app/src/hooks/useSetFieldValue.ts @@ -0,0 +1,25 @@ +import { useCallback } from 'react'; +import { + EntriesAsList, + isCallable, +} from '@togglecorp/toggle-form'; + +function useSetFieldValue( + setValue: React.Dispatch>, +) { + const setFieldValue = useCallback((...entries: EntriesAsList) => { + setValue((oldState) => { + const newValue = isCallable(entries[0]) + ? entries[0](oldState[entries[1]]) + : entries[0]; + return { + ...oldState, + [entries[1]]: newValue, + }; + }); + }, [setValue]); + + return setFieldValue; +} + +export default useSetFieldValue; diff --git a/app/src/utils/constants.ts b/app/src/utils/constants.ts index 8337906c48..9f89c3d556 100644 --- a/app/src/utils/constants.ts +++ b/app/src/utils/constants.ts @@ -65,6 +65,7 @@ export const COLOR_BLUE = '#4c5d9b'; export const COLOR_LIGHT_BLUE = '#c7d3e0'; export const COLOR_ORANGE = '#ff8000'; export const COLOR_RED = '#f5333f'; +export const COLOR_GREEN = '#8BB656'; export const COLOR_DARK_RED = '#730413'; export const COLOR_PRIMARY_BLUE = '#011e41'; export const COLOR_PRIMARY_RED = '#f5333f'; diff --git a/app/src/utils/domain/risk.ts b/app/src/utils/domain/risk.ts index e809338edc..3c52538cee 100644 --- a/app/src/utils/domain/risk.ts +++ b/app/src/utils/domain/risk.ts @@ -354,11 +354,23 @@ export function riskScoreToCategory( export function isValidFeature( maybeFeature: unknown, ): maybeFeature is GeoJSON.Feature { + if (typeof maybeFeature !== 'object') { + return false; + } + if (isNotDefined(maybeFeature)) { return false; } - if (typeof maybeFeature !== 'object') { + if ( + !('type' in maybeFeature) + || !('geometry' in maybeFeature) + || !('properties' in maybeFeature) + ) { + return false; + } + + if (maybeFeature.type !== 'Feature' as const) { return false; } diff --git a/packages/ui/src/components/Checkbox/index.tsx b/packages/ui/src/components/Checkbox/index.tsx index 583abec159..2d6a893cb5 100644 --- a/packages/ui/src/components/Checkbox/index.tsx +++ b/packages/ui/src/components/Checkbox/index.tsx @@ -25,6 +25,7 @@ export interface Props { tooltip?: string; value: boolean | undefined | null; description?: React.ReactNode; + withBackground?: boolean; } function Checkbox(props: Props) { @@ -46,6 +47,7 @@ function Checkbox(props: Props) { tooltip, value, description, + withBackground, ...otherProps } = props; @@ -66,6 +68,7 @@ function Checkbox(props: Props) { styles.checkbox, classNameFromProps, !indeterminate && checked && styles.checked, + withBackground && styles.withBackground, disabled && styles.disabledCheckbox, readOnly && styles.readOnly, ); diff --git a/packages/ui/src/components/Checkbox/styles.module.css b/packages/ui/src/components/Checkbox/styles.module.css index 2b84edf416..7651266e18 100644 --- a/packages/ui/src/components/Checkbox/styles.module.css +++ b/packages/ui/src/components/Checkbox/styles.module.css @@ -4,8 +4,14 @@ cursor: pointer; gap: var(--go-ui-spacing-sm); + &.with-background { + background-color: var(--go-ui-color-background); + padding: var(--go-ui-spacing-sm); + } + .checkmark-container { position: relative; + flex-shrink: 0; line-height: 0; font-size: var(--go-ui-height-icon-multiplier); @@ -26,8 +32,9 @@ .content { flex-direction: column; - gap: 0; + flex-grow: 1; line-height: var(--go-ui-line-height-sm); + gap: 0; } .description { diff --git a/packages/ui/src/components/Container/index.tsx b/packages/ui/src/components/Container/index.tsx index ad7db9c199..550005c4a8 100644 --- a/packages/ui/src/components/Container/index.tsx +++ b/packages/ui/src/components/Container/index.tsx @@ -57,6 +57,7 @@ export interface Props { spacing?: SpacingType; withHeaderBorder?: boolean; withFooterBorder?: boolean; + withBorderAndHeaderBackground?: boolean; withInternalPadding?: boolean; withOverflowInContent?: boolean; withoutWrapInHeading?: boolean; @@ -111,6 +112,7 @@ function Container(props: Props) { spacing = 'default', withHeaderBorder = false, withFooterBorder = false, + withBorderAndHeaderBackground = false, withOverflowInContent = false, withInternalPadding = false, withoutWrapInHeading = false, @@ -187,9 +189,10 @@ function Container(props: Props) { ref={containerRef} className={_cs( styles.container, - gapSpacingTokens, + !withBorderAndHeaderBackground && gapSpacingTokens, withInternalPadding && verticalPaddingSpacingTokens, withOverflowInContent && styles.withOverflowInContent, + withBorderAndHeaderBackground && styles.withBorderAndHeaderBackground, contentViewType === 'grid' && styles.withGridView, contentViewType === 'grid' && numColumnToClassNameMap[numPreferredGridContentColumns], contentViewType === 'vertical' && styles.withVerticalView, @@ -201,7 +204,9 @@ function Container(props: Props) { actions={actions} className={_cs( styles.header, - withInternalPadding && horizontalPaddingSpacingTokens, + withBorderAndHeaderBackground && verticalPaddingSpacingTokens, + (withInternalPadding || withBorderAndHeaderBackground) + && horizontalPaddingSpacingTokens, headerClassName, )} elementRef={headerElementRef} @@ -238,7 +243,9 @@ function Container(props: Props) { className={_cs( styles.content, contentViewType !== 'default' && childrenGapTokens, - withInternalPadding && horizontalPaddingSpacingTokens, + (withInternalPadding || withBorderAndHeaderBackground) + && horizontalPaddingSpacingTokens, + withBorderAndHeaderBackground && verticalPaddingSpacingTokens, overlayPending && pending && styles.pendingOverlaid, childrenContainerClassName, )} @@ -268,6 +275,7 @@ function Container(props: Props) { className={_cs( styles.footer, withInternalPadding && horizontalPaddingSpacingTokens, + withBorderAndHeaderBackground && verticalPaddingSpacingTokens, footerClassName, )} actionsContainerClassName={footerActionsContainerClassName} diff --git a/packages/ui/src/components/Container/styles.module.css b/packages/ui/src/components/Container/styles.module.css index 79e2d53ac4..bb932f9381 100644 --- a/packages/ui/src/components/Container/styles.module.css +++ b/packages/ui/src/components/Container/styles.module.css @@ -20,6 +20,16 @@ } } + &.with-border-and-header-background { + border: var(--go-ui-width-separator-thin) solid var(--go-ui-color-separator); + border-radius: var(--go-ui-border-radius-md); + + .header { + border-radius: var(--go-ui-border-radius-md); + background-color: var(--go-ui-color-background); + } + } + .border { flex-shrink: 0; margin: 0; diff --git a/packages/ui/src/components/Legend/index.tsx b/packages/ui/src/components/Legend/index.tsx index 9c431529cc..20fc72b9e0 100644 --- a/packages/ui/src/components/Legend/index.tsx +++ b/packages/ui/src/components/Legend/index.tsx @@ -12,7 +12,9 @@ import styles from './styles.module.css'; export interface Props { className?: string; label?: React.ReactNode; + labelClassName?: string; items: ITEM[] | undefined | null; + itemListContainerClassName?: string; keySelector: (item: ITEM) => React.Key; colorSelector?: (item: ITEM) => string | undefined; labelSelector?: (item: ITEM) => React.ReactNode; @@ -34,6 +36,8 @@ function Legend(props: Props) { itemClassName, iconElementClassName, colorElementClassName, + labelClassName, + itemListContainerClassName, } = props; const legendItemRendererParams = useCallback( @@ -58,11 +62,11 @@ function Legend(props: Props) { return (
{isDefined(label) && ( -
+
{label}
)} -
+
= Omit, 'indeterminate' | 'checkmark'> +export interface SwitchProps extends Omit, 'indeterminate' | 'checkmark'> { + withInvertedView?: boolean; +} function Switch(props: SwitchProps) { + const { + className, + checkmarkContainerClassName, + withInvertedView, + ...otherProps + } = props; + return ( ); diff --git a/packages/ui/src/components/Switch/styles.module.css b/packages/ui/src/components/Switch/styles.module.css index 794c827c39..fa7e340ded 100644 --- a/packages/ui/src/components/Switch/styles.module.css +++ b/packages/ui/src/components/Switch/styles.module.css @@ -1,3 +1,9 @@ -.container { - --width: var(--go-ui-font-size-4xl); +.switch { + .checkmark-container { + --width: var(--go-ui-font-size-4xl); + } + + &.with-inverted-view { + flex-direction: row-reverse; + } } diff --git a/packages/ui/src/components/TextOutput/index.tsx b/packages/ui/src/components/TextOutput/index.tsx index 5cab813c13..292b05fba5 100644 --- a/packages/ui/src/components/TextOutput/index.tsx +++ b/packages/ui/src/components/TextOutput/index.tsx @@ -20,6 +20,7 @@ interface BaseProps { strongDescription?: boolean; withoutLabelColon?: boolean; invalidText?: React.ReactNode; + withBackground?: boolean; } interface BooleanProps extends BooleanOutputProps { @@ -61,6 +62,7 @@ function TextOutput(props: Props) { strongValue, strongDescription, withoutLabelColon, + withBackground, invalidText = DEFAULT_INVALID_TEXT, ...otherProps } = props; @@ -97,7 +99,13 @@ function TextOutput(props: Props) { } return ( -
+
{icon} {label && (
Date: Tue, 8 Oct 2024 13:56:20 +0545 Subject: [PATCH 02/59] Update styling for the cyclone track points - Remove track arrow --- .../domain/RiskImminentEventMap/index.tsx | 6 ++- .../domain/RiskImminentEventMap/mapStyles.ts | 45 ++++++++++++------- .../domain/RiskImminentEventMap/utils.ts | 2 + .../domain/RiskImminentEvents/Gdacs/index.tsx | 23 +++++++--- .../RiskImminentEvents/MeteoSwiss/index.tsx | 11 ++--- .../domain/RiskImminentEvents/Pdc/index.tsx | 38 ++++++++++++---- .../WfpAdam/EventDetails/index.tsx | 1 + .../RiskImminentEvents/WfpAdam/index.tsx | 11 ++--- 8 files changed, 96 insertions(+), 41 deletions(-) diff --git a/app/src/components/domain/RiskImminentEventMap/index.tsx b/app/src/components/domain/RiskImminentEventMap/index.tsx index e36eca74d0..4381b140e3 100644 --- a/app/src/components/domain/RiskImminentEventMap/index.tsx +++ b/app/src/components/domain/RiskImminentEventMap/index.tsx @@ -54,8 +54,6 @@ import { invisibleFillLayer, invisibleLayout, invisibleLineLayer, - invisibleSymbolLayer, - trackArrowLayer, trackLineLayer, trackPointLayer, trackPointOuterCircleLayer, @@ -297,9 +295,11 @@ function RiskImminentEventMap< 1, 0, ], + /* 'icon-opacity-transition': { duration: 200, }, + */ }, layout: allIconsLoaded ? hazardPointIconLayout : invisibleLayout, }), @@ -356,12 +356,14 @@ function RiskImminentEventMap< ? trackLineLayer : invisibleLineLayer} /> + {/* + */} = { export const hazardPointLayer: Omit = { type: 'circle', paint: { - 'circle-radius': [ - 'case', - ['boolean', ['feature-state', 'eventVisible'], true], - 12, - 0, - ], - 'circle-radius-transition': { - delay: 200, - duration: 200, - }, + 'circle-radius': 12, 'circle-color': hazardTypeColorPaint, 'circle-opacity': [ 'case', @@ -115,7 +106,9 @@ export const hazardPointLayer: Omit = { 1, 0, ], - 'circle-opacity-transition': { duration: 200 }, + /* + 'circle-opacity-transition': { duration: 2000, delay: 0 }, + */ }, }; @@ -204,11 +197,13 @@ export const trackLineLayer: Omit = { ], paint: { 'line-color': COLOR_BLACK, - 'line-opacity': 0.5, + 'line-width': 2, + 'line-opacity': 1, }, layout: { visibility: 'visible' }, }; +/* export const trackArrowLayer: Omit = { type: 'symbol', filter: [ @@ -229,6 +224,7 @@ export const trackArrowLayer: Omit = { 'icon-rotate': 90, }, }; +*/ export const trackPointLayer: Omit = { type: 'circle', @@ -238,9 +234,16 @@ export const trackPointLayer: Omit = { 'track-point' satisfies RiskLayerTypes, ], paint: { - 'circle-radius': 3, + 'circle-radius': 4, 'circle-color': COLOR_BLACK, 'circle-opacity': 1, + 'circle-stroke-color': COLOR_WHITE, + 'circle-stroke-width': [ + 'case', + ['boolean', ['get', 'isFuture'], true], + 0, + 1, + ], }, layout: { visibility: 'visible' }, }; @@ -253,9 +256,21 @@ export const trackPointOuterCircleLayer: Omit = { 'track-point' satisfies RiskLayerTypes, ], paint: { - 'circle-radius': 7, + 'circle-radius': 12, 'circle-color': COLOR_BLACK, - 'circle-opacity': 0.3, + 'circle-opacity': [ + 'case', + ['boolean', ['get', 'isFuture'], true], + 0.2, + 0.0, + ], + 'circle-stroke-color': COLOR_WHITE, + 'circle-stroke-width': [ + 'case', + ['boolean', ['get', 'isFuture'], true], + 1, + 0, + ], }, layout: { visibility: 'visible' }, }; diff --git a/app/src/components/domain/RiskImminentEventMap/utils.ts b/app/src/components/domain/RiskImminentEventMap/utils.ts index 899425110e..56d7c8b766 100644 --- a/app/src/components/domain/RiskImminentEventMap/utils.ts +++ b/app/src/components/domain/RiskImminentEventMap/utils.ts @@ -19,6 +19,8 @@ export interface HazardPointLayerProperties extends BaseLayerProperties { export interface TrackPointLayerProperties extends BaseLayerProperties { type: 'track-point'; + // FIXME: added this + isFuture: boolean; } export interface TrackPointBoundaryLayerProperties extends BaseLayerProperties { diff --git a/app/src/components/domain/RiskImminentEvents/Gdacs/index.tsx b/app/src/components/domain/RiskImminentEvents/Gdacs/index.tsx index 320c983d73..49f5bd3c13 100644 --- a/app/src/components/domain/RiskImminentEvents/Gdacs/index.tsx +++ b/app/src/components/domain/RiskImminentEvents/Gdacs/index.tsx @@ -55,6 +55,7 @@ const severityMapping: Record = { // Poly_Cones is cone of uncertainty function getLayerProperties( feature: GeoJSON.Feature, + hazardDate: string | undefined, ): RiskLayerProperties { if (isNotDefined(feature.properties) || !('Class' in feature.properties)) { return { @@ -78,8 +79,16 @@ function getLayerProperties( }; } + // Converting format from 'dd/MM/yyyy hh:mm:ss' to 'yyyy-MM-ddThh:mm:ss.sssZ' + const [date, time] = feature.properties.trackdate.split(' '); + const [d, m, y] = date.split('/'); + const standardDateTime = `${y}-${m}-${d}T${time}.000Z`; + return { type: 'track-point', + isFuture: hazardDate + ? new Date(standardDateTime).getTime() > new Date(hazardDate).getTime() + : false, }; } @@ -160,7 +169,7 @@ function Gdacs(props: Props) { const { response: exposureResponse, pending: exposureResponsePending, - trigger: getExposureDetails, + trigger: fetchExposure, } = useRiskLazyRequest<'/api/v1/gdacs/{id}/exposure/', { eventId: number | string, }>({ @@ -215,10 +224,13 @@ function Gdacs(props: Props) { return undefined; } + // FIXME: the type from server is not correct const footprint = isValidFeatureCollection(footprint_geojson) ? footprint_geojson : undefined; + const hazardDate = (footprint?.metadata as ({ todate?: string } | undefined))?.todate; + const geoJson: GeoJSON.FeatureCollection = { type: 'FeatureCollection' as const, features: [ @@ -227,7 +239,8 @@ function Gdacs(props: Props) { ...feature, properties: { ...feature.properties, - ...getLayerProperties(feature), + // NOTE: the todate format is 'dd MMM yyyy hh:mm:ss' + ...getLayerProperties(feature, hazardDate), }, }), ) ?? [], @@ -242,12 +255,12 @@ function Gdacs(props: Props) { const handleActiveEventChange = useCallback( (eventId: number | undefined) => { if (isDefined(eventId)) { - getExposureDetails({ eventId }); + fetchExposure({ eventId }); } else { - getExposureDetails(undefined); + fetchExposure(undefined); } }, - [getExposureDetails], + [fetchExposure], ); return ( diff --git a/app/src/components/domain/RiskImminentEvents/MeteoSwiss/index.tsx b/app/src/components/domain/RiskImminentEvents/MeteoSwiss/index.tsx index 2128744757..f93c7641ff 100644 --- a/app/src/components/domain/RiskImminentEvents/MeteoSwiss/index.tsx +++ b/app/src/components/domain/RiskImminentEvents/MeteoSwiss/index.tsx @@ -41,7 +41,8 @@ function getLayerProperties( const geometryType = feature.geometry.type; if (geometryType === 'Point' || geometryType === 'MultiPoint') { - return { type: 'track-point' }; + // FIXME: calculate isFuture + return { type: 'track-point', isFuture: true }; } if (geometryType === 'LineString' || geometryType === 'MultiLineString') { @@ -104,7 +105,7 @@ function MeteoSwiss(props: Props) { const { response: exposureResponse, pending: exposureResponsePending, - trigger: getFootprint, + trigger: fetchExposure, } = useRiskLazyRequest<'/api/v1/meteoswiss/{id}/exposure/', { eventId: number | string, }>({ @@ -198,12 +199,12 @@ function MeteoSwiss(props: Props) { const handleActiveEventChange = useCallback( (eventId: number | undefined) => { if (isDefined(eventId)) { - getFootprint({ eventId }); + fetchExposure({ eventId }); } else { - getFootprint(undefined); + fetchExposure(undefined); } }, - [getFootprint], + [fetchExposure], ); return ( diff --git a/app/src/components/domain/RiskImminentEvents/Pdc/index.tsx b/app/src/components/domain/RiskImminentEvents/Pdc/index.tsx index 5e2a4249ba..0ff3f68b14 100644 --- a/app/src/components/domain/RiskImminentEvents/Pdc/index.tsx +++ b/app/src/components/domain/RiskImminentEvents/Pdc/index.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; import { numericIdSelector } from '@ifrc-go/ui/utils'; import { isDefined, @@ -50,6 +50,8 @@ function Pdc(props: Props) { variant, } = props; + const [activeEventId, setActiveEventId] = useState(); + const { pending: pendingCountryRiskResponse, response: countryRiskResponse, @@ -72,7 +74,7 @@ function Pdc(props: Props) { const { response: exposureResponse, pending: exposureResponsePending, - trigger: getFootprint, + trigger: fetchExposure, } = useRiskLazyRequest<'/api/v1/pdc/{id}/exposure/', { eventId: number | string, }>({ @@ -115,6 +117,10 @@ function Pdc(props: Props) { [], ); + const activeEvent = countryRiskResponse?.results?.find( + (item) => item.id === activeEventId, + ); + const footprintSelector = useCallback( (exposure: RiskApiResponse<'/api/v1/pdc/{id}/exposure/'> | undefined) => { if (isNotDefined(exposure)) { @@ -124,10 +130,13 @@ function Pdc(props: Props) { const { footprint_geojson, storm_position_geojson, - // cyclone_five_days_cou, + cyclone_five_days_cou, cyclone_three_days_cou, } = exposure; + // FIXME: showing five days cou when three days cou is not available + const cyclone_cou = cyclone_three_days_cou?.[0] ?? cyclone_five_days_cou?.[0]; + if (isNotDefined(footprint_geojson) && isNotDefined(storm_position_geojson)) { return undefined; } @@ -138,8 +147,8 @@ function Pdc(props: Props) { ?.filter(isValidPointFeature); // FIXME: fix typing in server (low priority) - const forecastUncertainty = isValidFeature(cyclone_three_days_cou?.[0]) - ? cyclone_three_days_cou[0] + const forecastUncertainty = isValidFeature(cyclone_cou) + ? cyclone_cou : undefined; // severity @@ -199,6 +208,16 @@ function Pdc(props: Props) { properties: { ...pointFeature.properties, type: 'track-point' as const, + isFuture: ( + activeEvent + && activeEvent.pdc_updated_at + && pointFeature.properties?.forecast_date_time + ? ( + new Date(pointFeature.properties.forecast_date_time) + > new Date(activeEvent.pdc_updated_at) + ) + : false + ), }, }), ) ?? [], @@ -207,18 +226,19 @@ function Pdc(props: Props) { return geoJson; }, - [], + [activeEvent], ); const handleActiveEventChange = useCallback( (eventId: number | undefined) => { if (isDefined(eventId)) { - getFootprint({ eventId }); + fetchExposure({ eventId }); } else { - getFootprint(undefined); + fetchExposure(undefined); } + setActiveEventId(eventId); }, - [getFootprint], + [fetchExposure], ); return ( diff --git a/app/src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/index.tsx b/app/src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/index.tsx index a39a3159b5..03329622d6 100644 --- a/app/src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/index.tsx +++ b/app/src/components/domain/RiskImminentEvents/WfpAdam/EventDetails/index.tsx @@ -128,6 +128,7 @@ function EventDetails(props: Props) { const date = new Date(track_date); return { + // NOTE: using date.getTime() caused duplicate ids id: track_date, windSpeed: wind_speed, date, diff --git a/app/src/components/domain/RiskImminentEvents/WfpAdam/index.tsx b/app/src/components/domain/RiskImminentEvents/WfpAdam/index.tsx index d23f9c38fa..a3b7682b17 100644 --- a/app/src/components/domain/RiskImminentEvents/WfpAdam/index.tsx +++ b/app/src/components/domain/RiskImminentEvents/WfpAdam/index.tsx @@ -43,7 +43,8 @@ function getLayerProperties( const geometryType = feature.geometry.type; if (geometryType === 'Point' || geometryType === 'MultiPoint') { - return { type: 'track-point' }; + // FIXME: calculate isFuture + return { type: 'track-point', isFuture: true }; } if (geometryType === 'LineString' || geometryType === 'MultiLineString') { @@ -121,7 +122,7 @@ function WfpAdam(props: Props) { const { response: exposureResponse, pending: exposureResponsePending, - trigger: getFootprint, + trigger: fetchExposure, } = useRiskLazyRequest<'/api/v1/adam-exposure/{id}/exposure/', { eventId: number | string, }>({ @@ -205,12 +206,12 @@ function WfpAdam(props: Props) { const handleActiveEventChange = useCallback( (eventId: number | undefined) => { if (isDefined(eventId)) { - getFootprint({ eventId }); + fetchExposure({ eventId }); } else { - getFootprint(undefined); + fetchExposure(undefined); } }, - [getFootprint], + [fetchExposure], ); return ( From c617aa708da495e77a7678b0dd64a27dbb382640 Mon Sep 17 00:00:00 2001 From: frozenhelium Date: Tue, 17 Sep 2024 12:40:09 +0545 Subject: [PATCH 03/59] Fix contact details field being required when filled once - Add new form utility useFormArrayWithEmptyCheck --- app/src/utils/form.ts | 63 +++++++++++++++++++ .../FieldReportForm/ResponseFields/index.tsx | 26 ++++++-- app/src/views/FieldReportForm/common.ts | 21 +++++-- app/src/views/FieldReportForm/index.tsx | 14 ++--- 4 files changed, 107 insertions(+), 17 deletions(-) diff --git a/app/src/utils/form.ts b/app/src/utils/form.ts index 4b20c54906..6b67f196ed 100644 --- a/app/src/utils/form.ts +++ b/app/src/utils/form.ts @@ -1,8 +1,14 @@ +import { useCallback } from 'react'; import type { Maybe } from '@togglecorp/fujs'; import { isDefined, isInteger, + isNotDefined, } from '@togglecorp/fujs'; +import { + isCallable, + SetValueArg, +} from '@togglecorp/toggle-form'; function isNumber(value: unknown): value is number { return typeof value === 'number'; @@ -45,3 +51,60 @@ export function nonZeroCondition(value: Maybe) { ? 'The field must not be 0' : undefined; } + +export function useFormArrayWithEmptyCheck( + name: NAME, + onChange: ( + newValue: SetValueArg, + inputName: NAME, + ) => void, + isEmpty: (item: VALUE) => boolean, +) { + const setValue = useCallback( + (val: SetValueArg, index: number | undefined) => { + onChange( + (oldValue: VALUE[] | undefined): VALUE[] => { + const newVal = [...(oldValue ?? [])]; + + if (isNotDefined(index)) { + const resolvedVal = isCallable(val) ? val(undefined) : val; + + if (!isEmpty(resolvedVal)) { + newVal.push(resolvedVal); + } + } else { + const resolvedVal = isCallable(val) ? val(newVal[index]) : val; + + if (isEmpty(resolvedVal)) { + newVal.splice(index, 1); + } else { + newVal[index] = resolvedVal; + } + } + return newVal; + }, + name, + ); + }, + [name, onChange, isEmpty], + ); + + const removeValue = useCallback( + (index: number) => { + onChange( + (oldValue: VALUE[] | undefined): VALUE[] => { + if (!oldValue) { + return []; + } + const newVal = [...oldValue]; + newVal.splice(index, 1); + return newVal; + }, + name, + ); + }, + [name, onChange], + ); + + return { setValue, removeValue }; +} diff --git a/app/src/views/FieldReportForm/ResponseFields/index.tsx b/app/src/views/FieldReportForm/ResponseFields/index.tsx index 6142f5c6bf..48b6ffa6ea 100644 --- a/app/src/views/FieldReportForm/ResponseFields/index.tsx +++ b/app/src/views/FieldReportForm/ResponseFields/index.tsx @@ -6,7 +6,10 @@ import { RadioInput, } from '@ifrc-go/ui'; import { useTranslation } from '@ifrc-go/ui/hooks'; -import { resolveToString } from '@ifrc-go/ui/utils'; +import { + hasSomeDefinedValue, + resolveToString, +} from '@ifrc-go/ui/utils'; import { isDefined, listToMap, @@ -15,7 +18,6 @@ import { type EntriesAsList, type Error, getErrorObject, - useFormArray, } from '@togglecorp/toggle-form'; import NonFieldError from '#components/NonFieldError'; @@ -30,6 +32,7 @@ import { VISIBILITY_PUBLIC, VISIBILITY_RCRC_MOVEMENT, } from '#utils/constants'; +import { useFormArrayWithEmptyCheck } from '#utils/form'; import { type PartialFormValue } from '../common'; import ContactInput, { type ContactValue } from './ContactInput'; @@ -73,9 +76,20 @@ function ResponseFields(props: Props) { const { setValue: setContactValue, - } = useFormArray<'contacts', ContactValue>( + } = useFormArrayWithEmptyCheck<'contacts', ContactValue>( 'contacts', onValueChange, + (newValue) => { + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + id, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ctype, + ...other + } = newValue; + + return !hasSomeDefinedValue(other); + }, ); const requestOptions = useMemo(() => api_request_choices?.filter((item) => ( @@ -163,7 +177,7 @@ function ResponseFields(props: Props) { strings.fieldsStep4ContactRowsMediaContactEVTEPIEWDesc, ]); - const mapping = useMemo(() => listToMap( + const contactsIndexMapping = useMemo(() => listToMap( value.contacts, (item) => item.ctype, (_, __, index) => index, @@ -341,7 +355,7 @@ function ResponseFields(props: Props) { > {contacts.map((contact) => { - const index = mapping?.[contact.key]; + const index = contactsIndexMapping?.[contact.key]; return ( ({ fields: (): ContactField => ({ ctype: { required: true }, - name: {}, - title: {}, - email: { validations: [emailCondition] }, - phone: {}, + name: { + required: true, + requiredValidation: requiredStringCondition, + }, + title: { + required: true, + requiredValidation: requiredStringCondition, + }, + email: { + required: true, + requiredValidation: requiredStringCondition, + validations: [emailCondition], + }, + phone: { + required: true, + requiredValidation: requiredStringCondition, + }, }), }), }, diff --git a/app/src/views/FieldReportForm/index.tsx b/app/src/views/FieldReportForm/index.tsx index 0941740256..387aac126f 100644 --- a/app/src/views/FieldReportForm/index.tsx +++ b/app/src/views/FieldReportForm/index.tsx @@ -172,7 +172,7 @@ export function Component() { const { value, error, - setFieldValue: onValueChange, + setFieldValue, validate, setError: onErrorSet, setValue: onValueSet, @@ -748,7 +748,7 @@ export function Component() { @@ -772,7 +772,7 @@ export function Component() { Date: Tue, 17 Sep 2024 13:59:43 +0545 Subject: [PATCH 04/59] Fix JS lint issues --- app/src/utils/form.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/utils/form.ts b/app/src/utils/form.ts index 6b67f196ed..116131862b 100644 --- a/app/src/utils/form.ts +++ b/app/src/utils/form.ts @@ -52,6 +52,8 @@ export function nonZeroCondition(value: Maybe) { : undefined; } +// NOTE: This hook is an extension over useFormArray +// If an element is empty, we remove that element from the array export function useFormArrayWithEmptyCheck( name: NAME, onChange: ( From df80c4f4231c119ba19499721794514daedd31f8 Mon Sep 17 00:00:00 2001 From: tnagorra Date: Tue, 17 Sep 2024 14:10:59 +0545 Subject: [PATCH 05/59] Mark locations where useFormArrayWithEmptyCheck needs to be used - Add changelog --- .changeset/tricky-carrots-peel.md | 5 +++++ app/src/views/CountryProfileOverview/index.tsx | 1 + app/src/views/Emergencies/index.tsx | 1 + app/src/views/FieldReportForm/ActionsFields/index.tsx | 1 + app/src/views/FieldReportForm/EarlyActionsFields/index.tsx | 1 + .../PerAssessmentForm/AreaInput/ComponentInput/index.tsx | 2 ++ app/src/views/PerAssessmentForm/AreaInput/index.tsx | 1 + app/src/views/PerAssessmentForm/index.tsx | 1 + app/src/views/PerPrioritizationForm/index.tsx | 1 + app/src/views/PerWorkPlanForm/index.tsx | 3 +++ 10 files changed, 17 insertions(+) create mode 100644 .changeset/tricky-carrots-peel.md diff --git a/.changeset/tricky-carrots-peel.md b/.changeset/tricky-carrots-peel.md new file mode 100644 index 0000000000..fa2cad774f --- /dev/null +++ b/.changeset/tricky-carrots-peel.md @@ -0,0 +1,5 @@ +--- +"go-web-app": patch +--- + +Fix contact details in Field Report being always required when filled once diff --git a/app/src/views/CountryProfileOverview/index.tsx b/app/src/views/CountryProfileOverview/index.tsx index 7ec55def4e..e9cd7e62a5 100644 --- a/app/src/views/CountryProfileOverview/index.tsx +++ b/app/src/views/CountryProfileOverview/index.tsx @@ -168,6 +168,7 @@ export function Component() { return undefined; } + // FIXME: this sort will mutate the data const orderedMonth = month.sort( (a, b) => compareNumber(monthToOrderMap[a], monthToOrderMap[b]), ); diff --git a/app/src/views/Emergencies/index.tsx b/app/src/views/Emergencies/index.tsx index 35831e6f65..53214d03a9 100644 --- a/app/src/views/Emergencies/index.tsx +++ b/app/src/views/Emergencies/index.tsx @@ -139,6 +139,7 @@ export function Component() { const numAffectedCalculated = sumSafe( (events?.map( (event) => { + // FIXME: this sort will mutate the data const latestFieldReport = event.field_reports.sort( (a, b) => ( new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime() diff --git a/app/src/views/FieldReportForm/ActionsFields/index.tsx b/app/src/views/FieldReportForm/ActionsFields/index.tsx index 4682448b1e..56907e9469 100644 --- a/app/src/views/FieldReportForm/ActionsFields/index.tsx +++ b/app/src/views/FieldReportForm/ActionsFields/index.tsx @@ -114,6 +114,7 @@ function ActionsFields(props: Props) { const actionsError = getErrorObject(error?.actions_taken); + // FIXME: We might need to use useFormArrayWithEmptyCheck const { setValue: setActionValue, } = useFormArray<'actions_taken', ActionValue>( diff --git a/app/src/views/FieldReportForm/EarlyActionsFields/index.tsx b/app/src/views/FieldReportForm/EarlyActionsFields/index.tsx index b25b1baa1f..0b50c46dc6 100644 --- a/app/src/views/FieldReportForm/EarlyActionsFields/index.tsx +++ b/app/src/views/FieldReportForm/EarlyActionsFields/index.tsx @@ -104,6 +104,7 @@ function EarlyActionFields(props: Props) { const actionsError = getErrorObject(error?.actions_taken); + // FIXME: We might need to use useFormArrayWithEmptyCheck const { setValue: setActionValue, } = useFormArray<'actions_taken', ActionValue>( diff --git a/app/src/views/PerAssessmentForm/AreaInput/ComponentInput/index.tsx b/app/src/views/PerAssessmentForm/AreaInput/ComponentInput/index.tsx index 4146988b44..aeadff5e4d 100644 --- a/app/src/views/PerAssessmentForm/AreaInput/ComponentInput/index.tsx +++ b/app/src/views/PerAssessmentForm/AreaInput/ComponentInput/index.tsx @@ -122,6 +122,7 @@ function ComponentInput(props: Props) { [error], ); + // FIXME: We might need to use useFormArrayWithEmptyCheck const { setValue: setQuestionValue, } = useFormArray<'question_responses', NonNullable[number]>( @@ -150,6 +151,7 @@ function ComponentInput(props: Props) { const groupedQuestions = useMemo( () => listToGroupList( + // FIXME: this sort will mutate the data questions?.sort((q1, q2) => compareNumber(q1.question_num, q2.question_num)), (question) => question.question_group ?? NO_GROUP, ), diff --git a/app/src/views/PerAssessmentForm/AreaInput/index.tsx b/app/src/views/PerAssessmentForm/AreaInput/index.tsx index 95dd698806..9ae5da7669 100644 --- a/app/src/views/PerAssessmentForm/AreaInput/index.tsx +++ b/app/src/views/PerAssessmentForm/AreaInput/index.tsx @@ -81,6 +81,7 @@ function AreaInput(props: Props) { }), ); + // FIXME: We might need to use useFormArrayWithEmptyCheck const { setValue: setQuestionResponseValue, } = useFormArray('component_responses', setFieldValue); diff --git a/app/src/views/PerAssessmentForm/index.tsx b/app/src/views/PerAssessmentForm/index.tsx index 15aaceac34..5ae75f48f7 100644 --- a/app/src/views/PerAssessmentForm/index.tsx +++ b/app/src/views/PerAssessmentForm/index.tsx @@ -310,6 +310,7 @@ export function Component() { formContentRef.current?.scrollIntoView(); }, []); + // FIXME: We might need to use useFormArrayWithEmptyCheck const { setValue: setAreaResponsesValue, } = useFormArray('area_responses', setFieldValue); diff --git a/app/src/views/PerPrioritizationForm/index.tsx b/app/src/views/PerPrioritizationForm/index.tsx index 1b87ed6adc..f73a910086 100644 --- a/app/src/views/PerPrioritizationForm/index.tsx +++ b/app/src/views/PerPrioritizationForm/index.tsx @@ -151,6 +151,7 @@ export function Component() { }, }); + // FIXME: We might need to use useFormArrayWithEmptyCheck const { setValue: setComponentValue, removeValue: removeComponentValue, diff --git a/app/src/views/PerWorkPlanForm/index.tsx b/app/src/views/PerWorkPlanForm/index.tsx index ce7914f28a..0888fa6e19 100644 --- a/app/src/views/PerWorkPlanForm/index.tsx +++ b/app/src/views/PerWorkPlanForm/index.tsx @@ -141,6 +141,7 @@ export function Component() { [value?.prioritized_action_responses], ); + // FIXME: Not sure if this is required const customComponentResponseMapping = useMemo( () => ( listToMap( @@ -211,6 +212,7 @@ export function Component() { }, }); + // FIXME: We might need to use useFormArrayWithEmptyCheck const { setValue: setComponentValue, } = useFormArray<'prioritized_action_responses', NonNullable[number]>( @@ -218,6 +220,7 @@ export function Component() { setFieldValue, ); + // FIXME: We might need to use useFormArrayWithEmptyCheck const { setValue: setCustomComponentValue, removeValue: removeCustomComponentValue, From 94f5f6a2dd36cda396d188bfd288e444c00f986e Mon Sep 17 00:00:00 2001 From: frozenhelium Date: Wed, 3 Apr 2024 17:30:25 +0545 Subject: [PATCH 06/59] Add basic import template generation for the DREF form --- app/package.json | 2 +- app/src/utils/domain/dref.ts | 7 + app/src/utils/importTemplate.ts | 203 +++++++ .../ActiveDrefTable/styles.module.css | 4 +- .../DownloadImportTemplateButton/index.tsx | 538 ++++++++++++++++++ .../styles.module.css | 0 app/src/views/AccountMyFormsDref/index.tsx | 4 + .../DrefImportButton/index.tsx | 53 ++ app/src/views/DrefApplicationForm/index.tsx | 4 + yarn.lock | 2 +- 10 files changed, 814 insertions(+), 3 deletions(-) create mode 100644 app/src/utils/importTemplate.ts create mode 100644 app/src/views/AccountMyFormsDref/DownloadImportTemplateButton/index.tsx create mode 100644 app/src/views/AccountMyFormsDref/DownloadImportTemplateButton/styles.module.css create mode 100644 app/src/views/DrefApplicationForm/DrefImportButton/index.tsx diff --git a/app/package.json b/app/package.json index 9e056d7f47..37e49c1471 100644 --- a/app/package.json +++ b/app/package.json @@ -110,7 +110,7 @@ "surge": "^0.23.1", "ts-md5": "^1.3.1", "tsx": "^4.7.2", - "typescript": "^5.0.4", + "typescript": "^5.5.2", "unimported": "1.28.0", "vite": "^5.0.10", "vite-plugin-checker": "^0.6.2", diff --git a/app/src/utils/domain/dref.ts b/app/src/utils/domain/dref.ts index e71a9c6f41..a6efb9f52b 100644 --- a/app/src/utils/domain/dref.ts +++ b/app/src/utils/domain/dref.ts @@ -57,3 +57,10 @@ export const nsActionsOrder: Record = { national_society_eoc: 17, other: 18, }; + +export type DrefSheetName = 'Operation Overview' | 'Event Detail' | 'Actions-Needs' | 'Operation' | 'Timeframes and Contacts'; +export const SHEET_OPERATION_OVERVIEW = 'Operation Overview' satisfies DrefSheetName; +export const SHEET_EVENT_DETAIL = 'Event Detail' satisfies DrefSheetName; +export const SHEET_ACTIONS_NEEDS = 'Actions-Needs' satisfies DrefSheetName; +export const SHEET_OPERATION = 'Operation' satisfies DrefSheetName; +export const SHEET_TIMEFRAMES_AND_CONTACTS = 'Timeframes and Contacts' satisfies DrefSheetName; diff --git a/app/src/utils/importTemplate.ts b/app/src/utils/importTemplate.ts new file mode 100644 index 0000000000..e3b70dc6ee --- /dev/null +++ b/app/src/utils/importTemplate.ts @@ -0,0 +1,203 @@ +import { isNotDefined } from '@togglecorp/fujs'; + +type ValidationType = string | number | boolean; +type TypeToLiteral = T extends string + ? 'string' | 'date' + : T extends number + ? 'number' + : T extends boolean + ? 'boolean' + : never; +type ExtractValidation = T extends ValidationType + ? T + : never; + +interface BaseField { + label: string; +} + +interface InputField< + VALIDATION extends ValidationType +> extends BaseField { + type: 'input' + validation: TypeToLiteral +} + +interface SelectField< + VALIDATION extends ValidationType, + OPTIONS_MAPPING extends OptionsMapping, +> extends BaseField { + type: 'select' + validation: TypeToLiteral + // Make this more strict + optionsKey: { + [key in keyof OPTIONS_MAPPING]: VALIDATION extends OPTIONS_MAPPING[key][number]['key'] + ? key + : never + }[keyof OPTIONS_MAPPING] +} + +interface ListField< + VALUE, + OPTIONS_MAPPING extends OptionsMapping +> extends BaseField { + type: 'list' + // TODO: Make this more strict + optionsKey: keyof OPTIONS_MAPPING; + children: TemplateSchema< + VALUE, + OPTIONS_MAPPING + >; +} + +interface ObjectField { + type: 'object', + fields: { + [key in keyof VALUE]+?: TemplateSchema< + VALUE[key], + OPTIONS_MAPPING + > + }, +} + +interface OptionItem { + key: T; + label: string; +} + +interface OptionsMapping { + [key: string]: OptionItem[] | OptionItem[] | OptionItem[] +} + +export type TemplateSchema< + VALUE, + OPTIONS_MAPPING extends OptionsMapping, +> = VALUE extends (infer LIST_ITEM)[] + ? ( + ListField + | InputField> + | SelectField, OPTIONS_MAPPING> + ) : ( + VALUE extends object + ? ObjectField + : (InputField> + | SelectField, OPTIONS_MAPPING>) + ); + +interface HeadingTemplateField { + type: 'heading'; + name: string | number | boolean; + label: string; + outlineLevel: number; +} + +type ObjectKey = string | number | symbol; + +type InputTemplateField = { + type: 'input'; + name: string | number | boolean; + label: string; + outlineLevel: number; +} & ({ + dataValidation: 'list'; + optionsKey: ObjectKey; +} | { + dataValidation?: never; + optionsKey?: never; +}) + +type TemplateField = HeadingTemplateField | InputTemplateField; + +export function createImportTemplate( + schema: TemplateSchema, + optionsMap: OPTIONS_MAPPING, + fieldName: string | undefined = undefined, + outlineLevel = -1, +): TemplateField[] { + if (schema.type === 'object') { + return [ + ...Object.keys(schema.fields).flatMap((key) => { + const fieldSchema = schema.fields[key as keyof typeof schema.fields]; + + if (fieldSchema) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const newFields = createImportTemplate( + fieldSchema, + optionsMap, + key, + outlineLevel + 1, + ); + + return newFields; + } + + return []; + }), + ]; + } + + if (isNotDefined(fieldName)) { + return []; + } + + if (schema.type === 'input') { + const field = { + type: 'input', + name: fieldName, + label: schema.label, + outlineLevel, + } satisfies InputTemplateField; + + return [field]; + } + + if (schema.type === 'select') { + const field = { + type: 'input', + name: fieldName, + label: schema.label, + outlineLevel, + dataValidation: 'list', + optionsKey: schema.optionsKey, + } satisfies InputTemplateField; + + return [field]; + } + + const headingField = { + type: 'heading', + name: fieldName, + label: schema.label, + outlineLevel, + } satisfies HeadingTemplateField; + + // fields.push(headingField); + const options = optionsMap[schema.optionsKey]; + + const optionFields = options.flatMap((option) => { + const subHeadingField = { + type: 'heading', + name: option.key, + label: option.label, + outlineLevel: outlineLevel + 1, + } satisfies HeadingTemplateField; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const newFields = createImportTemplate( + schema.children, + optionsMap, + undefined, + outlineLevel + 1, + ); + + return [ + subHeadingField, + ...newFields, + ]; + }); + + return [ + headingField, + ...optionFields, + ]; +} diff --git a/app/src/views/AccountMyFormsDref/ActiveDrefTable/styles.module.css b/app/src/views/AccountMyFormsDref/ActiveDrefTable/styles.module.css index a08e04bb03..65879e952b 100644 --- a/app/src/views/AccountMyFormsDref/ActiveDrefTable/styles.module.css +++ b/app/src/views/AccountMyFormsDref/ActiveDrefTable/styles.module.css @@ -6,13 +6,14 @@ } .table { + .type, .status, .date { width: 0%; min-width: 7rem; } - .type, + .country, .appeal-code { width: 0%; min-width: 8rem; @@ -23,6 +24,7 @@ } .country { + width: 0%; min-width: 9rem; } diff --git a/app/src/views/AccountMyFormsDref/DownloadImportTemplateButton/index.tsx b/app/src/views/AccountMyFormsDref/DownloadImportTemplateButton/index.tsx new file mode 100644 index 0000000000..3a92311039 --- /dev/null +++ b/app/src/views/AccountMyFormsDref/DownloadImportTemplateButton/index.tsx @@ -0,0 +1,538 @@ +import { + useCallback, + useState, +} from 'react'; +import { Button } from '@ifrc-go/ui'; +import { + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; +import xlsx from 'exceljs'; +import FileSaver from 'file-saver'; + +import ifrcLogoFile from '#assets/icons/ifrc-square.png'; +import useCountry from '#hooks/domain/useCountry'; +import useDisasterTypes from '#hooks/domain/useDisasterType'; +import useGlobalEnums from '#hooks/domain/useGlobalEnums'; +import useNationalSociety from '#hooks/domain/useNationalSociety'; +import { + COLOR_DARK_GREY, + COLOR_PRIMARY_BLUE, + COLOR_PRIMARY_RED, +} from '#utils/constants'; +import { + DrefSheetName, + SHEET_ACTIONS_NEEDS, + SHEET_EVENT_DETAIL, + SHEET_OPERATION, + SHEET_OPERATION_OVERVIEW, + SHEET_TIMEFRAMES_AND_CONTACTS, +} from '#utils/domain/dref'; +import { + createImportTemplate, + TemplateSchema, +} from '#utils/importTemplate'; +import { DrefRequestBody } from '#views/DrefApplicationForm/schema'; + +function hexToArgb(hexStr: string, alphaStr = 'ff') { + const hexWithoutHash = hexStr.substring(1, hexStr.length); + + return `${alphaStr}${hexWithoutHash}`; +} + +const headerRowStyle: Partial = { + font: { + name: 'Montserrat', + bold: true, + }, + fill: { + type: 'pattern', + pattern: 'lightVertical', + fgColor: { argb: hexToArgb(COLOR_PRIMARY_RED, '10') }, + }, + alignment: { + vertical: 'middle', + horizontal: 'center', + }, +}; + +const headingStyle: Partial = { + font: { + name: 'Montserrat', + color: { argb: hexToArgb(COLOR_PRIMARY_BLUE) }, + }, + alignment: { + horizontal: 'left', + vertical: 'middle', + }, +}; + +const defaultCellStyle: Partial = { + font: { + name: 'Poppins', + }, + alignment: { + horizontal: 'left', + vertical: 'top', + wrapText: true, + }, +}; + +const inputBorderStyle: Partial = { + style: 'dashed', + color: { argb: hexToArgb(COLOR_PRIMARY_BLUE) }, +}; + +const inputCellStyle: Partial = { + fill: { + type: 'pattern', + pattern: 'lightVertical', + fgColor: { argb: hexToArgb(COLOR_DARK_GREY, '10') }, + }, + border: { + top: inputBorderStyle, + left: inputBorderStyle, + right: inputBorderStyle, + bottom: inputBorderStyle, + }, + alignment: { + vertical: 'top', + wrapText: true, + }, +}; + +function addRow( + sheet: xlsx.Worksheet, + rowNum: number, + outlineLevel: number, + name: string, + label: string, + style?: Partial, +) { + const col = 1; + + const row = sheet.getRow(rowNum); + + row.getCell(col).name = name; + row.getCell(col + 1).name = name; + + row.getCell(col).value = label; + row.outlineLevel = outlineLevel; + + if (style) { + row.getCell(col).style = style; + } else { + row.getCell(col).style = defaultCellStyle; + } + + const prevStyle = row.getCell(col).style; + row.getCell(col).style = { + ...prevStyle, + alignment: { + ...prevStyle?.alignment, + indent: outlineLevel * 2, + }, + }; + + return row; +} + +function addInputRow( + sheet: xlsx.Worksheet, + rowNum: number, + outlineLevel: number, + name: string, + label: string, + optionKey?: string, + optionsWorksheet?: xlsx.Worksheet, + style?: Partial, +) { + const col = 1; + + const row = addRow( + sheet, + rowNum, + outlineLevel, + name, + label, + style, + ); + + const inputCell = row.getCell(col + 1); + inputCell.style = inputCellStyle; + + if (isDefined(optionKey) && isDefined(optionsWorksheet)) { + const optionsColumn = optionsWorksheet.getColumnKey(optionKey); + + if (optionsColumn) { + const colLetter = optionsColumn.letter; + const numOptions = optionsColumn.values.length; + + const formulae = `=${optionsWorksheet.name}!$${colLetter}$2:$${colLetter}$${numOptions}`; + + inputCell.dataValidation = { + type: 'list', + formulae: [formulae], + }; + } + } + + return row; +} + +function DownloadImportTemplateButton() { + const [generationPending, setGenerationPending] = useState(false); + + const nationalSocieties = useNationalSociety(); + const countries = useCountry(); + const disasterTypes = useDisasterTypes(); + + const { + dref_planned_intervention_title, + dref_national_society_action_title, + dref_identified_need_title, + dref_dref_onset_type, + dref_dref_disaster_category, + } = useGlobalEnums(); + + const handleClick = useCallback( + () => { + const optionsMap = { + __boolean: [ + { + key: true, + label: 'Yes', + }, + { + key: false, + label: 'No', + }, + ], + national_society: nationalSocieties.map( + ({ id, society_name }) => ({ key: id, label: society_name }), + ), + country: countries.map( + ({ id, name }) => ({ key: id, label: name }), + ), + disaster_type: disasterTypes?.map( + ({ id, name }) => ({ key: id, label: name }), + ) ?? [], + type_of_onset: dref_dref_onset_type?.map( + ({ key, value }) => ({ key, label: value }), + ) ?? [], + disaster_category: dref_dref_disaster_category?.map( + ({ key, value }) => ({ key, label: value }), + ) ?? [], + planned_interventions: dref_planned_intervention_title?.map( + ({ key, value }) => ({ key, label: value }), + ) ?? [], + planned_interventions__indicators: [ + { key: 'indicator__0', label: 'Indicator #1' }, + { key: 'indicator__1', label: 'Indicator #2' }, + { key: 'indicator__2', label: 'Indicator #3' }, + ], + ns_actions: dref_national_society_action_title?.map( + ({ key, value }) => ({ key, label: value }), + ) ?? [], + identified_needs: dref_identified_need_title?.map( + ({ key, value }) => ({ key, label: value }), + ) ?? [], + }; + + const drefFormSchema: TemplateSchema = { + type: 'object', + fields: { + national_society: { + type: 'select', + label: 'National society', + validation: 'number', + optionsKey: 'national_society', + }, + + // We're skipping type of DREF since we'll have separate + // template for each type of dref + // type_of_dref: xxx + + disaster_type: { + type: 'select', + label: 'Type of disaster', + validation: 'number', + optionsKey: 'disaster_type', + }, + + // type_of_onset: + // disaster_category: + + country: { + type: 'select', + label: 'Country', + validation: 'number', + optionsKey: 'country', + }, + + title: { + type: 'input', + label: 'DREF Title', + validation: 'string', + }, + + emergency_appeal_planned: { + type: 'select', + label: 'Emergency appeal planned', + optionsKey: '__boolean', + validation: 'boolean', + }, + + did_it_affect_same_population: { + type: 'select', + label: 'Has a similar event affected the same area(s) in the last 3 years?', + optionsKey: '__boolean', + validation: 'boolean', + }, + + planned_interventions: { + type: 'list', + label: 'Planned interventions', + optionsKey: 'planned_interventions', + children: { + type: 'object', + fields: { + budget: { + type: 'input', + validation: 'number', + label: 'Budget', + }, + person_targeted: { + type: 'input', + validation: 'number', + label: 'Person targeted', + }, + description: { + type: 'input', + validation: 'string', + label: 'Description', + }, + indicators: { + type: 'list', + label: 'Indicators', + optionsKey: 'planned_interventions__indicators', + children: { + type: 'object', + fields: { + title: { + type: 'input', + validation: 'string', + label: 'Title', + }, + target: { + type: 'input', + validation: 'number', + label: 'Target', + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const templateActions = createImportTemplate(drefFormSchema, optionsMap); + + async function generateTemplate() { + const workbook = new xlsx.Workbook(); + const now = new Date(); + workbook.created = now; + // workbook.description = JSON.stringify(drefFormSchema); + + const response = await fetch(ifrcLogoFile); + const buffer = await response.arrayBuffer(); + + const ifrcLogo = workbook.addImage({ + buffer, + extension: 'png', + }); + + const coverWorksheet = workbook.addWorksheet('DREF Import'); + + const overviewWorksheet = workbook.addWorksheet(SHEET_OPERATION_OVERVIEW); + const eventDetailsWorksheet = workbook.addWorksheet(SHEET_EVENT_DETAIL); + const actionsNeedsWorksheet = workbook.addWorksheet(SHEET_ACTIONS_NEEDS); + const operationWorksheet = workbook.addWorksheet(SHEET_OPERATION); + const timeframeAndContactsWorksheet = workbook.addWorksheet( + SHEET_TIMEFRAMES_AND_CONTACTS, + ); + + const sheetMap: Record = { + [SHEET_OPERATION_OVERVIEW]: overviewWorksheet, + [SHEET_EVENT_DETAIL]: eventDetailsWorksheet, + [SHEET_ACTIONS_NEEDS]: actionsNeedsWorksheet, + [SHEET_OPERATION]: operationWorksheet, + [SHEET_TIMEFRAMES_AND_CONTACTS]: timeframeAndContactsWorksheet, + }; + + const optionsWorksheet = workbook.addWorksheet('options'); + optionsWorksheet.state = 'veryHidden'; + const optionKeys = Object.keys(optionsMap) as (keyof (typeof optionsMap))[]; + + optionsWorksheet.columns = optionKeys.map((key) => ( + { header: key, key } + )); + + optionKeys.forEach((key) => { + const options = optionsMap[key]; + + if (isDefined(options)) { + const column = optionsWorksheet.getColumnKey(key); + + options.forEach((option, i) => { + const cell = optionsWorksheet.getCell(i + 2, column.number); + cell.name = String(option.key); + cell.value = option.label; + }); + } + }); + + coverWorksheet.addImage(ifrcLogo, 'A1:B6'); + coverWorksheet.getCell('C1').value = 'DISASTER RESPONSE EMERGENCY FUND'; + coverWorksheet.mergeCells('C1:L3'); + coverWorksheet.getCell('C1:L3').style = { + font: { + name: 'Montserrat', + family: 2, + bold: true, + size: 20, + color: { argb: hexToArgb(COLOR_PRIMARY_RED) }, + }, + alignment: { horizontal: 'center', vertical: 'middle' }, + }; + coverWorksheet.addRow(''); + coverWorksheet.addRow(''); + coverWorksheet.addRow(''); + coverWorksheet.addRow(''); + coverWorksheet.mergeCells('C4:L6'); + coverWorksheet.getCell('C4').value = 'Import template'; + coverWorksheet.getCell('C4').style = { + font: { + bold: true, size: 18, name: 'Montserrat', family: 2, + }, + alignment: { horizontal: 'center', vertical: 'middle' }, + }; + + const rowOffset = 2; + + templateActions.forEach((templateAction, i) => { + if (templateAction.type === 'heading') { + addRow( + overviewWorksheet, + i + rowOffset, + templateAction.outlineLevel, + String(templateAction.name), + templateAction.label, + { + ...headingStyle, + font: { + ...headingStyle.font, + }, + }, + ); + } else if (templateAction.type === 'input') { + if (templateAction.dataValidation === 'list') { + addInputRow( + overviewWorksheet, + i + rowOffset, + templateAction.outlineLevel, + String(templateAction.name), + templateAction.label, + String(templateAction.optionsKey), + optionsWorksheet, + ); + } else { + addInputRow( + overviewWorksheet, + i + rowOffset, + templateAction.outlineLevel, + String(templateAction.name), + templateAction.label, + ); + } + } + }); + + Object.values(sheetMap).forEach( + (sheet) => { + const worksheet = sheet; + worksheet.properties.defaultRowHeight = 20; + worksheet.properties.showGridLines = false; + + worksheet.columns = [ + { + key: 'field', + header: 'Field', + protection: { locked: true }, + width: 50, + }, + { + key: 'value', + header: 'Value', + width: 50, + }, + ]; + + worksheet.getRow(1).eachCell( + (cell) => { + // eslint-disable-next-line no-param-reassign + cell.style = headerRowStyle; + }, + ); + }, + ); + + await workbook.xlsx.writeBuffer().then( + (sheet) => { + FileSaver.saveAs( + new Blob([sheet], { type: 'application/vnd.ms-excel;charset=utf-8' }), + `DREF import template ${now.toLocaleString()}.xlsx`, + ); + }, + ); + + setGenerationPending(false); + } + + setGenerationPending((alreadyGenerating) => { + if (!alreadyGenerating) { + generateTemplate(); + } + + return true; + }); + }, + [ + countries, + disasterTypes, + nationalSocieties, + dref_planned_intervention_title, + dref_national_society_action_title, + dref_identified_need_title, + dref_dref_onset_type, + dref_dref_disaster_category, + ], + ); + + return ( + + ); +} + +export default DownloadImportTemplateButton; diff --git a/app/src/views/AccountMyFormsDref/DownloadImportTemplateButton/styles.module.css b/app/src/views/AccountMyFormsDref/DownloadImportTemplateButton/styles.module.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/src/views/AccountMyFormsDref/index.tsx b/app/src/views/AccountMyFormsDref/index.tsx index b3e7d8eae2..50df64ec5e 100644 --- a/app/src/views/AccountMyFormsDref/index.tsx +++ b/app/src/views/AccountMyFormsDref/index.tsx @@ -10,6 +10,7 @@ import Link from '#components/Link'; import ActiveDrefTable from './ActiveDrefTable'; import CompletedDrefTable from './CompletedDrefTable'; +import DownloadImportTemplateButton from './DownloadImportTemplateButton'; import i18n from './i18n.json'; import styles from './styles.module.css'; @@ -32,6 +33,9 @@ export function Component() { {strings.drefFeedbackForm}
+
+ +
{currentView === 'active' && ( void; +} + +function DrefImportButton(props: Props) { + const { onImport } = props; + + const handleChange = useCallback((file: File | undefined) => { + if (isNotDefined(file)) { + return; + } + + async function loadFile(excelFile: File) { + const workbook = new xlsx.Workbook(); + const buffer = await excelFile.arrayBuffer(); + await workbook.xlsx.load(buffer); + + const worksheet = workbook.getWorksheet(SHEET_OPERATION_OVERVIEW); + worksheet?.eachRow((row) => { + const fieldName = row.getCell(1)?.name; + console.info(fieldName); + }); + + if (onImport) { + onImport(); + } + } + + loadFile(file); + }, [onImport]); + + return ( + + Import + + ); +} + +export default DrefImportButton; diff --git a/app/src/views/DrefApplicationForm/index.tsx b/app/src/views/DrefApplicationForm/index.tsx index 861bc5e921..320984627b 100644 --- a/app/src/views/DrefApplicationForm/index.tsx +++ b/app/src/views/DrefApplicationForm/index.tsx @@ -77,6 +77,7 @@ import Submission from './Submission'; import i18n from './i18n.json'; import styles from './styles.module.css'; +import DrefImportButton from './DrefImportButton'; type GetDrefResponse = GoApiResponse<'/api/v2/dref/{id}/'>; @@ -572,6 +573,9 @@ export function Component() { )} actions={( <> + {isNotDefined(drefId) && ( + + )} {isDefined(drefId) && ( <> + )} + contentViewType="vertical" + spacing="comfortable" + onClose={onComplete} + > + +
+ {strings.description} +
+ + ); +} + +export default DownloadImportTemplateModal; diff --git a/app/src/views/AccountMyFormsDref/DownloadImportTemplateButton/DownloadImportTemplateModal/useImportTemplateSchema.ts b/app/src/views/AccountMyFormsDref/DownloadImportTemplateButton/DownloadImportTemplateModal/useImportTemplateSchema.ts new file mode 100644 index 0000000000..c82c61f8a5 --- /dev/null +++ b/app/src/views/AccountMyFormsDref/DownloadImportTemplateButton/DownloadImportTemplateModal/useImportTemplateSchema.ts @@ -0,0 +1,740 @@ +import { useMemo } from 'react'; + +import useCountry from '#hooks/domain/useCountry'; +import useDisasterTypes from '#hooks/domain/useDisasterType'; +import useGlobalEnums from '#hooks/domain/useGlobalEnums'; +import useNationalSociety from '#hooks/domain/useNationalSociety'; +import { type TemplateSchema } from '#utils/importTemplate'; +import { type DrefRequestBody } from '#views/DrefApplicationForm/schema'; + +function useImportTemplateSchema() { + const nationalSocieties = useNationalSociety(); + const countries = useCountry(); + const disasterTypes = useDisasterTypes(); + + const { + dref_planned_intervention_title, + dref_national_society_action_title, + dref_identified_need_title, + dref_dref_onset_type, + dref_dref_disaster_category, + } = useGlobalEnums(); + + const optionsMap = useMemo(() => ({ + __boolean: [ + { + key: true, + label: 'Yes', + }, + { + key: false, + label: 'No', + }, + ], + national_society: nationalSocieties.map( + ({ id, society_name }) => ({ key: id, label: society_name }), + ), + country: countries?.map( + ({ id, name }) => ({ key: id, label: name }), + ), + disaster_type: disasterTypes?.map( + ({ id, name }) => ({ key: id, label: name }), + ) ?? [], + type_of_onset: dref_dref_onset_type?.map( + ({ key, value }) => ({ key, label: value }), + ) ?? [], + disaster_category: dref_dref_disaster_category?.map( + ({ key, value }) => ({ key, label: value }), + ) ?? [], + planned_interventions: dref_planned_intervention_title?.map( + ({ key, value }) => ({ key, label: value }), + ) ?? [], + source_information: [ + { key: 'source__0', label: 'Source #1' }, + { key: 'source__1', label: 'Source #2' }, + { key: 'source__2', label: 'Source #3' }, + ], + planned_interventions__indicators: [ + { key: 'indicator__0', label: 'Indicator #1' }, + { key: 'indicator__1', label: 'Indicator #2' }, + { key: 'indicator__2', label: 'Indicator #3' }, + ], + risk_security: [ + { key: 'risk__0', label: 'Risk #1' }, + { key: 'risk__1', label: 'Risk #2' }, + { key: 'risk__2', label: 'Risk #3' }, + ], + national_society_actions: dref_national_society_action_title?.map( + ({ key, value }) => ({ key, label: value }), + ) ?? [], + identified_needs: dref_identified_need_title?.map( + ({ key, value }) => ({ key, label: value }), + ) ?? [], + }), [ + countries, + disasterTypes, + nationalSocieties, + dref_planned_intervention_title, + dref_national_society_action_title, + dref_identified_need_title, + dref_dref_onset_type, + dref_dref_disaster_category, + ]); + + const drefFormSchema: TemplateSchema = useMemo(() => ({ + type: 'object', + fields: { + national_society: { + type: 'select', + label: 'National society', + validation: 'number', + optionsKey: 'national_society', + }, + + // We're skipping type of DREF since we'll have separate + // template for each type of dref + // type_of_dref: xxx + + disaster_type: { + type: 'select', + label: 'Type of disaster', + validation: 'number', + optionsKey: 'disaster_type', + }, + + type_of_onset: { + type: 'select', + label: 'Type of Onset', + validation: 'number', + optionsKey: 'type_of_onset', + }, + + is_man_made_event: { + type: 'select', + label: 'Is this a man made event?', + validation: 'boolean', + optionsKey: '__boolean', + }, + + disaster_category: { + type: 'select', + label: 'Disaster Category', + validation: 'number', + optionsKey: 'disaster_category', + }, + + country: { + type: 'select', + label: 'Country', + validation: 'number', + optionsKey: 'country', + }, + + title: { + type: 'input', + label: 'DREF Title', + validation: 'string', + }, + + emergency_appeal_planned: { + type: 'select', + label: 'Emergency appeal planned', + optionsKey: '__boolean', + validation: 'boolean', + }, + + // Event eventDetail + // Previous Operations + did_it_affect_same_area: { + type: 'select', + label: 'Has a similar event affected the same area(s) in the last 3 years?', + optionsKey: '__boolean', + validation: 'boolean', + }, + + did_it_affect_same_population: { + type: 'select', + label: 'Did it affect the same population groups?', + optionsKey: '__boolean', + validation: 'boolean', + description: 'Select only if you\'ve selected Yes for the above', + }, + + did_ns_respond: { + type: 'select', + label: 'Did the National Society respond?', + optionsKey: '__boolean', + validation: 'boolean', + description: 'Select only if you\'ve selected Yes for the above', + }, + + did_ns_request_fund: { + type: 'select', + label: 'Did the National Society request funding from DREF for that event(s)?', + optionsKey: '__boolean', + validation: 'boolean', + description: 'Select only if you\'ve selected Yes for the above', + }, + + ns_request_text: { + type: 'input', + label: 'If yes, please specify which operations', + validation: 'string', + description: 'Select only if you\'ve selected Yes for the above', + }, + + dref_recurrent_text: { + type: 'input', + label: 'If you have answered yes to all questions above, justify why the use of DREF for a recurrent event, or how this event should not be considered recurrent', + validation: 'string', + }, + + lessons_learned: { + type: 'input', + label: 'Lessons Learned', + validation: 'string', + description: 'Specify how the lessons learnt from these previous operations are being used to mitigate similar challenges in the current operation', + }, + + event_date: { + type: 'input', + label: 'Date of the Event', + validation: 'date', + }, + + num_affected: { + type: 'input', + validation: 'number', + label: 'Total affected population', + description: 'People Affected include all those whose lives and livelihoods have been impacted as a direct result of the shock or stress.', + }, + + people_in_need: { + type: 'input', + validation: 'number', + label: 'People in need (Optional)', + description: 'People in Need (PIN) are those members whose physical security, basic rights, dignity, living conditions or livelihoods are threatened or have been disrupted, and whose current level of access to basic services, goods and social protection is inadequate to re-establish normal living conditions without additional assistance.', + }, + + event_description: { + type: 'input', + validation: 'string', + label: 'What happened, where and when?', + }, + + event_scope: { + type: 'input', + validation: 'string', + label: 'Scope and scale of the event', + }, + + source_information: { + type: 'list', + label: 'Source Information', + optionsKey: 'source_information', + children: { + type: 'object', + fields: { + source_name: { + type: 'input', + validation: 'string', + label: 'Name', + }, + source_link: { + type: 'input', + validation: 'string', + label: 'Link', + }, + }, + }, + }, + + did_national_society: { + type: 'select', + validation: 'boolean', + optionsKey: '__boolean', + label: 'Has the National Society started any actions?', + }, + + national_society_actions: { + type: 'list', + label: 'National Society Actions', + keyFieldName: 'title', + optionsKey: 'national_society_actions', + children: { + type: 'object', + fields: { + description: { + type: 'input', + validation: 'string', + label: 'Description', + }, + }, + }, + }, + + ifrc: { + type: 'input', + validation: 'string', + label: 'IFRC', + }, + + partner_national_society: { + type: 'input', + validation: 'string', + label: 'Participating National Societies', + }, + + icrc: { + type: 'input', + validation: 'string', + label: 'ICRC', + }, + + government_requested_assistance: { + type: 'select', + validation: 'boolean', + optionsKey: '__boolean', + label: 'Government has requested international assistance', + }, + + national_authorities: { + type: 'input', + validation: 'string', + label: 'National authorities', + }, + + un_or_other_actor: { + type: 'input', + validation: 'string', + label: 'UN or other actors', + }, + + is_there_major_coordination_mechanism: { + type: 'select', + validation: 'boolean', + optionsKey: '__boolean', + label: 'Are there major coordination mechanisms in place?', + }, + + needs_identified: { + type: 'list', + label: 'Identified Needs', + optionsKey: 'identified_needs', + children: { + type: 'object', + fields: { + description: { + type: 'input', + validation: 'string', + label: 'Description', + }, + }, + }, + }, + + identified_gaps: { + type: 'input', + validation: 'string', + label: 'Any identified gaps/limitations in the assessment', + }, + + // Operation + operation_objective: { + type: 'input', + validation: 'string', + label: 'Overall objective of the operation', + }, + + response_strategy: { + type: 'input', + validation: 'string', + label: 'Operation strategy rationale', + }, + + people_assisted: { + type: 'input', + validation: 'string', + label: 'Who will be targeted through this operation?', + }, + + selection_criteria: { + type: 'input', + validation: 'string', + label: 'Explain the selection criteria for the targeted population', + }, + + women: { + type: 'input', + validation: 'number', + label: 'Women', + }, + + men: { + type: 'input', + validation: 'number', + label: 'Men', + }, + + girls: { + type: 'input', + validation: 'number', + label: 'Girls', + }, + + boys: { + type: 'input', + validation: 'number', + label: 'Boys (under 18)', + }, + + total_targeted_population: { + type: 'input', + validation: 'number', + label: 'Total Population', + }, + + disability_people_per: { + type: 'input', + validation: 'number', + label: 'Estimated Percentage People with Disability', + }, + + people_per_urban: { + type: 'input', + validation: 'number', + label: 'Estimated Percentage (Urban to Rural)', + }, + + displaced_people: { + type: 'input', + validation: 'number', + label: 'Estimated number of People on the move (if any)', + }, + + risk_security: { + type: 'list', + label: 'Please indicate about potential operational risk for this operations and mitigation actions', + optionsKey: 'risk_security', + children: { + type: 'object', + fields: { + risk: { + type: 'input', + validation: 'string', + label: 'Risk', + }, + mitigation: { + type: 'input', + validation: 'string', + label: 'Mitigation action', + }, + }, + }, + }, + + risk_security_concern: { + type: 'input', + validation: 'string', + label: 'Please indicate any security and safety concerns for this operation', + }, + + has_child_safeguarding_risk_analysis_assessment: { + type: 'select', + optionsKey: '__boolean', + validation: 'boolean', + label: 'Has the child safeguarding risk analysis assessment been completed?', + }, + + amount_requested: { + type: 'input', + validation: 'number', + label: 'Requested Amount in CHF', + }, + + planned_interventions: { + type: 'list', + label: 'Planned interventions', + optionsKey: 'planned_interventions', + keyFieldName: 'title', + children: { + type: 'object', + fields: { + budget: { + type: 'input', + validation: 'number', + label: 'Budget', + }, + person_targeted: { + type: 'input', + validation: 'number', + label: 'Person targeted', + }, + description: { + type: 'input', + validation: 'string', + label: 'List of activities', + }, + indicators: { + type: 'list', + label: 'Indicators', + optionsKey: 'planned_interventions__indicators', + children: { + type: 'object', + fields: { + title: { + type: 'input', + validation: 'string', + label: 'Title', + }, + target: { + type: 'input', + validation: 'number', + label: 'Target', + }, + }, + }, + }, + }, + }, + }, + + human_resource: { + type: 'input', + validation: 'string', + label: 'How many staff and volunteers will be involved in this operation. Briefly describe their role.', + }, + + is_surge_personnel_deployed: { + type: 'select', + validation: 'boolean', + optionsKey: '__boolean', + label: 'Will be surge personnel be deployed?', + }, + + surge_personnel_deployed: { + type: 'input', + validation: 'string', + label: 'Description', + }, + + logistic_capacity_of_ns: { + type: 'input', + validation: 'string', + label: 'If there is procurement, will be done by National Society or IFRC?', + }, + + pmer: { + type: 'input', + validation: 'string', + label: 'How will this operation be monitored?', + }, + + communication: { + type: 'input', + validation: 'string', + label: 'Please briefly explain the National Societies communication strategy for this operation.', + }, + + // Submission + ns_request_date: { + type: 'input', + validation: 'date', + label: 'Date of National Society Application', + }, + + submission_to_geneva: { + type: 'input', + validation: 'date', + label: 'Date of Submission to GVA', + }, + + date_of_approval: { + type: 'input', + validation: 'date', + label: 'Date of Approval', + }, + + operation_timeframe: { + type: 'input', + validation: 'number', + label: 'Operation timeframe', + }, + + end_date: { + type: 'input', + validation: 'date', + label: 'End date of Operation', + }, + + publishing_date: { + type: 'input', + validation: 'date', + label: 'Date of Publishing', + }, + + appeal_code: { + type: 'input', + validation: 'string', + label: 'Appeal Code', + }, + + glide_code: { + type: 'input', + validation: 'string', + label: 'GLIDE number', + }, + + ifrc_appeal_manager_name: { + type: 'input', + validation: 'string', + label: 'IFRC Appeal Manager Name', + }, + + ifrc_appeal_manager_title: { + type: 'input', + validation: 'string', + label: 'IFRC Appeal Manager Title', + }, + + ifrc_appeal_manager_email: { + type: 'input', + validation: 'string', + label: 'IFRC Appeal Manager Email', + }, + + ifrc_appeal_manager_phone_number: { + type: 'input', + validation: 'string', + label: 'IFRC Appeal Manager Phone Number', + }, + + ifrc_project_manager_name: { + type: 'input', + validation: 'string', + label: 'IFRC Project Manager Name', + }, + + ifrc_project_manager_title: { + type: 'input', + validation: 'string', + label: 'IFRC Project Manager Title', + }, + + ifrc_project_manager_email: { + type: 'input', + validation: 'string', + label: 'IFRC Project Manager Email', + }, + + ifrc_project_manager_phone_number: { + type: 'input', + validation: 'string', + label: 'IFRC Project Manager Phone Number', + }, + + national_society_contact_name: { + type: 'input', + validation: 'string', + label: 'National Society Contact Name', + }, + + national_society_contact_title: { + type: 'input', + validation: 'string', + label: 'National Society Contact Title', + }, + + national_society_contact_email: { + type: 'input', + validation: 'string', + label: 'National Society Contact Email', + }, + + national_society_contact_phone_number: { + type: 'input', + validation: 'string', + label: 'National Society Contact Phone Number', + }, + + ifrc_emergency_name: { + type: 'input', + validation: 'string', + label: 'IFRC focal point for the emergency Name', + }, + + ifrc_emergency_title: { + type: 'input', + validation: 'string', + label: 'IFRC focal point for the emergency Title', + }, + + ifrc_emergency_email: { + type: 'input', + validation: 'string', + label: 'IFRC focal point for the emergency Email', + }, + + ifrc_emergency_phone_number: { + type: 'input', + validation: 'string', + label: 'IFRC focal point for the emergency Phone number', + }, + + regional_focal_point_name: { + type: 'input', + validation: 'string', + label: 'DREF Regional Focal Point Name', + }, + + regional_focal_point_title: { + type: 'input', + validation: 'string', + label: 'DREF Regional Focal Point Title', + }, + + regional_focal_point_email: { + type: 'input', + validation: 'string', + label: 'DREF Regional Focal Point Email', + }, + + regional_focal_point_phone_number: { + type: 'input', + validation: 'string', + label: 'DREF Regional Focal Point Phone Number', + }, + + media_contact_name: { + type: 'input', + validation: 'string', + label: 'Media Contact Name', + }, + + media_contact_title: { + type: 'input', + validation: 'string', + label: 'Media Contact Title', + }, + + media_contact_email: { + type: 'input', + validation: 'string', + label: 'Media Contact Email', + }, + + media_contact_phone_number: { + type: 'input', + validation: 'string', + label: 'Media Contact Phone Number', + }, + }, + }), []); + + return { + drefFormSchema, + optionsMap, + }; +} + +export default useImportTemplateSchema; diff --git a/app/src/views/AccountMyFormsDref/DownloadImportTemplateButton/index.tsx b/app/src/views/AccountMyFormsDref/DownloadImportTemplateButton/index.tsx index 61815b9a23..bdfd78a9af 100644 --- a/app/src/views/AccountMyFormsDref/DownloadImportTemplateButton/index.tsx +++ b/app/src/views/AccountMyFormsDref/DownloadImportTemplateButton/index.tsx @@ -1,1078 +1,32 @@ -import { - useCallback, - useState, -} from 'react'; import { Button } from '@ifrc-go/ui'; -import { - isDefined, - isNotDefined, -} from '@togglecorp/fujs'; -import xlsx from 'exceljs'; -import FileSaver from 'file-saver'; +import { useBooleanState } from '@ifrc-go/ui/hooks'; -import ifrcLogoFile from '#assets/icons/ifrc-square.png'; -import useCountry from '#hooks/domain/useCountry'; -import useDisasterTypes from '#hooks/domain/useDisasterType'; -import useGlobalEnums from '#hooks/domain/useGlobalEnums'; -import useNationalSociety from '#hooks/domain/useNationalSociety'; -import { - COLOR_DARK_GREY, - COLOR_PRIMARY_BLUE, - COLOR_PRIMARY_RED, -} from '#utils/constants'; -import { - DrefSheetName, - SHEET_ACTIONS_NEEDS, - SHEET_EVENT_DETAIL, - SHEET_OPERATION, - SHEET_OPERATION_OVERVIEW, - SHEET_TIMEFRAMES_AND_CONTACTS, -} from '#utils/domain/dref'; -import { - createImportTemplate, - TemplateSchema, -} from '#utils/importTemplate'; -import { DrefRequestBody } from '#views/DrefApplicationForm/schema'; - -function hexToArgb(hexStr: string, alphaStr = 'ff') { - const hexWithoutHash = hexStr.substring(1, hexStr.length); - - return `${alphaStr}${hexWithoutHash}`; -} - -const headerRowStyle: Partial = { - font: { - name: 'Montserrat', - bold: true, - }, - fill: { - type: 'pattern', - pattern: 'lightVertical', - fgColor: { argb: hexToArgb(COLOR_PRIMARY_RED, '10') }, - }, - alignment: { - vertical: 'middle', - horizontal: 'center', - }, -}; - -const headingStyle: Partial = { - font: { - name: 'Montserrat', - color: { argb: hexToArgb(COLOR_PRIMARY_BLUE) }, - }, - alignment: { - horizontal: 'left', - vertical: 'middle', - }, -}; - -const defaultCellStyle: Partial = { - font: { - name: 'Poppins', - }, - alignment: { - horizontal: 'left', - vertical: 'top', - wrapText: true, - }, -}; - -const inputBorderStyle: Partial = { - style: 'dashed', - color: { argb: hexToArgb(COLOR_PRIMARY_BLUE) }, -}; - -const inputCellStyle: Partial = { - fill: { - type: 'pattern', - pattern: 'lightVertical', - fgColor: { argb: hexToArgb(COLOR_DARK_GREY, '10') }, - }, - border: { - top: inputBorderStyle, - left: inputBorderStyle, - right: inputBorderStyle, - bottom: inputBorderStyle, - }, - alignment: { - vertical: 'top', - wrapText: true, - }, -}; - -function addRow( - sheet: xlsx.Worksheet, - rowNum: number, - outlineLevel: number, - name: string, - label: string, - style?: Partial, -) { - const col = 1; - - const row = sheet.getRow(rowNum); - - row.getCell(col).name = name; - row.getCell(col + 1).name = name; - - row.getCell(col).value = label; - row.outlineLevel = outlineLevel; - - if (style) { - row.getCell(col).style = style; - } else { - row.getCell(col).style = defaultCellStyle; - } - - const prevStyle = row.getCell(col).style; - row.getCell(col).style = { - ...prevStyle, - alignment: { - ...prevStyle?.alignment, - indent: outlineLevel * 2, - }, - }; - - return row; -} - -function addInputRow( - sheet: xlsx.Worksheet, - rowNum: number, - outlineLevel: number, - name: string, - label: string, - optionKey?: string, - optionsWorksheet?: xlsx.Worksheet, - style?: Partial, -) { - const col = 1; - - const row = addRow( - sheet, - rowNum, - outlineLevel, - name, - label, - style, - ); - - const inputCell = row.getCell(col + 1); - inputCell.style = inputCellStyle; - - if (isDefined(optionKey) && isDefined(optionsWorksheet)) { - const optionsColumn = optionsWorksheet.getColumnKey(optionKey); - - if (optionsColumn) { - const colLetter = optionsColumn.letter; - const numOptions = optionsColumn.values.length; - - const formulae = `=${optionsWorksheet.name}!$${colLetter}$2:$${colLetter}$${numOptions}`; - - inputCell.dataValidation = { - type: 'list', - formulae: [formulae], - }; - } - } - - return row; -} +import DownloadImportTemplateModal from './DownloadImportTemplateModal'; function DownloadImportTemplateButton() { - const [generationPending, setGenerationPending] = useState(false); - - const nationalSocieties = useNationalSociety(); - const countries = useCountry(); - const disasterTypes = useDisasterTypes(); - - const { - dref_planned_intervention_title, - dref_national_society_action_title, - dref_identified_need_title, - dref_dref_onset_type, - dref_dref_disaster_category, - } = useGlobalEnums(); - - const handleClick = useCallback( - () => { - const optionsMap = { - __boolean: [ - { - key: true, - label: 'Yes', - }, - { - key: false, - label: 'No', - }, - ], - national_society: nationalSocieties.map( - ({ id, society_name }) => ({ key: id, label: society_name }), - ), - country: countries.map( - ({ id, name }) => ({ key: id, label: name }), - ), - disaster_type: disasterTypes?.map( - ({ id, name }) => ({ key: id, label: name }), - ) ?? [], - type_of_onset: dref_dref_onset_type?.map( - ({ key, value }) => ({ key, label: value }), - ) ?? [], - disaster_category: dref_dref_disaster_category?.map( - ({ key, value }) => ({ key, label: value }), - ) ?? [], - planned_interventions: dref_planned_intervention_title?.map( - ({ key, value }) => ({ key, label: value }), - ) ?? [], - source_information: [ - { key: 'source__0', label: 'Source #1' }, - { key: 'source__1', label: 'Source #2' }, - { key: 'source__2', label: 'Source #3' }, - ], - planned_interventions__indicators: [ - { key: 'indicator__0', label: 'Indicator #1' }, - { key: 'indicator__1', label: 'Indicator #2' }, - { key: 'indicator__2', label: 'Indicator #3' }, - ], - risk_security: [ - { key: 'risk__0', label: 'Risk #1' }, - { key: 'risk__1', label: 'Risk #2' }, - { key: 'risk__2', label: 'Risk #3' }, - ], - ns_actions: dref_national_society_action_title?.map( - ({ key, value }) => ({ key, label: value }), - ) ?? [], - identified_needs: dref_identified_need_title?.map( - ({ key, value }) => ({ key, label: value }), - ) ?? [], - }; - - const drefFormSchema: TemplateSchema = { - type: 'object', - fields: { - national_society: { - type: 'select', - label: 'National society', - validation: 'number', - optionsKey: 'national_society', - }, - - // We're skipping type of DREF since we'll have separate - // template for each type of dref - // type_of_dref: xxx - - disaster_type: { - type: 'select', - label: 'Type of disaster', - validation: 'number', - optionsKey: 'disaster_type', - }, - - type_of_onset: { - type: 'select', - label: 'Type of Onset', - validation: 'number', - optionsKey: 'type_of_onset', - }, - - is_man_made_event: { - type: 'select', - label: 'Is this a man made event?', - validation: 'boolean', - optionsKey: '__boolean', - }, - - disaster_category: { - type: 'select', - label: 'Disaster Category', - validation: 'number', - optionsKey: 'disaster_category', - }, - - country: { - type: 'select', - label: 'Country', - validation: 'number', - optionsKey: 'country', - }, - - title: { - type: 'input', - label: 'DREF Title', - validation: 'string', - }, - - emergency_appeal_planned: { - type: 'select', - label: 'Emergency appeal planned', - optionsKey: '__boolean', - validation: 'boolean', - }, - - // Event eventDetail - // Previous Operations - did_it_affect_same_area: { - type: 'select', - label: 'Has a similar event affected the same area(s) in the last 3 years?', - optionsKey: '__boolean', - validation: 'boolean', - }, - - did_it_affect_same_population: { - type: 'select', - label: 'Did it affect the same population groups?', - optionsKey: '__boolean', - validation: 'boolean', - }, - - did_ns_respond: { - type: 'select', - label: 'Did the National Society respond?', - optionsKey: '__boolean', - validation: 'boolean', - }, - - did_ns_request_fund: { - type: 'select', - label: 'Did the National Society request funding from DREF for that event(s)?', - optionsKey: '__boolean', - validation: 'boolean', - }, - - ns_request_text: { - type: 'input', - label: 'If yes, please specify which operations', - validation: 'string', - }, - - dref_recurrent_text: { - type: 'input', - label: 'If you have answered yes to all questions above, justify why the use of DREF for a recurrent event, or how this event should not be considered recurrent', - validation: 'string', - }, - - lessons_learned: { - type: 'input', - label: 'Lessons Learned', - validation: 'string', - }, - - event_date: { - type: 'input', - label: 'Date of the Event', - validation: 'date', - }, - - num_affected: { - type: 'input', - validation: 'number', - label: 'Total affected population', - }, - - people_in_need: { - type: 'input', - validation: 'number', - label: 'People in need(Optional)', - }, - - event_description: { - type: 'input', - validation: 'string', - label: 'What happened, where and when?', - }, - - event_scope: { - type: 'input', - validation: 'string', - label: 'Scope and scale of the event', - }, - - source_information: { - type: 'list', - label: 'Source Information', - optionsKey: 'source_information', - children: { - type: 'object', - fields: { - source_name: { - type: 'input', - validation: 'string', - label: 'Name', - }, - source_link: { - type: 'input', - validation: 'string', - label: 'Link', - }, - }, - }, - }, - - did_national_society: { - type: 'select', - validation: 'boolean', - optionsKey: '__boolean', - label: 'Has the National Society started any actions?', - }, - - ns_actions: { - type: 'select', - label: 'Select the actions that apply.', - validation: 'number', - optionsKey: 'ns_actions', - }, - - ifrc: { - type: 'input', - validation: 'string', - label: 'IFRC', - }, - - partner_national_society: { - type: 'input', - validation: 'string', - label: 'Participating National Societies', - }, - - icrc: { - type: 'input', - validation: 'string', - label: 'ICRC', - }, - - government_requested_assistance: { - type: 'select', - validation: 'boolean', - optionsKey: '__boolean', - label: 'Government has requested international assistance', - }, - - national_authorities: { - type: 'input', - validation: 'string', - label: 'National authorities', - }, - - un_or_other_actor: { - type: 'input', - validation: 'string', - label: 'UN or other actors', - }, - - is_there_major_coordination_mechanism: { - type: 'select', - validation: 'boolean', - optionsKey: '__boolean', - label: 'Are there major coordination mechanisms in place?', - }, - - dref_identified_need_title: { - type: 'select', - label: 'Select the needs that apply.', - validation: 'number', - optionsKey: 'dref_identified_need_title', - }, - - identified_gaps: { - type: 'input', - validation: 'string', - label: 'Any identified gaps/limitations in the assessment', - }, - - // Operation - operation_objective: { - type: 'input', - validation: 'string', - label: 'Overall objective of the operation', - }, - - response_strategy: { - type: 'input', - validation: 'string', - label: 'Operation strategy rationale', - }, - - people_assisted: { - type: 'input', - validation: 'string', - label: 'Who will be targeted through this operation?', - }, - - selection_criteria: { - type: 'input', - validation: 'string', - label: 'Explain the selection criteria for the targeted population', - }, - - women: { - type: 'input', - validation: 'number', - label: 'Women', - }, - - men: { - type: 'input', - validation: 'number', - label: 'Men', - }, - - girls: { - type: 'input', - validation: 'number', - label: 'Girls', - }, - - boys: { - type: 'input', - validation: 'number', - label: 'Boys (under 18)', - }, - - total_targeted_population: { - type: 'input', - validation: 'number', - label: 'Total Population', - }, - - disability_people_per: { - type: 'input', - validation: 'number', - label: 'Estimated Percentage People with Disability', - }, - - people_per_urban: { - type: 'input', - validation: 'number', - label: 'Estimated Percentage (Urban to Rural)', - }, - - displaced_people: { - type: 'input', - validation: 'number', - label: 'Estimated number of People on the move (if any)', - }, - - risk_security: { - type: 'list', - label: 'Please indicate about potential operational risk for this operations and mitigation actions', - optionsKey: 'risk_security', - children: { - type: 'object', - fields: { - risk: { - type: 'input', - validation: 'string', - label: 'Risk', - }, - mitigation: { - type: 'input', - validation: 'string', - label: 'Mitigation action', - }, - }, - }, - }, - - risk_security_concern: { - type: 'input', - validation: 'string', - label: 'Please indicate any security and safety concerns for this operation', - }, - - has_child_safeguarding_risk_analysis_assessment: { - type: 'select', - optionsKey: '__boolean', - validation: 'boolean', - label: 'Has the child safeguarding risk analysis assessment been completed?', - }, - - amount_requested: { - type: 'input', - validation: 'number', - label: 'Requested Amount in CHF', - }, - - dref_planned_intervention_title: { - type: 'select', - label: 'Select the interventions that apply.', - validation: 'number', - optionsKey: 'dref_planned_intervention_title', - }, - - planned_interventions: { - type: 'list', - label: 'Planned interventions', - optionsKey: 'planned_interventions', - children: { - type: 'object', - fields: { - budget: { - type: 'input', - validation: 'number', - label: 'Budget', - }, - person_targeted: { - type: 'input', - validation: 'number', - label: 'Person targeted', - }, - description: { - type: 'input', - validation: 'string', - label: 'Description', - }, - indicators: { - type: 'list', - label: 'Indicators', - optionsKey: 'planned_interventions__indicators', - children: { - type: 'object', - fields: { - title: { - type: 'input', - validation: 'string', - label: 'Title', - }, - target: { - type: 'input', - validation: 'number', - label: 'Target', - }, - }, - }, - }, - }, - }, - }, - - human_resource: { - type: 'input', - validation: 'string', - label: 'How many staff and volunteers will be involved in this operation. Briefly describe their role.', - }, - - is_surge_personnel_deployed: { - type: 'select', - validation: 'boolean', - optionsKey: '__boolean', - label: 'Will be surge personnel be deployed?', - }, - - surge_personnel_deployed: { - type: 'input', - validation: 'string', - label: 'Description', - }, - - logistic_capacity_of_ns: { - type: 'input', - validation: 'string', - label: 'If there is procurement, will be done by National Society or IFRC?', - }, - - pmer: { - type: 'input', - validation: 'string', - label: 'How will this operation be monitored?', - }, - - communication: { - type: 'input', - validation: 'string', - label: 'Please briefly explain the National Societies communication strategy for this operation.', - }, - - // Submission - ns_request_date: { - type: 'input', - validation: 'date', - label: 'Date of National Society Application', - }, - - submission_to_geneva: { - type: 'input', - validation: 'date', - label: 'Date of Submission to GVA', - }, - - date_of_approval: { - type: 'input', - validation: 'date', - label: 'Date of Approval', - }, - - operation_timeframe: { - type: 'input', - validation: 'number', - label: 'Operation timeframe', - }, - - end_date: { - type: 'input', - validation: 'date', - label: 'End date of Operation', - }, - - publishing_date: { - type: 'input', - validation: 'date', - label: 'Date of Publishing', - }, - - appeal_code: { - type: 'input', - validation: 'string', - label: 'Appeal Code', - }, - - glide_code: { - type: 'input', - validation: 'string', - label: 'GLIDE number', - }, - - ifrc_appeal_manager_name: { - type: 'input', - validation: 'string', - label: 'IFRC Appeal Manager Name', - }, - - ifrc_appeal_manager_title: { - type: 'input', - validation: 'string', - label: 'IFRC Appeal Manager Title', - }, - - ifrc_appeal_manager_email: { - type: 'input', - validation: 'string', - label: 'IFRC Appeal Manager Email', - }, - - ifrc_appeal_manager_phone_number: { - type: 'input', - validation: 'string', - label: 'IFRC Appeal Manager Phone Number', - }, - - ifrc_project_manager_name: { - type: 'input', - validation: 'string', - label: 'IFRC Project Manager Name', - }, - - ifrc_project_manager_title: { - type: 'input', - validation: 'string', - label: 'IFRC Project Manager Title', - }, - - ifrc_project_manager_email: { - type: 'input', - validation: 'string', - label: 'IFRC Project Manager Email', - }, - - ifrc_project_manager_phone_number: { - type: 'input', - validation: 'string', - label: 'IFRC Project Manager Phone Number', - }, - - national_society_contact_name: { - type: 'input', - validation: 'string', - label: 'National Society Contact Name', - }, - - national_society_contact_title: { - type: 'input', - validation: 'string', - label: 'National Society Contact Title', - }, - - national_society_contact_email: { - type: 'input', - validation: 'string', - label: 'National Society Contact Email', - }, - - national_society_contact_phone_number: { - type: 'input', - validation: 'string', - label: 'National Society Contact Phone Number', - }, - - ifrc_emergency_name: { - type: 'input', - validation: 'string', - label: 'IFRC focal point for the emergency Name', - }, - - ifrc_emergency_title: { - type: 'input', - validation: 'string', - label: 'IFRC focal point for the emergency Title', - }, - - ifrc_emergency_email: { - type: 'input', - validation: 'string', - label: 'IFRC focal point for the emergency Email', - }, - - ifrc_emergency_phone_number: { - type: 'input', - validation: 'string', - label: 'IFRC focal point for the emergency Phone number', - }, - - regional_focal_point_name: { - type: 'input', - validation: 'string', - label: 'DREF Regional Focal Point Name', - }, - - regional_focal_point_title: { - type: 'input', - validation: 'string', - label: 'DREF Regional Focal Point Title', - }, - - regional_focal_point_email: { - type: 'input', - validation: 'string', - label: 'DREF Regional Focal Point Email', - }, - - regional_focal_point_phone_number: { - type: 'input', - validation: 'string', - label: 'DREF Regional Focal Point Phone Number', - }, - - media_contact_name: { - type: 'input', - validation: 'string', - label: 'Media Contact Name', - }, - - media_contact_title: { - type: 'input', - validation: 'string', - label: 'Media Contact Title', - }, - - media_contact_email: { - type: 'input', - validation: 'string', - label: 'Media Contact Email', - }, - - media_contact_phone_number: { - type: 'input', - validation: 'string', - label: 'Media Contact Phone Number', - }, - }, - }; - - const templateActions = createImportTemplate(drefFormSchema, optionsMap); - - async function generateTemplate() { - const workbook = new xlsx.Workbook(); - const now = new Date(); - workbook.created = now; - // workbook.description = JSON.stringify(drefFormSchema); - - const response = await fetch(ifrcLogoFile); - const buffer = await response.arrayBuffer(); - - const ifrcLogo = workbook.addImage({ - buffer, - extension: 'png', - }); - - const coverWorksheet = workbook.addWorksheet('DREF Import'); - - const overviewWorksheet = workbook.addWorksheet(SHEET_OPERATION_OVERVIEW); - const eventDetailsWorksheet = workbook.addWorksheet(SHEET_EVENT_DETAIL); - const actionsNeedsWorksheet = workbook.addWorksheet(SHEET_ACTIONS_NEEDS); - const operationWorksheet = workbook.addWorksheet(SHEET_OPERATION); - const timeframeAndContactsWorksheet = workbook.addWorksheet( - SHEET_TIMEFRAMES_AND_CONTACTS, - ); - - const sheetMap: Record = { - [SHEET_OPERATION_OVERVIEW]: overviewWorksheet, - [SHEET_EVENT_DETAIL]: eventDetailsWorksheet, - [SHEET_ACTIONS_NEEDS]: actionsNeedsWorksheet, - [SHEET_OPERATION]: operationWorksheet, - [SHEET_TIMEFRAMES_AND_CONTACTS]: timeframeAndContactsWorksheet, - }; - - const optionsWorksheet = workbook.addWorksheet('options'); - optionsWorksheet.state = 'veryHidden'; - const optionKeys = Object.keys(optionsMap) as (keyof (typeof optionsMap))[]; - - optionsWorksheet.columns = optionKeys.map((key) => ( - { header: key, key } - )); - - optionKeys.forEach((key) => { - const options = optionsMap[key]; - - if (isDefined(options)) { - const column = optionsWorksheet.getColumnKey(key); - - options.forEach((option, i) => { - const cell = optionsWorksheet.getCell(i + 2, column.number); - cell.name = String(option.key); - cell.value = option.label; - }); - } - }); - - coverWorksheet.addImage(ifrcLogo, 'A1:B6'); - coverWorksheet.getCell('C1').value = 'DISASTER RESPONSE EMERGENCY FUND'; - coverWorksheet.mergeCells('C1:L3'); - coverWorksheet.getCell('C1:L3').style = { - font: { - name: 'Montserrat', - family: 2, - bold: true, - size: 20, - color: { argb: hexToArgb(COLOR_PRIMARY_RED) }, - }, - alignment: { horizontal: 'center', vertical: 'middle' }, - }; - coverWorksheet.addRow(''); - coverWorksheet.addRow(''); - coverWorksheet.addRow(''); - coverWorksheet.addRow(''); - coverWorksheet.mergeCells('C4:L6'); - coverWorksheet.getCell('C4').value = 'Import template'; - coverWorksheet.getCell('C4').style = { - font: { - bold: true, size: 18, name: 'Montserrat', family: 2, - }, - alignment: { horizontal: 'center', vertical: 'middle' }, - }; - - const rowOffset = 2; - - templateActions.forEach((templateAction, i) => { - if (templateAction.type === 'heading') { - addRow( - overviewWorksheet, - i + rowOffset, - templateAction.outlineLevel, - String(templateAction.name), - templateAction.label, - { - ...headingStyle, - font: { - ...headingStyle.font, - }, - }, - ); - } else if (templateAction.type === 'input') { - if (templateAction.dataValidation === 'list') { - addInputRow( - overviewWorksheet, - i + rowOffset, - templateAction.outlineLevel, - String(templateAction.name), - templateAction.label, - String(templateAction.optionsKey), - optionsWorksheet, - ); - } else { - addInputRow( - overviewWorksheet, - i + rowOffset, - templateAction.outlineLevel, - String(templateAction.name), - templateAction.label, - ); - } - } - }); - - Object.values(sheetMap).forEach( - (sheet) => { - const worksheet = sheet; - worksheet.properties.defaultRowHeight = 20; - worksheet.properties.showGridLines = false; - - worksheet.columns = [ - { - key: 'field', - header: 'Field', - protection: { locked: true }, - width: 50, - }, - { - key: 'value', - header: 'Value', - width: 50, - }, - ]; - - worksheet.getRow(1).eachCell( - (cell) => { - // eslint-disable-next-line no-param-reassign - cell.style = headerRowStyle; - }, - ); - }, - ); - - await workbook.xlsx.writeBuffer().then( - (sheet) => { - FileSaver.saveAs( - new Blob([sheet], { type: 'application/vnd.ms-excel;charset=utf-8' }), - `DREF import template ${now.toLocaleString()}.xlsx`, - ); - }, - ); - - setGenerationPending(false); - } - - setGenerationPending((alreadyGenerating) => { - if (!alreadyGenerating) { - generateTemplate(); - } - - return true; - }); + const [ + showDownloadImportTemplateModal, + { + setTrue: setShowDownloadImportTemplateTrue, + setFalse: setShowDownloadImportTemplateFalse, }, - [ - countries, - disasterTypes, - nationalSocieties, - dref_planned_intervention_title, - dref_national_society_action_title, - dref_identified_need_title, - dref_dref_onset_type, - dref_dref_disaster_category, - ], - ); + ] = useBooleanState(false); return ( - + <> + + {showDownloadImportTemplateModal && ( + + )} + ); } diff --git a/app/src/views/AccountMyFormsDref/DownloadImportTemplateButton/utils.ts b/app/src/views/AccountMyFormsDref/DownloadImportTemplateButton/utils.ts new file mode 100644 index 0000000000..c7fdbc0d49 --- /dev/null +++ b/app/src/views/AccountMyFormsDref/DownloadImportTemplateButton/utils.ts @@ -0,0 +1,297 @@ +import { + isDefined, + isTruthyString, +} from '@togglecorp/fujs'; +import xlsx, { + type Border, + type Row, + type Style, + type Workbook, + type Worksheet, +} from 'exceljs'; + +import ifrcLogoFile from '#assets/icons/ifrc-square.png'; +import { + COLOR_DARK_GREY, + COLOR_PRIMARY_BLUE, + COLOR_PRIMARY_RED, +} from '#utils/constants'; + +function hexToArgb(hexStr: string, alphaStr = 'ff') { + const hexWithoutHash = hexStr.substring(1, hexStr.length); + + return `${alphaStr}${hexWithoutHash}`; +} + +export const headerRowStyle: Partial