From 3258b96e896e913332de15814d3223e1aa674fe0 Mon Sep 17 00:00:00 2001 From: Shreeyash Shrestha <45554490+shreeyash07@users.noreply.github.com> Date: Tue, 17 Dec 2024 10:42:32 +0545 Subject: [PATCH 1/2] Add validation workflow to local units - feat(local-units): add delete modal and refine permissions - Add delete modal in Local Units Form and Local Units Table - Separate delete and validate permissions for local units - Simplify delete permission logic --- .changeset/pink-ties-smash.md | 5 + app/src/components/DiffWrapper/index.tsx | 52 + .../components/MultiSelectOutput/index.tsx | 47 + app/src/components/SelectOutput/index.tsx | 41 + .../domain/BaseMapPointInput/index.tsx | 7 + .../BaseMapPointInput/styles.module.css | 6 + app/src/utils/common.ts | 43 +- app/src/utils/constants.ts | 2 +- .../FormGrid/index.tsx | 23 + .../FormGrid/styles.module.css | 10 + .../LocalUnitDeleteButton/i18n.json | 10 - .../LocalUnitDeleteButton/index.tsx | 104 - .../LocalUnitDeleteModal/i18n.json | 11 + .../LocalUnitDeleteModal/index.tsx | 188 ++ .../LocalUnitDeleteModal/styles.module.css | 11 + .../LocalUnitValidateButton/i18n.json | 6 +- .../LocalUnitValidateButton/index.tsx | 107 +- .../LocalUnitValidateModal/i18n.json | 9 + .../LocalUnitValidateModal/index.tsx | 103 + .../LocalUnitView/i18n.json | 91 + .../LocalUnitView/index.tsx | 558 +++++ .../LocalUnitViewModal/i18n.json | 7 + .../LocalUnitViewModal/index.tsx | 42 + .../LocalUnitsForm/i18n.json | 16 +- .../LocalUnitsForm/index.tsx | 1873 +++++++++++------ .../LocalUnitsForm/schema.ts | 16 + .../LocalUnitsForm/styles.module.css | 31 +- .../LocalUnitsFormModal/index.tsx | 3 + .../LocalUnitsMap/i18n.json | 18 +- .../LocalUnitsMap/index.tsx | 20 +- .../LocalUnitTableActions/i18n.json | 6 +- .../LocalUnitTableActions/index.tsx | 168 +- .../LocalUnitsTable/index.tsx | 155 +- app/src/views/DrefApplicationForm/common.tsx | 2 +- app/src/views/DrefFinalReportForm/common.tsx | 2 +- .../DrefOperationalUpdateForm/common.tsx | 2 +- packages/ui/src/index.css | 1 + 37 files changed, 2818 insertions(+), 978 deletions(-) create mode 100644 .changeset/pink-ties-smash.md create mode 100644 app/src/components/DiffWrapper/index.tsx create mode 100644 app/src/components/MultiSelectOutput/index.tsx create mode 100644 app/src/components/SelectOutput/index.tsx create mode 100644 app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/FormGrid/index.tsx create mode 100644 app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/FormGrid/styles.module.css delete mode 100644 app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitDeleteButton/i18n.json delete mode 100644 app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitDeleteButton/index.tsx create mode 100644 app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitDeleteModal/i18n.json create mode 100644 app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitDeleteModal/index.tsx create mode 100644 app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitDeleteModal/styles.module.css create mode 100644 app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitValidateModal/i18n.json create mode 100644 app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitValidateModal/index.tsx create mode 100644 app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitView/i18n.json create mode 100644 app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitView/index.tsx create mode 100644 app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitViewModal/i18n.json create mode 100644 app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitViewModal/index.tsx diff --git a/.changeset/pink-ties-smash.md b/.changeset/pink-ties-smash.md new file mode 100644 index 0000000000..fcde62ea08 --- /dev/null +++ b/.changeset/pink-ties-smash.md @@ -0,0 +1,5 @@ +--- +"go-web-app": minor +--- + +Add local unit validation workflow diff --git a/app/src/components/DiffWrapper/index.tsx b/app/src/components/DiffWrapper/index.tsx new file mode 100644 index 0000000000..528ae4217e --- /dev/null +++ b/app/src/components/DiffWrapper/index.tsx @@ -0,0 +1,52 @@ +import { useMemo } from 'react'; +import { isNotDefined } from '@togglecorp/fujs'; + +interface Props { + diffContainerClassName?: string; + value?: T; + oldValue?: T; + children: React.ReactNode; + enabled: boolean; + showOnlyDiff?: boolean; +} + +function DiffWrapper(props: Props) { + const { + diffContainerClassName, + oldValue, + value, + children, + enabled = false, + showOnlyDiff, + } = props; + + const hasChanged = useMemo(() => { + // NOTE: we consider `null` and `undefined` as same for + // this scenario + if (isNotDefined(oldValue) && isNotDefined(value)) { + return false; + } + + return JSON.stringify(oldValue) !== JSON.stringify(value); + }, [oldValue, value]); + + if (!enabled) { + return children; + } + + if (!hasChanged && showOnlyDiff) { + return null; + } + + if (!hasChanged) { + return children; + } + + return ( +
+ {children} +
+ ); +} + +export default DiffWrapper; diff --git a/app/src/components/MultiSelectOutput/index.tsx b/app/src/components/MultiSelectOutput/index.tsx new file mode 100644 index 0000000000..1287d89d05 --- /dev/null +++ b/app/src/components/MultiSelectOutput/index.tsx @@ -0,0 +1,47 @@ +import { useMemo } from 'react'; +import { TextOutput } from '@ifrc-go/ui'; +import { listToMap } from '@togglecorp/fujs'; + +interface Props { + value: VALUE[] | undefined; + options: OPTION[] | undefined; + keySelector: (datum: OPTION) => VALUE; + labelSelector: (datum: OPTION) => React.ReactNode; + label: React.ReactNode; +} + +function MultiSelectOutput(props: Props) { + const { + value, + options, + keySelector, + labelSelector, + label, + } = props; + + const valueMap = useMemo( + () => listToMap(value ?? [], (val) => val, () => true), + [value], + ); + + const selectedOptions = useMemo(() => options?.filter( + (option) => valueMap[keySelector(option)], + ), [keySelector, options, valueMap]); + + const valueLabel = useMemo( + () => selectedOptions?.map( + (selectedOption) => labelSelector(selectedOption), + ).join(', ') ?? '--', + [labelSelector, selectedOptions], + ); + + return ( + + ); +} + +export default MultiSelectOutput; diff --git a/app/src/components/SelectOutput/index.tsx b/app/src/components/SelectOutput/index.tsx new file mode 100644 index 0000000000..f9d6748988 --- /dev/null +++ b/app/src/components/SelectOutput/index.tsx @@ -0,0 +1,41 @@ +import { useMemo } from 'react'; +import { TextOutput } from '@ifrc-go/ui'; +import { isDefined } from '@togglecorp/fujs'; + +interface Props { + value: VALUE | undefined; + options: OPTION[] | undefined; + keySelector: (datum: OPTION) => VALUE; + labelSelector: (datum: OPTION) => React.ReactNode; + label: React.ReactNode; +} + +function SelectOutput(props: Props) { + const { + value, + options, + keySelector, + labelSelector, + label, + } = props; + + const selectedOption = useMemo(() => options?.find( + (option) => keySelector(option) === value, + ), [options, keySelector, value]); + + const valueLabel = useMemo(() => ( + isDefined(selectedOption) + ? labelSelector(selectedOption) + : '--' + ), [labelSelector, selectedOption]); + + return ( + + ); +} + +export default SelectOutput; diff --git a/app/src/components/domain/BaseMapPointInput/index.tsx b/app/src/components/domain/BaseMapPointInput/index.tsx index 0cb7c2cc2c..d429f59ed8 100644 --- a/app/src/components/domain/BaseMapPointInput/index.tsx +++ b/app/src/components/domain/BaseMapPointInput/index.tsx @@ -56,6 +56,9 @@ interface Props extends BaseMapProps { readOnly?: boolean; required?: boolean; error?: ObjectError; + showChanges: boolean; + latitudeInputSectionClassName?: string; + longitudeInputSectionClassName?: string; } function BaseMapPointInput(props: Props) { @@ -74,6 +77,8 @@ function BaseMapPointInput(props: Props) { country, required, error, + latitudeInputSectionClassName, + longitudeInputSectionClassName, ...otherProps } = props; @@ -183,6 +188,7 @@ function BaseMapPointInput(props: Props) {
(props: Props) { required={required} /> oldArray.includes(id)); + } + return false; +} + +// TODO: write tests for the function +export function flattenObject>( + inputObject: T, + prefix?: string, +): Record { + return Object.entries(inputObject).reduce((acc, [key, value]) => { + const newKey = prefix ? `${prefix}.${key}` : key; + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return { ...acc, ...flattenObject(value as Record, newKey) }; + } + return { ...acc, [newKey]: value }; + }, {} as Record); +} + +// TODO: write tests for the function +export function getLastSegment(str: string, delimiter: string) { + const parts = str.split(delimiter); + return parts[parts.length - 1]; +} diff --git a/app/src/utils/constants.ts b/app/src/utils/constants.ts index 1c17fe85b0..4a505b8cc7 100644 --- a/app/src/utils/constants.ts +++ b/app/src/utils/constants.ts @@ -109,7 +109,7 @@ export const DREF_TYPE_ASSESSMENT = 1 satisfies TypeOfDrefEnum; export const DREF_TYPE_RESPONSE = 2 satisfies TypeOfDrefEnum; export const DREF_TYPE_LOAN = 3 satisfies TypeOfDrefEnum; -type TypeOfOnsetEnum = components<'read'>['schemas']['TypeValidatedEnum']; +type TypeOfOnsetEnum = components<'read'>['schemas']['DrefDrefOnsetTypeEnumKey']; export const ONSET_SLOW = 1 satisfies TypeOfOnsetEnum; // Subscriptions diff --git a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/FormGrid/index.tsx b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/FormGrid/index.tsx new file mode 100644 index 0000000000..064c278311 --- /dev/null +++ b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/FormGrid/index.tsx @@ -0,0 +1,23 @@ +import { _cs } from '@togglecorp/fujs'; + +import styles from './styles.module.css'; + +interface Props { + className?: string; + children?: React.ReactNode; +} + +function FormGrid(props: Props) { + const { + className, + children, + } = props; + + return ( +
+ {children} +
+ ); +} + +export default FormGrid; diff --git a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/FormGrid/styles.module.css b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/FormGrid/styles.module.css new file mode 100644 index 0000000000..0c842eff17 --- /dev/null +++ b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/FormGrid/styles.module.css @@ -0,0 +1,10 @@ +/* NOTE: this element is portaled */ +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--go-ui-spacing-lg); + + @media screen and (max-width: 60rem) { + grid-template-columns: 1fr; + } +} diff --git a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitDeleteButton/i18n.json b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitDeleteButton/i18n.json deleted file mode 100644 index 9b3766190e..0000000000 --- a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitDeleteButton/i18n.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "namespace": "countryNsOverviewContextAndStructure", - "strings": { - "localUnitsDelete": "Delete", - "deleteSuccessMessage": "{localUnitName} was deleted.", - "deleteFailureMessage": "Failed to validate {localUnitName}", - "deleteLocalUnitHeading": "Delete Local Unit Confirmation", - "deleteLocalUnitMessage": "Are you sure you want to delete \"{localUnitName}\"?" - } -} \ No newline at end of file diff --git a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitDeleteButton/index.tsx b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitDeleteButton/index.tsx deleted file mode 100644 index 6894fefd34..0000000000 --- a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitDeleteButton/index.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { - type ButtonVariant, - ConfirmButton, -} from '@ifrc-go/ui'; -import { useTranslation } from '@ifrc-go/ui/hooks'; -import { resolveToString } from '@ifrc-go/ui/utils'; - -import usePermissions from '#hooks/domain/usePermissions'; -import useAlert from '#hooks/useAlert'; -import { useLazyRequest } from '#utils/restRequest'; - -import i18n from './i18n.json'; - -const hideDelete = true; - -interface Props { - countryId: number; - localUnitId: number; - onActionSuccess: () => void; - localUnitName: string | null | undefined; - disabled?: boolean; - variant?: ButtonVariant; -} - -function LocalUnitDeleteButton(props: Props) { - const strings = useTranslation(i18n); - const { - countryId, - localUnitId, - localUnitName, - onActionSuccess, - disabled, - variant = 'secondary', - } = props; - - const { isCountryAdmin, isSuperUser } = usePermissions(); - const alert = useAlert(); - - const hasDeletePermission = isSuperUser || isCountryAdmin(countryId); - - const { - pending: validateLocalUnitPending, - trigger: validateLocalUnit, - } = useLazyRequest({ - method: 'DELETE', - url: '/api/v2/local-units/{id}/', - pathVariables: { id: localUnitId }, - onSuccess: () => { - const validationMessage = resolveToString( - strings.deleteSuccessMessage, - { localUnitName }, - ); - alert.show( - validationMessage, - { variant: 'success' }, - ); - onActionSuccess(); - }, - onFailure: (response) => { - const { - value: { messageForNotification }, - debugMessage, - } = response; - - alert.show( - resolveToString( - strings.deleteFailureMessage, - { localUnitName }, - ), - { - variant: 'danger', - description: messageForNotification, - debugMessage, - }, - ); - }, - }); - - if (hideDelete) { - return null; - } - - return ( - - {strings.localUnitsDelete} - - ); -} - -export default LocalUnitDeleteButton; diff --git a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitDeleteModal/i18n.json b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitDeleteModal/i18n.json new file mode 100644 index 0000000000..d553b1c387 --- /dev/null +++ b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitDeleteModal/i18n.json @@ -0,0 +1,11 @@ +{ + "namespace": "countryNsOverviewContextAndStructure", + "strings": { + "submitLabel": "Submit", + "deleteSuccessMessage": "{localUnitName} has been successfully deleted.", + "deleteFailureMessage": "Unable to delete {localUnitName}. Please try again.", + "chooseDeleteReasonMessage": "Choose the reason to delete local unit.", + "deleteReasonExplanation": "Explain the reason why the local unit is being deleted.", + "deleteLocalUnitHeading": "Are you sure you want to delete \"{localUnitName}\"?" + } +} \ No newline at end of file diff --git a/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitDeleteModal/index.tsx b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitDeleteModal/index.tsx new file mode 100644 index 0000000000..afb70fab38 --- /dev/null +++ b/app/src/views/CountryNsOverviewContextAndStructure/NationalSocietyLocalUnits/LocalUnitDeleteModal/index.tsx @@ -0,0 +1,188 @@ +import { + useCallback, + useMemo, +} from 'react'; +import { + Button, + Modal, + RadioInput, + TextArea, +} from '@ifrc-go/ui'; +import { useTranslation } from '@ifrc-go/ui/hooks'; +import { resolveToString } from '@ifrc-go/ui/utils'; +import { + createSubmitHandler, + getErrorObject, + type ObjectSchema, + type PartialForm, + requiredStringCondition, + useForm, +} from '@togglecorp/toggle-form'; + +import type { GlobalEnums } from '#contexts/domain'; +import useGlobalEnums from '#hooks/domain/useGlobalEnums'; +import useAlert from '#hooks/useAlert'; +import { + type GoApiBody, + useLazyRequest, +} from '#utils/restRequest'; + +import i18n from './i18n.json'; +import styles from './styles.module.css'; + +type DeprecateReason = NonNullable[number]; + +type LocalUnitDeprecateBody = GoApiBody<'/api/v2/local-units/{id}/deprecate/', 'POST'>; + +type DeprecateFormType = PartialForm; +type FormSchema = ObjectSchema; +type FormSchemaFields = ReturnType; + +const deprecateReasonKeySelector = ( + item: DeprecateReason, +) => item.key; + +const deprecateReasonLabelSelector = ( + item: DeprecateReason, +) => item.value; + +const defaultFormValue: DeprecateFormType = { +}; + +const schema: FormSchema = { + fields: (): FormSchemaFields => ({ + deprecated_reason: { + required: true, + }, + deprecated_reason_overview: { + required: true, + requiredValidation: requiredStringCondition, + }, + }), +}; + +interface Props { + localUnitId: number; + onDeleteActionSuccess?: () => void; + onClose: () => void; + localUnitName: string; +} + +function LocalUnitDeleteModal(props: Props) { + const strings = useTranslation(i18n); + const { + localUnitId, + localUnitName, + onDeleteActionSuccess, + onClose, + } = props; + + const { + local_units_deprecate_reason: deprecateReasonOptions, + } = useGlobalEnums(); + + const { + value, + error: formError, + setFieldValue, + setError, + validate, + } = useForm(schema, { value: defaultFormValue }); + + const error = useMemo( + () => getErrorObject(formError), + [formError], + ); + + const alert = useAlert(); + + const { + pending: deprecateLocalUnitPending, + trigger: deprecateLocalUnit, + } = useLazyRequest({ + method: 'POST', + url: '/api/v2/local-units/{id}/deprecate/', + body: (body: LocalUnitDeprecateBody) => body, + pathVariables: { id: localUnitId }, + onSuccess: () => { + const validationMessage = resolveToString( + strings.deleteSuccessMessage, + { localUnitName }, + ); + alert.show( + validationMessage, + { variant: 'success' }, + ); + if (onDeleteActionSuccess) { + onDeleteActionSuccess(); + } + }, + onFailure: (response) => { + const { + value: { messageForNotification }, + } = response; + + alert.show( + resolveToString( + strings.deleteFailureMessage, + { localUnitName }, + ), + { + variant: 'danger', + description: messageForNotification, + }, + ); + }, + }); + + const handleFormSubmit = useCallback( + (formValues: DeprecateFormType) => { + deprecateLocalUnit(formValues as LocalUnitDeprecateBody); + }, + [deprecateLocalUnit], + ); + + return ( + + {strings.submitLabel} + + )} + > + +