diff --git a/app/src/components/domain/Admin2Input/index.tsx b/app/src/components/domain/Admin2Input/index.tsx index 9771f90d17..233fde52e1 100644 --- a/app/src/components/domain/Admin2Input/index.tsx +++ b/app/src/components/domain/Admin2Input/index.tsx @@ -49,6 +49,10 @@ import { MAX_PAGE_LIMIT, } from '#utils/constants'; import { getGeoJsonBounds } from '#utils/geo'; +import { + getAdmin2CentroidTileset, + getAdmin2Tileset, +} from '#utils/map'; import { useLazyRequest, useRequest, @@ -190,7 +194,7 @@ function Admin2Input(props: Props) { return { type: 'line', - 'source-layer': `go-admin2-${iso3}-staging`, + 'source-layer': getAdmin2Tileset(iso3).sourceLayer, paint: { 'line-color': COLOR_BLACK, 'line-opacity': 1, @@ -213,7 +217,7 @@ function Admin2Input(props: Props) { ]; const options: Omit = { type: 'fill', - 'source-layer': `go-admin2-${iso3}-staging`, + 'source-layer': getAdmin2Tileset(iso3).sourceLayer, paint: { 'fill-color': (!value || value.length <= 0) ? defaultColor @@ -237,6 +241,10 @@ function Admin2Input(props: Props) { }, [iso3, value, admin2CodeMap]); const adminTwoLabelLayerOptions = useMemo((): Omit | undefined => { + if (!iso3) { + return undefined; + } + const textColor: NonNullable['text-color'] = ( value && value.length > 0 ? [ @@ -253,7 +261,7 @@ function Admin2Input(props: Props) { const options: Omit = { type: 'symbol', - 'source-layer': `go-admin2-${iso3}-centroids`, + 'source-layer': getAdmin2CentroidTileset(iso3).sourceLayer, paint: { 'text-color': textColor, 'text-opacity': 1, @@ -314,11 +322,10 @@ function Admin2Input(props: Props) { {strings.buttonLabel} @@ -398,14 +405,13 @@ function Admin2Input(props: Props) { /> )} {/* eslint-disable-next-line max-len */} - {adminTwoFillLayerOptions && adminTwoLineLayerOptions && adminTwoLabelLayerOptions && ( + {iso3 && adminTwoFillLayerOptions && adminTwoLineLayerOptions && adminTwoLabelLayerOptions && ( <> (props: Props) { sourceKey="country-admin-2-labels" sourceOptions={{ type: 'vector', - url: `mapbox://go-ifrc.go-admin2-${iso3}-centroids`, + url: getAdmin2CentroidTileset(iso3).url, }} > | null; +} + +interface MapControllerProps { + bounds: [number, number, number, number] | undefined; + onLoad?: () => void; +} + +// NOTE: This lets the consumer (eg. pdf export) know that the map is +// completely rendered +function MapController(props: MapControllerProps) { + const { + bounds, + onLoad, + } = props; + + const { map } = useContext(MapChildContext); + + useEffect(() => { + if (isNotDefined(map) || isNotDefined(onLoad) || isNotDefined(bounds)) { + return undefined; + } + const handleIdle = () => { + if (!map.areTilesLoaded()) { + return; + } + map.off('idle', handleIdle); + onLoad(); + }; + + map.on('idle', handleIdle); + + return () => { + map.off('idle', handleIdle); + }; + }, [map, onLoad, bounds]); + + if (isNotDefined(bounds)) { + return null; + } + return ( + + ); +} + +interface Props { + className?: string; + countryId: number; + admin2Details: Admin2[] | undefined; + onLoad?: () => void; +} + +function Admin2Map(props: Props) { + const { + className, + countryId, + admin2Details, + onLoad, + } = props; + + const countryDetails = useCountry({ id: countryId }); + const iso3 = countryDetails?.iso3; + + useEffect(() => { + if (isNotDefined(onLoad)) { + return undefined; + } + + const timeout = setTimeout(onLoad, MAP_LOAD_TIMEOUT); + + return () => { + clearTimeout(timeout); + }; + }, [onLoad]); + + const isSelectedExpression = useMemo( + () => [ + 'in', + ['get', 'code'], + ['literal', admin2Details?.map(({ code }) => code) ?? []], + ], + [admin2Details], + ); + + const bounds = useMemo(() => { + const selectedBounds = getBboxListBoundingBox( + admin2Details?.map(({ bbox }) => bbox), + ); + + if (isDefined(selectedBounds)) { + return selectedBounds; + } + + if (isNotDefined(countryDetails?.bbox)) { + return undefined; + } + + return getGeoJsonBounds(countryDetails.bbox); + }, [admin2Details, countryDetails]); + + const adminOneLabelLayerOptions: Omit = useMemo(() => ({ + type: 'symbol', + paint: { + 'text-opacity': [ + 'match', + ['get', 'country_id'], + countryId, + 1, + 0, + ], + }, + layout: { + 'text-offset': [ + 0, + 1, + ], + visibility: 'visible', + }, + }), [countryId]); + + const adminTwoLayerOptions = useMemo(() => { + if (isNotDefined(iso3)) { + return undefined; + } + + const { sourceLayer } = getAdmin2Tileset(iso3); + const { sourceLayer: centroidSourceLayer } = getAdmin2CentroidTileset(iso3); + + const fill: Omit = { + type: 'fill', + 'source-layer': sourceLayer, + paint: { + 'fill-color': [ + 'case', + isSelectedExpression, + COLOR_PRIMARY_RED, + COLOR_LIGHT_GREY, + ], + 'fill-opacity': [ + 'case', + isSelectedExpression, + 1, + 0.5, + ], + }, + layout: { + visibility: 'visible', + }, + }; + + const line: Omit = { + type: 'line', + 'source-layer': sourceLayer, + paint: { + 'line-color': [ + 'case', + isSelectedExpression, + COLOR_WHITE, + COLOR_DARK_GREY, + ], + 'line-width': 0.5, + 'line-opacity': 1, + }, + layout: { + visibility: 'visible', + }, + }; + + const label: Omit = { + type: 'symbol', + 'source-layer': centroidSourceLayer, + filter: isSelectedExpression, + layout: { + 'text-field': ['get', 'name'], + 'text-anchor': 'center', + 'text-size': 10, + 'text-padding': 4, + }, + }; + + return { + fill, + line, + label, + }; + }, [iso3, isSelectedExpression]); + + return ( + + )} + > + + + {isNotDefined(iso3) || isNotDefined(adminTwoLayerOptions) ? null : ( + <> + + + + + + + + + )} + + ); +} + +export default Admin2Map; diff --git a/app/src/components/domain/Admin2Map/styles.module.css b/app/src/components/domain/Admin2Map/styles.module.css new file mode 100644 index 0000000000..539bdd7b94 --- /dev/null +++ b/app/src/components/domain/Admin2Map/styles.module.css @@ -0,0 +1,3 @@ +.admin2-map { + min-height: 12rem; +} diff --git a/app/src/components/domain/ConfirmationModal/i18n.json b/app/src/components/domain/ConfirmationModal/i18n.json new file mode 100644 index 0000000000..55976d738b --- /dev/null +++ b/app/src/components/domain/ConfirmationModal/i18n.json @@ -0,0 +1,8 @@ +{ + "namespace": "confirmationModal", + "strings": { + "confirmationHeading": "Are you sure?", + "confirmationCancelButton": "Cancel", + "confirmationConfirmButton": "Continue" + } +} diff --git a/app/src/components/domain/ConfirmationModal/index.tsx b/app/src/components/domain/ConfirmationModal/index.tsx new file mode 100644 index 0000000000..54ea30d557 --- /dev/null +++ b/app/src/components/domain/ConfirmationModal/index.tsx @@ -0,0 +1,67 @@ +import { + Button, + Description, + ListView, + Modal, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; + +import i18n from './i18n.json'; + +interface Props { + heading?: React.ReactNode; + message: React.ReactNode; + cancelButtonLabel?: string; + confirmButtonLabel?: string; + onCancel: () => void; + onConfirm: () => void; + disabled?: boolean; +} + +function ConfirmationModal(props: Props) { + const { + heading, + message, + cancelButtonLabel, + confirmButtonLabel, + onCancel, + onConfirm, + disabled, + } = props; + + const strings = useTranslation(i18n); + + return ( + + + + + )} + > + + {message} + + + ); +} + +export default ConfirmationModal; diff --git a/app/src/components/domain/ContactInputsSection/index.tsx b/app/src/components/domain/ContactInputsSection/index.tsx index ae09641c14..c7136cec04 100644 --- a/app/src/components/domain/ContactInputsSection/index.tsx +++ b/app/src/components/domain/ContactInputsSection/index.tsx @@ -48,6 +48,7 @@ interface Props< readOnly?: boolean; withAsteriskOnTitle?: boolean; withRequiredNameAndEmail?: boolean; + withRequiredTitle?:boolean } function ContactInputsSection< @@ -65,6 +66,7 @@ function ContactInputsSection< readOnly, withAsteriskOnTitle, withRequiredNameAndEmail, + withRequiredTitle, } = props; const strings = useTranslation(i18n); @@ -112,6 +114,7 @@ function ContactInputsSection< onChange={setContactFieldValue} disabled={disabled} readOnly={readOnly} + required={withRequiredTitle} /> { - if (name === 'early_action_activities') { - return eap_timeframe?.filter((item) => item.key !== TIMEFRAME_YEAR); + if (name !== 'early_action_activities') { + return eap_timeframe; } - return eap_timeframe; - }, [eap_timeframe, name]); + if (isDefined(leadTimeframeUnit)) { + return eap_timeframe?.filter((item) => item.key === leadTimeframeUnit); + } + return eap_timeframe?.filter((item) => item.key !== TIMEFRAME_YEAR); + }, [eap_timeframe, name, leadTimeframeUnit]); - const eapTimeFrameReadOnly = name === 'readiness_activities' || name === 'prepositioning_activities'; + const eapTimeFrameReadOnly = name === 'readiness_activities' || isTimeframeFixedToLeadTime; const getTimeValueOptions = useCallback( (timeframe?: number) => { @@ -154,36 +175,78 @@ function EapOperationActivityInput(props: Props) { readOnly={readOnly} withAsterisk /> - - - {value?.timeframe && ( - + {withActivationSelection && ( + + + + + )} /> )} + {!withoutTimeframeSelection && ( + + + {value?.timeframe && ( + + ) } + + )} diff --git a/app/src/components/domain/EapOperationActivityInput/schema.ts b/app/src/components/domain/EapOperationActivityInput/schema.ts index 1eb11d29d2..bb66494ed6 100644 --- a/app/src/components/domain/EapOperationActivityInput/schema.ts +++ b/app/src/components/domain/EapOperationActivityInput/schema.ts @@ -1,13 +1,22 @@ import { type ObjectSchema, type PartialForm, + type PurgeNull, requiredStringCondition, undefinedValue, } from '@togglecorp/toggle-form'; import { type components } from '#generated/types'; -type OperationActivity = components<'write'>['schemas']['OperationActivity']; +type ReadinessOperationActivity = PurgeNull['schemas']['OperationActivity']>; +type PrepositioningOperationActivity = PurgeNull['schemas']['PrepositioningOperationActivity']>; +type EarlyActionOperationActivity = PurgeNull['schemas']['EarlyActionOperationActivity']>; + +export type ActivityInputType = 'readiness_activities' | 'prepositioning_activities' | 'early_action_activities'; + +type OperationActivity = Omit + & Pick + & Pick; export type OperationActivityFormFields = PartialForm & { client_id: string; @@ -15,7 +24,7 @@ export type OperationActivityFormFields = PartialForm & { type OperationActivitySchema = ObjectSchema; -const schema = (isSubmit: boolean): OperationActivitySchema => ({ +const schema = (isSubmit: boolean, type: ActivityInputType): OperationActivitySchema => ({ fields: (): ReturnType => ({ client_id: {}, id: { defaultValue: undefinedValue }, @@ -25,7 +34,20 @@ const schema = (isSubmit: boolean): OperationActivitySchema => ({ requiredValidation: requiredStringCondition, }, time_value: {}, - timeframe: {}, + // Prepositioning activities are not tied to a timeframe + timeframe: { + required: isSubmit && type !== 'prepositioning_activities', + }, + // Activations are only applicable to prepositioning and early actions + ...(type === 'readiness_activities' + ? { + activation_one: { forceValue: undefinedValue }, + activation_two: { forceValue: undefinedValue }, + } + : { + activation_one: {}, + activation_two: {}, + }), }), }); diff --git a/app/src/components/domain/EapOperationActivityListInput/i18n.json b/app/src/components/domain/EapOperationActivityListInput/i18n.json index 2f3ba72329..adf86c8099 100644 --- a/app/src/components/domain/EapOperationActivityListInput/i18n.json +++ b/app/src/components/domain/EapOperationActivityListInput/i18n.json @@ -4,11 +4,13 @@ "addButtonLabel": "Add activity", "emptyMessage": "No activities yet!", "readinessTitle": "Readiness Activities", + "readinessTitleDescription": "Add activities and select when the specific activity will be implemented.", "readinessDescription": "Readiness activities are done year on year to ensure that the National Society is ready to conduct the early actions. These are activities that will happen irrespective of an activation. Readiness activities may include refresher training, coordination meetings with government, readiness meetings, simulations, etc. Under readiness they can include any ongoing costs and services (human resources and logistics) that are deemed indispensable for subsequent trigger-based early action activities. If, during the simplified EAP development process the National Society finds some areas for improvement to deliver on their selected early actions, these could be addressed with activities included under readiness.", "prepositioningTitle": "Pre-positioning Activities", + "prepositioningTitleDescription": "Add activities and select when the specific activity will be implemented. Pre-positioning for Activation 1 should be done in year 1 and pre-positioning for Activation 2 – after Activation 1 is concluded.", "prepositioningDescription": "The National Society should preposition the materials needed to undertake the early action, especially those that may require a longer procurement process. For example, prepositioned stocks could include shelter kits (for house reinforcement), sandbags (for protecting infrastructure), or tarpaulins (for protecting water sources), etc. Food, medicine and other items with a shelf life of less than two years are not eligible as pre-positioning, they will have to be procured as part of the early actions. Pre-positioning activities are one-off and done in the first year following approval of the simplified EAP.", "earlyActionTitle": "Early Action Activities", - "earlyActionDescription": "Early action activities are implemented once a trigger is reached and before the impact of the hazard. Early actions seek to reduce or mitigate the impact of the hazard. Consider selecting only a few early actions, especially for sudden onset events, as they will have to be implemented within a short timeframe. The early actions will be unique to each hazard and context, but may be activities such as evacuation of at-risk communities and/or livestock, early harvest of crops, cash transfer, shelter strengthening, provision of water treatment, hygiene kits or mosquito nets, etc. For more examples of early action activities, visit the {earlyActionDatabaseLink} on the Anticipation Hub.", - "earlyActionDatabaseLinkLabel": "early action database" + "earlyActionTitleDescription": "Add activities and select when the specific activity will be implemented.", + "earlyActionDescription": "Early action activities are implemented once a trigger is reached and before the impact of the hazard. Early actions seek to reduce or mitigate the impact of the hazard. Consider selecting only a few early actions, especially for sudden onset events, as they will have to be implemented within a short timeframe. The early actions will be unique to each hazard and context, but may be activities such as evacuation of at-risk communities and/or livestock, early harvest of crops, cash transfer, shelter strengthening, provision of water treatment, hygiene kits or mosquito nets, etc. Start counting the timeframe after the trigger is reached: day 1 is the day the trigger is met." } } diff --git a/app/src/components/domain/EapOperationActivityListInput/index.tsx b/app/src/components/domain/EapOperationActivityListInput/index.tsx index e5b90a2771..3f2d4b1f7b 100644 --- a/app/src/components/domain/EapOperationActivityListInput/index.tsx +++ b/app/src/components/domain/EapOperationActivityListInput/index.tsx @@ -10,7 +10,6 @@ import { ListView, } from '@ifrc-go/ui'; import { useTranslation } from '@ifrc-go/ui/hooks'; -import { resolveToComponent } from '@ifrc-go/ui/utils'; import { isNotDefined, randomString, @@ -23,12 +22,17 @@ import { useFormArray, } from '@togglecorp/toggle-form'; -import EapOperationActivityInput, { type ActivityInputType } from '#components/domain/EapOperationActivityInput'; -import { type OperationActivityFormFields } from '#components/domain/EapOperationActivityInput/schema'; +import EapOperationActivityInput from '#components/domain/EapOperationActivityInput'; +import { + type ActivityInputType, + type OperationActivityFormFields, +} from '#components/domain/EapOperationActivityInput/schema'; import ExplanatoryNote from '#components/ExplanatoryNote'; -import Link from '#components/Link'; import NonFieldError from '#components/NonFieldError'; -import { TIMEFRAME_YEAR } from '#utils/constants'; +import { + TIMEFRAME_YEAR, + type TimeFrameEnumKey, +} from '#utils/constants'; import i18n from './i18n.json'; @@ -40,6 +44,9 @@ interface Props { value: OperationActivityFormFields[] | undefined; onChange: (newValue: SetValueArg, name: NAME) => void; error: ArrayError | LeafError | undefined; + withActivationSelection?: boolean; + withoutTimeframeSelection?: boolean; + leadTimeframeUnit?: TimeFrameEnumKey; } function EapOperationActivityListInput(props: Props) { @@ -51,6 +58,9 @@ function EapOperationActivityListInput(pro value, onChange, error, + withActivationSelection, + withoutTimeframeSelection, + leadTimeframeUnit, } = props; const strings = useTranslation(i18n); @@ -65,9 +75,12 @@ function EapOperationActivityListInput(pro const handleReadinessAddButtonClick = useCallback( () => { - const timeframeValue = name === 'readiness_activities' || name === 'prepositioning_activities' - ? TIMEFRAME_YEAR - : undefined; + let timeframeValue: TimeFrameEnumKey | undefined; + if (name === 'readiness_activities') { + timeframeValue = TIMEFRAME_YEAR; + } else if (name === 'early_action_activities') { + timeframeValue = leadTimeframeUnit; + } const newActionItem: OperationActivityFormFields = { client_id: randomString(), timeframe: timeframeValue, @@ -80,39 +93,34 @@ function EapOperationActivityListInput(pro name, ); }, - [onChange, name], + [onChange, name, leadTimeframeUnit], ); - const [ title, + titleDescription, description, ] = useMemo(() => { if (name === 'readiness_activities') { - return [strings.readinessTitle, strings.readinessDescription]; + return [ + strings.readinessTitle, + strings.readinessTitleDescription, + strings.readinessDescription, + ]; } if (name === 'prepositioning_activities') { - return [strings.prepositioningTitle, strings.prepositioningDescription]; + return [ + strings.prepositioningTitle, + strings.prepositioningTitleDescription, + strings.prepositioningDescription, + ]; } if (name === 'early_action_activities') { return [ strings.earlyActionTitle, - resolveToComponent( - strings.earlyActionDescription, - { - earlyActionDatabaseLink: ( - - {strings.earlyActionDatabaseLinkLabel} - - ), - }, - ), + strings.earlyActionTitleDescription, + strings.earlyActionDescription, ]; } @@ -123,6 +131,7 @@ function EapOperationActivityListInput(pro (pro error={getErrorObject(error)} disabled={disabled} readOnly={readOnly} + withActivationSelection={withActivationSelection} + withoutTimeframeSelection={withoutTimeframeSelection} + leadTimeframeUnit={leadTimeframeUnit} /> ))} diff --git a/app/src/components/domain/GoMultiFileInput/index.tsx b/app/src/components/domain/GoMultiFileInput/index.tsx index a83f06105c..87c34bc2db 100644 --- a/app/src/components/domain/GoMultiFileInput/index.tsx +++ b/app/src/components/domain/GoMultiFileInput/index.tsx @@ -51,7 +51,7 @@ function getFileNameFromUrl(urlString: string | undefined) { return splits[splits.length - 1]; } -type Props = Omit, 'value'> & { +export type Props = Omit, 'value'> & { name: NAME; clearable?: boolean; description?: React.ReactNode; diff --git a/app/src/components/domain/MultiFileObjectInput/i18n.json b/app/src/components/domain/MultiFileObjectInput/i18n.json new file mode 100644 index 0000000000..a64b2daa21 --- /dev/null +++ b/app/src/components/domain/MultiFileObjectInput/i18n.json @@ -0,0 +1,10 @@ +{ + "namespace": "multiFileObjectInput", + "strings": { + "removeFileButtonTitle": "Delete", + "imagePreviewFallbackText": "Preview not available", + "captionInputPlaceholder": "Enter caption", + "acceptedFileFormatsDescription": "Accepted file formats: {fileFormats}", + "imageFilesLabel": "Images" + } +} diff --git a/app/src/components/domain/MultiFileObjectInput/index.tsx b/app/src/components/domain/MultiFileObjectInput/index.tsx new file mode 100644 index 0000000000..7b4bc28153 --- /dev/null +++ b/app/src/components/domain/MultiFileObjectInput/index.tsx @@ -0,0 +1,321 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { DeleteBinLineIcon } from '@ifrc-go/icons'; +import { + Description, + IconButton, + Image, + InlineLayout, + ListView, + type NameType, + TextInput, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { resolveToString } from '@ifrc-go/ui/utils'; +import { + isDefined, + isNotDefined, + isTruthyString, + randomString, +} from '@togglecorp/fujs'; +import { + type ArrayError, + getErrorObject, + type SetValueArg, + useFormArray, +} from '@togglecorp/toggle-form'; + +import GoMultiFileInput, { type Props as GoMultiFileInputProps } from '#components/domain/GoMultiFileInput'; +import Link from '#components/Link'; +import NonFieldError from '#components/NonFieldError'; +import { + getFileNameFromUrl, + isImageFile, +} from '#utils/common'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +function getFileExtension(fileName: string | undefined) { + if (isNotDefined(fileName)) { + return undefined; + } + + const [, extension] = fileName.match(/\.([^.]+)$/) ?? []; + + return extension?.toUpperCase(); +} + +type InputValue = { + id?: number; + client_id: string; + caption?: string | null; +}; + +type OutputValue = { + id?: number; + client_id: string; + caption?: string; +}; + +type Props = Omit, 'value' | 'onChange' | 'error'> & { + value: InputValue[] | null | undefined; + onChange: (value: SetValueArg, name: N) => void; + error: ArrayError | undefined; +}; + +function MultiFileObjectInput(props: Props) { + const { + className, + name, + value, + onChange, + error: formError, + fileIdToUrlMap, + disabled, + readOnly, + accept, + description, + ...otherProps + } = props; + + const strings = useTranslation(i18n); + + const error = getErrorObject(formError); + + const { + setValue: setFieldValue, + removeValue, + } = useFormArray(name, onChange); + + const fileInputValue = useMemo(() => ( + value + ?.map((fileValue) => fileValue.id) + .filter(isDefined) + ), [value]); + + const acceptedFileFormatsDescription = useMemo( + () => { + if (isNotDefined(accept)) { + return undefined; + } + + const fileFormats = accept + .split(',') + .map((fileFormat) => fileFormat.trim()) + .filter(isTruthyString) + .map((fileFormat) => ( + fileFormat === 'image/*' + ? strings.imageFilesLabel + : fileFormat.replace(/^\./, '').toUpperCase() + )); + + if (fileFormats.length === 0) { + return undefined; + } + + return resolveToString( + strings.acceptedFileFormatsDescription, + { fileFormats: fileFormats.join(', ') }, + ); + }, + [accept, strings], + ); + + const handleFileInputChange = useCallback( + (newValue: number[] | undefined, inputName: N) => { + if (isNotDefined(newValue)) { + onChange(undefined, inputName); + return; + } + + newValue.forEach( + (fileId, index) => { + const oldValue = value?.[index]; + + if (isNotDefined(oldValue)) { + setFieldValue( + { + client_id: String(fileId), + id: fileId, + }, + index, + ); + } + }, + ); + }, + [value, setFieldValue, onChange], + ); + + const handleCaptionChange = useCallback( + (newValue: string | undefined, index: number) => { + setFieldValue( + (prevValue) => { + if (isNotDefined(prevValue)) { + return { + client_id: randomString(), + caption: newValue, + }; + } + + return { + ...prevValue, + caption: newValue, + }; + }, + index, + ); + }, + [setFieldValue], + ); + + return ( + + + + {description} + {isDefined(acceptedFileFormatsDescription) && ( + + {acceptedFileFormatsDescription} + + )} + + )} + withoutPreview + /> + {isDefined(value) && value.length > 0 && ( + + {value.map((fileValue, index) => { + if (isNotDefined(fileValue.id)) { + return null; + } + + const fileError = getErrorObject(error?.[fileValue.client_id]); + const fileUrl = fileIdToUrlMap?.[fileValue.id]; + const fileName = getFileNameFromUrl(fileUrl); + + // NOTE: only the images get a preview and a caption + if (!isImageFile(fileUrl)) { + const fileExtension = getFileExtension(fileName); + + return ( + + + + + )} + /> + +
+ {isDefined(fileExtension) && ( +
+ {fileExtension} +
+ )} +
+ + {fileName} + +
+ ); + } + + return ( + + + + + )} + /> + + {strings.imagePreviewFallbackText} + + + ); + })} +
+ )} +
+ ); +} + +export default MultiFileObjectInput; diff --git a/app/src/components/domain/MultiFileObjectInput/styles.module.css b/app/src/components/domain/MultiFileObjectInput/styles.module.css new file mode 100644 index 0000000000..506faa9813 --- /dev/null +++ b/app/src/components/domain/MultiFileObjectInput/styles.module.css @@ -0,0 +1,31 @@ +.delete-button { + margin-block-end: -1.5rem; +} + +.file-preview { + display: flex; + align-items: center; + justify-content: center; + background-color: var(--go-ui-color-background); + height: 8rem; +} + +.file-extension { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--go-ui-color-gray-60); + font-size: var(--go-ui-font-size-2xl); + font-weight: var(--go-ui-font-weight-bold); + padding-inline: var(--go-ui-spacing-sm); +} + +.file-name { + justify-content: center; + text-align: center; +} + +.accepted-file-formats { + font-style: italic; +} diff --git a/app/src/components/domain/PrintableActivityOutput/i18n.json b/app/src/components/domain/PrintableActivityOutput/i18n.json new file mode 100644 index 0000000000..fc31267148 --- /dev/null +++ b/app/src/components/domain/PrintableActivityOutput/i18n.json @@ -0,0 +1,7 @@ +{ + "namespace": "printableActivityOutput", + "strings": { + "activationOneLabel": "Activation 1", + "activationTwoLabel": "Activation 2" + } +} diff --git a/app/src/components/domain/PrintableActivityOutput/index.tsx b/app/src/components/domain/PrintableActivityOutput/index.tsx index baa3d53ed6..a6a1810a5f 100644 --- a/app/src/components/domain/PrintableActivityOutput/index.tsx +++ b/app/src/components/domain/PrintableActivityOutput/index.tsx @@ -2,10 +2,13 @@ import { useCallback, useMemo, } from 'react'; +import { useTranslation } from '@ifrc-go/ui/hooks'; import { compareNumber, isDefined, + isFalsyString, isNotDefined, + isTruthyString, listToMap, } from '@togglecorp/fujs'; @@ -21,7 +24,11 @@ import { type TimeFrameEnumKey, } from '#utils/constants'; -type OperationActivity = components['schemas']['OperationActivity']; +import i18n from './i18n.json'; + +type OperationActivity = components['schemas']['OperationActivity'] + | components['schemas']['PrepositioningOperationActivity'] + | components['schemas']['EarlyActionOperationActivity']; type ExtendedOperationActivity = Omit & { time_value: number[] | string[]; @@ -31,7 +38,17 @@ type DaysTimeFrameKey = components['schemas']['EapDaysTimeframeValueEnumKey']; type YearsTimeFrameKey = components['schemas']['EapYearsTimeframeValueEnumKey']; type MonthsTimeFrameKey = components['schemas']['EapMonthsTimeframeValueEnumKey']; -function getFormattedActivityTimeline(activity: ExtendedOperationActivity | undefined) { +interface TimelineOptions { + withActivation?: boolean; + withoutTimeframe?: boolean; + activationOneLabel: string; + activationTwoLabel: string; +} + +function getFormattedActivityTimeline( + activity: ExtendedOperationActivity | undefined, + options: TimelineOptions, +) { if (isNotDefined(activity)) { return undefined; } @@ -39,13 +56,37 @@ function getFormattedActivityTimeline(activity: ExtendedOperationActivity | unde const { time_value, timeframe_display, + activation_one, + activation_two, } = activity; - const timeValueDisplay = time_value.join(','); + const { + withActivation, + withoutTimeframe, + activationOneLabel, + activationTwoLabel, + } = options; + + const timeframeDisplay = withoutTimeframe || time_value.length === 0 + ? undefined + : [time_value.join(','), timeframe_display].filter(isTruthyString).join(' '); + + const activationDisplay = withActivation + ? [ + activation_one ? activationOneLabel : undefined, + activation_two ? activationTwoLabel : undefined, + ].filter(isDefined).join(', ') + : undefined; + + if (isFalsyString(activationDisplay)) { + return timeframeDisplay; + } - return ( - `${timeValueDisplay} ${timeframe_display}` - ); + if (isFalsyString(timeframeDisplay)) { + return activationDisplay; + } + + return `${timeframeDisplay} (${activationDisplay})`; } function getFormattedActivityLabel(activity: ExtendedOperationActivity | undefined, index: number) { @@ -61,6 +102,8 @@ interface Props { prevActivity: OperationActivity | undefined; withDiff: boolean; index: number; + withActivation?: boolean; + withoutTimeframe?: boolean; } function PrintableActivityOutput(props: Props) { @@ -69,8 +112,12 @@ function PrintableActivityOutput(props: Props) { prevActivity: previousActivity, withDiff, index, + withActivation, + withoutTimeframe, } = props; + const strings = useTranslation(i18n); + const { eap_years_timeframe_value, eap_months_timeframe_value, @@ -110,7 +157,14 @@ function PrintableActivityOutput(props: Props) { ) ), [eap_years_timeframe_value]); - const timeValue = useCallback((timeArray: number[], timeframeKey: TimeFrameEnumKey) => { + const timeValue = useCallback(( + timeArray: number[] | undefined | null, + timeframeKey: TimeFrameEnumKey | undefined | null, + ) => { + if (isNotDefined(timeArray)) { + return []; + } + const sortedTimeValue = timeArray.toSorted(compareNumber); if (timeframeKey === TIMEFRAME_HOURS && isDefined(hoursTimeframeMap)) { @@ -154,6 +208,13 @@ function PrintableActivityOutput(props: Props) { time_value: timeValue(previousActivity?.time_value, previousActivity?.timeframe), } : previousActivity; + const timelineOptions = { + withActivation, + withoutTimeframe, + activationOneLabel: strings.activationOneLabel, + activationTwoLabel: strings.activationTwoLabel, + }; + return ( )} - value={getFormattedActivityTimeline(activity)} + value={getFormattedActivityTimeline(activity, timelineOptions)} prevValue={ - getFormattedActivityTimeline(prevActivity) + getFormattedActivityTimeline(prevActivity, timelineOptions) } valueType="text" variant="contents" diff --git a/app/src/components/printable/PrintableFileOutput/index.tsx b/app/src/components/printable/PrintableFileOutput/index.tsx new file mode 100644 index 0000000000..dd29e96218 --- /dev/null +++ b/app/src/components/printable/PrintableFileOutput/index.tsx @@ -0,0 +1,70 @@ +import { ListView } from '@ifrc-go/ui'; +import { Image } from '@ifrc-go/ui/printable'; +import { isNotDefined } from '@togglecorp/fujs'; + +import Link from '#components/printable/Link'; +import { + getFileNameFromUrl, + isImageFile, +} from '#utils/common'; + +interface FileType { + id: number; + file?: string | null; + caption?: string | null; +} + +interface Props { + className?: string; + files: FileType[] | null | undefined; +} + +function PrintableFileOutput(props: Props) { + const { + className, + files, + } = props; + + if (isNotDefined(files) || files.length === 0) { + return null; + } + + const imageFiles = files.filter(({ file }) => isImageFile(file)); + const otherFiles = files.filter(({ file }) => !isImageFile(file)); + + return ( + + + {imageFiles.map((imageFile) => ( + + ))} + + + {otherFiles.map((otherFile) => ( + + {getFileNameFromUrl(otherFile.file)} + + ))} + + + ); +} + +export default PrintableFileOutput; diff --git a/app/src/hooks/domain/useChecklistFormArray.ts b/app/src/hooks/domain/useChecklistFormArray.ts new file mode 100644 index 0000000000..6dad10410a --- /dev/null +++ b/app/src/hooks/domain/useChecklistFormArray.ts @@ -0,0 +1,116 @@ +import { + useCallback, + useMemo, + useState, +} from 'react'; +import { + isNotDefined, + listToMap, +} from '@togglecorp/fujs'; + +type PendingRemoval = { + type: 'checklist'; + keys: KEY[] | undefined; +} | { + type: 'delete'; + index: number; +}; + +interface Options { + value: ITEM[] | undefined; + // NOTE: keySelector and createItem must be stable references, otherwise the + // returned handlers are re-created on each render + keySelector: (item: ITEM) => KEY; + createItem: (key: KEY) => ITEM; + setValue: (getNewValue: (previousValue: ITEM[] | undefined) => ITEM[] | undefined) => void; + removeValue: (index: number) => void; +} + +// FIXME: Revisit this hook in the future +function useChecklistFormArray( + options: Options, +) { + const { + value, + keySelector, + createItem, + setValue, + removeValue, + } = options; + + const [pendingRemoval, setPendingRemoval] = useState | undefined>(); + + const applyKeys = useCallback( + (keys: KEY[] | undefined) => { + setValue((previousValue) => { + const previousValueMapping = listToMap(previousValue, keySelector); + + return keys?.map((key) => previousValueMapping?.[key] ?? createItem(key)); + }); + }, + [setValue, keySelector, createItem], + ); + + const handleChecklistChange = useCallback( + (keys: KEY[] | undefined) => { + const hasRemovedKey = value?.some( + (item) => isNotDefined(keys) || !keys.includes(keySelector(item)), + ); + + if (hasRemovedKey) { + setPendingRemoval({ type: 'checklist', keys }); + return; + } + + applyKeys(keys); + }, + [value, keySelector, applyKeys], + ); + + const handleRemoveClick = useCallback( + (index: number) => { + setPendingRemoval({ type: 'delete', index }); + }, + [], + ); + + const handleRemovalCancel = useCallback( + () => { + setPendingRemoval(undefined); + }, + [], + ); + + const handleRemovalConfirm = useCallback( + () => { + if (isNotDefined(pendingRemoval)) { + return; + } + + if (pendingRemoval.type === 'checklist') { + applyKeys(pendingRemoval.keys); + } else { + removeValue(pendingRemoval.index); + } + + setPendingRemoval(undefined); + }, + [pendingRemoval, applyKeys, removeValue], + ); + + const selectedKeys = useMemo( + () => value?.map(keySelector), + [value, keySelector], + ); + + return { + selectedKeys, + pendingRemoval, + handleChecklistChange, + handleRemoveClick, + handleRemovalCancel, + handleRemovalConfirm, + }; +} + +export default useChecklistFormArray; diff --git a/app/src/utils/common.ts b/app/src/utils/common.ts index 855251944c..d82d22eb48 100644 --- a/app/src/utils/common.ts +++ b/app/src/utils/common.ts @@ -1,7 +1,9 @@ import { type Language } from '@ifrc-go/ui/contexts'; -import { DEFAULT_INVALID_TEXT } from '@ifrc-go/ui/utils'; import { - isDefined, + DEFAULT_INVALID_TEXT, + getWordCount, +} from '@ifrc-go/ui/utils'; +import { isNotDefined, isTruthyString, } from '@togglecorp/fujs'; @@ -73,6 +75,29 @@ export function joinStrings( return values.filter(Boolean).join(separator); } +const imageFileExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'avif']; + +// NOTE: not using `new URL` here as it throws for the relative urls +export function getFileNameFromUrl(urlString: string | undefined | null) { + if (isNotDefined(urlString)) { + return undefined; + } + + const [pathname] = urlString.split(/[?#]/); + return pathname?.split('/').pop(); +} + +export function isImageFile(urlString: string | undefined | null) { + const fileName = getFileNameFromUrl(urlString); + + if (isNotDefined(fileName)) { + return false; + } + + const extension = fileName.split('.').pop()?.toLowerCase(); + return isTruthyString(extension) && imageFileExtensions.includes(extension); +} + export function formatSourceLink(value: string | undefined): string | undefined { if ( isNotDefined(value) @@ -94,10 +119,24 @@ export function formatSourceLink(value: string | undefined): string | undefined return `https://${value}`; } -export function lengthSmallerOrEqualToCondition(x?: number) { - return (value: string | undefined) => ( - (isDefined(value) && isDefined(x)) && value.length > x - ? `Length must be smaller or equal to ${x}` - : undefined - ); +export function lengthSmallerOrEqualToCondition( + x?: number, + type: 'word' | 'character' = 'word', +) { + return (value: string | undefined) => { + if (isNotDefined(value) || isNotDefined(x)) { + return undefined; + } + + const length = type === 'word' ? getWordCount(value) : value.length; + + if (length <= x) { + return undefined; + } + + // FIXME: use translations + return type === 'word' + ? `Must be smaller or equal to ${x} words` + : `Length must be smaller or equal to ${x}`; + }; } diff --git a/app/src/utils/constants.ts b/app/src/utils/constants.ts index fc0b0b3ae6..50227bf29c 100644 --- a/app/src/utils/constants.ts +++ b/app/src/utils/constants.ts @@ -138,8 +138,9 @@ export type CategoryType = components<'read'>['schemas']['ApiActionCategoryEnumK // Common -// FIXME: we need to identify a typesafe way to get this value +// FIXME: we need to identify a typesafe way to get these values export const DISASTER_TYPE_EPIDEMIC = 1; +export const DISASTER_TYPE_OTHER = 13; type Visibility = components<'read'>['schemas']['ApiVisibilityChoicesEnumKey']; export const VISIBILITY_RCRC_MOVEMENT = 1 satisfies Visibility; @@ -245,5 +246,6 @@ export const EAP_STATUS_UNDER_DEVELOPMENT = 10 satisfies EapStatus; export const EAP_STATUS_UNDER_REVIEW = 20 satisfies EapStatus; export const EAP_STATUS_NS_ADDRESSING_COMMENTS = 30 satisfies EapStatus; export const EAP_STATUS_TECHNICALLY_VALIDATED = 40 satisfies EapStatus; -export const EAP_STATUS_PENDING_PFA = 50 satisfies EapStatus; -export const EAP_STATUS_APPROVED = 60 satisfies EapStatus; +export const EAP_STATUS_APPROVED = 50 satisfies EapStatus; +export const EAP_STATUS_PROJECT_AGREEMENT_SIGNED = 60 satisfies EapStatus; +export const EAP_ACCEPTED_FILE_FORMATS = '.pdf, .docx, .pptx, image/*'; diff --git a/app/src/utils/form.ts b/app/src/utils/form.ts index 1b24de4825..b8a9bf85f2 100644 --- a/app/src/utils/form.ts +++ b/app/src/utils/form.ts @@ -1,4 +1,5 @@ import { useCallback } from 'react'; +import { getWordCount } from '@ifrc-go/ui/utils'; import type { Maybe } from '@togglecorp/fujs'; import { isDefined, @@ -45,6 +46,15 @@ export function dateGreaterThanOrEqualCondition(x: string) { ); } +export function wordCountLessThanOrEqualToCondition(maxWords: number) { + // FIXME: use translations + return (value: Maybe) => ( + isDefined(value) && getWordCount(value) > maxWords + ? `The field must have at most ${maxWords} words` + : undefined + ); +} + export function nonZeroCondition(value: Maybe) { // FIXME: use translations return isDefined(value) && value === 0 diff --git a/app/src/utils/map.ts b/app/src/utils/map.ts index 643a18ed86..d30543161a 100644 --- a/app/src/utils/map.ts +++ b/app/src/utils/map.ts @@ -53,3 +53,41 @@ export function getCountryListBoundingBox(countryList: Country[]) { return getGeoJsonBounds(collection); } + +type Bbox = Record | GeoJSON.Geometry; + +export function getBboxListBoundingBox(bboxList: (Bbox | null | undefined)[] | undefined) { + const definedBboxList = bboxList?.filter(isDefined) ?? []; + + if (definedBboxList.length < 1) { + return undefined; + } + + const collection: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: definedBboxList.map((bbox) => ({ + type: 'Feature' as const, + geometry: bbox as GeoJSON.Geometry, + properties: null, + })), + }; + + return getGeoJsonBounds(collection); +} + +const ADMIN_2_TILESET_OWNER = 'go-ifrc'; + +function getTileset(sourceLayer: string) { + return { + sourceLayer, + url: `mapbox://${ADMIN_2_TILESET_OWNER}.${sourceLayer}`, + }; +} + +export function getAdmin2Tileset(iso3: string) { + return getTileset(`go-admin2-${iso3}-staging`); +} + +export function getAdmin2CentroidTileset(iso3: string) { + return getTileset(`go-admin2-${iso3}-centroids`); +} diff --git a/app/src/views/AccountMyFormsEap/EapStatus/index.tsx b/app/src/views/AccountMyFormsEap/EapStatus/index.tsx index 17ebf8a0b7..aae6b7dd10 100644 --- a/app/src/views/AccountMyFormsEap/EapStatus/index.tsx +++ b/app/src/views/AccountMyFormsEap/EapStatus/index.tsx @@ -32,7 +32,7 @@ import useAlert from '#hooks/useAlert'; import { EAP_STATUS_APPROVED, EAP_STATUS_NS_ADDRESSING_COMMENTS, - EAP_STATUS_PENDING_PFA, + EAP_STATUS_PROJECT_AGREEMENT_SIGNED, EAP_STATUS_TECHNICALLY_VALIDATED, EAP_STATUS_UNDER_DEVELOPMENT, EAP_STATUS_UNDER_REVIEW, @@ -63,10 +63,10 @@ const validStatusTransition: Record = { ], [EAP_STATUS_TECHNICALLY_VALIDATED]: [ EAP_STATUS_NS_ADDRESSING_COMMENTS, - EAP_STATUS_PENDING_PFA, + EAP_STATUS_APPROVED, ], - [EAP_STATUS_PENDING_PFA]: [EAP_STATUS_APPROVED], - [EAP_STATUS_APPROVED]: [], + [EAP_STATUS_APPROVED]: [EAP_STATUS_PROJECT_AGREEMENT_SIGNED], + [EAP_STATUS_PROJECT_AGREEMENT_SIGNED]: [], }; export interface Props { @@ -179,7 +179,7 @@ function EapStatus(props: Props) { const confirmDisabled = ( (newStatus === EAP_STATUS_NS_ADDRESSING_COMMENTS && isNotDefined(checklistFile)) - || (newStatus === EAP_STATUS_PENDING_PFA && !hasValidatedBudgetFile) + || (newStatus === EAP_STATUS_APPROVED && !hasValidatedBudgetFile) || isDefined(responseFormErrors) || isSimplifiedEapLocked || isFullEapLocked ); @@ -312,7 +312,7 @@ function EapStatus(props: Props) { withoutShadow /> )} - {newStatus === EAP_STATUS_PENDING_PFA && !hasValidatedBudgetFile && ( + {newStatus === EAP_STATUS_APPROVED && !hasValidatedBudgetFile && ( )} - {type === 'pending-pfa' && eap.status >= EAP_STATUS_PENDING_PFA && ( + {type === 'approved' && eap.status >= EAP_STATUS_APPROVED && ( ({ - label: `EAP Application v${simplifiedEap.version}`, + label: resolveToString( + strings.eapApplicationVersion, + { version: simplifiedEap.version }, + ), lastUpdated: simplifiedEap.modified_at, eap: eapListItem, type: 'development', @@ -215,7 +219,10 @@ export function Component() { ), ...(eap_type === EAP_TYPE_FULL ? full_eap_details.map((fullEap) => ({ - label: `EAP Application v${fullEap.version}`, + label: resolveToString( + strings.eapApplicationVersion, + { version: fullEap.version }, + ), lastUpdated: fullEap.modified_at, eap: eapListItem, type: 'development', @@ -229,7 +236,10 @@ export function Component() { ), ((isNotDefined(eap_type) || !eapStarted) ? ({ - label: 'EAP Application v1', + label: resolveToString( + strings.eapApplicationVersion, + { version: 1 }, + ), eap: eapListItem, type: 'development', details: undefined, @@ -238,7 +248,7 @@ export function Component() { : undefined ), { - label: 'Technically Validated', + label: strings.eapTechnicallyValidated, eap: eapListItem, type: 'validated', lastUpdated: eapListItem.technically_validated_at ?? undefined, @@ -246,20 +256,20 @@ export function Component() { disabled: status < EAP_STATUS_TECHNICALLY_VALIDATED, } satisfies EapExpandedListItem, { - label: 'Approved (Pending PFA)', - lastUpdated: eapListItem.pending_pfa_at ?? undefined, + label: strings.eapApproved, + lastUpdated: eapListItem.approved_at ?? undefined, eap: eapListItem, - type: 'pending-pfa', + type: 'approved', details: undefined, - disabled: status < EAP_STATUS_PENDING_PFA, + disabled: status < EAP_STATUS_APPROVED, } satisfies EapExpandedListItem, { - label: 'Approved', + label: strings.eapProjectAgreementSigned, eap: eapListItem, - lastUpdated: eapListItem.approved_at ?? undefined, - type: 'approved', + lastUpdated: eapListItem.project_agreement_signed_at ?? undefined, + type: 'project-agreement-signed', details: undefined, - disabled: status < EAP_STATUS_APPROVED, + disabled: status < EAP_STATUS_PROJECT_AGREEMENT_SIGNED, } satisfies EapExpandedListItem, ].filter(isDefined).toReversed(); @@ -269,7 +279,14 @@ export function Component() { } satisfies EapExpandedItem; }, ) - ), [eapListResponse]); + ), [ + eapListResponse, + strings.eapDevelopmentRegistration, + strings.eapApplicationVersion, + strings.eapTechnicallyValidated, + strings.eapApproved, + strings.eapProjectAgreementSigned, + ]); const detailColumns = useMemo( () => ([ diff --git a/app/src/views/AccountMyFormsEap/utils.ts b/app/src/views/AccountMyFormsEap/utils.ts index c12eda4c8e..b9827999fd 100644 --- a/app/src/views/AccountMyFormsEap/utils.ts +++ b/app/src/views/AccountMyFormsEap/utils.ts @@ -21,7 +21,7 @@ export type EapExpandedListItem = { label: string; lastUpdated?: string; eap: EapListItem; - type: 'registration' | 'development' | 'validated' | 'pending-pfa' | 'approved'; + type: 'registration' | 'development' | 'validated' | 'approved' | 'project-agreement-signed'; disabled?: boolean; // Only applicable for development type diff --git a/app/src/views/EapFullExport/i18n.json b/app/src/views/EapFullExport/i18n.json index c6dcb3fe31..379dc059c1 100644 --- a/app/src/views/EapFullExport/i18n.json +++ b/app/src/views/EapFullExport/i18n.json @@ -17,15 +17,11 @@ "nationalLabel": "National", "nationalSocietyContactLabel": "National Society Contact", "partnerNationalSocietyContactLabel": "Partner National Society Contact", - "delegationLabel": "Delegation", - "delegationFocalLabel": "IFRC Delegation AA Focal Point", - "delegationHeadLabel": "IFRC Head of Delegation", "regionalGlobalLabel": "Regional and Global", "drefFocalLabel": "DREF Focal Point", "regionalFocalLabel":"IFRC Regional AA Focal Point", "regionalOpsLabel":"IFRC Regional Ops Manager", "regionalHeadLabel":"IFRC Regional Head of DCC", - "globalOpsLabel":"IFRC Global Ops Coordinator", "stakeholdersHeading":"Stakeholders", "workWithGovernmentLabel":"Did you work with the government and other relevant actors in the development of this EAP? *", "workWithGovernmentDescription":"Please briefly describe the process", @@ -66,8 +62,8 @@ "linkLabel": "Link", "descriptionLabel": "Description", "titleLabel": "Title", - "mealHeading": "Monitoring, Evaluation, Accountability Learning(Meal)", - "mealLabel": "Meal", + "mealHeading": "Monitoring, Evaluation, Accountability Learning (MEAL)", + "mealLabel": "MEAL", "nationalSocietyHeading": "National Society Capacity", "operationalThematicLabel": "Operational, thematic and administrative capacity", "strategiesPlanLabel": "Strategies and plans", diff --git a/app/src/views/EapFullExport/index.tsx b/app/src/views/EapFullExport/index.tsx index 15d314dc77..399ef02473 100644 --- a/app/src/views/EapFullExport/index.tsx +++ b/app/src/views/EapFullExport/index.tsx @@ -22,6 +22,7 @@ import Link from '#components/printable/Link'; import PrintableContainer from '#components/printable/PrintableContainer'; import PrintableDataDisplay from '#components/printable/PrintableDataDisplay'; import PrintableDescription from '#components/printable/PrintableDescription'; +import PrintableFileOutput from '#components/printable/PrintableFileOutput'; import PrintableLabel from '#components/printable/PrintableLabel'; import PrintablePage from '#components/printable/PrintablePage'; import useGlobalEnums from '#hooks/domain/useGlobalEnums'; @@ -100,7 +101,7 @@ export function Component() { url: '/api/v2/eap/options/', }); - const { eap_sector, eap_approach } = useGlobalEnums(); + const { eap_sector, eap_approach, eap_timeframe } = useGlobalEnums(); const eapSectorTitleMap = listToMap( eap_sector, @@ -114,6 +115,12 @@ export function Component() { ({ value }) => value, ); + const eapTimeframeTitleMap = listToMap( + eap_timeframe, + ({ key }) => key, + ({ value }) => value, + ); + const { disaster_type_details, country_details, @@ -135,29 +142,30 @@ export function Component() { technical_working_groups_in_place_description, hazard_selection, - hazard_selection_images, + hazard_selection_files, exposed_element_and_vulnerability_factor, - exposed_element_and_vulnerability_factor_images, + exposed_element_and_vulnerability_factor_files, prioritized_impact, - prioritized_impact_images, + prioritized_impact_files, prioritized_impacts, risk_analysis_source_of_information, trigger_statement, trigger_statement_source_of_information, lead_time, + lead_timeframe_unit, forecast_selection, - forecast_selection_images, + forecast_selection_files, forecast_table_file_details, definition_and_justification_impact_level, - definition_and_justification_impact_level_images, + definition_and_justification_impact_level_files, identification_of_the_intervention_area, - identification_of_the_intervention_area_images, + identification_of_the_intervention_area_files, trigger_model_source_of_information, early_actions, early_action_selection_process, - early_action_selection_process_images, + early_action_selection_process_files, theory_of_change_table_file_details, evidence_base, evidence_base_source_of_information, @@ -168,10 +176,10 @@ export function Component() { feasibility, early_action_implementation_process, - early_action_implementation_images, + early_action_implementation_files, trigger_activation_system, - trigger_activation_system_images, - people_targeted, + trigger_activation_system_files, + total_people_targeted, selection_of_target_population, stop_mechanism, activation_process_source_of_information, @@ -219,6 +227,7 @@ export function Component() { trigger_statement_source_of_information: prev_trigger_statement_source_of_information, lead_time: prev_lead_time, + lead_timeframe_unit: prev_lead_timeframe_unit, forecast_selection: prev_forecast_selection, definition_and_justification_impact_level: prev_definition_and_justification_impact_level, @@ -241,7 +250,7 @@ export function Component() { early_action_implementation_process: prev_early_action_implementation_process, trigger_activation_system: prev_trigger_activation_system, - people_targeted: prev_people_targeted, + total_people_targeted: prev_total_people_targeted, selection_of_target_population: prev_selection_of_target_population, stop_mechanism: prev_stop_mechanism, activation_process_source_of_information: @@ -274,7 +283,7 @@ export function Component() { .join(' | '); const prevKeyActorsMapping = useMemo( - () => listToMap(prev_key_actors ?? [], (actor) => actor.national_society), + () => listToMap(prev_key_actors ?? [], (actor) => actor.partner), [prev_key_actors], ); @@ -343,6 +352,22 @@ export function Component() { [prev_early_actions], ); + const leadTimeUnitLabel = lead_timeframe_unit + ? eapTimeframeTitleMap?.[lead_timeframe_unit] + : undefined; + + const leadTimeWithUnit = lead_time && leadTimeUnitLabel + ? `${lead_time} ${leadTimeUnitLabel}` + : lead_time; + + const prevLeadTimeUnitLabel = prev_lead_timeframe_unit + ? eapTimeframeTitleMap?.[prev_lead_timeframe_unit] + : undefined; + + const prevLeadTimeWithUnit = prev_lead_time && prevLeadTimeUnitLabel + ? `${prev_lead_time} ${prevLeadTimeUnitLabel}` + : prev_lead_time; + const previewReady = !eapRegistrationPending && !fullEapPending && !prevFullEapPending; // NOTE: We render Table of Content after preview is ready so adding additional delay @@ -545,7 +570,7 @@ export function Component() { - - - - - @@ -721,11 +720,8 @@ export function Component() { headingLevel={4} heading={( )} @@ -733,7 +729,7 @@ export function Component() { - {hazard_selection_images?.map((hazard) => ( - - ))} + -
- {exposed_element_and_vulnerability_factor_images?.map((element) => ( - - ))} -
+
-
- {prioritized_impact_images?.map((element) => ( - - ))} -
+
-
- {forecast_selection_images?.map((element) => ( - - ))} -
+
@@ -1030,17 +1008,9 @@ export function Component() { /> -
- {definition_and_justification_impact_level_images?.map( - (element) => ( - - ), - )} -
+
-
- {identification_of_the_intervention_area_images?.map((element) => ( - - ))} -
+
-
- {early_action_selection_process_images?.map((element) => ( - - ))} -
+
@@ -1407,6 +1369,8 @@ export function Component() { prevActivity={prevActivity} index={index} withDiff={withDiff} + withActivation + withoutTimeframe /> ); }, @@ -1428,6 +1392,7 @@ export function Component() { prevActivity={prevActivity} index={index} withDiff={withDiff} + withActivation /> ); })} @@ -1572,6 +1537,8 @@ export function Component() { prevActivity={prevActivity} index={index} withDiff={withDiff} + withActivation + withoutTimeframe /> ); })} @@ -1592,6 +1559,7 @@ export function Component() { prevActivity={prevActivity} index={index} withDiff={withDiff} + withActivation /> ); })} @@ -1624,11 +1592,9 @@ export function Component() { /> -
- {early_action_implementation_images?.map((element) => ( - - ))} -
+
-
- {trigger_activation_system_images?.map((element) => ( - - ))} -
+
- + > + {strings.activationSelectFilesLabel} + - + > + {strings.activationSelectFilesLabel} + @@ -363,7 +368,7 @@ function EapActivationProcess(props: Props) { error={error?.selection_of_target_population} disabled={disabled} readOnly={readOnly} - maxLength={charLimits.selection_of_target_population} + maxWords={wordLimits.selection_of_target_population} /> @@ -466,7 +466,7 @@ function FinanceLogistics(props: Props) { onChange={setFieldValue} disabled={disabled} readOnly={readOnly} - maxLength={charLimits.eap_endorsement} + maxWords={wordLimits.eap_endorsement} />
diff --git a/app/src/views/EapFullForm/Meal/i18n.json b/app/src/views/EapFullForm/Meal/i18n.json index 91501db7cd..9161601672 100644 --- a/app/src/views/EapFullForm/Meal/i18n.json +++ b/app/src/views/EapFullForm/Meal/i18n.json @@ -1,23 +1,23 @@ { "namespace": "eapFullForm", "strings": { - "mealHeading": "Monitoring, Evaluation, Accountability Learning (Meal)", - "mealTitle": "Meal", - "mealDescription1": "Following the guidance of the FbF Manual, describe the M&E plan for this EAP. Including:", + "mealHeading": "Monitoring, Evaluation, Accountability Learning (MEAL)", + "mealTitle": "MEAL", + "mealDescription1": "Following the guidance of the Anticipatory Action Manual, describe the M&E plan for this EAP. Including:", "mealDescription11": "EAP Monitoring (between the trigger moment and the implementation of the actions)", "mealDescription12": "Impact evaluation plan: how, when and by whom will the robust impact evaluation to assess how early actions reduced the humanitarian impact of the extreme event be conducted. Please note that the IFRC DREF...", "mealDescription13": "Trigger evaluation by whom, when and how the trigger evaluation will be conducted)", "mealDescription14": "Learning (when, who and how the learning will take place. E.g workshop, interviews etc). ", "mealDescription2": "If a more detailed M&E plan is available add it as an annex. ", - "mealDescription3": "Describe how the FbF M&E plan is linked to the existing PMER system of the National Society. If the NS does not have a PMER system, explain how the EAP will be used to strengthen this area prior to activation", + "mealDescription3": "Describe how the Anticipatory Action M&E plan is linked to the existing PMER system of the National Society. If the NS does not have a PMER system, explain how the EAP will be used to strengthen this area prior to activation", "mealDescriptionLabel": "Description", "mealExplanatoryNoteLabel": "Explanatory Note", "mealRequiredPointsLabel": "Required Points", - "mealExplanatoryNote": "Building evidence about the impact of FbF systems is a priority. Therefore, the EAP should include an M&E plan to 1) assess the impact of the early actions and the extreme event after each activation and 2) identify if all activities were carried out as planned and document how early actions were implemented 3) learn from the process to improve the system in the future. The chapters 4.3 Design M&E plan and 6. Activate, Monitor, Evaluate in the FbF Manual, provide guidance on the set-up and implementation of the monitoring, evaluation, learning tools. Ideally, the M&E system should be set up to allow comparison of impacts between communities that received early actions and those that didn´t (please refer to FbF manual for more information). This M&E plan should be harmonized with the existing IFRC PMER guidance and tools, available on FedNet.", + "mealExplanatoryNote": "Building evidence about the impact of Anticipatory Action systems is a priority. Therefore, the EAP should include an M&E plan to 1) assess the impact of the early actions and the extreme event after each activation and 2) identify if all activities were carried out as planned and document how early actions were implemented 3) learn from the process to improve the system in the future. The chapters 4.3 Design M&E plan and 6. Activate, Monitor, Evaluate in the Anticipatory Action Manual, provide guidance on the set-up and implementation of the monitoring, evaluation, learning tools. Ideally, the M&E system should be set up to allow comparison of impacts between communities that received early actions and those that didn´t (please refer to Anticipatory Action manual for more information). This M&E plan should be harmonized with the existing IFRC PMER guidance and tools, available on FedNet.", "mealAttachRelevantFilesTitle": "Attach relevant files", "mealAttachRelevantFilesDescription": "Attach any additional maps, documentation, files, images, etc.", "mealAttachRelevantFilesUploadLabel": "Upload", - "mealSectionHeading": "Quality Criteria: Meal", + "mealSectionHeading": "Quality Criteria: MEAL", "mealCriteriaIntroduction1": "The EAP includes an M&E plan to", "mealCriteriaIntroduction11": "assess the impact of the early actions and the extreme event after each activation and", "mealCriteriaIntroduction12": "identify if all activities were carried out as planned and document how early actions were implemented", diff --git a/app/src/views/EapFullForm/Meal/index.tsx b/app/src/views/EapFullForm/Meal/index.tsx index 254149d12d..c2201f56d9 100644 --- a/app/src/views/EapFullForm/Meal/index.tsx +++ b/app/src/views/EapFullForm/Meal/index.tsx @@ -23,8 +23,9 @@ import GoMultiFileInput from '#components/domain/GoMultiFileInput'; import ExplanatoryNote from '#components/ExplanatoryNote'; import NonFieldError from '#components/NonFieldError'; import TabPage from '#components/TabPage'; +import { EAP_ACCEPTED_FILE_FORMATS } from '#utils/constants'; -import { charLimits } from '../common'; +import { wordLimits } from '../common'; import EAPSourceInformationInput, { type SourceInformationFormFields } from '../EAPSourceInformationInput'; import { type PartialEapFullFormType } from '../schema'; import SectionQualityCriteria from '../SectionQualityCriteria'; @@ -173,7 +174,7 @@ function Meal(props: Props) { onChange={setFieldValue} disabled={disabled} readOnly={readOnly} - maxLength={charLimits.meal} + maxWords={wordLimits.meal} /> - diff --git a/app/src/views/EapFullForm/Overview/PartnerContactsInput/i18n.json b/app/src/views/EapFullForm/Overview/PartnerContactsInput/i18n.json deleted file mode 100644 index e76d66ee38..0000000000 --- a/app/src/views/EapFullForm/Overview/PartnerContactsInput/i18n.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "namespace": "eapFullForm", - "strings": { - "partnerNSNameLabel": "Name", - "partnerNSEmailLabel": "Email", - "partnerNSPhoneNumberLabel": "Phone Number", - "partnerNSTitleLabel": "Title", - "partnerNSDeleteButton": "Remove Contact" - } -} diff --git a/app/src/views/EapFullForm/Overview/PartnerContactsInput/index.tsx b/app/src/views/EapFullForm/Overview/PartnerContactsInput/index.tsx deleted file mode 100644 index 34b47a53f9..0000000000 --- a/app/src/views/EapFullForm/Overview/PartnerContactsInput/index.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { DeleteBinTwoLineIcon } from '@ifrc-go/icons'; -import { - Container, - IconButton, - InlineLayout, - ListView, - TextInput, -} from '@ifrc-go/ui'; -import { useTranslation } from '@ifrc-go/ui/hooks'; -import { randomString } from '@togglecorp/fujs'; -import { - type ArrayError, - getErrorObject, - type SetValueArg, - useFormObject, -} from '@togglecorp/toggle-form'; - -import { type PartialEapFullFormType } from '../../schema'; - -import i18n from './i18n.json'; - -type PartnerContactsFormFields = NonNullable[number]; - -interface Props { - value: PartnerContactsFormFields; - error: ArrayError | undefined; - onChange: (value: SetValueArg, index: number) => void; - onRemove: (index: number) => void; - index: number; - disabled?: boolean; - readOnly?: boolean; -} - -function PartnerContactsInput(props: Props) { - const { - error: errorFromProps, - onChange, - value, - index, - onRemove, - disabled, - readOnly, - } = props; - - const strings = useTranslation(i18n); - - const onFieldChange = useFormObject( - index, - onChange, - () => ({ - client_id: randomString(), - }), - ); - - const error = (value && value.client_id && errorFromProps) - ? getErrorObject(errorFromProps?.[value.client_id]) - : undefined; - - return ( - - - - - )} - contentAlignment="start" - > - - - - - - - - - ); -} - -export default PartnerContactsInput; diff --git a/app/src/views/EapFullForm/Overview/i18n.json b/app/src/views/EapFullForm/Overview/i18n.json index c10ceca461..e92d515b59 100644 --- a/app/src/views/EapFullForm/Overview/i18n.json +++ b/app/src/views/EapFullForm/Overview/i18n.json @@ -8,6 +8,8 @@ "formCountryDescription": "The country will be pre-populated based on the NS selection, but can be adapted as needed.", "disasterType": "Disaster type", "disasterTypeDescription": "Select the disaster type for which the EAP is needed.", + "epidemicType": "Type of Epidemic", + "otherDisasterType": "Other Disaster Type", "formUploadCoverImage": "Cover photo", "formUploadCoverImageDescription": "Upload a image for the cover page of the publicly published DREF application.", "formUploadAnImageLabel": "Select an Image", @@ -15,33 +17,26 @@ "formExpectedSubmissionTimeDescription": "Include the propose time of submission, accounting for the time it will take to deliver the application.", "notSure": "Not Sure", "objectiveTitle": "Objective", - "objectiveDescription": "Please provide an objective statement that describes the main goal of the intervention.", + "objectiveDescription": "Please provide an objective statement that describes the main goal of the intervention. Example: {exampleObjective}", + "objectiveDescriptionExample": "This EAP aims to reduce/minimize the impact of river flooding in X region by providing early warning dissemination and cash distribution to at risk families.", "partnersInvolved": "Partners Involved", "partnersInvolvedDescription": "Select from the list the partners involved in this process. Add as many as needed or select not applicable if no partners involved.", "formContacts": "Contacts", - "nationalHeader": "National", - "delegationHeader": "Delegation", - "regionalHeader": "Regional and global", "stakeholderHeader": "Stakeholder", "nSContact": "National Society Contact", "nSContactDescription": "National Society contact responsible for the EAP process", - "partnerNS": "Partner NS", - "partnerNSDescription": "Partner National Society contact", - "formFocalPoint": "IFRC Delegation AA Focal Point", - "delegation": "IFRC Head of Delegation", "drefFocalPoint": "DREF Focal Point", - "drefFocalPointDescription": "The DREF contact person fro IFRC", + "drefFocalPointDescription": "The DREF contact person for IFRC", "regionalFocalPoint": "IFRC Regional AA Focal Point", "regionalManager": "IFRC Regional Ops Manager", "regionalHead": "IFRC Regional Head of DCC", - "regionalCoordinator": "IFRC Global Ops Coordinator", "keyActorsAddButton": "Add new actor", "workWithGovernmentTitle": "Did you work with the government and other relevant actors in the development of this EAP?", "workWithGovernmentDescription": "Please briefly describe the process", "overviewExplanatoryNoteLabel": "Explanatory Note", "overviewRequiredPointsLabel": "Required Points", "overviewRequiredPoint1": "Name the external actors and Movement components that have been involved in the development of this EAP. Include international, national, regional and local actors, if applicable.", - "overviewRequiredPoint2": "In case there are technical working groups for the development of the FbF system in country, indicate which organizations participate in these groups.", + "overviewRequiredPoint2": "In case there are technical working groups for the development of the Anticipatory Action system in country, indicate which organizations participate in these groups.", "overviewRequiredPoint3": "If stakeholders other than RC are involved in implementation/activation of EAP, indicate in one bullet point per organization, their role and list the formal agreement document.", "workExplanatoryNote": "In order to avoid creating parallel systems and to minimize additional discussions on permissions, etc. when a trigger occurs, all relevant key stakeholders in the country should be involved in the development, and when necessary, the approval, of the EAP. If stakeholders other than the National Society are involved in implementation of the EAP, roles and responsibilities should be determined in MoUs or other appropriate documents. It is key to highlight the importance of involving the IFRC at the country, cluster and/ or regional level early in the EAP development process as these offices can provide technical support.", "actorsExplanatoryNote": "In order to avoid creating parallel systems and to minimize additional discussions on permissions, etc. when a trigger occurs, all relevant key stakeholders in the country should be involved in the development, and when necessary, the approval, of the EAP. If stakeholders other than the National Society are involved in implementation of the EAP, roles and responsibilities should be determined in MoUs or other appropriate documents. It is key to highlight the importance of involving the IFRC at the country, cluster and/ or regional level early in the EAP development process as these offices can provide technical support. ", @@ -50,12 +45,12 @@ "keyActorsDescription": "Name the external actors and Movement components that have been involved in the development of this EAP. Include international, national, regional and local actors, if applicable.", "keyActorsDescription2": "Use the \"{addNewActorButtonLabel}\" button to list all the actors involved and explain their role.", "technicalWorkingGroupsTitle": "Technical working groups in place?", - "technicalWorkingGroupDescription": "In case there are technical working groups for the development of the FbF system in country, indicate which organizations participate in these groups.", - "addPartnerNSContactButton": "Add Partner NS", + "technicalWorkingGroupDescription": "In case there are technical working groups for the development of the Anticipatory Action system in country, indicate which organizations participate in these groups.", "technicalWorkingGroupsTitleLabel": "Title", "overviewSectionHeading": "Quality Criteria: Overview", "sectionCriteriaIntroduction1": "The following quality criteria are used by the IFRC Validation Committee as a benchmark to determine if the Early Action Protocol is eligible to be funded by the DREF. If any of the criteria are not adequately met or just partially met, the Validation Committee may require further information or additional work to demonstrate that the criteria are justified.", "sectionCriteriaIntroduction2": "The Forecast based Financing process has been conducted in a participatory manner with involvement of key stakeholders, including communities, movement components and external actors, especially Hydro-Met agencies, disaster risk management authorities, government ministries, development organizations, other hazard specific agencies (local and national level), and other major anticipatory humanitarian actors in the country/region.", - "sectionCriteriaComment2": "In order to avoid creating parallel systems and to minimize additional discussions on permissions, etc. when a trigger occurs, all relevant key stakeholders in the country should be involved in the development, and when necessary, the approval of the EAP." + "sectionCriteriaComment2": "In order to avoid creating parallel systems and to minimize additional discussions on permissions, etc. when a trigger occurs, all relevant key stakeholders in the country should be involved in the development, and when necessary, the approval of the EAP.", + "rcrcClimateCenter": "RCRC Climate Center" } } diff --git a/app/src/views/EapFullForm/Overview/index.tsx b/app/src/views/EapFullForm/Overview/index.tsx index fa9d4c6546..00cf7ec30d 100644 --- a/app/src/views/EapFullForm/Overview/index.tsx +++ b/app/src/views/EapFullForm/Overview/index.tsx @@ -3,6 +3,7 @@ import { AddLineIcon } from '@ifrc-go/icons'; import { BooleanInput, Button, + Checkbox, Container, Description, InputSection, @@ -13,7 +14,10 @@ import { TextInput, } from '@ifrc-go/ui'; import { useTranslation } from '@ifrc-go/ui/hooks'; -import { resolveToString } from '@ifrc-go/ui/utils'; +import { + resolveToComponent, + resolveToString, +} from '@ifrc-go/ui/utils'; import { isNotDefined, randomString, @@ -36,13 +40,16 @@ import NationalSocietySelectInput from '#components/domain/NationalSocietySelect import ExplanatoryNote from '#components/ExplanatoryNote'; import NonFieldError from '#components/NonFieldError'; import TabPage from '#components/TabPage'; +import { + DISASTER_TYPE_EPIDEMIC, + DISASTER_TYPE_OTHER, +} from '#utils/constants'; import { type GoApiResponse } from '#utils/restRequest'; -import { charLimits } from '../common'; +import { wordLimits } from '../common'; import { type PartialEapFullFormType } from '../schema'; import SectionQualityCriteria from '../SectionQualityCriteria'; import KeyActorsInput from './KeyActorsInput'; -import PartnerContactsInput from './PartnerContactsInput'; import i18n from './i18n.json'; @@ -51,10 +58,6 @@ type KeyActorsFormFields = NonNullable< PartialEapFullFormType['key_actors'] >[number]; -type PartnerContactFormFields = NonNullable< - PartialEapFullFormType['partner_contacts'] ->[number]; - interface Props { value: PartialEapFullFormType; setFieldValue: (...entries: EntriesAsList) => void; @@ -83,28 +86,21 @@ function Overview(props: Props) { const strings = useTranslation(i18n); const error = getErrorObject(formError); - // NOTE: We dont want some fields to have onChange functionality - const noop = () => { }; - const { - setValue: onPartnerContactChange, - removeValue: onPartnerContactRemove, - } = useFormArray<'partner_contacts', PartnerContactFormFields>( - 'partner_contacts', - setFieldValue, + const objectiveDescription = ( + + {resolveToComponent( + strings.objectiveDescription, + { exampleObjective: {strings.objectiveDescriptionExample} }, + )} + ); - const handlePartnerContactAdd = useCallback(() => { - const newPartnerContactItem: PartnerContactFormFields = { - client_id: randomString(), - }; + // NOTE: We dont want some fields to have onChange functionality + const noop = () => { }; - setFieldValue( - (oldValue: PartnerContactFormFields[] | undefined) => ( - [...(oldValue ?? []), newPartnerContactItem] - ), - 'partner_contacts' as const, - ); - }, [setFieldValue]); + const isEpidemicDisasterType = eapRegistrationDetail + ?.disaster_type === DISASTER_TYPE_EPIDEMIC; + const isOtherDisasterType = eapRegistrationDetail?.disaster_type === DISASTER_TYPE_OTHER; const { setValue: onKeyActorsChange, removeValue: onKeyActorsRemove } = useFormArray<'key_actors', KeyActorsFormFields>( 'key_actors', @@ -197,6 +193,18 @@ function Overview(props: Props) { disabled={disabled} readOnly /> + {(isEpidemicDisasterType || isOtherDisasterType) && ( + + )}