diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bb06336a..c420de854 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,26 @@ +# [1.186.0](https://github.com/bcgov/CONN-CCBC-portal/compare/v1.185.0...v1.186.0) (2024-08-27) + +### Bug Fixes + +- 500 error when visiting non-locations page ([b3a101c](https://github.com/bcgov/CONN-CCBC-portal/commit/b3a101c68f6ee5351f335e872b8583a8391dbce8)) +- add permissions for executing function ([6defd8f](https://github.com/bcgov/CONN-CCBC-portal/commit/6defd8f09689bdf6f9cfc320294c2953ca70e182)) +- cleanup of [section] to remove dataBySection ([35a85ee](https://github.com/bcgov/CONN-CCBC-portal/commit/35a85ee778573f92f526c8bff124c79abd40b43c)) +- clear button and inputs show correct values at all times ([abd9f20](https://github.com/bcgov/CONN-CCBC-portal/commit/abd9f20a15b2d15564a114f13f18ac4faa8b8542)) +- empty communities are not saved ([9c0c906](https://github.com/bcgov/CONN-CCBC-portal/commit/9c0c906e4b17c9e6e5105903bcfee4a74a7c0dc1)) +- geographic name options are by economic region and regional district ([a1c6927](https://github.com/bcgov/CONN-CCBC-portal/commit/a1c6927b67f89e81683716e0de8a39a4b0fd080d)) +- geographic name options are populated with only economic region ([40b9438](https://github.com/bcgov/CONN-CCBC-portal/commit/40b9438ef9793d5b83c178d9901e0eb587c8771d)) +- update on save instead of after refresh ([56b42f2](https://github.com/bcgov/CONN-CCBC-portal/commit/56b42f246d06b423ad279333199115fcccec035a)) +- user correct rowId ([378dcfc](https://github.com/bcgov/CONN-CCBC-portal/commit/378dcfcebef4b01a48e8da34ae92b03f3c2d3269)) + +### Features + +- add and remove community source behaves as expected ([ce70b29](https://github.com/bcgov/CONN-CCBC-portal/commit/ce70b29e6f100032f9c92b6f8d7023d18d0e10a3)) +- added community sources are read only ([39e3834](https://github.com/bcgov/CONN-CCBC-portal/commit/39e3834d2b75707dd0cdf0ebcc1379b3ede77869)) +- community source data is now displayed properly ([b8e811e](https://github.com/bcgov/CONN-CCBC-portal/commit/b8e811e456700752e0d18c9ca18ea835915c790c)) +- disable previously selected options ([7c810e3](https://github.com/bcgov/CONN-CCBC-portal/commit/7c810e3ace1a19e3da2217f28b9dd2a5dd3c0236)) +- section community source data behaves similar to quick edit ([b9d8e7c](https://github.com/bcgov/CONN-CCBC-portal/commit/b9d8e7c96d348515f99a3b3b8644a3dad618fa6b)) +- style buttons for add, remove, and clear ([35698ff](https://github.com/bcgov/CONN-CCBC-portal/commit/35698ff6ee1164d2a9e90828ce3d1a79193ecf24)) + # [1.185.0](https://github.com/bcgov/CONN-CCBC-portal/compare/v1.184.2...v1.185.0) (2024-08-26) ### Features diff --git a/app/components/Analyst/CBC/CbcTheme.ts b/app/components/Analyst/CBC/CbcTheme.ts index 292b899c8..59c8ebc5d 100644 --- a/app/components/Analyst/CBC/CbcTheme.ts +++ b/app/components/Analyst/CBC/CbcTheme.ts @@ -11,12 +11,13 @@ import { TextWidget, DatePickerWidget, } from 'lib/theme/widgets'; +import ArrayLocationFieldTemplate from 'lib/theme/fields/ArrayLocationDataField'; +import CommunitySourceWidget from 'lib/theme/widgets/custom/CommunitySourceWidget'; import ArrayBooleanField from '../../Review/fields/ArrayBooleanField'; import ReviewCheckboxField from '../../Review/fields/ReviewCheckboxField'; import ReviewInlineArrayField from '../../Review/fields/ReviewInlineArrayField'; import ReviewObjectFieldTemplate from '../../Review/ReviewObjectFieldTemplate'; import ReviewSectionField from '../../Review/ReviewSectionField'; -import ReviewArrayFieldTemplate from '../../Review/fields/ReviewArrayFieldTemplate'; import ReviewFieldTemplate from '../../Review/fields/ReviewFieldTemplate'; import DefaultWidget from '../../Review/widgets/DefaultWidget'; import BooleanWidget from '../../Review/widgets/BooleanWidget'; @@ -47,12 +48,13 @@ const CbcTheme: ThemeProps = { NumberWidget, NumericStringWidget, ReadOnlyWidget: DefaultWidget, + CommunitySourceWidget, }, templates: { ...templates, ObjectFieldTemplate: ReviewObjectFieldTemplate, FieldTemplate: ReviewFieldTemplate, - ArrayFieldTemplate: ReviewArrayFieldTemplate, + ArrayFieldTemplate: ArrayLocationFieldTemplate, }, }; diff --git a/app/components/Analyst/Project/ProjectTheme.ts b/app/components/Analyst/Project/ProjectTheme.ts index c7ef5c836..17a018486 100644 --- a/app/components/Analyst/Project/ProjectTheme.ts +++ b/app/components/Analyst/Project/ProjectTheme.ts @@ -3,6 +3,7 @@ import ArrayFieldTemplate from 'lib/theme/fields/ArrayFieldTemplate'; import * as widgets from 'lib/theme/widgets'; import ReadOnlyWidget from 'components/Analyst/Project/ConditionalApproval/widgets/ReadOnlyWidget'; import ExcelImportFileWidget from 'components/Analyst/Project/ProjectInformation/widgets/ExcelImportFileWidget'; +import CommunitySourceWidget from 'lib/theme/widgets/custom/CommunitySourceWidget'; import { StatusSelectWidget } from './ConditionalApproval/widgets'; import ProjectFieldTemplate from './fields/ProjectFieldTemplate'; import ProjectObjectFieldTemplate from './fields/ProjectObjectFieldTemplate'; @@ -30,6 +31,7 @@ const ProjectTheme: ThemeProps = { CcbcIdWidget, HiddenWidget, ContextErrorWidget, + CommunitySourceWidget, }, templates: { ...templates, diff --git a/app/components/Review/fields/ReviewFieldTemplate.tsx b/app/components/Review/fields/ReviewFieldTemplate.tsx index 2d33b6900..f1511d862 100644 --- a/app/components/Review/fields/ReviewFieldTemplate.tsx +++ b/app/components/Review/fields/ReviewFieldTemplate.tsx @@ -37,6 +37,12 @@ const ReviewFieldTemplate: React.FC = ({ const title = (uiSchema?.['ui:options']?.customTitle as JSX.Element) ?? schema.title; + const isExcludeTableFormat = uiSchema?.['ui:options']?.excludeTableFormat; + + if (isExcludeTableFormat) { + return <>{children}; + } + const before = uiSchema?.['ui:before']; const after = uiSchema?.['ui:after']; const fieldName = id?.split('_')?.[2]; diff --git a/app/cypress/e2e/analyst/cbc/[cbcId].cy.js b/app/cypress/e2e/analyst/cbc/[cbcId].cy.js index ef90f32d1..671acc21a 100644 --- a/app/cypress/e2e/analyst/cbc/[cbcId].cy.js +++ b/app/cypress/e2e/analyst/cbc/[cbcId].cy.js @@ -16,7 +16,8 @@ describe('The cbc project view', () => { cy.contains('h1', 'Project 1'); cy.contains('h2', 'Tombstone'); cy.contains('h2', 'Project type'); - cy.contains('h2', 'Locations and counts'); + cy.contains('h2', 'Locations'); + cy.contains('h2', 'Counts'); cy.contains('h2', 'Funding'); cy.contains('h2', 'Events and dates'); cy.contains('h2', 'Miscellaneous'); diff --git a/app/formSchema/analyst/cbc/locations.ts b/app/formSchema/analyst/cbc/locations.ts new file mode 100644 index 000000000..564b62761 --- /dev/null +++ b/app/formSchema/analyst/cbc/locations.ts @@ -0,0 +1,36 @@ +import { RJSFSchema } from '@rjsf/utils'; + +const locations: RJSFSchema = { + title: 'Locations', + description: '', + type: 'object', + required: [], + properties: { + projectLocations: { + type: 'string', + title: 'Project Locations', + }, + geographicNames: { + type: 'string', + title: 'Geographic Names', + }, + regionalDistricts: { + type: 'string', + title: 'Regional Districts', + }, + economicRegions: { + type: 'string', + title: 'Economic Regions', + }, + communitySourceData: { + type: 'array', + default: [], + items: { + type: 'integer', + enum: [], + }, + }, + }, +}; + +export default locations; diff --git a/app/formSchema/analyst/cbc/locationsAndCounts.ts b/app/formSchema/analyst/cbc/locationsAndCounts.ts index e983ee8d3..48a8029a4 100644 --- a/app/formSchema/analyst/cbc/locationsAndCounts.ts +++ b/app/formSchema/analyst/cbc/locationsAndCounts.ts @@ -1,7 +1,7 @@ import { RJSFSchema } from '@rjsf/utils'; const locationsAndCounts: RJSFSchema = { - title: 'Locations and counts', + title: 'Counts', description: '', type: 'object', required: [ @@ -10,22 +10,6 @@ const locationsAndCounts: RJSFSchema = { 'householdCount', ], properties: { - projectLocations: { - type: 'string', - title: 'Project Locations', - }, - geographicNames: { - type: 'string', - title: 'Geographic Names', - }, - regionalDistricts: { - type: 'string', - title: 'Regional Districts', - }, - economicRegions: { - type: 'string', - title: 'Economic Regions', - }, communitiesAndLocalesCount: { type: 'number', title: 'Communities and locales count', diff --git a/app/formSchema/analyst/cbc/review.ts b/app/formSchema/analyst/cbc/review.ts index 63455e48c..cd2eb8df2 100644 --- a/app/formSchema/analyst/cbc/review.ts +++ b/app/formSchema/analyst/cbc/review.ts @@ -6,6 +6,7 @@ import funding from './funding'; import eventsAndDates from './eventsAndDates'; import miscellaneous from './miscellaneous'; import projectDataReviews from './projectDataReviews'; +import locationsUi from './locations'; const review: RJSFSchema = { type: 'object', @@ -24,6 +25,12 @@ const review: RJSFSchema = { ...projectType.properties, }, }, + locations: { + title: locationsUi.title, + properties: { + ...locationsUi.properties, + }, + }, locationsAndCounts: { required: locationsAndCounts.required, title: locationsAndCounts.title, diff --git a/app/formSchema/uiSchema/cbc/editLocationsUiSchema.ts b/app/formSchema/uiSchema/cbc/editLocationsUiSchema.ts new file mode 100644 index 000000000..def9b5520 --- /dev/null +++ b/app/formSchema/uiSchema/cbc/editLocationsUiSchema.ts @@ -0,0 +1,37 @@ +const locationsUiSchema = { + 'ui:field': 'SectionField', + 'ui:options': { + dividers: true, + }, + projectLocations: { + 'ui:widget': 'TextAreaWidget', + 'ui:label': 'Project Locations', + }, + geographicNames: { + 'ui:hidden': true, + 'ui:widget': 'HiddenWidget', + }, + regionalDistricts: { + 'ui:hidden': true, + 'ui:widget': 'HiddenWidget', + }, + economicRegions: { + 'ui:hidden': true, + 'ui:widget': 'HiddenWidget', + }, + communitySourceData: { + 'ui:field': 'ArrayLocationDataField', + 'ui:label': 'Community Location Data', + items: { + 'ui:widget': 'CommunitySourceWidget', + 'ui:options': { + excludeTableFormat: true, + }, + }, + 'ui:options': { + excludeTableFormat: true, + }, + }, +}; + +export default locationsUiSchema; diff --git a/app/formSchema/uiSchema/cbc/editUiSchema.ts b/app/formSchema/uiSchema/cbc/editUiSchema.ts index daa80ad1d..9cfb88acd 100644 --- a/app/formSchema/uiSchema/cbc/editUiSchema.ts +++ b/app/formSchema/uiSchema/cbc/editUiSchema.ts @@ -6,6 +6,7 @@ import fundingUiSchema from './fundingUiSchema'; import eventsAndDatesUiSchema from './eventsAndDatesUiSchema'; import miscellaneousUiSchema from './miscellaneousUiSchema'; import projectDataReviewsUiSchema from './projectDataReviewsUiSchema'; +import editLocationsUiSchema from './editLocationsUiSchema'; const editUiSchema = { 'ui:title': 'CBC Edit', @@ -21,6 +22,10 @@ const editUiSchema = { 'ui:title': 'Locations and Counts', ...locationsAndCountsUiSchema, }, + locations: { + 'ui:title': 'Locations', + ...editLocationsUiSchema, + }, funding: { 'ui:title': 'Funding', ...fundingUiSchema, diff --git a/app/formSchema/uiSchema/cbc/locationsAndCountsUiSchema.ts b/app/formSchema/uiSchema/cbc/locationsAndCountsUiSchema.ts index 67830a66a..ef9dc963f 100644 --- a/app/formSchema/uiSchema/cbc/locationsAndCountsUiSchema.ts +++ b/app/formSchema/uiSchema/cbc/locationsAndCountsUiSchema.ts @@ -4,25 +4,6 @@ const locationsAndCountsUiSchema = { dividers: true, }, 'ui:title': 'Locations and Counts', - projectLocations: { - 'ui:widget': 'TextAreaWidget', - 'ui:label': 'Project Locations', - 'ui:options': { - maxLength: 1000, - }, - }, - geographicNames: { - 'ui:widget': 'ReadOnlyWidget', - 'ui:label': 'Geographic Names', - }, - regionalDistricts: { - 'ui:widget': 'ReadOnlyWidget', - 'ui:label': 'Regional Districts', - }, - economicRegions: { - 'ui:widget': 'ReadOnlyWidget', - 'ui:label': 'Economic Regions', - }, communitiesAndLocalesCount: { 'ui:widget': 'NumberWidget', 'ui:label': 'Communities and Locales Count', diff --git a/app/formSchema/uiSchema/cbc/locationsUiSchema.ts b/app/formSchema/uiSchema/cbc/locationsUiSchema.ts new file mode 100644 index 000000000..ae2b76b2b --- /dev/null +++ b/app/formSchema/uiSchema/cbc/locationsUiSchema.ts @@ -0,0 +1,27 @@ +const locationsUiSchema = { + 'ui:field': 'SectionField', + 'ui:options': { + dividers: true, + }, + projectLocations: { + 'ui:widget': 'TextAreaWidget', + 'ui:label': 'Project Locations', + }, + geographicNames: { + 'ui:widget': 'TextAreaWidget', + 'ui:label': 'Geographic Names', + }, + regionalDistricts: { + 'ui:widget': 'TextAreaWidget', + 'ui:label': 'Regional Districts', + }, + economicRegions: { + 'ui:widget': 'TextAreaWidget', + 'ui:label': 'Economic Regions', + }, + communitySourceData: { + 'ui:hidden': true, + }, +}; + +export default locationsUiSchema; diff --git a/app/formSchema/uiSchema/cbc/reviewUiSchema.ts b/app/formSchema/uiSchema/cbc/reviewUiSchema.ts index 98dda150e..ba0bb41f5 100644 --- a/app/formSchema/uiSchema/cbc/reviewUiSchema.ts +++ b/app/formSchema/uiSchema/cbc/reviewUiSchema.ts @@ -2,10 +2,10 @@ import projectTypeUiSchema from './projectTypeUiSchema'; import tombstoneUiSchema from './tombstoneUiSchema'; import locationsAndCountsUiSchema from './locationsAndCountsUiSchema'; import fundingUiSchema from './fundingUiSchema'; - import eventsAndDatesUiSchema from './eventsAndDatesUiSchema'; import miscellaneousUiSchema from './miscellaneousUiSchema'; import projectDataReviewsUiSchema from './projectDataReviewsUiSchema'; +import locationsUiSchema from './locationsUiSchema'; const reviewUiSchema = { tombstone: { @@ -31,6 +31,10 @@ const reviewUiSchema = { ...projectTypeUiSchema, 'ui:options': { allowAnalystEdit: true }, }, + locations: { + ...locationsUiSchema, + 'ui:options': { allowAnalystEdit: true }, + }, locationsAndCounts: { ...locationsAndCountsUiSchema, 'ui:options': { allowAnalystEdit: true }, diff --git a/app/lib/theme/fields/ArrayLocationDataField.tsx b/app/lib/theme/fields/ArrayLocationDataField.tsx new file mode 100644 index 000000000..a827808b1 --- /dev/null +++ b/app/lib/theme/fields/ArrayLocationDataField.tsx @@ -0,0 +1,84 @@ +import { ArrayFieldTemplateProps } from '@rjsf/utils'; +import React from 'react'; +import styled from 'styled-components'; +import Button from '@button-inc/bcgov-theme/Button'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faTrash } from '@fortawesome/free-solid-svg-icons'; + +const StyledDiv = styled.div` + display: flex; + flex-direction: row; + gap: 6px; + align-items: center; +`; + +const StyledButton = styled(Button)` + height: 40px; + padding: 5px; + margin: 2px; +`; + +const ArrayLocationFieldTemplate = (props: ArrayFieldTemplateProps) => { + const { items, onAddClick, canAdd, formContext } = props; + + const deleteCommunitySource = formContext?.deleteCommunitySource as Function; + + return ( + <> + {items.map((element, index) => ( +
+ {index === 0 ? ( +
+ + {element.children} + {canAdd && ( + + Add + + )} + + +
+
+ ) : ( + + {element.children} + + + )} +
+ ))} + + ); +}; + +export default ArrayLocationFieldTemplate; diff --git a/app/lib/theme/widgets/SelectWidget.tsx b/app/lib/theme/widgets/SelectWidget.tsx index 76bbc075a..687782d90 100644 --- a/app/lib/theme/widgets/SelectWidget.tsx +++ b/app/lib/theme/widgets/SelectWidget.tsx @@ -3,8 +3,14 @@ import { Dropdown } from '@button-inc/bcgov-theme'; import styled from 'styled-components'; import Label from 'components/Form/Label'; +interface ObjectOptionProps { + value: string | number; + label: string; +} + interface SelectWidgetProps extends WidgetProps { customOption?: React.ReactNode; + objectOptions?: ObjectOptionProps[]; } interface SelectProps { @@ -64,10 +70,11 @@ const SelectWidget: React.FC = ({ uiSchema, customOption, rawErrors, + objectOptions, }) => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - const options = schema.enum as Array; + const options = objectOptions ?? (schema.enum as Array); const description = uiSchema ? uiSchema['ui:description'] : null; const isError = rawErrors && rawErrors.length > 0 && !value; @@ -91,11 +98,17 @@ const SelectWidget: React.FC = ({ - {options?.map((opt) => ( - - ))} + {options?.map((opt, index) => { + return ( + + ); + })} {customOption ?? customOption} {description && } diff --git a/app/lib/theme/widgets/custom/CommunitySourceWidget.tsx b/app/lib/theme/widgets/custom/CommunitySourceWidget.tsx new file mode 100644 index 000000000..e1d42d46e --- /dev/null +++ b/app/lib/theme/widgets/custom/CommunitySourceWidget.tsx @@ -0,0 +1,226 @@ +import { WidgetProps } from '@rjsf/utils'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import Autocomplete from '@mui/material/Autocomplete'; +import { TextField } from '@mui/material'; +import styled from 'styled-components'; +import { Button } from '@button-inc/bcgov-theme'; + +interface CommunitySourceWidgetProps extends WidgetProps { + children: React.ReactNode; +} + +const StyledDiv = styled.div` + display: flex; + flex-direction: row; + gap: 4px; + margin-top: 8px; + margin-bottom: 8px; + align-items: center; +`; + +const StyledButton = styled(Button)` + height: 40px; + padding: 5px; + margin: 2px; +`; + +const CommunitySourceWidget: React.FC = (props) => { + const { value, formContext, onChange } = props; + + const { + rowId, + bcGeographicName, + economicRegion, + regionalDistrict, + geographicNameId, + } = value ?? {}; + const [selectedEconomicRegion, setSelectedEconomicRegion] = useState( + economicRegion ?? '' + ); + const [selectedRegionalDistrict, setSelectedRegionalDistrict] = + useState(regionalDistrict ?? ''); + const [selectedGeographicName, setSelectedGeographicName] = useState({ + value: geographicNameId, + label: bcGeographicName, + }); + + useEffect(() => { + setSelectedEconomicRegion(economicRegion); + setSelectedRegionalDistrict(regionalDistrict); + setSelectedGeographicName({ + value: geographicNameId ?? null, + label: bcGeographicName ?? '', + }); + }, [geographicNameId, bcGeographicName, economicRegion, regionalDistrict]); + + const selectedGeographicNameIdList = useMemo(() => { + return [ + ...formContext.cbcCommunitiesData.map( + (community) => + community.communitiesSourceDataByCommunitiesSourceDataId + .geographicNameId + ), + ...formContext.addedCommunities, + ]; + }, [formContext.cbcCommunitiesData, formContext.addedCommunities]); + + const clearWidget = useCallback(() => { + setSelectedEconomicRegion(null); + setSelectedRegionalDistrict(null); + setSelectedGeographicName({ value: null, label: '' }); + // check if the form value has been touched, and if so clear it + if (Object.keys(value).length > 0) { + onChange({}); + } + }, [ + setSelectedEconomicRegion, + setSelectedRegionalDistrict, + setSelectedGeographicName, + value, + onChange, + ]); + + const economicRegionOptions = formContext.economicRegions; + const regionalDistrictOptions = formContext.regionalDistrictsByEconomicRegion; + const geographicNameOptions = formContext.geographicNamesByRegionalDistrict; + + const isGeographicNameOptionDisabled = (option) => { + return selectedGeographicNameIdList.includes(option.value); + }; + + const getGeographicNameOptions = (selectedRegDis, selEcoReg) => { + if (!selectedRegDis && !selEcoReg) { + return []; + } + + if (!selectedRegDis && geographicNameOptions[selEcoReg]['null']) { + return [...geographicNameOptions[selEcoReg]['null']]; + } + + if (geographicNameOptions[selEcoReg][selectedRegDis]) { + return [...geographicNameOptions[selEcoReg][selectedRegDis]]; + } + return []; + }; + + return ( + + { + if (reason === 'clear') { + clearWidget(); + } + if (e) { + setSelectedRegionalDistrict(null); + setSelectedGeographicName({ value: null, label: '' }); + setSelectedEconomicRegion(val); + } + }} + style={{ width: '200px' }} + value={selectedEconomicRegion} + inputValue={selectedEconomicRegion ?? ''} + options={economicRegionOptions} + getOptionLabel={(option) => option} + renderInput={(params) => ( + + )} + /> + + { + if (reason === 'clear') { + setSelectedRegionalDistrict(''); + setSelectedGeographicName({ value: null, label: '' }); + } + if (e) { + setSelectedRegionalDistrict(val); + } + }} + value={selectedRegionalDistrict} + inputValue={selectedRegionalDistrict ?? ''} + options={ + regionalDistrictOptions[selectedEconomicRegion] + ? [...regionalDistrictOptions[selectedEconomicRegion]] + : [] + } + getOptionLabel={(option) => option} + renderInput={(params) => ( + + )} + /> + + ( + + )} + options={getGeographicNameOptions( + selectedRegionalDistrict, + selectedEconomicRegion + )} + isOptionEqualToValue={(option, val) => { + return option.value === val.value; + }} + getOptionDisabled={isGeographicNameOptionDisabled} + getOptionLabel={(option) => { + return option.label ?? ''; + }} + value={selectedGeographicName} + inputValue={selectedGeographicName?.label ?? ''} + onChange={(e, val, reason) => { + if (reason === 'clear') { + setSelectedGeographicName({ value: null, label: '' }); + return; + } + if (e) { + setSelectedGeographicName(val); + onChange({ + bcGeographicName: val.label, + economicRegion: selectedEconomicRegion, + regionalDistrict: selectedRegionalDistrict, + geographicNameId: val.value, + }); + } + }} + /> + {!rowId && ( + { + e.preventDefault(); + clearWidget(); + }} + data-testid="clear-community-button" + > + Clear + + )} + + ); +}; + +export default CommunitySourceWidget; diff --git a/app/pages/analyst/cbc/[cbcId].tsx b/app/pages/analyst/cbc/[cbcId].tsx index 3375a1521..fa0073c1c 100644 --- a/app/pages/analyst/cbc/[cbcId].tsx +++ b/app/pages/analyst/cbc/[cbcId].tsx @@ -9,17 +9,23 @@ import CbcForm from 'components/Analyst/CBC/CbcForm'; import { ChangeModal } from 'components/Analyst'; import styled from 'styled-components'; import ReviewTheme from 'components/Review/ReviewTheme'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useUpdateCbcDataAndInsertChangeRequest } from 'schema/mutations/cbc/updateCbcDataAndInsertChangeReason'; import review from 'formSchema/analyst/cbc/review'; import reviewUiSchema from 'formSchema/uiSchema/cbc/reviewUiSchema'; import editUiSchema from 'formSchema/uiSchema/cbc/editUiSchema'; import { useFeature } from '@growthbook/growthbook-react'; import CbcTheme from 'components/Analyst/CBC/CbcTheme'; -import { createCbcSchemaData } from 'utils/schemaUtils'; +import { + createCbcSchemaData, + generateGeographicNamesByRegionalDistrict, + generateRegionalDistrictsByEconomicRegion, + getAllEconomicRegionNames, +} from 'utils/schemaUtils'; import customValidate, { CBC_WARN_COLOR } from 'utils/cbcCustomValidator'; import CbcRecordLock from 'components/Analyst/CBC/CbcRecordLock'; import useModal from 'lib/helpers/useModal'; +import { useUpdateCbcCommunityDataMutationMutation } from 'schema/mutations/cbc/updateCbcCommunityData'; const getCbcQuery = graphql` query CbcIdQuery($rowId: Int!) { @@ -39,7 +45,8 @@ const getCbcQuery = graphql` } } } - cbcProjectCommunitiesByCbcId { + cbcProjectCommunitiesByCbcId(filter: { archivedAt: { isNull: true } }) { + __id nodes { communitiesSourceDataByCommunitiesSourceDataId { economicRegion @@ -47,10 +54,19 @@ const getCbcQuery = graphql` geographicType regionalDistrict bcGeographicName + rowId } } } } + allCommunitiesSourceData { + nodes { + geographicNameId + bcGeographicName + economicRegion + regionalDistrict + } + } session { authRole sub @@ -95,6 +111,60 @@ const Cbc = ({ const { rowId } = query.cbcByRowId; const [formData, setFormData] = useState({} as any); const [baseFormData, setBaseFormData] = useState({} as any); + const [addedCommunities, setAddedCommunities] = useState([]); + const [removedCommunities, setRemovedCommunities] = useState([]); + const [responseCommunityData, setResponseCommunityData] = useState([]); + + const addCommunity = (communityId) => { + setAddedCommunities((prevList) => [...prevList, communityId]); + }; + + const removeCommunity = (communityId) => { + setRemovedCommunities((prevList) => [...prevList, communityId]); + const indexOfRemovedCommunity = + formData.locations.communitySourceData.findIndex( + (community) => community.geographicNameId === communityId + ); + setFormData({ + ...formData, + locations: { + ...formData.locations, + communitySourceData: [ + ...formData.locations.communitySourceData.slice( + 0, + indexOfRemovedCommunity + ), + ...formData.locations.communitySourceData.slice( + indexOfRemovedCommunity + 1 + ), + ], + }, + }); + }; + + const handleAddClick = (formPayload) => { + const communitySourceArray = formPayload.locations + .communitySourceData as Array; + const communitySourceArrayLength = + formPayload.locations.communitySourceData?.length; + if (communitySourceArray[communitySourceArrayLength - 1] === undefined) { + if (communitySourceArray[0]) + addCommunity(communitySourceArray[0].geographicNameId); + return { + ...formPayload, + locations: { + ...formPayload.locations, + communitySourceData: [ + {}, + // done to ensure that the added piece is now readonly + { ...communitySourceArray[0], rowId: true }, + ...communitySourceArray.slice(1, communitySourceArrayLength - 1), + ], + }, + }; + } + return formPayload; + }; const changeModal = useModal(); @@ -102,19 +172,40 @@ const Cbc = ({ const [allowEdit, setAllowEdit] = useState( isCbcAdmin && editFeatureEnabled && !recordLocked ); + + const allCommunitiesSourceData = query.allCommunitiesSourceData.nodes; + + const geographicNamesByRegionalDistrict = useMemo(() => { + return generateGeographicNamesByRegionalDistrict(allCommunitiesSourceData); + }, [allCommunitiesSourceData]); + + const regionalDistrictsByEconomicRegion = useMemo(() => { + return generateRegionalDistrictsByEconomicRegion(allCommunitiesSourceData); + }, [allCommunitiesSourceData]); + + const allEconomicRegions = useMemo(() => { + return getAllEconomicRegionNames(allCommunitiesSourceData); + }, [allCommunitiesSourceData]); + + const cbcCommunitiesData = useMemo(() => { + if (responseCommunityData.length > 0) return responseCommunityData; + return query.cbcByRowId.cbcProjectCommunitiesByCbcId.nodes?.map( + (node) => node.communitiesSourceDataByCommunitiesSourceDataId + ); + }, [query, responseCommunityData]); + useEffect(() => { const { cbcByRowId } = query; - const { cbcDataByCbcId, cbcProjectCommunitiesByCbcId } = cbcByRowId; + const { cbcDataByCbcId } = cbcByRowId; + const { edges } = cbcDataByCbcId; - const cbcCommunitiesData = cbcProjectCommunitiesByCbcId.nodes?.map( - (node) => node.communitiesSourceDataByCommunitiesSourceDataId - ); const cbcData = edges[0].node; const { jsonData } = cbcData; const { tombstone, projectType, + locations, locationsAndCounts, funding, eventsAndDates, @@ -128,6 +219,7 @@ const Cbc = ({ setFormData({ tombstone, projectType, + locations, locationsAndCounts, funding, eventsAndDates, @@ -137,6 +229,7 @@ const Cbc = ({ setBaseFormData({ tombstone, projectType, + locations, locationsAndCounts, funding, eventsAndDates, @@ -147,15 +240,45 @@ const Cbc = ({ setAllowEdit( isCbcAdmin && editFeatureEnabled && !projectDataReviews?.locked ); - }, [query, isCbcAdmin, editFeatureEnabled]); + }, [query, isCbcAdmin, editFeatureEnabled, cbcCommunitiesData]); const [updateFormData] = useUpdateCbcDataAndInsertChangeRequest(); + const [updateCbcCommunitySourceData] = + useUpdateCbcCommunityDataMutationMutation(); const handleChangeRequestModal = () => { setChangeReason(null); changeModal.open(); }; + const handleUpdateCommunitySource = useCallback(() => { + updateCbcCommunitySourceData({ + variables: { + input: { + _projectId: rowId, + _communityIdsToAdd: addedCommunities, + _communityIdsToArchive: removedCommunities, + }, + }, + debounceKey: 'cbc_update_community_source_data', + onCompleted: (response) => { + setAddedCommunities([]); + setRemovedCommunities([]); + setResponseCommunityData( + response.editCbcProjectCommunities.cbcProjectCommunities.map( + (proj) => proj.communitiesSourceDataByCommunitiesSourceDataId + ) + ); + }, + }); + }, [ + addedCommunities, + removedCommunities, + updateCbcCommunitySourceData, + rowId, + setResponseCommunityData, + ]); + const handleSubmit = () => { const { geographicNames, @@ -163,6 +286,7 @@ const Cbc = ({ economicRegions, ...updatedLocationsAndCounts } = formData.locationsAndCounts; + const { projectLocations } = formData.locations; updateFormData({ variables: { inputCbcData: { @@ -171,6 +295,7 @@ const Cbc = ({ jsonData: { ...formData.tombstone, ...formData.projectType, + projectLocations, ...updatedLocationsAndCounts, ...formData.funding, ...formData.eventsAndDates, @@ -191,6 +316,7 @@ const Cbc = ({ onCompleted: () => { setEditMode(false); changeModal.close(); + handleUpdateCommunitySource(); setAllowEdit(isCbcAdmin && editFeatureEnabled); }, }); @@ -312,10 +438,18 @@ const Cbc = ({ errors: formErrors, showErrorHint: true, recordLocked, + geographicNamesByRegionalDistrict, + regionalDistrictsByEconomicRegion, + economicRegions: allEconomicRegions, + cbcCommunitiesData: + query.cbcByRowId.cbcProjectCommunitiesByCbcId.nodes, + addedCommunities, + addCommunitySource: addCommunity, + deleteCommunitySource: removeCommunity, }} formData={formData} handleChange={(e) => { - setFormData({ ...e.formData }); + setFormData(handleAddClick(e.formData)); }} hiddenSubmitRef={hiddenSubmitRef} isExpanded @@ -337,6 +471,8 @@ const Cbc = ({ setChangeReason(null); setFormData(baseFormData); setEditMode(false); + setAddedCommunities([]); + setRemovedCommunities([]); changeModal.close(); }} value={changeReason} diff --git a/app/pages/analyst/cbc/[cbcId]/edit/[section].tsx b/app/pages/analyst/cbc/[cbcId]/edit/[section].tsx index 5e57cb824..f145d0399 100644 --- a/app/pages/analyst/cbc/[cbcId]/edit/[section].tsx +++ b/app/pages/analyst/cbc/[cbcId]/edit/[section].tsx @@ -7,7 +7,7 @@ import { RelayProps, withRelay } from 'relay-nextjs'; import { graphql } from 'relay-runtime'; import review from 'formSchema/analyst/cbc/review'; import { ProjectTheme } from 'components/Analyst/Project'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useEffect, useState } from 'react'; import { useRouter } from 'next/router'; import editUiSchema from 'formSchema/uiSchema/cbc/editUiSchema'; import { FormBase } from 'components/Form'; @@ -16,7 +16,14 @@ import { RJSFSchema } from '@rjsf/utils'; import useModal from 'lib/helpers/useModal'; import { ChangeModal } from 'components/Analyst'; import { useUpdateCbcDataAndInsertChangeRequest } from 'schema/mutations/cbc/updateCbcDataAndInsertChangeReason'; -import { createCbcSchemaData } from 'utils/schemaUtils'; +import { + createCbcSchemaData, + generateGeographicNamesByRegionalDistrict, + generateRegionalDistrictsByEconomicRegion, + getAllEconomicRegionNames, +} from 'utils/schemaUtils'; +import ArrayLocationFieldTemplate from 'lib/theme/fields/ArrayLocationDataField'; +import { useUpdateCbcCommunityDataMutationMutation } from 'schema/mutations/cbc/updateCbcCommunityData'; import customValidate, { CBC_WARN_COLOR } from 'utils/cbcCustomValidator'; const getCbcSectionQuery = graphql` @@ -37,7 +44,7 @@ const getCbcSectionQuery = graphql` } } } - cbcProjectCommunitiesByCbcId { + cbcProjectCommunitiesByCbcId(filter: { archivedAt: { isNull: true } }) { nodes { communitiesSourceDataByCommunitiesSourceDataId { economicRegion @@ -45,10 +52,19 @@ const getCbcSectionQuery = graphql` geographicType regionalDistrict bcGeographicName + rowId } } } } + allCommunitiesSourceData { + nodes { + geographicNameId + bcGeographicName + economicRegion + regionalDistrict + } + } session { sub } @@ -66,26 +82,129 @@ const EditCbcSection = ({ const [updateFormData] = useUpdateCbcDataAndInsertChangeRequest(); const [changeReason, setChangeReason] = useState(null); const [formData, setFormData] = useState(null); + const [addedCommunities, setAddedCommunities] = useState([]); + const [removedCommunities, setRemovedCommunities] = useState([]); const { cbcDataByCbcId, rowId, cbcProjectCommunitiesByCbcId } = cbcByRowId; const { jsonData, rowId: cbcDataRowId } = cbcDataByCbcId.edges[0].node; - const cbcCommunitiesData = - cbcProjectCommunitiesByCbcId.nodes?.map( - (node) => node.communitiesSourceDataByCommunitiesSourceDataId - ) || []; - const dataBySection = createCbcSchemaData({ - ...jsonData, - cbcCommunitiesData, - }); + useEffect(() => { + const cbcCommunitiesData = + cbcProjectCommunitiesByCbcId.nodes?.map( + (node) => node.communitiesSourceDataByCommunitiesSourceDataId + ) || []; + setFormData( + createCbcSchemaData({ + ...jsonData, + cbcCommunitiesData, + }) + ); + }, [jsonData, cbcProjectCommunitiesByCbcId]); const changeModal = useModal(); + const addCommunity = (communityId) => { + setAddedCommunities((prevList) => [...prevList, communityId]); + }; + + const removeCommunity = (communityId) => { + setRemovedCommunities((prevList) => [...prevList, communityId]); + const indexOfRemovedCommunity = + formData.locations?.communitySourceData?.findIndex( + (community) => community.geographicNameId === communityId + ); + setFormData({ + ...formData, + locations: { + ...formData.locations, + communitySourceData: [ + ...formData.locations.communitySourceData.slice( + 0, + indexOfRemovedCommunity + ), + ...formData.locations.communitySourceData.slice( + indexOfRemovedCommunity + 1 + ), + ], + }, + }); + }; + + const handleAddClick = (formPayload) => { + const communitySourceArray = formPayload.communitySourceData as Array; + const communitySourceArrayLength = formPayload.communitySourceData?.length; + if (communitySourceArray[communitySourceArrayLength - 1] === undefined) { + if (communitySourceArray[0]) + addCommunity(communitySourceArray[0].geographicNameId); + return { + ...formPayload, + communitySourceData: [ + {}, + // setRowId to make widget readonly + { ...communitySourceArray[0], rowId: true }, + ...communitySourceArray.slice(1, communitySourceArrayLength - 1), + ], + }; + } + return formPayload; + }; + + const [updateCbcCommunitySourceData] = + useUpdateCbcCommunityDataMutationMutation(); + + const handleOnChange = (e) => { + if (section === 'locations') { + setFormData({ + ...formData, + [section]: handleAddClick(e.formData), + }); + } else setFormData({ ...formData, [section]: e.formData }); + }; + + const handleUpdateCommunitySource = useCallback(() => { + updateCbcCommunitySourceData({ + variables: { + input: { + _projectId: rowId, + _communityIdsToAdd: addedCommunities, + _communityIdsToArchive: removedCommunities, + }, + }, + debounceKey: 'cbc_update_community_source_data', + onCompleted: () => { + setAddedCommunities([]); + setRemovedCommunities([]); + }, + }); + }, [ + addedCommunities, + removedCommunities, + updateCbcCommunitySourceData, + rowId, + ]); + const handleChangeRequestModal = (e) => { changeModal.open(); - setFormData({ ...dataBySection, [section]: e.formData }); + setFormData({ ...formData, [section]: e.formData }); }; + const allCommunitiesSourceData = query.allCommunitiesSourceData.nodes; + + const geographicNamesByRegionalDistrict = useMemo(() => { + return generateGeographicNamesByRegionalDistrict(allCommunitiesSourceData); + }, [allCommunitiesSourceData]); + + const regionalDistrictsByEconomicRegion = useMemo(() => { + return generateRegionalDistrictsByEconomicRegion(allCommunitiesSourceData); + }, [allCommunitiesSourceData]); + + const allEconomicRegions = useMemo(() => { + return getAllEconomicRegionNames(allCommunitiesSourceData); + }, [allCommunitiesSourceData]); + + const theme = { ...ProjectTheme }; + theme.templates.ArrayFieldTemplate = ArrayLocationFieldTemplate; + const handleSubmit = () => { const { geographicNames, @@ -93,6 +212,7 @@ const EditCbcSection = ({ economicRegions, ...updatedLocationsAndCounts } = formData.locationsAndCounts; + const { projectLocations } = formData.locations; updateFormData({ variables: { inputCbcData: { @@ -101,6 +221,7 @@ const EditCbcSection = ({ jsonData: { ...formData.tombstone, ...formData.projectType, + projectLocations, ...updatedLocationsAndCounts, ...formData.funding, ...formData.eventsAndDates, @@ -118,6 +239,7 @@ const EditCbcSection = ({ }, debounceKey: 'cbc_update_section_data', onCompleted: () => { + handleUpdateCommunitySource(); router.push(`/analyst/cbc/${rowId}`); }, }); @@ -144,26 +266,35 @@ const EditCbcSection = ({ ); const formErrors = useMemo( - () => - validateSection(formData || dataBySection, review.properties[section]), - [dataBySection, formData, section, validateSection] + () => validateSection(formData ?? {}, review.properties[section]), + [formData, section, validateSection] ); return ( { - setFormData({ ...dataBySection, [section]: e.formData }); + onChange={handleOnChange} + formContext={{ + economicRegions: allEconomicRegions, + regionalDistrictsByEconomicRegion, + geographicNamesByRegionalDistrict, + allCommunitiesSourceData, + addCommunitySource: addCommunity, + deleteCommunitySource: removeCommunity, + errors: formErrors, + showErrorHint: true, + cbcCommunitiesData: + query.cbcByRowId.cbcProjectCommunitiesByCbcId.nodes, + addedCommunities, }} > diff --git a/app/schema/mutations/cbc/updateCbcCommunityData.ts b/app/schema/mutations/cbc/updateCbcCommunityData.ts new file mode 100644 index 000000000..81b81e83e --- /dev/null +++ b/app/schema/mutations/cbc/updateCbcCommunityData.ts @@ -0,0 +1,31 @@ +import { graphql } from 'react-relay'; +import { updateCbcCommunityDataMutation } from '__generated__/updateCbcCommunityDataMutation.graphql'; +import useDebouncedMutation from '../useDebouncedMutation'; + +const mutation = graphql` + mutation updateCbcCommunityDataMutation( + $input: EditCbcProjectCommunitiesInput! + ) { + editCbcProjectCommunities(input: $input) { + cbcProjectCommunities { + communitiesSourceDataId + cbcId + communitiesSourceDataByCommunitiesSourceDataId { + geographicNameId + economicRegion + regionalDistrict + bcGeographicName + rowId + } + } + } + } +`; + +const useUpdateCbcCommunityDataMutationMutation = () => + useDebouncedMutation( + mutation, + () => 'An error occurred while attempting to update the cbc data.' + ); + +export { mutation, useUpdateCbcCommunityDataMutationMutation }; diff --git a/app/schema/schema.graphql b/app/schema/schema.graphql index d3b32e620..18fc3720b 100644 --- a/app/schema/schema.graphql +++ b/app/schema/schema.graphql @@ -80556,6 +80556,12 @@ type Mutation { """ input: DeleteCommunityProgressReportInput! ): DeleteCommunityProgressReportPayload + editCbcProjectCommunities( + """ + The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. + """ + input: EditCbcProjectCommunitiesInput! + ): EditCbcProjectCommunitiesPayload importApplicationAnalystLead( """ The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. @@ -93707,6 +93713,33 @@ input DeleteCommunityProgressReportInput { formData: JSON! } +"""The output of our `editCbcProjectCommunities` mutation.""" +type EditCbcProjectCommunitiesPayload { + """ + The exact same `clientMutationId` that was provided in the mutation input, + unchanged and unused. May be used by a client to track mutations. + """ + clientMutationId: String + cbcProjectCommunities: [CbcProjectCommunity] + + """ + Our root query field type. Allows us to run any query from our mutation payload. + """ + query: Query +} + +"""All input for the `editCbcProjectCommunities` mutation.""" +input EditCbcProjectCommunitiesInput { + """ + An arbitrary string value with no semantic meaning. Will be included in the + payload verbatim. May be used to track mutations by the client. + """ + clientMutationId: String + _projectId: Int! + _communityIdsToAdd: [Int]! + _communityIdsToArchive: [Int]! +} + """The output of our `importApplicationAnalystLead` mutation.""" type ImportApplicationAnalystLeadPayload { """ diff --git a/app/tests/components/Form/CommunitySourceWidget.test.tsx b/app/tests/components/Form/CommunitySourceWidget.test.tsx new file mode 100644 index 000000000..1cb811acc --- /dev/null +++ b/app/tests/components/Form/CommunitySourceWidget.test.tsx @@ -0,0 +1,129 @@ +import FormTestRenderer from 'tests/utils/formTestRenderer'; +import { waitFor, render, screen, fireEvent } from '@testing-library/react'; +import CommunitySourceWidget from 'lib/theme/widgets/custom/CommunitySourceWidget'; +import { RJSFSchema } from '@rjsf/utils'; + +const mockSchema = { + title: 'Community Source Widget Test', + type: 'object', + properties: { + communitySourceData: { + type: 'array', + default: [], + items: { + type: 'object', + enum: [], + }, + }, + }, +}; + +const mockUiSchema = { + communitySourceData: { + 'ui:widget': CommunitySourceWidget, + }, +}; + +const renderStaticLayout = (schema: RJSFSchema, uiSchema: RJSFSchema) => { + return render( + + ); +}; + +describe('The Community Source Widget', () => { + beforeEach(() => { + renderStaticLayout(mockSchema as RJSFSchema, mockUiSchema as RJSFSchema); + }); + + it('should render the economic region input field', () => { + expect(screen.getByLabelText('Economic Region')).toBeInTheDocument(); + }); + + it('should render the regional district input field', () => { + expect(screen.getByLabelText('Regional District')).toBeInTheDocument(); + }); + + it('should render the geographic name input field', () => { + expect(screen.getByLabelText('Geographic Name')).toBeInTheDocument(); + }); + + it('should contain the correct economic region value', async () => { + await waitFor(() => { + expect( + screen.getByDisplayValue('Economic Region 1', { exact: false }) + ).toBeInTheDocument(); + }); + }); + + it('should contain the correct regional district value', () => { + expect(screen.getByDisplayValue('Regional District 1')).toBeInTheDocument(); + }); + + it('should contain the correct geographic name value', () => { + expect(screen.getByDisplayValue('Geographic Name 1')).toBeInTheDocument(); + }); + + it('should clear the economic region, regional district, and geographic name value when clear button is clicked', () => { + expect(screen.getByDisplayValue('Economic Region 1')).toBeInTheDocument(); + fireEvent.click(screen.getAllByTitle('Clear')[0]); + expect( + screen.queryByDisplayValue('Economic Region 1') + ).not.toBeInTheDocument(); + + expect( + screen.queryByDisplayValue('Regional District 1') + ).not.toBeInTheDocument(); + + expect( + screen.queryByDisplayValue('Geographic Name 1') + ).not.toBeInTheDocument(); + }); + + it('should clear the regional district value when clear button is clicked', () => { + expect(screen.getByDisplayValue('Regional District 1')).toBeInTheDocument(); + fireEvent.click(screen.getAllByTitle('Clear')[1]); + expect( + screen.queryByDisplayValue('Regional District 1') + ).not.toBeInTheDocument(); + expect( + screen.queryByDisplayValue('Geographic Name 1') + ).not.toBeInTheDocument(); + expect(screen.getByDisplayValue('Economic Region 1')).toBeInTheDocument(); + }); + + it('should clear the geographic name value when clear button is clicked', () => { + expect(screen.getByDisplayValue('Geographic Name 1')).toBeInTheDocument(); + fireEvent.click(screen.getAllByTitle('Clear')[2]); + expect( + screen.queryByDisplayValue('Geographic Name 1') + ).not.toBeInTheDocument(); + expect(screen.getByDisplayValue('Economic Region 1')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Regional District 1')).toBeInTheDocument(); + }); +}); diff --git a/app/tests/pages/analyst/cbc/[cbcId].test.tsx b/app/tests/pages/analyst/cbc/[cbcId].test.tsx index facb934c7..160d423d0 100644 --- a/app/tests/pages/analyst/cbc/[cbcId].test.tsx +++ b/app/tests/pages/analyst/cbc/[cbcId].test.tsx @@ -91,6 +91,7 @@ const mockQueryPayload = { geographicType: 'Geographic Type 1', regionalDistrict: 'Regional District 1', bcGeographicName: 'BC Geographic Name 1', + rowId: 1, }, }, { @@ -100,6 +101,7 @@ const mockQueryPayload = { geographicType: 'Geographic Type 2', regionalDistrict: 'Regional District 2', bcGeographicName: 'BC Geographic Name 2', + rowId: 2, }, }, { @@ -109,15 +111,37 @@ const mockQueryPayload = { geographicType: 'Geographic Type 2', regionalDistrict: 'Regional District 1', bcGeographicName: 'BC Geographic Name 3', + rowId: 3, }, }, ], }, }, + allCommunitiesSourceData: { + nodes: [ + { + geographicNameId: 10, + bcGeographicName: 'BC Geographic Name 10', + economicRegion: 'Economic Region 1', + regionalDistrict: 'Regional District 1', + }, + { + geographicNameId: 11, + bcGeographicName: 'BC Geographic Name 11', + economicRegion: 'Economic Region 1', + regionalDistrict: 'Regional District 2', + }, + { + geographicNameId: 12, + bcGeographicName: 'BC Geographic Name 12', + economicRegion: 'Economic Region 2', + regionalDistrict: 'Regional District 1', + }, + ], + }, session: { authRole: 'cbc_admin', sub: '4e0ac88c-bf05-49ac-948f-7fd53c7a9fd6', - authRole: 'cbc_admin', }, }; }, @@ -205,7 +229,8 @@ describe('Cbc', () => { expect(screen.getByText('Tombstone')).toBeInTheDocument(); expect(screen.getByText('Project type')).toBeInTheDocument(); - expect(screen.getByText('Locations and counts')).toBeInTheDocument(); + expect(screen.getByText('Locations')).toBeInTheDocument(); + expect(screen.getByText('Counts')).toBeInTheDocument(); expect(screen.getByText('Funding')).toBeInTheDocument(); expect(screen.getByText('Events and dates')).toBeInTheDocument(); expect(screen.getByText('Miscellaneous')).toBeInTheDocument(); @@ -645,6 +670,129 @@ describe('Cbc', () => { }); }); + it('should fire community mutation for community source data', async () => { + jest.spyOn(moduleApi, 'useFeature').mockReturnValue(mockShowCbcEdit); + pageTestingHelper.loadQuery(); + pageTestingHelper.renderPage(); + + const editButton = screen.getByRole('button', { + name: 'Quick edit', + }); + act(() => { + fireEvent.click(editButton); + }); + + const removeButton = screen.getAllByTestId( + 'delete-community-source-button' + )[1]; + + act(() => { + fireEvent.click(removeButton); + }); + + const saveButton = screen.getByRole('button', { + name: 'Save', + }); + act(() => { + fireEvent.click(saveButton); + }); + + const changeReasonInput = screen.getByTestId('reason-for-change'); + act(() => { + fireEvent.change(changeReasonInput, { + target: { value: 'Updated reason' }, + }); + }); + + const saveModalButton = screen.getByRole('button', { name: /save/i }); + act(() => { + fireEvent.click(saveModalButton); + }); + + pageTestingHelper.expectMutationToBeCalled( + 'updateCbcDataAndInsertChangeReasonMutation', + { + inputCbcData: { + rowId: 1, + cbcDataPatch: { + jsonData: { + projectNumber: 5555, + phase: 2, + intake: 1, + projectStatus: 'Reporting Complete', + changeRequestPending: 'No', + projectTitle: 'Project 1', + projectDescription: 'Description 1', + applicantContractualName: 'Internet company 1', + currentOperatingName: 'Internet company 1', + eightThirtyMillionFunding: 'No', + federalFundingSource: 'ISED-CTI', + projectType: 'Transport', + transportProjectType: 'Fibre', + connectedCoastNetworkDependant: 'NO', + projectLocations: 'Location 1', + communitiesAndLocalesCount: 5, + indigenousCommunities: 5, + householdCount: null, + transportKm: 124, + highwayKm: null, + bcFundingRequested: 5555555, + federalFundingRequested: 555555, + applicantAmount: 555555, + otherFundingRequested: 265000, + totalProjectBudget: 5555555, + conditionalApprovalLetterSent: 'YES', + agreementSigned: 'YES', + announcedByProvince: 'YES', + dateApplicationReceived: null, + dateConditionallyApproved: '2019-06-26T00:00:00.000Z', + dateAgreementSigned: '2021-02-24T00:00:00.000Z', + proposedStartDate: '2020-07-01T00:00:00.000Z', + proposedCompletionDate: '2023-03-31T00:00:00.000Z', + reportingCompletionDate: null, + dateAnnounced: '2019-07-02T00:00:00.000Z', + projectMilestoneCompleted: 0.5, + constructionCompletedOn: null, + milestoneComments: 'Requested extension to March 31, 2024', + primaryNewsRelease: + 'https://www.canada.ca/en/innovation-science-economic-development/news/2019/07/rural-communities-in-british-columbia-will-benefit-from-faster-internet.html', + locked: false, + lastReviewed: '2023-07-11T00:00:00.000Z', + reviewNotes: 'Qtrly Report: Progress 0.39 -> 0.38', + }, + }, + }, + inputCbcChangeReason: { + cbcDataChangeReason: { + description: 'Updated reason', + cbcDataId: 1, + }, + }, + } + ); + + pageTestingHelper.environment.mock.resolveMostRecentOperation({ + data: { + updateCbcDataAndInsertChangeReason: { + cbcData: { + rowId: 1, + }, + }, + }, + }); + + pageTestingHelper.expectMutationToBeCalled( + 'updateCbcCommunityDataMutation', + { + input: { + _projectId: 1, + _communityIdsToAdd: [], + _communityIdsToArchive: [1], + }, + } + ); + }); + it('do nothing on cancel modal', async () => { jest.spyOn(moduleApi, 'useFeature').mockReturnValue(mockShowCbcEdit); pageTestingHelper.loadQuery(); diff --git a/app/tests/pages/analyst/cbc/[cbcId]/[section].test.tsx b/app/tests/pages/analyst/cbc/[cbcId]/[section].test.tsx index fdc480273..5a11ef572 100644 --- a/app/tests/pages/analyst/cbc/[cbcId]/[section].test.tsx +++ b/app/tests/pages/analyst/cbc/[cbcId]/[section].test.tsx @@ -1,5 +1,6 @@ import cbcSection from 'pages/analyst/cbc/[cbcId]/edit/[section]'; import { act, fireEvent, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import compiledSectionQuery, { SectionCbcDataQuery, } from '../../../../../__generated__/SectionCbcDataQuery.graphql'; @@ -63,16 +64,39 @@ const mockQueryPayload = { nodes: [ { communitiesSourceDataByCommunitiesSourceDataId: { - economicRegion: 'Economic Region', - geographicNameId: 1, + economicRegion: 'Economic Region 1', + geographicNameId: 10, geographicType: 'Geographic Type', - regionalDistrict: 'Regional District', - bcGeographicName: 'BC Geographic Name', + regionalDistrict: 'Regional District 1', + bcGeographicName: 'BC Geographic Name 10', + rowId: 1, }, }, ], }, }, + allCommunitiesSourceData: { + nodes: [ + { + geographicNameId: 10, + bcGeographicName: 'BC Geographic Name 10', + economicRegion: 'Economic Region 1', + regionalDistrict: 'Regional District 1', + }, + { + geographicNameId: 11, + bcGeographicName: 'BC Geographic Name 11', + economicRegion: 'Economic Region 1', + regionalDistrict: 'Regional District 2', + }, + { + geographicNameId: 12, + bcGeographicName: 'BC Geographic Name 12', + economicRegion: 'Economic Region 2', + regionalDistrict: 'Regional District 3', + }, + ], + }, }; }, }; @@ -287,4 +311,143 @@ describe('EditCbcSection', () => { const tooltip = await screen.findByText(/Missing Federal project number/); expect(tooltip).toBeInTheDocument(); }); + + it('should call update function and cbcCommunityUpdate with correct data on save', async () => { + pageTestingHelper.setMockRouterValues({ + query: { cbcId: '1', section: 'locations' }, + }); + pageTestingHelper.loadQuery(); + pageTestingHelper.renderPage(); + + const economicRegion = screen.getAllByTestId( + 'economic-region-autocomplete' + )[0]; + await act(async () => { + await userEvent.type(economicRegion, '{ArrowDown}{Enter}', { + skipClick: false, + skipHover: false, + }); + }); + const regionalDistrict = screen.getAllByTestId( + 'regional-district-autocomplete' + )[0]; + await act(async () => { + await userEvent.type(regionalDistrict, '{ArrowDown}{Enter}', { + skipClick: false, + skipHover: false, + }); + }); + const geographicName = screen.getAllByTestId( + 'geographic-name-autocomplete' + )[0]; + + await act(async () => { + await userEvent.type(geographicName, '{ArrowDown}{Enter}', { + skipClick: false, + skipHover: false, + }); + }); + + const addButton = screen.getByTestId('add-community-button'); + await act(async () => { + await userEvent.click(addButton); + }); + + const clearButton = screen.getByTestId('clear-community-button'); + await act(async () => { + await userEvent.click(clearButton); + }); + + const saveButton = screen.getByRole('button', { name: /save/i }); + + act(() => { + fireEvent.click(saveButton); + }); + + const changeReasonInput = screen.getByTestId('reason-for-change'); + act(() => { + fireEvent.change(changeReasonInput, { + target: { value: 'Updated reason' }, + }); + }); + + const saveModalButton = screen.getByRole('button', { name: /save/i }); + act(() => { + fireEvent.click(saveModalButton); + }); + + pageTestingHelper.expectMutationToBeCalled( + 'updateCbcDataAndInsertChangeReasonMutation', + { + inputCbcData: { + rowId: 20, + cbcDataPatch: { + jsonData: { + projectNumber: 5555, + originalProjectNumber: 5555, + phase: 2, + intake: 1, + projectStatus: 'Reporting Complete', + changeRequestPending: 'No', + projectTitle: 'Project 1', + projectDescription: 'Description 1', + applicantContractualName: 'Test project contractual name', + currentOperatingName: 'Internet company 1', + federalFundingSource: 'ISED-CTI', + projectType: 'Transport', + transportProjectType: 'Fibre', + projectLocations: 'Location 1', + indigenousCommunities: 5, + householdCount: null, + transportKm: 124, + highwayKm: null, + bcFundingRequested: 5555555, + federalFundingRequested: 555555, + applicantAmount: 555555, + otherFundingRequested: 265000, + totalProjectBudget: 5555555, + announcedByProvince: 'YES', + dateAgreementSigned: '2021-02-24T00:00:00.000Z', + proposedStartDate: '2020-07-01T00:00:00.000Z', + proposedCompletionDate: '2023-03-31T00:00:00.000Z', + dateAnnounced: '2019-07-02T00:00:00.000Z', + projectMilestoneCompleted: 0.75, + milestoneComments: 'Requested extension to March 31, 2024', + primaryNewsRelease: + 'https://www.somethingmadeup.ca/en/innovation-science-economic-development/internet.html', + lastReviewed: '2023-07-11T00:00:00.000Z', + reviewNotes: 'Qtrly Report: Progress 0.39 -> 0.38', + }, + }, + }, + inputCbcChangeReason: { + cbcDataChangeReason: { + description: 'Updated reason', + cbcDataId: 20, + }, + }, + } + ); + + pageTestingHelper.environment.mock.resolveMostRecentOperation({ + data: { + updateCbcDataAndInsertChangeReason: { + cbcData: { + rowId: 1, + }, + }, + }, + }); + + pageTestingHelper.expectMutationToBeCalled( + 'updateCbcCommunityDataMutation', + { + input: { + _projectId: 1, + _communityIdsToAdd: expect.anything(), + _communityIdsToArchive: [], + }, + } + ); + }); }); diff --git a/app/tests/utils/schemaUtils.test.ts b/app/tests/utils/schemaUtils.test.ts new file mode 100644 index 000000000..47bd2048f --- /dev/null +++ b/app/tests/utils/schemaUtils.test.ts @@ -0,0 +1,131 @@ +import { + generateGeographicNamesByRegionalDistrict, + generateRegionalDistrictsByEconomicRegion, + getAllEconomicRegionNames, +} from '../../utils/schemaUtils'; + +describe('generateGeographicNamesByRegionalDistrict', () => { + it('should return an empty array if no communities are provided', () => { + const result = generateGeographicNamesByRegionalDistrict([]); + expect(result).toEqual({}); + }); + + it('should return a dict of geographic names grouped by regional district', () => { + const communities = [ + { + bcGeographicName: 'Community 1', + geographicNameId: 1, + regionalDistrict: 'Regional District 1', + economicRegion: 'Economic Region 1', + }, + { + bcGeographicName: 'Community 2', + geographicNameId: 2, + regionalDistrict: 'Regional District 1', + economicRegion: 'Economic Region 2', + }, + { + bcGeographicName: 'Community 3', + geographicNameId: 3, + regionalDistrict: 'Regional District 2', + economicRegion: 'Economic Region 1', + }, + { + bcGeographicName: 'Community 4', + geographicNameId: 4, + regionalDistrict: null, + economicRegion: 'Economic Region 1', + }, + ]; + + const result = generateGeographicNamesByRegionalDistrict(communities); + expect(result).toEqual({ + 'Economic Region 1': { + 'Regional District 1': new Set([{ label: 'Community 1', value: 1 }]), + 'Regional District 2': new Set([{ label: 'Community 3', value: 3 }]), + null: new Set([{ label: 'Community 4', value: 4 }]), + }, + 'Economic Region 2': { + 'Regional District 1': new Set([{ label: 'Community 2', value: 2 }]), + }, + }); + }); +}); + +describe('generateRegionalDistrictsByEconomicRegion', () => { + it('should return an empty array if no communities are provided', () => { + const result = generateRegionalDistrictsByEconomicRegion([]); + expect(result).toEqual({}); + }); + + it('should return a dict of regional districts grouped by economic region', () => { + const communities = [ + { + bcGeographicName: 'Community 1', + geographicNameId: 1, + regionalDistrict: 'Regional District 1', + economicRegion: 'Economic Region 1', + }, + { + bcGeographicName: 'Community 2', + geographicNameId: 2, + regionalDistrict: 'Regional District 4', + economicRegion: 'Economic Region 2', + }, + { + bcGeographicName: 'Community 3', + geographicNameId: 3, + regionalDistrict: 'Regional District 2', + economicRegion: 'Economic Region 1', + }, + { + bcGeographicName: 'Community 4', + geographicNameId: 4, + regionalDistrict: null, + economicRegion: 'Economic Region 1', + }, + ]; + + const result = generateRegionalDistrictsByEconomicRegion(communities); + expect(result).toEqual({ + 'Economic Region 1': new Set([ + 'Regional District 1', + 'Regional District 2', + ]), + 'Economic Region 2': new Set(['Regional District 4']), + }); + }); +}); + +describe('getAllEconomicRegionNames', () => { + it('should return an empty array if no communities are provided', () => { + const result = getAllEconomicRegionNames([]); + expect(result).toEqual([]); + }); + + it('should return an array of unique economic region names', () => { + const communities = [ + { + bcGeographicName: 'Community 1', + geographicNameId: 1, + regionalDistrict: 'Regional District 1', + economicRegion: 'Economic Region 1', + }, + { + bcGeographicName: 'Community 2', + geographicNameId: 2, + regionalDistrict: 'Regional District 1', + economicRegion: 'Economic Region 2', + }, + { + bcGeographicName: 'Community 3', + geographicNameId: 3, + regionalDistrict: 'Regional District 2', + economicRegion: 'Economic Region 1', + }, + ]; + + const result = getAllEconomicRegionNames(communities); + expect(result).toEqual(['Economic Region 1', 'Economic Region 2']); + }); +}); diff --git a/app/utils/schemaUtils.ts b/app/utils/schemaUtils.ts index f4a5c8abc..f09e69e0d 100644 --- a/app/utils/schemaUtils.ts +++ b/app/utils/schemaUtils.ts @@ -39,6 +39,7 @@ export const createCbcSchemaData = (jsonData) => { return { tombstone: null, projectType: null, + locations: null, locationsAndCounts: null, funding: null, eventsAndDates: null, @@ -72,7 +73,17 @@ export const createCbcSchemaData = (jsonData) => { connectedCoastNetworkDependant: jsonData.connectedCoastNetworkDependant, }; const locationsAndCounts = { + communitiesAndLocalesCount: jsonData.communitiesAndLocalesCount, + indigenousCommunities: jsonData.indigenousCommunities, + householdCount: jsonData.householdCount, + transportKm: jsonData.transportKm, + highwayKm: jsonData.highwayKm, + restAreas: jsonData.restAreas, + }; + + const locations = { projectLocations: jsonData.projectLocations, + communitySourceData: [{}, ...jsonData.cbcCommunitiesData], geographicNames: getDistinctValues( jsonData.cbcCommunitiesData, 'bcGeographicName' @@ -85,12 +96,6 @@ export const createCbcSchemaData = (jsonData) => { jsonData.cbcCommunitiesData, 'economicRegion' ), - communitiesAndLocalesCount: jsonData.communitiesAndLocalesCount, - indigenousCommunities: jsonData.indigenousCommunities, - householdCount: jsonData.householdCount, - transportKm: jsonData.transportKm, - highwayKm: jsonData.highwayKm, - restAreas: jsonData.restAreas, }; const funding = { @@ -132,6 +137,7 @@ export const createCbcSchemaData = (jsonData) => { const dataBySection = { tombstone, projectType, + locations, locationsAndCounts, funding, eventsAndDates, @@ -141,3 +147,79 @@ export const createCbcSchemaData = (jsonData) => { return dataBySection; }; + +type CommunitySourceData = { + readonly bcGeographicName: string; + readonly geographicNameId: number; + readonly regionalDistrict: string | null; + readonly economicRegion: string | null; +}; + +export const generateGeographicNamesByRegionalDistrict = ( + allCommunitiesSourceData: readonly CommunitySourceData[] +) => { + const geographicNamesDict = {}; + allCommunitiesSourceData.forEach((community) => { + const { + regionalDistrict, + bcGeographicName, + geographicNameId, + economicRegion, + } = community; + + if (geographicNamesDict[economicRegion] === undefined) { + geographicNamesDict[economicRegion] = {}; + } + + if ( + geographicNamesDict[economicRegion] === undefined && + !regionalDistrict + ) { + geographicNamesDict[economicRegion]['null'] = new Set(); + } + + if (geographicNamesDict[economicRegion][regionalDistrict] === undefined) { + geographicNamesDict[economicRegion][regionalDistrict] = new Set(); + } + + if (regionalDistrict === null) { + geographicNamesDict[economicRegion]['null'].add({ + label: bcGeographicName, + value: geographicNameId, + }); + } else { + geographicNamesDict[economicRegion][regionalDistrict].add({ + label: bcGeographicName, + value: geographicNameId, + }); + } + }); + return geographicNamesDict; +}; + +export const generateRegionalDistrictsByEconomicRegion = ( + allCommunitiesSourceData: readonly CommunitySourceData[] +) => { + const economicRegionRegionalDistrictsDict = {}; + allCommunitiesSourceData.forEach((community) => { + const { economicRegion, regionalDistrict } = community; + if (!economicRegionRegionalDistrictsDict[economicRegion]) { + economicRegionRegionalDistrictsDict[economicRegion] = new Set(); + } + if (regionalDistrict) + economicRegionRegionalDistrictsDict[economicRegion].add(regionalDistrict); + }); + + return economicRegionRegionalDistrictsDict; +}; + +export const getAllEconomicRegionNames = ( + allCommunitiesSourceData: readonly CommunitySourceData[] +) => { + const economicRegionsSet = new Set(); + allCommunitiesSourceData.forEach((community) => { + const { economicRegion } = community; + economicRegionsSet.add(economicRegion); + }); + return [...economicRegionsSet]; +}; diff --git a/db/deploy/mutations/edit_cbc_project_communities.sql b/db/deploy/mutations/edit_cbc_project_communities.sql new file mode 100644 index 000000000..31b8fbabf --- /dev/null +++ b/db/deploy/mutations/edit_cbc_project_communities.sql @@ -0,0 +1,32 @@ +-- Deploy ccbc:mutations/edit_cbc_project_communities to pg +begin; + +create or replace function ccbc_public.edit_cbc_project_communities(_project_id int, _community_ids_to_add int[], _community_ids_to_archive int[]) returns setof ccbc_public.cbc_project_communities as +$$ +declare + _community_id int; +begin + -- Archive community ids that belong to _project_id + update ccbc_public.cbc_project_communities + set archived_at = now() + where cbc_id = _project_id + and archived_at is null + and communities_source_data_id = any(_community_ids_to_archive); + + -- Insert new community ids into ccbc_public.cbc_project_communities table + foreach _community_id in array _community_ids_to_add + loop + if _community_id is not null then + insert into ccbc_public.cbc_project_communities (cbc_id, communities_source_data_id) + values (_project_id, _community_id); + end if; + end loop; + + return query select * from ccbc_public.cbc_project_communities where cbc_id = _project_id and archived_at is null; + +end; +$$ language plpgsql volatile; + +grant execute on function ccbc_public.edit_cbc_project_communities to cbc_admin; + +commit; diff --git a/db/revert/mutations/edit_cbc_project_communities.sql b/db/revert/mutations/edit_cbc_project_communities.sql new file mode 100644 index 000000000..4756dc35a --- /dev/null +++ b/db/revert/mutations/edit_cbc_project_communities.sql @@ -0,0 +1,7 @@ +-- Revert ccbc:mutations/edit_cbc_project_communities from pg + +BEGIN; + +drop function ccbc_public.edit_cbc_project_communities(int, int[], int[]); + +COMMIT; diff --git a/db/sqitch.plan b/db/sqitch.plan index b09a97387..80852551b 100644 --- a/db/sqitch.plan +++ b/db/sqitch.plan @@ -646,3 +646,5 @@ tables/cbc_add_fk_update_constraint 2024-07-11T20:32:11Z Rafael Solorzano <61289 @1.184.1 2024-08-26T19:57:58Z CCBC Service Account # release v1.184.1 @1.184.2 2024-08-26T20:34:36Z CCBC Service Account # release v1.184.2 @1.185.0 2024-08-26T22:26:30Z CCBC Service Account # release v1.185.0 +mutations/edit_cbc_project_communities 2024-08-21T15:16:56Z Anthony Bushara # Add and delete project communities to a cbc project +@1.186.0 2024-08-27T20:55:46Z CCBC Service Account # release v1.186.0 diff --git a/package.json b/package.json index 3815d0eb0..aa86c8d79 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CONN-CCBC-portal", - "version": "1.185.0", + "version": "1.186.0", "main": "index.js", "repository": "https://github.com/bcgov/CONN-CCBC-portal.git", "author": "Romer, Meherzad CITZ:EX ",