diff --git a/source/frontend/src/components/common/mapFSM/MapStateMachineContext.tsx b/source/frontend/src/components/common/mapFSM/MapStateMachineContext.tsx index 96c92f205e..91b69cd0ba 100644 --- a/source/frontend/src/components/common/mapFSM/MapStateMachineContext.tsx +++ b/source/frontend/src/components/common/mapFSM/MapStateMachineContext.tsx @@ -1,5 +1,6 @@ import { useInterpret, useSelector } from '@xstate/react'; import { dequal } from 'dequal'; +import { Feature, Geometry } from 'geojson'; import { LatLngBounds, LatLngLiteral } from 'leaflet'; import React, { useCallback, useMemo } from 'react'; import { useHistory } from 'react-router-dom'; @@ -12,23 +13,15 @@ import { defaultPropertyFilter, IPropertyFilter, } from '@/features/properties/filter/IPropertyFilter'; +import { ApiGen_Concepts_FileProperty } from '@/models/api/generated/ApiGen_Concepts_FileProperty'; +import { PIMS_Property_Location_View } from '@/models/layers/pimsPropertyLocationView'; import { exists, firstOrNull, isValidString } from '@/utils'; import { pidParser, pinParser } from '@/utils/propertyUtils'; import { mapMachine } from './machineDefinition/mapMachine'; import { MachineContext, SideBarType } from './machineDefinition/types'; -import { - LocationBoundaryDataset, - MapFeatureData, - MarkerSelected, - RequestedCenterTo, - RequestedFlyTo, -} from './models'; -import useLocationFeatureLoader, { - LocationFeatureDataset, - SelectedFeatureDataset, - WorklistLocationFeatureDataset, -} from './useLocationFeatureLoader'; +import { MapFeatureData, MarkerSelected, RequestedCenterTo, RequestedFlyTo } from './models'; +import useLocationFeatureLoader, { LocationFeatureDataset } from './useLocationFeatureLoader'; import { useMapSearch } from './useMapSearch'; export interface IMapStateMachineContext { @@ -42,22 +35,21 @@ export interface IMapStateMachineContext { mapMarkedLocation: LatLngLiteral | null; mapLocationSelected: LatLngLiteral | null; mapLocationFeatureDataset: LocationFeatureDataset | null; - selectedFeatures: SelectedFeatureDataset[]; - repositioningFeatureDataset: SelectedFeatureDataset | null; - repositioningPropertyIndex: number | null; + locationFeaturesForAddition: LocationFeatureDataset[] | null; + repositioningFeature: Feature | null; + pendingLocationFeaturesAddition: boolean; // worklist-related state worklistSelectedMapLocation: LatLngLiteral | null; - worklistLocationFeatureDataset: WorklistLocationFeatureDataset | null; + worklistLocationFeatureDataset: LocationFeatureDataset | null; showPopup: boolean; isLoading: boolean; mapSearchCriteria: IPropertyFilter | null; mapFeatureData: MapFeatureData; - filePropertyLocations: LocationBoundaryDataset[]; + filePropertyLocations: ApiGen_Concepts_FileProperty[]; pendingFitBounds: boolean; requestedFitBounds: LatLngBounds; isSelecting: boolean; isRepositioning: boolean; - selectingComponentId: string | null; isFiltering: boolean; isShowingMapFilter: boolean; isShowingMapLayers: boolean; @@ -94,19 +86,15 @@ export interface IMapStateMachineContext { // worklist worklistMapClick: (latlng: LatLngLiteral) => void; - worklistAdd: (dataset: WorklistLocationFeatureDataset) => void; + worklistAdd: (dataset: LocationFeatureDataset) => void; setMapSearchCriteria: (searchCriteria: IPropertyFilter) => void; refreshMapProperties: () => void; - prepareForCreation: (selectedFeatures: SelectedFeatureDataset[]) => void; - processCreation: () => void; - startSelection: (selectingComponentId?: string) => void; + requestLocationFeatureAddition: (selectedFeatures: LocationFeatureDataset[]) => void; + processLocationFeaturesAddition: () => void; + startSelection: () => void; finishSelection: () => void; - startReposition: ( - repositioningFeatureDataset: SelectedFeatureDataset, - index: number, - selectingComponentId?: string, - ) => void; + startReposition: (featureDataSet: Feature) => void; finishReposition: () => void; toggleMapFilterDisplay: () => void; toggleMapLayerControl: () => void; @@ -116,7 +104,7 @@ export interface IMapStateMachineContext { openQuickInfo: () => void; closeQuickInfo: () => void; minimizeQuickInfo: () => void; - setFilePropertyLocations: (locations: LocationBoundaryDataset[]) => void; + setFilePropertyLocations: (locations: ApiGen_Concepts_FileProperty[]) => void; setMapLayers: (layers: Set) => void; setMapLayersToRefresh: (layers: Set) => void; setDefaultMapLayers: (layers: Set) => void; @@ -217,7 +205,7 @@ export const MapStateMachineProvider: React.FC> loadWorklistLocationData: async ( context: MachineContext, event: AnyEventObject & { type: 'WORKLIST_MAP_CLICK'; latlng: LatLngLiteral }, - ): Promise => { + ): Promise => { const response = locationLoader.loadWorklistLocationDetails({ latLng: event.latlng }); return response; }, @@ -336,7 +324,7 @@ export const MapStateMachineProvider: React.FC> ); const worklistAdd = useCallback( - (dataset: WorklistLocationFeatureDataset) => { + (dataset: LocationFeatureDataset) => { serviceSend({ type: 'WORKLIST_ADD', dataset, @@ -415,41 +403,32 @@ export const MapStateMachineProvider: React.FC> [serviceSend], ); - const prepareForCreation = useCallback( - (selectedFeatures: SelectedFeatureDataset[]) => { - serviceSend({ type: 'PREPARE_FOR_CREATION', selectedFeatures }); + const requestLocationFeatureAddition = useCallback( + (selectedFeatures: LocationFeatureDataset[]) => { + serviceSend({ type: 'REQUEST_LOCATION_ADDITION', selectedFeatures }); }, [serviceSend], ); - const processCreation = useCallback(() => { + const processLocationFeaturesAddition = useCallback(() => { serviceSend({ - type: 'PROCESS_CREATION', + type: 'PROCESS_LOCATION_ADDITION', }); }, [serviceSend]); - const startSelection = useCallback( - (selectingComponentId?: string) => { - serviceSend({ type: 'START_SELECTION', selectingComponentId }); - }, - [serviceSend], - ); + const startSelection = useCallback(() => { + serviceSend({ type: 'START_SELECTION' }); + }, [serviceSend]); const finishSelection = useCallback(() => { serviceSend({ type: 'FINISH_SELECTION' }); }, [serviceSend]); const startReposition = useCallback( - ( - repositioningFeatureDataset: SelectedFeatureDataset, - index: number, - selectingComponentId?: string, - ) => { + (feature: Feature) => { serviceSend({ type: 'START_REPOSITION', - repositioningFeatureDataset, - repositioningPropertyIndex: index, - selectingComponentId, + feature, }); }, [serviceSend], @@ -460,7 +439,7 @@ export const MapStateMachineProvider: React.FC> }, [serviceSend]); const setFilePropertyLocations = useCallback( - (locations: LocationBoundaryDataset[]) => { + (locations: ApiGen_Concepts_FileProperty[]) => { serviceSend({ type: 'SET_FILE_PROPERTY_LOCATIONS', locations }); }, [serviceSend], @@ -626,10 +605,8 @@ export const MapStateMachineProvider: React.FC> mapMarkerSelected: state.context.mapFeatureSelected, mapLocationSelected: state.context.mapLocationSelected, mapMarkedLocation: state.context.mapMarkedLocation, - selectedFeatures: state.context.selectedFeatures, + locationFeaturesForAddition: state.context.locationFeaturesForAddition, mapLocationFeatureDataset: state.context.mapLocationFeatureDataset, - repositioningFeatureDataset: state.context.repositioningFeatureDataset, - repositioningPropertyIndex: state.context.repositioningPropertyIndex, worklistSelectedMapLocation: state.context.worklistSelectedMapLocation, worklistLocationFeatureDataset: state.context.worklistLocationFeatureDataset, showPopup: showPopup, @@ -642,7 +619,6 @@ export const MapStateMachineProvider: React.FC> requestedFitBounds: state.context.requestedFitBounds, isSelecting: state.matches({ mapVisible: { featureView: 'selecting' } }), isRepositioning: isRepositioning, - selectingComponentId: state.context.selectingComponentId, isFiltering: !dequal(state.context.advancedSearchCriteria, new PropertyFilterFormModel()), isShowingMapFilter: isShowingMapFilter, isShowingMapLayers: isShowingMapLayers, @@ -658,6 +634,10 @@ export const MapStateMachineProvider: React.FC> isMapVisible: state.matches({ mapVisible: {} }), currentMapBounds: state.context.currentMapBounds, isEditPropertiesMode: state.context.isEditPropertiesMode, + pendingLocationFeaturesAddition: state.matches({ + mapVisible: { locationFeatureAddition: 'pendingLocationFeatureAddition' }, + }), + repositioningFeature: state.context.repositioningFeature, setMapSearchCriteria, refreshMapProperties, @@ -677,8 +657,8 @@ export const MapStateMachineProvider: React.FC> worklistMapClick, worklistAdd, closePopup, - prepareForCreation, - processCreation, + requestLocationFeatureAddition, + processLocationFeaturesAddition, startSelection, finishSelection, startReposition, diff --git a/source/frontend/src/components/common/mapFSM/machineDefinition/mapMachine.ts b/source/frontend/src/components/common/mapFSM/machineDefinition/mapMachine.ts index d5c5b0c218..ddeda63f1f 100644 --- a/source/frontend/src/components/common/mapFSM/machineDefinition/mapMachine.ts +++ b/source/frontend/src/components/common/mapFSM/machineDefinition/mapMachine.ts @@ -7,9 +7,9 @@ import { PropertyFilterFormModel } from '@/components/maps/leaflet/Control/Advan import { pimsBoundaryLayers } from '@/components/maps/leaflet/Control/LayersControl/LayerDefinitions'; import { initialEnabledLayers } from '@/components/maps/leaflet/Control/LayersControl/LayersMenuLayout'; import { defaultPropertyFilter } from '@/features/properties/filter/IPropertyFilter'; +import { ApiGen_Concepts_FileProperty } from '@/models/api/generated/ApiGen_Concepts_FileProperty'; -import { emptyFeatureData, LocationBoundaryDataset } from '../models'; -import { SelectedFeatureDataset } from '../useLocationFeatureLoader'; +import { emptyFeatureData } from '../models'; import { MachineContext, SideBarType } from './types'; const featureViewStates = { @@ -19,17 +19,12 @@ const featureViewStates = { on: { START_SELECTION: { target: 'selecting', - actions: [ - assign({ selectingComponentId: (_, event: any) => event.selectingComponentId }), - ], }, START_REPOSITION: { target: 'repositioning', actions: [ assign({ - selectingComponentId: (_, event: any) => event.selectingComponentId, - repositioningFeatureDataset: (_, event: any) => event.repositioningFeatureDataset, - repositioningPropertyIndex: (_, event: any) => event.repositioningPropertyIndex, + repositioningFeature: (_, event: any) => event.feature, }), ], }, @@ -39,14 +34,13 @@ const featureViewStates = { on: { FINISH_SELECTION: { target: 'browsing', - actions: [assign({ selectingComponentId: () => null })], }, SET_FILE_PROPERTY_LOCATIONS: { actions: [ assign({ filePropertyLocations: ( _, - event: AnyEventObject & { locations: LocationBoundaryDataset[] }, + event: AnyEventObject & { locations: ApiGen_Concepts_FileProperty[] }, ) => event.locations ?? [], }), ], @@ -59,9 +53,7 @@ const featureViewStates = { target: 'browsing', actions: [ assign({ - repositioningFeatureDataset: () => null, - repositioningPropertyIndex: () => null, - selectingComponentId: () => null, + repositioningFeature: () => null, }), ], }, @@ -70,7 +62,7 @@ const featureViewStates = { assign({ filePropertyLocations: ( _, - event: AnyEventObject & { locations: LocationBoundaryDataset[] }, + event: AnyEventObject & { locations: ApiGen_Concepts_FileProperty[] }, ) => event.locations ?? [], }), ], @@ -239,6 +231,32 @@ const mapRequestStates = { }, }; +const locationFeatureAdditionStates = { + initial: 'nothingPending', + states: { + nothingPending: { + on: { + REQUEST_LOCATION_ADDITION: { + actions: assign({ + locationFeaturesForAddition: (_, event: any) => event.selectedFeatures, + }), + target: 'pendingLocationFeatureAddition', + }, + }, + }, + pendingLocationFeatureAddition: { + on: { + PROCESS_LOCATION_ADDITION: { + actions: assign({ + locationFeaturesForAddition: () => null, + }), + target: 'nothingPending', + }, + }, + }, + }, +}; + const selectedFeatureLoaderStates = { initial: 'idle', states: { @@ -249,7 +267,7 @@ const selectedFeatureLoaderStates = { assign({ isLoading: () => false, showPopup: () => false, - mapLocationFeatureDataset: (context: any, event: any) => { + mapLocationFeatureDataset: (_, event: any) => { return { ...event.locationDataset, }; @@ -312,10 +330,9 @@ const selectedFeatureLoaderStates = { assign({ isLoading: () => false, showPopup: () => false, - mapLocationFeatureDataset: (context: any, event: any) => { + mapLocationFeatureDataset: (_, event: any) => { return { ...event.data, - selectingComponentId: context.selectingComponentId, }; }, }), @@ -396,7 +413,7 @@ const sideBarStates = { assign({ filePropertyLocations: ( _, - event: AnyEventObject & { locations: LocationBoundaryDataset[] }, + event: AnyEventObject & { locations: ApiGen_Concepts_FileProperty[] }, ) => event.locations ?? [], }), ], @@ -661,10 +678,7 @@ export const mapMachine = createMachine({ mapFeatureSelected: null, mapLocationFeatureDataset: null, mapMarkedLocation: null, - selectedFeatures: [], - repositioningFeatureDataset: null, - repositioningPropertyIndex: null, - selectingComponentId: null, + locationFeaturesForAddition: null, worklistSelectedMapLocation: null, worklistLocationFeatureDataset: null, isLoading: false, @@ -680,6 +694,7 @@ export const mapMachine = createMachine({ mapLayersToRefresh: new Set(), currentMapBounds: null, isEditPropertiesMode: false, + repositioningFeature: null, }, // State definitions @@ -722,19 +737,6 @@ export const mapMachine = createMachine({ EXIT_MAP: { target: 'notMap', }, - PREPARE_FOR_CREATION: { - actions: assign({ - selectedFeatures: ( - _, - event: AnyEventObject & { selectedFeatures: SelectedFeatureDataset[] }, - ) => event.selectedFeatures, - }), - }, - PROCESS_CREATION: { - actions: assign({ - selectedFeatures: () => [], - }), - }, SET_EDIT_PROPERTIES_MODE: { actions: assign({ isEditPropertiesMode: (_, event: AnyEventObject & { isEditPropertiesMode: boolean }) => @@ -755,6 +757,7 @@ export const mapMachine = createMachine({ featureView: featureViewStates, featureDataLoader: featureDataLoaderStates, mapRequest: mapRequestStates, + locationFeatureAddition: locationFeatureAdditionStates, selectedFeatureLoader: selectedFeatureLoaderStates, sideBar: sideBarStates, rightSideBar: rightSideBarStates, diff --git a/source/frontend/src/components/common/mapFSM/machineDefinition/types.ts b/source/frontend/src/components/common/mapFSM/machineDefinition/types.ts index bcb45fd187..e4a1371e5f 100644 --- a/source/frontend/src/components/common/mapFSM/machineDefinition/types.ts +++ b/source/frontend/src/components/common/mapFSM/machineDefinition/types.ts @@ -1,21 +1,14 @@ +import { Feature, Geometry } from 'geojson'; import { LatLngBounds, LatLngLiteral } from 'leaflet'; import { PropertyFilterFormModel } from '@/components/maps/leaflet/Control/AdvancedFilter/models'; import { IMapSideBarViewState as IMapSideBarState } from '@/features/mapSideBar/MapSideBar'; import { IPropertyFilter } from '@/features/properties/filter/IPropertyFilter'; +import { ApiGen_Concepts_FileProperty } from '@/models/api/generated/ApiGen_Concepts_FileProperty'; +import { PIMS_Property_Location_View } from '@/models/layers/pimsPropertyLocationView'; -import { - LocationBoundaryDataset, - MapFeatureData, - MarkerSelected, - RequestedCenterTo, - RequestedFlyTo, -} from '../models'; -import { - LocationFeatureDataset, - SelectedFeatureDataset, - WorklistLocationFeatureDataset, -} from '../useLocationFeatureLoader'; +import { MapFeatureData, MarkerSelected, RequestedCenterTo, RequestedFlyTo } from '../models'; +import { LocationFeatureDataset } from '../useLocationFeatureLoader'; export enum SideBarType { NOT_DEFINED = 'NOT_DEFINED', @@ -37,16 +30,13 @@ export type MachineContext = { mapLocationSelected: LatLngLiteral | null; mapLocationFeatureDataset: LocationFeatureDataset | null; mapMarkedLocation: LatLngLiteral | null; - selectedFeatures: SelectedFeatureDataset[]; - repositioningFeatureDataset: SelectedFeatureDataset | null; - repositioningPropertyIndex: number | null; - selectingComponentId: string | null; - + locationFeaturesForAddition: LocationFeatureDataset[] | null; + repositioningFeature: Feature | null; mapFeatureData: MapFeatureData; // worklist-related state worklistSelectedMapLocation: LatLngLiteral | null; - worklistLocationFeatureDataset: WorklistLocationFeatureDataset | null; + worklistLocationFeatureDataset: LocationFeatureDataset | null; // TODO: this is partially in the URL. Either move it completly there or remove it searchCriteria: IPropertyFilter | null; @@ -56,7 +46,7 @@ export type MachineContext = { requestedFitBounds: LatLngBounds; requestedFlyTo: RequestedFlyTo; requestedCenterTo: RequestedCenterTo; - filePropertyLocations: LocationBoundaryDataset[]; + filePropertyLocations: ApiGen_Concepts_FileProperty[]; activePimsPropertyIds: number[]; activeLayers: Set; mapLayersToRefresh: Set; diff --git a/source/frontend/src/components/common/mapFSM/models.ts b/source/frontend/src/components/common/mapFSM/models.ts index 3bf97ca133..cec41452a4 100644 --- a/source/frontend/src/components/common/mapFSM/models.ts +++ b/source/frontend/src/components/common/mapFSM/models.ts @@ -45,13 +45,6 @@ export interface RequestedCenterTo { readonly location: LatLngLiteral | null; } -export interface LocationBoundaryDataset { - readonly location: LatLngLiteral; - readonly boundary: Geometry | null; - readonly fileBoundary: Geometry | null; - readonly isActive?: boolean; -} - export const emptyPimsLocationFeatureCollection: FeatureCollection< Geometry, PIMS_Property_Location_View diff --git a/source/frontend/src/components/common/mapFSM/useLocationFeatureLoader.tsx b/source/frontend/src/components/common/mapFSM/useLocationFeatureLoader.tsx index 7eeb69046e..3d93bc57a0 100644 --- a/source/frontend/src/components/common/mapFSM/useLocationFeatureLoader.tsx +++ b/source/frontend/src/components/common/mapFSM/useLocationFeatureLoader.tsx @@ -1,4 +1,4 @@ -import { Feature, FeatureCollection, Geometry, MultiPolygon, Polygon } from 'geojson'; +import { Feature, FeatureCollection, Geometry } from 'geojson'; import { LatLngLiteral } from 'leaflet'; import { useCallback } from 'react'; @@ -7,6 +7,8 @@ import { useFullyAttributedParcelMapLayer } from '@/hooks/repositories/mapLayer/ import { useLegalAdminBoundariesMapLayer } from '@/hooks/repositories/mapLayer/useLegalAdminBoundariesMapLayer'; import { usePimsPropertyLayer } from '@/hooks/repositories/mapLayer/usePimsPropertyLayer'; import { useMapProperties } from '@/hooks/repositories/useMapProperties'; +import { ADM_IndianReserveBands_Feature_Properties } from '@/models/layers/admIndianReserveBands'; +import { WHSE_AgriculturalLandReservePoly_Feature_Properties } from '@/models/layers/alcAgriculturalReserve'; import { TANTALIS_CrownLandInclusions_Feature_Properties, TANTALIS_CrownLandInventory_Feature_Properties, @@ -14,23 +16,22 @@ import { TANTALIS_CrownLandLicenses_Feature_Properties, TANTALIS_CrownLandTenures_Feature_Properties, } from '@/models/layers/crownLand'; +import { EBC_ELECTORAL_DISTS_BS10_SVW_Feature_Properties } from '@/models/layers/electoralBoundaries'; import { MOT_DistrictBoundary_Feature_Properties } from '@/models/layers/motDistrictBoundary'; import { MOT_RegionalBoundary_Feature_Properties } from '@/models/layers/motRegionalBoundary'; import { WHSE_Municipalities_Feature_Properties } from '@/models/layers/municipalities'; import { PMBC_FullyAttributed_Feature_Properties } from '@/models/layers/parcelMapBC'; import { ISS_ProvincialPublicHighway } from '@/models/layers/pimsHighwayLayer'; -import { PIMS_Property_Location_View } from '@/models/layers/pimsPropertyLocationView'; +import { + PIMS_Property_Boundary_View, + PIMS_Property_Location_View, +} from '@/models/layers/pimsPropertyLocationView'; import { exists, isValidId } from '@/utils'; export interface FeatureDataset { - selectingComponentId: string | null; - location: LatLngLiteral; - fileLocation: LatLngLiteral | null; -} - -export interface LocationFeatureDataset extends FeatureDataset { parcelFeatures: Feature[] | null; pimsFeatures: Feature[] | null; + pimsBoundaryFeatures: Feature[] | null; regionFeature: Feature | null; districtFeature: Feature | null; municipalityFeatures: Feature[] | null; @@ -42,36 +43,41 @@ export interface LocationFeatureDataset extends FeatureDataset { crownLandTenuresFeatures: | Feature[] | null; + crownLandInventoryFeatures: | Feature[] | null; crownLandInclusionsFeatures: | Feature[] | null; + + electoralFeatures: Feature[] | null; + alrFeatures: Feature[] | null; + firstNationFeatures: Feature[] | null; } -export interface SelectedFeatureDataset extends FeatureDataset { - id?: string; - location: LatLngLiteral; - fileBoundary: Polygon | MultiPolygon | null; - parcelFeature: Feature | null; - pimsFeature: Feature | null; - regionFeature: Feature | null; - districtFeature: Feature | null; - municipalityFeature: Feature | null; - isActive?: boolean; - displayOrder?: number; +export function emptyFeatureDataset(): FeatureDataset { + return { + parcelFeatures: null, + pimsFeatures: null, + pimsBoundaryFeatures: null, + regionFeature: null, + districtFeature: null, + municipalityFeatures: null, + highwayFeatures: null, + crownLandLeasesFeatures: null, + crownLandLicensesFeatures: null, + crownLandTenuresFeatures: null, + crownLandInventoryFeatures: null, + crownLandInclusionsFeatures: null, + electoralFeatures: null, + alrFeatures: null, + firstNationFeatures: null, + }; } -export interface WorklistLocationFeatureDataset - extends Omit { - fullyAttributedFeatures: FeatureCollection< - Geometry, - PMBC_FullyAttributed_Feature_Properties - > | null; - pimsFeature: Feature | null; - regionFeature: Feature | null; - districtFeature: Feature | null; +export interface LocationFeatureDataset extends FeatureDataset { + location: LatLngLiteral; } const useLocationFeatureLoader = () => { @@ -103,20 +109,8 @@ const useLocationFeatureLoader = () => { pimsPropertyId?: number | null; }): Promise => { const result: LocationFeatureDataset = { - selectingComponentId: null, + ...emptyFeatureDataset(), location: latLng, - fileLocation: latLng, - pimsFeatures: null, - parcelFeatures: null, - regionFeature: null, - districtFeature: null, - municipalityFeatures: null, - highwayFeatures: null, - crownLandLeasesFeatures: null, - crownLandLicensesFeatures: null, - crownLandTenuresFeatures: null, - crownLandInventoryFeatures: null, - crownLandInclusionsFeatures: null, }; // call these APIs in parallel - notice there is no "await" @@ -195,13 +189,10 @@ const useLocationFeatureLoader = () => { ); const loadWorklistLocationDetails = useCallback( - async ({ latLng }: { latLng: LatLngLiteral }): Promise => { - const result: WorklistLocationFeatureDataset = { + async ({ latLng }: { latLng: LatLngLiteral }): Promise => { + const result: LocationFeatureDataset = { + ...emptyFeatureDataset(), location: latLng, - fullyAttributedFeatures: null, - pimsFeature: null, - regionFeature: null, - districtFeature: null, }; // call these APIs in parallel - notice there is no "await" @@ -215,7 +206,7 @@ const useLocationFeatureLoader = () => { districtTask, ]); - result.fullyAttributedFeatures = pmbcFeatures ?? null; + result.parcelFeatures = pmbcFeatures.features ?? null; result.regionFeature = regionFeature ?? null; result.districtFeature = districtFeature ?? null; diff --git a/source/frontend/src/components/maps/MapLeafletView.tsx b/source/frontend/src/components/maps/MapLeafletView.tsx index 8292b559af..205ca64bd0 100644 --- a/source/frontend/src/components/maps/MapLeafletView.tsx +++ b/source/frontend/src/components/maps/MapLeafletView.tsx @@ -153,12 +153,8 @@ const MapLeafletView: React.FC> = ( [mapMachine.mapFeatureData], ); - const { - mapLocationFeatureDataset, - repositioningFeatureDataset, - isRepositioning, - setDefaultMapLayers, - } = mapMachine; + const { mapLocationFeatureDataset, repositioningFeature, isRepositioning, setDefaultMapLayers } = + mapMachine; // Initialize layers useEffect(() => { @@ -171,10 +167,9 @@ const MapLeafletView: React.FC> = ( activeFeatureLayer?.clearLayers(); if (isRepositioning) { - const pimsFeature = repositioningFeatureDataset?.pimsFeature; - if (exists(pimsFeature)) { + if (exists(repositioningFeature)) { // File marker repositioning is active - highlight the property and the corresponding boundary when user triggers the relocate action. - activeFeatureLayer?.addData(pimsFeature); + activeFeatureLayer?.addData(repositioningFeature); } } else if (exists(mapLocationFeatureDataset)) { if (firstOrNull(mapLocationFeatureDataset.parcelFeatures) !== null) { @@ -182,7 +177,7 @@ const MapLeafletView: React.FC> = ( activeFeatureLayer?.addData(activeFeature); } } - }, [activeFeatureLayer, isRepositioning, mapLocationFeatureDataset, repositioningFeatureDataset]); + }, [activeFeatureLayer, isRepositioning, mapLocationFeatureDataset, repositioningFeature]); useEffect(() => { if (hasPendingFlyTo && isMapReady) { diff --git a/source/frontend/src/components/maps/ZoomToLocation.tsx b/source/frontend/src/components/maps/ZoomToLocation.tsx index e80ac00226..68ef602b75 100644 --- a/source/frontend/src/components/maps/ZoomToLocation.tsx +++ b/source/frontend/src/components/maps/ZoomToLocation.tsx @@ -8,24 +8,26 @@ import { LinkButton } from '@/components/common/buttons/LinkButton'; import { useMapStateMachine } from '@/components/common/mapFSM/MapStateMachineContext'; import TooltipWrapper from '@/components/common/TooltipWrapper'; import { PropertyForm } from '@/features/mapSideBar/shared/models'; -import { ParcelDataset } from '@/features/properties/parcelList/models'; import { ApiGen_Concepts_FileProperty } from '@/models/api/generated/ApiGen_Concepts_FileProperty'; import { ApiGen_Concepts_Property } from '@/models/api/generated/ApiGen_Concepts_Property'; import { PIMS_Property_Location_View } from '@/models/layers/pimsPropertyLocationView'; import { boundaryFromFileProperty, exists, + firstValidOrNull, + isEmptyOrNull, latLngLiteralToGeometry, pimsGeomeryToGeometry, } from '@/utils'; +import { LocationFeatureDataset } from '../common/mapFSM/useLocationFeatureLoader'; import TooltipIcon from '../common/TooltipIcon'; -export interface IUpdatePropertiesProps { +export interface IZoomToLocationProps { formProperties?: PropertyForm[] | null; pimsProperties?: ApiGen_Concepts_Property[] | null; pimsFileProperties?: ApiGen_Concepts_FileProperty[] | null; - parcelDataset?: ParcelDataset | null; + locationFeatureDataset?: LocationFeatureDataset | null; featureCollection?: Feature[] | null; pimsFeatures?: Feature[] | null; @@ -39,11 +41,11 @@ export enum ZoomIconType { area, } -export const ZoomToLocation: React.FunctionComponent = ({ +export const ZoomToLocation: React.FunctionComponent = ({ formProperties, pimsProperties, pimsFileProperties, - parcelDataset, + locationFeatureDataset, featureCollection, geometry, icon, @@ -52,8 +54,19 @@ export const ZoomToLocation: React.FunctionComponent = ( const { requestFlyToBounds } = useMapStateMachine(); const bounds: LatLngBounds | null = useMemo(() => { - const propertyLocations: Geometry[] = - formProperties?.map(p => p?.polygon ?? latLngLiteralToGeometry(p?.fileLocation)) || []; + const propertyLocations: Geometry[] = []; + const points = + formProperties?.map(p => + p?.polygon ?? (p.latitude && p.longitude) + ? latLngLiteralToGeometry({ lat: p?.latitude, lng: p?.longitude }) + : null, + ) || []; + + if (!isEmptyOrNull(points)) { + points.forEach(point => { + propertyLocations.push(point); + }); + } if (exists(geometry)) { propertyLocations.push(geometry); @@ -80,20 +93,22 @@ export const ZoomToLocation: React.FunctionComponent = ( }); } - if (exists(parcelDataset?.pmbcFeature?.geometry)) { - const pmbcGeometry = parcelDataset.pmbcFeature.geometry; + const validParcelFeature = firstValidOrNull(locationFeatureDataset?.parcelFeatures, exists); + const validPimsFeature = firstValidOrNull(locationFeatureDataset?.pimsFeatures, exists); + if (exists(validParcelFeature?.geometry)) { + const pmbcGeometry = validParcelFeature.geometry; const pmbcBounds = geoJSON(pmbcGeometry)?.getBounds(); if (exists(pmbcBounds) && pmbcBounds.isValid()) { propertyLocations.push(pmbcGeometry); } - } else if (exists(parcelDataset?.pimsFeature?.geometry)) { - const pimsFeatureGeometry = parcelDataset.pimsFeature.geometry; + } else if (exists(validPimsFeature?.geometry)) { + const pimsFeatureGeometry = validPimsFeature.geometry; const pimsBounds = geoJSON(pimsFeatureGeometry)?.getBounds(); if (exists(pimsBounds) && pimsBounds.isValid()) { propertyLocations.push(pimsFeatureGeometry); } - } else if (exists(parcelDataset?.location)) { - propertyLocations.push(latLngLiteralToGeometry(parcelDataset.location)); + } else if (exists(locationFeatureDataset?.location)) { + propertyLocations.push(latLngLiteralToGeometry(locationFeatureDataset.location)); } if (exists(featureCollection)) { @@ -116,9 +131,9 @@ export const ZoomToLocation: React.FunctionComponent = ( featureCollection, formProperties, geometry, - parcelDataset?.location, - parcelDataset?.pimsFeature, - parcelDataset?.pmbcFeature, + locationFeatureDataset?.location, + locationFeatureDataset?.parcelFeatures, + locationFeatureDataset?.pimsFeatures, pimsFileProperties, pimsProperties, ]); diff --git a/source/frontend/src/components/maps/leaflet/Control/Search/PropertyQuickInfoContainer.tsx b/source/frontend/src/components/maps/leaflet/Control/Search/PropertyQuickInfoContainer.tsx index 5e3a426018..b22a95d3f8 100644 --- a/source/frontend/src/components/maps/leaflet/Control/Search/PropertyQuickInfoContainer.tsx +++ b/source/frontend/src/components/maps/leaflet/Control/Search/PropertyQuickInfoContainer.tsx @@ -10,10 +10,7 @@ import ManagementIcon from '@/assets/images/management-icon.svg?react'; import ResearchIcon from '@/assets/images/research-icon.svg?react'; import LoadingBackdrop from '@/components/common/LoadingBackdrop'; import { useMapStateMachine } from '@/components/common/mapFSM/MapStateMachineContext'; -import { - SelectedFeatureDataset, - WorklistLocationFeatureDataset, -} from '@/components/common/mapFSM/useLocationFeatureLoader'; +import { LocationFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; import MoreOptionsMenu, { MenuOption } from '@/components/common/MoreOptionsMenu'; import { SectionField } from '@/components/common/Section/SectionField'; import TooltipWrapper from '@/components/common/TooltipWrapper'; @@ -26,7 +23,7 @@ import { useLtsa } from '@/hooks/useLtsa'; import { exists, firstOrNull, - getPropertyNameFromSelectedFeatureSet, + getPropertyNameFromLocationFeatureSet, isValidString, pidFormatter, } from '@/utils'; @@ -38,7 +35,7 @@ export const PropertyQuickInfoContainer: React.FC = () const { mapLocationFeatureDataset, - prepareForCreation, + requestLocationFeatureAddition: requestAddition, worklistAdd, isEditPropertiesMode, mapMarkedLocation, @@ -158,74 +155,44 @@ export const PropertyQuickInfoContainer: React.FC = () mapMachine.closeQuickInfo(); }, [mapMachine]); - // Convert to an object that can be consumed by the file creation process - const selectedFeatureDataset = useMemo(() => { - return { - selectingComponentId: mapLocationFeatureDataset?.selectingComponentId ?? null, - location: mapLocationFeatureDataset?.location, - fileLocation: mapLocationFeatureDataset?.fileLocation ?? null, - fileBoundary: null, - parcelFeature: firstOrNull(mapLocationFeatureDataset?.parcelFeatures), - pimsFeature: firstOrNull(mapLocationFeatureDataset?.pimsFeatures), - regionFeature: mapLocationFeatureDataset?.regionFeature ?? null, - districtFeature: mapLocationFeatureDataset?.districtFeature ?? null, - municipalityFeature: firstOrNull(mapLocationFeatureDataset?.municipalityFeatures), - isActive: true, - displayOrder: 0, - }; - }, [ - mapLocationFeatureDataset?.selectingComponentId, - mapLocationFeatureDataset?.location, - mapLocationFeatureDataset?.fileLocation, - mapLocationFeatureDataset?.parcelFeatures, - mapLocationFeatureDataset?.pimsFeatures, - mapLocationFeatureDataset?.regionFeature, - mapLocationFeatureDataset?.districtFeature, - mapLocationFeatureDataset?.municipalityFeatures, - ]); - const onAddToWorklist = useCallback(() => { - const worklistDataSet: WorklistLocationFeatureDataset = { - ...selectedFeatureDataset, - fullyAttributedFeatures: { - type: 'FeatureCollection', - features: [selectedFeatureDataset.parcelFeature], - }, + const worklistDataSet: LocationFeatureDataset = { + ...mapLocationFeatureDataset, }; worklistAdd(worklistDataSet); - }, [selectedFeatureDataset, worklistAdd]); + }, [mapLocationFeatureDataset, worklistAdd]); const onCreateResearchFile = useCallback(() => { - prepareForCreation([selectedFeatureDataset]); + requestAddition([mapLocationFeatureDataset]); pathGenerator.newFile('research'); - }, [pathGenerator, prepareForCreation, selectedFeatureDataset]); + }, [pathGenerator, requestAddition, mapLocationFeatureDataset]); const onCreateAcquisitionFile = useCallback(() => { - prepareForCreation([selectedFeatureDataset]); + requestAddition([mapLocationFeatureDataset]); pathGenerator.newFile('acquisition'); - }, [pathGenerator, prepareForCreation, selectedFeatureDataset]); + }, [pathGenerator, requestAddition, mapLocationFeatureDataset]); const onCreateDispositionFile = useCallback(() => { - prepareForCreation([selectedFeatureDataset]); + requestAddition([mapLocationFeatureDataset]); pathGenerator.newFile('disposition'); - }, [pathGenerator, prepareForCreation, selectedFeatureDataset]); + }, [pathGenerator, requestAddition, mapLocationFeatureDataset]); const onCreateLeaseFile = useCallback(() => { - prepareForCreation([selectedFeatureDataset]); + requestAddition([mapLocationFeatureDataset]); pathGenerator.newFile('lease'); - }, [pathGenerator, prepareForCreation, selectedFeatureDataset]); + }, [pathGenerator, requestAddition, mapLocationFeatureDataset]); const onCreateManagementFile = useCallback(() => { - prepareForCreation([selectedFeatureDataset]); + requestAddition([mapLocationFeatureDataset]); pathGenerator.newFile('management'); - }, [pathGenerator, prepareForCreation, selectedFeatureDataset]); + }, [pathGenerator, requestAddition, mapLocationFeatureDataset]); const onAddToOpenFile = useCallback(() => { // If in edit properties mode, prepare the parcel for addition to an open file if (isEditPropertiesMode) { - prepareForCreation([selectedFeatureDataset]); + requestAddition([mapLocationFeatureDataset]); } - }, [isEditPropertiesMode, prepareForCreation, selectedFeatureDataset]); + }, [isEditPropertiesMode, requestAddition, mapLocationFeatureDataset]); const menuOptions: MenuOption[] = useMemo(() => { const options: MenuOption[] = []; @@ -405,7 +372,7 @@ export const PropertyQuickInfoContainer: React.FC = () {isEmpty && exists(mapMarkedLocation) && ( - {getPropertyNameFromSelectedFeatureSet(selectedFeatureDataset).value} + {getPropertyNameFromLocationFeatureSet(mapLocationFeatureDataset).value} )} diff --git a/source/frontend/src/components/maps/leaflet/Control/Search/SearchContainer.tsx b/source/frontend/src/components/maps/leaflet/Control/Search/SearchContainer.tsx index e1b48b364c..70ea7e2b98 100644 --- a/source/frontend/src/components/maps/leaflet/Control/Search/SearchContainer.tsx +++ b/source/frontend/src/components/maps/leaflet/Control/Search/SearchContainer.tsx @@ -4,15 +4,18 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { toast } from 'react-toastify'; import { useMapStateMachine } from '@/components/common/mapFSM/MapStateMachineContext'; -import { SelectedFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; +import { + emptyFeatureDataset, + LocationFeatureDataset, +} from '@/components/common/mapFSM/useLocationFeatureLoader'; import usePathGenerator from '@/features/mapSideBar/shared/sidebarPathGenerator'; import { IPropertyFilter } from '@/features/properties/filter/IPropertyFilter'; import { useAdminBoundaryMapLayer } from '@/hooks/repositories/mapLayer/useAdminBoundaryMapLayer'; import { exists, - featureSetToLatLngKey, getFeatureBoundedCenter, getRegionAndDistrictsResults, + latLngToKey, } from '@/utils'; import { ISearchViewProps } from './SearchView'; @@ -30,7 +33,7 @@ export const SearchContainer: React.FC = ({ View }) => { mapFeatureData, mapMarkLocation, mapClearLocationMark, - prepareForCreation, + requestLocationFeatureAddition: requestAddition, isEditPropertiesMode, } = useMapStateMachine(); @@ -73,27 +76,21 @@ export const SearchContainer: React.FC = ({ View }) => { }; // Base dataset (no region/district yet) - const baseDatasets = useMemo(() => { + const baseDatasets = useMemo(() => { return ( - mapFeatureData?.fullyAttributedFeatures.features.map(pmbcParcel => { + mapFeatureData?.fullyAttributedFeatures.features.map(pmbcParcel => { const center = getFeatureBoundedCenter(pmbcParcel); return { - parcelFeature: pmbcParcel, - pimsFeature: null, + ...emptyFeatureDataset(), location: { lat: center[1], lng: center[0] }, - regionFeature: null, - fileLocation: null, - fileBoundary: null, - districtFeature: null, - municipalityFeature: null, - selectingComponentId: null, + parcelFeatures: [pmbcParcel], }; }) ?? [] ); }, [mapFeatureData?.fullyAttributedFeatures?.features]); // Enrich dataset with region/district info - const [selectedFeatureDatasets, setSelectedFeatureDatasets] = useState( + const [locationFeatureDatasets, setLocationFeatureDatasets] = useState( [], ); @@ -102,7 +99,7 @@ export const SearchContainer: React.FC = ({ View }) => { async function fetchRegions() { if (baseDatasets.length === 0) { - setSelectedFeatureDatasets([]); + setLocationFeatureDatasets([]); return; } @@ -111,7 +108,7 @@ export const SearchContainer: React.FC = ({ View }) => { if (cancelled) return; const enriched = baseDatasets.map(dataset => { - const key = featureSetToLatLngKey(dataset); + const key = latLngToKey(dataset.location); if (results.has(key)) { const { regionResult, districtResult } = results.get(key)!; return { @@ -124,7 +121,7 @@ export const SearchContainer: React.FC = ({ View }) => { } }); - setSelectedFeatureDatasets(enriched); + setLocationFeatureDatasets(enriched); } fetchRegions(); @@ -137,36 +134,36 @@ export const SearchContainer: React.FC = ({ View }) => { // Actions for creating new files const onCreateResearchFile = useCallback(() => { - prepareForCreation(selectedFeatureDatasets); + requestAddition(locationFeatureDatasets); pathGenerator.newFile('research'); - }, [pathGenerator, prepareForCreation, selectedFeatureDatasets]); + }, [pathGenerator, requestAddition, locationFeatureDatasets]); const onCreateAcquisitionFile = useCallback(() => { - prepareForCreation(selectedFeatureDatasets); + requestAddition(locationFeatureDatasets); pathGenerator.newFile('acquisition'); - }, [pathGenerator, prepareForCreation, selectedFeatureDatasets]); + }, [pathGenerator, requestAddition, locationFeatureDatasets]); const onCreateDispositionFile = useCallback(() => { - prepareForCreation(selectedFeatureDatasets); + requestAddition(locationFeatureDatasets); pathGenerator.newFile('disposition'); - }, [pathGenerator, prepareForCreation, selectedFeatureDatasets]); + }, [pathGenerator, requestAddition, locationFeatureDatasets]); const onCreateLeaseFile = useCallback(() => { - prepareForCreation(selectedFeatureDatasets); + requestAddition(locationFeatureDatasets); pathGenerator.newFile('lease'); - }, [pathGenerator, prepareForCreation, selectedFeatureDatasets]); + }, [pathGenerator, requestAddition, locationFeatureDatasets]); const onCreateManagementFile = useCallback(() => { - prepareForCreation(selectedFeatureDatasets); + requestAddition(locationFeatureDatasets); pathGenerator.newFile('management'); - }, [pathGenerator, prepareForCreation, selectedFeatureDatasets]); + }, [pathGenerator, requestAddition, locationFeatureDatasets]); const onAddToOpenFile = useCallback(() => { // If in edit properties mode, prepare the parcel for addition to an open file if (isEditPropertiesMode) { - prepareForCreation(selectedFeatureDatasets); + requestAddition(locationFeatureDatasets); } - }, [isEditPropertiesMode, prepareForCreation, selectedFeatureDatasets]); + }, [isEditPropertiesMode, requestAddition, locationFeatureDatasets]); return ( = props => { groupedFeatures .value() .flatMap(x => x) - .map(x => ParcelDataset.fromFullyAttributedFeature(x.feature)) ?? []; + .map(x => featureToLocationFeatureDataset(x.feature)) ?? []; const pimsGroupedFeatures = chain(props.searchResult?.pimsLocationFeatures.features) .groupBy(feature => feature?.properties?.SURVEY_PLAN_NUMBER) @@ -104,7 +104,7 @@ export const SearchView: React.FC = props => { pimsGroupedFeatures .value() .flatMap(x => x) - .map(x => ParcelDataset.fromPimsFeature(x.feature)) ?? []; + .map(x => featureToLocationFeatureDataset(x.feature)) ?? []; const menuOptions: MenuOption[] = useMemo(() => { const options: MenuOption[] = []; @@ -191,7 +191,7 @@ export const SearchView: React.FC = props => { initiallyExpanded data-testid="pmbc-search-results-section" > - +
= props => { initiallyExpanded data-testid="pims-search-results-section" > - +
{exists(props.searchResult?.highwayPlanFeatures) && props.searchResult.highwayPlanFeatures.features.length > 0 && ( diff --git a/source/frontend/src/components/maps/leaflet/LayerPopup/MultiplePropertyPopupView.tsx b/source/frontend/src/components/maps/leaflet/LayerPopup/MultiplePropertyPopupView.tsx index ef9823a417..d8ca1ef91b 100644 --- a/source/frontend/src/components/maps/leaflet/LayerPopup/MultiplePropertyPopupView.tsx +++ b/source/frontend/src/components/maps/leaflet/LayerPopup/MultiplePropertyPopupView.tsx @@ -7,14 +7,11 @@ import styled from 'styled-components'; import { LinkButton, StyledIconButton } from '@/components/common/buttons'; import { useMapStateMachine } from '@/components/common/mapFSM/MapStateMachineContext'; -import { - LocationFeatureDataset, - WorklistLocationFeatureDataset, -} from '@/components/common/mapFSM/useLocationFeatureLoader'; +import { LocationFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; import TooltipWrapper from '@/components/common/TooltipWrapper'; import { StyledScrollable } from '@/features/documents/commonStyles'; import { PMBC_FullyAttributed_Feature_Properties } from '@/models/layers/parcelMapBC'; -import { exists, firstOrNull } from '@/utils'; +import { exists } from '@/utils'; import { isStrataCommonProperty, pidFormatter } from '@/utils/propertyUtils'; export interface IMultiplePropertyPopupView { @@ -48,17 +45,7 @@ export const MultiplePropertyPopupView: React.FC< }; const onAddAllToWorklist = () => { - const worklistDataSet: WorklistLocationFeatureDataset = { - fullyAttributedFeatures: { - features: featureDataset.parcelFeatures, - type: 'FeatureCollection', - }, - pimsFeature: firstOrNull(featureDataset.pimsFeatures), - regionFeature: featureDataset.regionFeature, - districtFeature: featureDataset.districtFeature, - location: featureDataset.location, - }; - mapMachine.worklistAdd(worklistDataSet); + mapMachine.worklistAdd(featureDataset); }; const groupedFeatures = chain(featureDataset?.parcelFeatures) diff --git a/source/frontend/src/components/maps/leaflet/Layers/FilePropertiesLayer.tsx b/source/frontend/src/components/maps/leaflet/Layers/FilePropertiesLayer.tsx index cd2887fb06..260bca5ea2 100644 --- a/source/frontend/src/components/maps/leaflet/Layers/FilePropertiesLayer.tsx +++ b/source/frontend/src/components/maps/leaflet/Layers/FilePropertiesLayer.tsx @@ -1,6 +1,6 @@ import { feature, featureCollection } from '@turf/turf'; -import { FeatureCollection } from 'geojson'; -import L, { LatLng } from 'leaflet'; +import { FeatureCollection, Geometry } from 'geojson'; +import L, { LatLng, LatLngLiteral } from 'leaflet'; import find from 'lodash/find'; import { useEffect, useMemo, useRef } from 'react'; import { FeatureGroup, GeoJSON, Marker, Pane } from 'react-leaflet'; @@ -8,13 +8,34 @@ import { useTheme } from 'styled-components'; import { v4 as uuidv4 } from 'uuid'; import { useMapStateMachine } from '@/components/common/mapFSM/MapStateMachineContext'; -import { LocationBoundaryDataset } from '@/components/common/mapFSM/models'; import { usePrevious } from '@/hooks/usePrevious'; import useDeepCompareEffect from '@/hooks/util/useDeepCompareEffect'; -import { exists } from '@/utils'; +import { ApiGen_Concepts_FileProperty } from '@/models/api/generated/ApiGen_Concepts_FileProperty'; +import { exists, getLatLng, locationFromFileProperty } from '@/utils'; import { useFilterContext } from '../../providers/FilterProvider'; import { getDisabledDraftIcon, getDraftIcon } from './util'; +interface FileLocationBoundaryDataset { + readonly location: LatLngLiteral; + readonly propertyBoundary: Geometry | null; + readonly fileBoundary: Geometry | null; + readonly isActive?: boolean; +} + +function filePropertyToFileLocationBoundaryDataset( + fileProperty: ApiGen_Concepts_FileProperty | undefined | null, +): FileLocationBoundaryDataset | null { + const geom = locationFromFileProperty(fileProperty); + const location = getLatLng(geom); + return exists(location) + ? { + location, + propertyBoundary: fileProperty?.property?.boundary ?? null, + fileBoundary: fileProperty?.boundary ?? null, + isActive: fileProperty.isActive, + } + : null; +} export const FilePropertiesLayer: React.FunctionComponent = () => { const draftFeatureGroupRef = useRef(null); @@ -23,19 +44,23 @@ export const FilePropertiesLayer: React.FunctionComponent = () => { const mapMachine = useMapStateMachine(); const mapMarkerClickFn = mapMachine.mapMarkerClick; - const filePropertyLocations = mapMachine.filePropertyLocations; - const draftPoints = useMemo(() => { + const filePropertyLocations = useMemo( + () => mapMachine.filePropertyLocations.map(x => filePropertyToFileLocationBoundaryDataset(x)), + [mapMachine.filePropertyLocations], + ); + + const draftPoints = useMemo(() => { return (filePropertyLocations ?? []).filter( dp => exists(dp?.location?.lat) && exists(dp?.location?.lng), ); }, [filePropertyLocations]); // These are the boundaries for the properties - const draftBoundaryFeatures = useMemo(() => { + const propertyBoundaryFeatures = useMemo(() => { // ignore properties without a valid boundary const validBoundaries = (filePropertyLocations ?? []) - .map(pl => pl.boundary) + .map(pl => pl?.propertyBoundary) .filter(exists) .map(boundary => feature(boundary)); @@ -45,7 +70,7 @@ export const FilePropertiesLayer: React.FunctionComponent = () => { // These are the user-uploaded shapes in the context of the file (can be different than the property boundaries that mirror PMBC) const fileBoundaryFeatures = useMemo(() => { const validBoundaries = (filePropertyLocations ?? []) - .map(pl => pl.fileBoundary) + .map(pl => pl?.fileBoundary) .filter(exists) .map(boundary => feature(boundary)); @@ -55,17 +80,17 @@ export const FilePropertiesLayer: React.FunctionComponent = () => { const boundaryLayerKeyRef = useRef(uuidv4()); const fileBoundaryLayerKeyRef = useRef(uuidv4()); - const previousBoundaries = usePrevious(draftBoundaryFeatures); + const previousPropertyBoundaries = usePrevious(propertyBoundaryFeatures); const previousFileBoundaries = usePrevious(fileBoundaryFeatures); // We need to regenerate an unique `key` on the `` element when the underlying data changes. // This is to force React to re-render the GeoJSON component with the updated property boundaries. // https://github.com/PaulLeCam/react-leaflet/issues/332 useEffect(() => { - if (previousBoundaries !== draftBoundaryFeatures) { + if (previousPropertyBoundaries !== propertyBoundaryFeatures) { boundaryLayerKeyRef.current = uuidv4(); } - }, [draftBoundaryFeatures, previousBoundaries]); + }, [propertyBoundaryFeatures, previousPropertyBoundaries]); useEffect(() => { if (previousFileBoundaries !== fileBoundaryFeatures) { @@ -138,11 +163,11 @@ export const FilePropertiesLayer: React.FunctionComponent = () => { - {draftBoundaryFeatures?.features?.length > 0 && ( + {propertyBoundaryFeatures?.features?.length > 0 && ( @@ -161,7 +186,7 @@ export const FilePropertiesLayer: React.FunctionComponent = () => { ), [ draftPoints, - draftBoundaryFeatures, + propertyBoundaryFeatures, fileBoundaryFeatures, theme.css.pimsRed80, mapMarkerClickFn, diff --git a/source/frontend/src/components/maps/leaflet/Layers/WorklistMarkersLayer.tsx b/source/frontend/src/components/maps/leaflet/Layers/WorklistMarkersLayer.tsx index 6b1494d6b9..f15b55f381 100644 --- a/source/frontend/src/components/maps/leaflet/Layers/WorklistMarkersLayer.tsx +++ b/source/frontend/src/components/maps/leaflet/Layers/WorklistMarkersLayer.tsx @@ -1,9 +1,8 @@ import React, { useMemo } from 'react'; import { Marker } from 'react-leaflet'; -import { ParcelDataset } from '@/features/properties/parcelList/models'; import { useWorklistContext } from '@/features/properties/worklist/context/WorklistContext'; -import { exists } from '@/utils'; +import { exists, firstOrNull } from '@/utils'; import { getNotOwnerMarkerIcon } from './util'; @@ -12,8 +11,9 @@ export const WorklistMarkersLayer: React.FunctionComponent = () => { // Now, lat/long properties in the worklist will display on the map as markers. // But must not have a Feature. - const validLocations = useMemo( - () => (parcels ?? []).filter(p => !exists(p?.pmbcFeature) && exists(p?.location)), + const validLocations = useMemo( + () => + (parcels ?? []).filter(p => !exists(firstOrNull(p?.parcelFeatures)) && exists(p?.location)), [parcels], ); diff --git a/source/frontend/src/components/maps/leaflet/Layers/WorklistParcelsLayer.tsx b/source/frontend/src/components/maps/leaflet/Layers/WorklistParcelsLayer.tsx index 82944f4ea0..4f1e2037f1 100644 --- a/source/frontend/src/components/maps/leaflet/Layers/WorklistParcelsLayer.tsx +++ b/source/frontend/src/components/maps/leaflet/Layers/WorklistParcelsLayer.tsx @@ -1,18 +1,17 @@ import React, { useMemo } from 'react'; import { GeoJSON } from 'react-leaflet'; -import { v4 as uuidv4 } from 'uuid'; -import { ParcelDataset } from '@/features/properties/parcelList/models'; import { useWorklistContext } from '@/features/properties/worklist/context/WorklistContext'; -import { exists } from '@/utils'; +import { exists, firstValidOrNull } from '@/utils'; export const WorklistParcelsLayer: React.FunctionComponent = () => { const { parcels } = useWorklistContext(); // For now, lat/long properties in the worklist will not display on the map // Ignore properties without a valid boundary - const validParcels = useMemo( - () => (parcels ?? []).filter(p => exists(p?.pmbcFeature?.geometry)), + const validParcels = useMemo( + () => + (parcels ?? []).filter(p => exists(firstValidOrNull(p?.parcelFeatures, exists)?.geometry)), [parcels], ); @@ -20,8 +19,8 @@ export const WorklistParcelsLayer: React.FunctionComponent = () => { {validParcels.map(vp => ( ))} diff --git a/source/frontend/src/components/propertySelector/MapClickMonitor.tsx b/source/frontend/src/components/propertySelector/MapClickMonitor.tsx index 9497053014..779c7b42eb 100644 --- a/source/frontend/src/components/propertySelector/MapClickMonitor.tsx +++ b/source/frontend/src/components/propertySelector/MapClickMonitor.tsx @@ -1,63 +1,28 @@ -import { LatLngLiteral } from 'leaflet'; -import { toast } from 'react-toastify'; - import { useMapStateMachine } from '@/components/common/mapFSM/MapStateMachineContext'; -import useDraftMarkerSynchronizer from '@/hooks/useDraftMarkerSynchronizer'; import { usePrevious } from '@/hooks/usePrevious'; import useDeepCompareEffect from '@/hooks/util/useDeepCompareEffect'; -import { exists, firstOrNull, isValidId } from '@/utils'; -import { featuresetToLocationBoundaryDataset } from '@/utils/mapPropertyUtils'; +import { exists, isValidId } from '@/utils'; -import { SelectedFeatureDataset } from '../common/mapFSM/useLocationFeatureLoader'; +import { LocationFeatureDataset } from '../common/mapFSM/useLocationFeatureLoader'; interface IMapClickMonitorProps { - addProperty?: (property: SelectedFeatureDataset) => void; - repositionProperty: ( - property: SelectedFeatureDataset, - latLng: LatLngLiteral, - propertyIndex: number | null, - ) => void; - modifiedProperties: SelectedFeatureDataset[]; // TODO: this should be just a list of lat longs - selectedComponentId: string | null; + onNewLocation: (locationDataset: LocationFeatureDataset, hasMultipleProperties: boolean) => void; } export const MapClickMonitor: React.FunctionComponent = ({ - addProperty, - repositionProperty, - modifiedProperties, - selectedComponentId, + onNewLocation, }) => { const mapMachine = useMapStateMachine(); const previous = usePrevious(mapMachine.mapLocationFeatureDataset); - const modifiedMapProperties = modifiedProperties.map(mp => - featuresetToLocationBoundaryDataset(mp), - ); - useDraftMarkerSynchronizer(selectedComponentId ? [] : modifiedMapProperties); // disable the draft marker synchronizer if the selecting component is set - the parent will need to control the draft markers. useDeepCompareEffect(() => { - if ( - mapMachine.isSelecting && - mapMachine.mapLocationFeatureDataset && - previous !== mapMachine.mapLocationFeatureDataset && - previous !== undefined && - (!selectedComponentId || - selectedComponentId === mapMachine.mapLocationFeatureDataset.selectingComponentId) - ) { - const selectedFeature: SelectedFeatureDataset = { - location: mapMachine.mapLocationFeatureDataset.location, - parcelFeature: firstOrNull(mapMachine.mapLocationFeatureDataset.parcelFeatures), - pimsFeature: firstOrNull(mapMachine.mapLocationFeatureDataset.pimsFeatures), - regionFeature: mapMachine.mapLocationFeatureDataset.regionFeature, - districtFeature: mapMachine.mapLocationFeatureDataset.districtFeature, - municipalityFeature: firstOrNull(mapMachine.mapLocationFeatureDataset.municipalityFeatures), - selectingComponentId: mapMachine.mapLocationFeatureDataset.selectingComponentId, - fileLocation: mapMachine.mapLocationFeatureDataset.fileLocation, - fileBoundary: null, - }; + const selectedFeature = mapMachine.mapLocationFeatureDataset; + + if (exists(selectedFeature) && previous !== selectedFeature) { const parcelFeaturesNotInPims = - mapMachine.mapLocationFeatureDataset.parcelFeatures?.filter(pf => { - const matchingProperty = mapMachine.mapLocationFeatureDataset.pimsFeatures?.find( + selectedFeature.parcelFeatures?.filter(pf => { + const matchingProperty = selectedFeature.pimsFeatures?.find( plp => (isValidId(pf.properties.PID_NUMBER) && plp.properties.PID === pf.properties.PID_NUMBER) || @@ -66,45 +31,11 @@ export const MapClickMonitor: React.FunctionComponent = ( return !exists(matchingProperty); }) ?? []; - // psp-10193, we cannot support adding a property via click when there are multiple parcel map of multiple pims references already at this location. - if ( - parcelFeaturesNotInPims.length + - (mapMachine.mapLocationFeatureDataset.pimsFeatures?.length ?? 0) > - 1 - ) { - toast.error( - 'There are multiple properties at the clicked location. Use the "Search" functionality instead of "Locate on Map" to add one of the properties at this location instead.', - { autoClose: false }, - ); - return; - } - - addProperty?.(selectedFeature); - } - - if ( - mapMachine.isRepositioning && - mapMachine.repositioningFeatureDataset && - mapMachine.mapLocationFeatureDataset && - previous !== mapMachine.mapLocationFeatureDataset && - previous !== undefined && - (!selectedComponentId || - selectedComponentId === mapMachine.mapLocationFeatureDataset.selectingComponentId) - ) { - repositionProperty( - mapMachine.repositioningFeatureDataset, - mapMachine.mapLocationFeatureDataset.location, - mapMachine.repositioningPropertyIndex, - ); + const hasMultipleProperties = + parcelFeaturesNotInPims.length + (selectedFeature.pimsFeatures?.length ?? 0) > 1; + onNewLocation(selectedFeature, hasMultipleProperties); } - }, [ - addProperty, - mapMachine.isSelecting, - mapMachine.isRepositioning, - mapMachine.mapLocationFeatureDataset, - mapMachine.repositioningFeatureDataset, - previous, - ]); + }, [mapMachine.mapLocationFeatureDataset, previous]); return <>; }; diff --git a/source/frontend/src/components/propertySelector/MapSelectorContainer.test.tsx b/source/frontend/src/components/propertySelector/MapSelectorContainer.test.tsx index ca682d7ec7..ebc1b8c8da 100644 --- a/source/frontend/src/components/propertySelector/MapSelectorContainer.test.tsx +++ b/source/frontend/src/components/propertySelector/MapSelectorContainer.test.tsx @@ -6,10 +6,7 @@ import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { useMapProperties } from '@/hooks/repositories/useMapProperties'; -import { - getMockLocationFeatureDataset, - getMockSelectedFeatureDataset, -} from '@/mocks/featureset.mock'; +import { getMockLocationFeatureDataset } from '@/mocks/featureset.mock'; import { mockFAParcelLayerResponse, mockGeocoderOptions } from '@/mocks/index.mock'; import { mapMachineBaseMock } from '@/mocks/mapFSM.mock'; import { act, fillInput, render, RenderOptions, screen, userEvent } from '@/utils/test-utils'; @@ -32,12 +29,7 @@ vi.mocked(useMapProperties).mockReturnValue({ error: null, response: { features: [] } as any, execute: vi.fn().mockResolvedValue({ - features: [ - { - ...getMockSelectedFeatureDataset().pimsFeature, - properties: {}, - }, - ], + features: getMockLocationFeatureDataset().pimsFeatures, }), loading: false, status: 200, @@ -51,9 +43,8 @@ describe('MapSelectorContainer component', () => { , @@ -109,23 +100,25 @@ describe('MapSelectorContainer component', () => { }); it('displays all selected property attributes', async () => { - const mockFeatureSet = getMockSelectedFeatureDataset(); + const mockFeatureSet = getMockLocationFeatureDataset(); const { getByText } = await setup({ modifiedProperties: [ { ...mockFeatureSet, - pimsFeature: { - ...mockFeatureSet.pimsFeature, - properties: { - ...mockFeatureSet.pimsFeature?.properties, - PID: 123456789, - SURVEY_PLAN_NUMBER: 'SPS22411', - LAND_LEGAL_DESCRIPTION: 'Test Legal Description', - STREET_ADDRESS_1: 'Test address 123', - REGION_CODE: 1, - DISTRICT_CODE: 5, + pimsFeatures: [ + { + ...mockFeatureSet.pimsFeatures[0], + properties: { + ...mockFeatureSet.pimsFeatures[0]?.properties, + PID: 123456789, + SURVEY_PLAN_NUMBER: 'SPS22411', + LAND_LEGAL_DESCRIPTION: 'Test Legal Description', + STREET_ADDRESS_1: 'Test address 123', + REGION_CODE: 1, + DISTRICT_CODE: 5, + }, }, - }, + ], regionFeature: { ...mockFeatureSet.regionFeature, properties: { @@ -155,23 +148,25 @@ describe('MapSelectorContainer component', () => { }); it('selected properties display a warning if added', async () => { - const mockFeatureSet = getMockSelectedFeatureDataset(); + const mockFeatureSet = getMockLocationFeatureDataset(); const { getByText, getByTitle, findByTestId, container } = await setup({ modifiedProperties: [ { ...mockFeatureSet, - pimsFeature: { - ...mockFeatureSet.pimsFeature, - properties: { - ...mockFeatureSet.pimsFeature?.properties, - PID: 123456789, - SURVEY_PLAN_NUMBER: 'SPS22411', - LAND_LEGAL_DESCRIPTION: 'Test Legal Description', - STREET_ADDRESS_1: 'Test address 123', - REGION_CODE: 1, - DISTRICT_CODE: 5, + pimsFeatures: [ + { + ...mockFeatureSet.pimsFeatures[0], + properties: { + ...mockFeatureSet.pimsFeatures[0]?.properties, + PID: 123456789, + SURVEY_PLAN_NUMBER: 'SPS22411', + LAND_LEGAL_DESCRIPTION: 'Test Legal Description', + STREET_ADDRESS_1: 'Test address 123', + REGION_CODE: 1, + DISTRICT_CODE: 5, + }, }, - }, + ], }, ], }); @@ -193,104 +188,4 @@ describe('MapSelectorContainer component', () => { expect(onSelectedProperties).toHaveBeenCalled(); }); - - it('selected properties display a warning if added multiple times', async () => { - const mockFeatureSet = getMockSelectedFeatureDataset(); - const { getByText, getByTitle, findByTestId, container } = await setup({ - modifiedProperties: [ - { - ...mockFeatureSet, - pimsFeature: { - ...mockFeatureSet.pimsFeature, - properties: { - ...mockFeatureSet.pimsFeature?.properties, - PID_PADDED: '009-727-493', - SURVEY_PLAN_NUMBER: 'SPS22411', - LAND_LEGAL_DESCRIPTION: 'Test Legal Description', - STREET_ADDRESS_1: 'Test address 123', - REGION_CODE: 1, - DISTRICT_CODE: 5, - }, - }, - }, - ], - }); - - const searchTab = getByText('Search'); - await act(async () => userEvent.click(searchTab)); - await act(async () => { - fillInput(container, 'searchBy', 'pid', 'select'); - }); - await fillInput(container, 'pid', '009-727-493'); - const searchButton = getByTitle('search'); - - await act(async () => userEvent.click(searchButton)); - - const checkbox = await findByTestId( - 'selectrow-PID-009-727-493-48.76613749999999--123.46163749999998', - ); - expect(checkbox).toBeVisible(); - - await act(async () => userEvent.click(checkbox)); - const addButton = getByText('Add to selection'); - await act(async () => userEvent.click(addButton)); - - const toast = await screen.findAllByText( - 'A property that the user is trying to select has already been added to the selected properties list', - ); - expect(toast[0]).toBeVisible(); - }); - - it(`calls "repositionSelectedProperty" callback when file marker has been repositioned`, async () => { - const mockFeatureSet = getMockSelectedFeatureDataset(); - - // simulate file marker repositioning via the map state machine - const testMapMock: IMapStateMachineContext = { - ...mapMachineBaseMock, - isRepositioning: true, - repositioningFeatureDataset: {} as any, - mapLocationFeatureDataset: {} as any, - }; - const mapProperties = [ - { - ...mockFeatureSet, - pimsFeature: { - ...mockFeatureSet.pimsFeature, - properties: { - ...mockFeatureSet.pimsFeature?.properties, - PID_PADDED: '009-727-493', - SURVEY_PLAN_NUMBER: 'SPS22411', - LAND_LEGAL_DESCRIPTION: 'Test Legal Description', - STREET_ADDRESS_1: 'Test address 123', - REGION_CODE: 1, - DISTRICT_CODE: 5, - }, - }, - }, - ]; - - const { rerender } = await setup({ - modifiedProperties: mapProperties, - mockMapMachine: testMapMock, - }); - - // simulate file marker repositioning via the map state machine - await act(async () => { - testMapMock.isRepositioning = true; - testMapMock.repositioningFeatureDataset = getMockSelectedFeatureDataset(); - testMapMock.mapLocationFeatureDataset = getMockLocationFeatureDataset(); - }); - - rerender( - - - , - ); - - expect(onRepositionSelectedProperty).toHaveBeenCalled(); - }); }); diff --git a/source/frontend/src/components/propertySelector/MapSelectorContainer.tsx b/source/frontend/src/components/propertySelector/MapSelectorContainer.tsx index 3c082ffd84..321b2f2789 100644 --- a/source/frontend/src/components/propertySelector/MapSelectorContainer.tsx +++ b/source/frontend/src/components/propertySelector/MapSelectorContainer.tsx @@ -1,52 +1,43 @@ -import { LatLngLiteral } from 'leaflet'; import { FunctionComponent, useCallback, useState } from 'react'; import { toast } from 'react-toastify'; import { Button } from '@/components/common/buttons'; import { useMapProperties } from '@/hooks/repositories/useMapProperties'; -import { isValidId } from '@/utils'; +import { firstOrNull, isValidId } from '@/utils'; import { - areSelectedFeaturesEqual, - getPropertyNameFromSelectedFeatureSet, + areLocationFeatureDatasetsEqual, + getPropertyNameFromLocationFeatureSet, pidFromFeatureSet, pinFromFeatureSet, } from '@/utils/mapPropertyUtils'; -import { SelectedFeatureDataset } from '../common/mapFSM/useLocationFeatureLoader'; +import { LocationFeatureDataset } from '../common/mapFSM/useLocationFeatureLoader'; import PropertyMapSelectorFormView from './map/PropertyMapSelectorFormView'; import { PropertySelectorTabsView, SelectorTabNames } from './PropertySelectorTabsView'; import PropertySelectorSearchContainer from './search/PropertySelectorSearchContainer'; export interface IMapSelectorContainerProps { - addSelectedProperties: (properties: SelectedFeatureDataset[]) => void; - repositionSelectedProperty: ( - property: SelectedFeatureDataset, - latLng: LatLngLiteral, - propertyIndex: number | null, - ) => void; - modifiedProperties: SelectedFeatureDataset[]; // TODO: Figure out if this component really needs the entire LocationFeatureDataset. It could be that only the lat long are needed. - selectedComponentId?: string; + addSelectedProperties: (properties: LocationFeatureDataset[]) => void; + modifiedProperties: LocationFeatureDataset[]; // TODO: Figure out if this component really needs the entire LocationFeatureDataset. It could be that only the lat long are needed. } export const MapSelectorContainer: FunctionComponent = ({ addSelectedProperties, - repositionSelectedProperty, modifiedProperties, - selectedComponentId, }) => { const [searchSelectedProperties, setSearchSelectedProperties] = useState< - SelectedFeatureDataset[] + LocationFeatureDataset[] >([]); const [activeSelectorTab, setActiveSelectorTab] = useState( SelectorTabNames.map, ); - const modifiedMapProperties = modifiedProperties.map(mp => mp); + const [lastSelectedProperty, setLastSelectedProperty] = useState< - SelectedFeatureDataset | undefined + LocationFeatureDataset | undefined >( modifiedProperties?.length === 1 && - (modifiedProperties[0]?.pimsFeature || modifiedProperties[0]?.parcelFeature) // why? Because create from map needs to show the info differently - ? modifiedMapProperties[0] + (modifiedProperties[0]?.pimsFeatures || modifiedProperties[0]?.parcelFeatures) // why? Because create from map needs to show the info differently + ? modifiedProperties[0] : undefined, ); const { @@ -54,9 +45,10 @@ export const MapSelectorContainer: FunctionComponent } = useMapProperties(); const addWithPimsFeature = useCallback( - async (properties: SelectedFeatureDataset[]) => { + async (properties: LocationFeatureDataset[]) => { const updatedPropertiesPromises = properties.map(async property => { - if (property.pimsFeature?.properties?.PROPERTY_ID) { + // TODO: Might need an update to work with multiple properties + if (firstOrNull(property.pimsFeatures)?.properties?.PROPERTY_ID) { return property; } const pid = pidFromFeatureSet(property); @@ -73,8 +65,7 @@ export const MapSelectorContainer: FunctionComponent } const pimsProperty = await loadProperties(queryObject); if (pimsProperty.features.length > 0) { - // TODO: Might need updates to work with multiple properties - property.pimsFeature = pimsProperty.features[0]; + property.pimsFeatures = [pimsProperty.features[0]]; } return property; }); @@ -90,26 +81,16 @@ export const MapSelectorContainer: FunctionComponent setActiveTab={setActiveSelectorTab} MapSelectorView={ { - setLastSelectedProperty(property); - await addProperties([property], modifiedMapProperties, addWithPimsFeature); - }} - onRepositionedProperty={( - property: SelectedFeatureDataset, - latLng: LatLngLiteral, - propertyIndex: number | null, - ) => { + onNewLocation={async (property: LocationFeatureDataset) => { setLastSelectedProperty(property); - repositionSelectedProperty(property, latLng, propertyIndex); + await addProperties([property], modifiedProperties, addWithPimsFeature); }} - selectedProperties={modifiedMapProperties} - selectedComponentId={selectedComponentId} lastSelectedProperty={ lastSelectedProperty - ? modifiedMapProperties.find( + ? modifiedProperties.find( p => - getPropertyNameFromSelectedFeatureSet(p).value === - getPropertyNameFromSelectedFeatureSet(lastSelectedProperty).value, + getPropertyNameFromLocationFeatureSet(p).value === + getPropertyNameFromLocationFeatureSet(lastSelectedProperty).value, ) : undefined // use the property from the modified properties list from the parent, for consistency. } @@ -126,11 +107,7 @@ export const MapSelectorContainer: FunctionComponent
- - )} - - { - arrayHelpersRef.current = arrayHelpers; - return ( -
- Selected Properties - - lf?.property)} - /> - - - } - > - - {formikProps.values.properties.map((leaseProperty, index) => { - const property = leaseProperty?.property; - if (exists(property)) { - return ( - onRemoveClick(index)} - nameSpace={`properties.${index}`} - index={index} - property={property.toFeatureDataset()} - showSeparator={index < formikProps.values.properties.length - 1} - /> - ); - } - return <>; - })} - {formikProps.values.properties.length === 0 && No Properties selected} -
- ); - }} - /> - - ); -}; - -export default LeasePropertySelector; - -const StyledButtonWrapper = styled.div` - margin: 0 1.6rem; - padding-left: 1.6rem; - text-align: left; - text-underline-offset: 2px; - - button { - font-size: 14px; - } -`; diff --git a/source/frontend/src/features/leases/shared/propertyPicker/LeaseUpdatePropertySelector.tsx b/source/frontend/src/features/leases/shared/propertyPicker/LeaseUpdatePropertySelector.tsx deleted file mode 100644 index 16fc55b0c6..0000000000 --- a/source/frontend/src/features/leases/shared/propertyPicker/LeaseUpdatePropertySelector.tsx +++ /dev/null @@ -1,449 +0,0 @@ -import { AxiosError } from 'axios'; -import { dequal } from 'dequal'; -import { FieldArray, FieldArrayRenderProps, Formik, FormikProps } from 'formik'; -import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; -import { Col, Row } from 'react-bootstrap'; -import { toast } from 'react-toastify'; -import styled from 'styled-components'; - -import { Button } from '@/components/common/buttons'; -import GenericModal, { ModalProps } from '@/components/common/GenericModal'; -import LoadingBackdrop from '@/components/common/LoadingBackdrop'; -import { useMapStateMachine } from '@/components/common/mapFSM/MapStateMachineContext'; -import { SelectedFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; -import { Section } from '@/components/common/Section/Section'; -import { ZoomIconType, ZoomToLocation } from '@/components/maps/ZoomToLocation'; -import { ModalContext } from '@/contexts/modalContext'; -import { SideBarContext } from '@/features/mapSideBar/context/sidebarContext'; -import MapSideBarLayout from '@/features/mapSideBar/layout/MapSideBarLayout'; -import SidebarFooter from '@/features/mapSideBar/shared/SidebarFooter'; -import usePathGenerator from '@/features/mapSideBar/shared/sidebarPathGenerator'; -import AddPropertiesGuide from '@/features/mapSideBar/shared/update/properties/AddPropertiesGuide'; -import { UpdatePropertiesYupSchema } from '@/features/mapSideBar/shared/update/properties/UpdatePropertiesYupSchema'; -import { usePropertyLeaseRepository } from '@/hooks/repositories/usePropertyLeaseRepository'; -import useApiUserOverride from '@/hooks/useApiUserOverride'; -import { useEnrichWithPimsFeatures } from '@/hooks/useEnrichWithPimsFeatures'; -import { useFeatureDatasetsWithAddresses } from '@/hooks/useFeatureDatasetsWithAddresses'; -import { getCancelModalProps } from '@/hooks/useModalContext'; -import { IApiError } from '@/interfaces/IApiError'; -import { ApiGen_Concepts_Lease } from '@/models/api/generated/ApiGen_Concepts_Lease'; -import { UserOverrideCode } from '@/models/api/UserOverrideCode'; -import { arePropertyFormsEqual, exists, firstOrNull, isValidId } from '@/utils'; - -import { useLeaseDetail } from '../../hooks/useLeaseDetail'; -import { FormLeaseProperty, LeaseFormModel } from '../../models'; -import SelectedPropertyHeaderRow from './selectedPropertyList/SelectedPropertyHeaderRow'; -import SelectedPropertyRow from './selectedPropertyList/SelectedPropertyRow'; -interface LeaseUpdatePropertySelectorProp { - lease: ApiGen_Concepts_Lease; -} - -export const LeaseUpdatePropertySelector: React.FunctionComponent< - LeaseUpdatePropertySelectorProp -> = ({ lease }) => { - const pathSolver = usePathGenerator(); - const [showSaveConfirmModal, setShowSaveConfirmModal] = useState(false); - const [isValid, setIsValid] = useState(true); - const hasWarnedRef = useRef(false); - - const { setModalContent, setDisplayModal } = useContext(ModalContext); - const { resetFilePropertyLocations } = useContext(SideBarContext); - - const formikRef = useRef>(); - const arrayHelpersRef = useRef(null); - - const { - datasets, - loading: pimsFeatureLoading, - enrichWithPimsFeatures, - } = useEnrichWithPimsFeatures(); - const { updateLeaseProperties } = usePropertyLeaseRepository(); - const { getCompleteLease } = useLeaseDetail(lease?.id ?? undefined); - - const { - refreshMapProperties, - setEditPropertiesMode, - selectedFeatures, - processCreation, - mapLocationFeatureDataset, - prepareForCreation, - } = useMapStateMachine(); - const prevSelectedRef = useRef(); - - const selectedFeatureDataset = useMemo(() => { - return { - selectingComponentId: mapLocationFeatureDataset?.selectingComponentId ?? null, - location: mapLocationFeatureDataset?.location, - fileLocation: mapLocationFeatureDataset?.fileLocation ?? null, - fileBoundary: null, - parcelFeature: firstOrNull(mapLocationFeatureDataset?.parcelFeatures), - pimsFeature: firstOrNull(mapLocationFeatureDataset?.pimsFeatures), - regionFeature: mapLocationFeatureDataset?.regionFeature ?? null, - districtFeature: mapLocationFeatureDataset?.districtFeature ?? null, - municipalityFeature: firstOrNull(mapLocationFeatureDataset?.municipalityFeatures), - isActive: true, - displayOrder: 0, - }; - }, [ - mapLocationFeatureDataset?.selectingComponentId, - mapLocationFeatureDataset?.location, - mapLocationFeatureDataset?.fileLocation, - mapLocationFeatureDataset?.parcelFeatures, - mapLocationFeatureDataset?.pimsFeatures, - mapLocationFeatureDataset?.regionFeature, - mapLocationFeatureDataset?.districtFeature, - mapLocationFeatureDataset?.municipalityFeatures, - ]); - - const withUserOverride = useApiUserOverride< - (userOverrideCodes: UserOverrideCode[]) => Promise - >('Failed to update Lease File Properties'); - - const addPropertiesToCurrentFile = useCallback( - ( - formikRef: React.RefObject>, - leasePropertyForms: FormLeaseProperty[], - ) => { - const existingProperties = formikRef.current?.values?.properties ?? []; - const uniqueProperties = leasePropertyForms.filter(newProperty => { - return !existingProperties.some(existingProperty => - arePropertyFormsEqual(existingProperty.property, newProperty.property), - ); - }); - - const duplicatesSkipped = leasePropertyForms.length - uniqueProperties.length; - - // If there are unique properties, add them to the formik values - if (uniqueProperties.length > 0) { - formikRef.current?.setFieldValue('properties', [ - ...existingProperties, - ...uniqueProperties, - ]); - formikRef.current?.setFieldTouched('properties', true); - toast.success(`Added ${uniqueProperties.length} new property(s) to the file.`); - } - - if (duplicatesSkipped > 0) { - toast.warn(`Skipped ${duplicatesSkipped} duplicate property(s).`); - } - }, - [], - ); - - // Enrich selected features with PIMS features - // This will add pimsFeature to each SelectedFeatureDataset if it exists - useEffect(() => { - if (selectedFeatures?.length > 0 && !dequal(prevSelectedRef.current, selectedFeatures)) { - hasWarnedRef.current = false; // reset the warning for new selection - prevSelectedRef.current = selectedFeatures; - enrichWithPimsFeatures(selectedFeatures); - } - }, [selectedFeatures, enrichWithPimsFeatures]); - - // Get FormLeaseProperties with addresses for all selected features - const { featuresWithAddresses, bcaLoading } = useFeatureDatasetsWithAddresses(datasets); - - // Convert SelectedFeatureDataset to FormLeaseProperty - const propertyForms = useMemo( - () => - featuresWithAddresses.map(obj => { - const formProperty = FormLeaseProperty.fromFeatureDataset(obj.feature); - if (exists(obj.address)) { - formProperty.property.address = obj.address; - } - return formProperty; - }), - [featuresWithAddresses], - ); - - // This effect is used to update the file properties when "add to open file" is clicked in the worklist. - useEffect(() => { - if (exists(formikRef.current) && propertyForms.length > 0 && !hasWarnedRef.current) { - const needsWarning = propertyForms.some( - formProperty => exists(formProperty.property) && !isValidId(formProperty.property.apiId), - ); - - if (needsWarning) { - hasWarnedRef.current = true; // mark as shown - setModalContent({ - variant: 'info', - title: 'Not inventory property', - message: - 'You have selected a property not previously in the inventory. Do you want to add this property to the lease?', - okButtonText: 'Add', - cancelButtonText: 'Cancel', - handleOk: () => { - setDisplayModal(false); - addPropertiesToCurrentFile(formikRef, propertyForms); - }, - handleCancel: () => { - setDisplayModal(false); - }, - }); - setDisplayModal(true); - } else { - // If no warning is needed, simply add the properties to the current file. - addPropertiesToCurrentFile(formikRef, propertyForms); - } - processCreation(); - } - }, [ - formikRef, - propertyForms, - setModalContent, - setDisplayModal, - addPropertiesToCurrentFile, - processCreation, - ]); - - const cancelRemove = useCallback(() => { - setDisplayModal(false); - }, [setDisplayModal]); - - const confirmRemove = useCallback( - (indexToRemove: number) => { - if (indexToRemove !== undefined) { - arrayHelpersRef.current?.remove(indexToRemove); - } - setDisplayModal(false); - }, - [setDisplayModal], - ); - - const getRemoveModalProps = useCallback<(index: number) => ModalProps>( - (index: number) => { - return { - variant: 'info', - title: 'Removing Property from Lease/Licence', - message: 'Are you sure you want to remove this property from this lease/licence?', - display: false, - okButtonText: 'Remove', - cancelButtonText: 'Cancel', - handleOk: () => confirmRemove(index), - handleCancel: cancelRemove, - }; - }, - [confirmRemove, cancelRemove], - ); - - const onRemoveClick = useCallback( - (index: number) => { - setModalContent(getRemoveModalProps(index)); - setDisplayModal(true); - }, - [getRemoveModalProps, setDisplayModal, setModalContent], - ); - - const handleCancelConfirm = () => { - if (formikRef !== undefined) { - formikRef.current?.resetForm(); - } - processCreation(); - resetFilePropertyLocations(); - pathSolver.showFile('lease', lease.id); - }; - - const handleSaveClick = async () => { - await formikRef?.current?.validateForm(); - if (!formikRef?.current?.isValid) { - setIsValid(false); - } else { - setIsValid(true); - } - setShowSaveConfirmModal(true); - }; - - const handleCancelClick = () => { - if (formikRef !== undefined) { - if (formikRef.current?.dirty) { - setModalContent({ - ...getCancelModalProps(), - handleOk: () => { - handleCancelConfirm(); - setDisplayModal(false); - }, - handleCancel: () => setDisplayModal(false), - }); - setDisplayModal(true); - } else { - handleCancelConfirm(); - } - } else { - handleCancelConfirm(); - } - }; - - const handleSaveConfirm = async () => { - if (formikRef !== undefined) { - formikRef.current?.setSubmitting(true); - formikRef.current?.submitForm(); - } - }; - - const saveFile = async (file: ApiGen_Concepts_Lease) => { - return withUserOverride( - (userOverrideCodes: UserOverrideCode[]) => { - return updateLeaseProperties.execute(file, userOverrideCodes).then(async response => { - formikRef.current?.setSubmitting(false); - if (isValidId(response?.id)) { - if (file.fileProperties?.find(fp => !fp.property?.address && !fp.property?.id)) { - toast.warn( - 'Address could not be retrieved for this property, it will have to be provided manually in property details tab', - { autoClose: 15000 }, - ); - } - formikRef.current?.resetForm(); - await getCompleteLease(); - refreshMapProperties(); - pathSolver.showFile('lease', lease.id); - } - }); - }, - [], - (axiosError: AxiosError) => { - setModalContent({ - variant: 'error', - title: 'Error', - message: axiosError?.response?.data.error, - okButtonText: 'Close', - handleOk: async () => { - formikRef.current?.resetForm(); - await getCompleteLease(); - setDisplayModal(false); - }, - }); - setDisplayModal(true); - }, - ); - }; - - const initialValues = LeaseFormModel.fromApi(lease); - - const handleAddToSelection = useCallback(() => { - prepareForCreation([selectedFeatureDataset]); - }, [prepareForCreation, selectedFeatureDataset]); - - useEffect(() => { - // Set the map state machine to edit properties mode so that the map selector knows what mode it is in. - setEditPropertiesMode(true); - return () => { - setEditPropertiesMode(false); - }; - }, [setEditPropertiesMode]); - - return ( - <> - - - } - > - - innerRef={formikRef} - initialValues={initialValues} - validationSchema={UpdatePropertiesYupSchema} - onSubmit={async (values: LeaseFormModel) => { - try { - const file: ApiGen_Concepts_Lease = LeaseFormModel.toApi(values); - await saveFile(file); - } finally { - processCreation(); - } - }} - > - {formikProps => ( - { - arrayHelpersRef.current = arrayHelpers; - return ( - <> - - {exists(selectedFeatureDataset?.parcelFeature) && ( - - - - )} -
- Selected Properties - - lp.property)} - /> - - - } - > - - {formikProps.values.properties.map((leaseProperty, index) => { - const property = leaseProperty?.property; - if (property !== undefined) { - return ( - onRemoveClick(index)} - nameSpace={`properties.${index}`} - index={index} - property={property.toFeatureDataset()} - showSeparator={index < formikProps.values.properties.length - 1} - /> - ); - } - return <>; - })} - {formikProps.values.properties.length === 0 && ( - No Properties selected - )} -
- - ); - }} - /> - )} - -
- -
You have made changes to the properties in this file.
-
- Do you want to save these changes? - - } - handleOk={handleSaveConfirm} - handleCancel={() => setShowSaveConfirmModal(false)} - okButtonText="Save" - cancelButtonText="Cancel" - show - /> - - ); -}; - -export default LeaseUpdatePropertySelector; - -const StyledButtonWrapper = styled.div` - margin: 0 1.6rem; - padding-left: 1.6rem; - text-align: left; - text-underline-offset: 2px; - - button { - font-size: 14px; - } -`; diff --git a/source/frontend/src/features/leases/shared/propertyPicker/selectedPropertyList/SelectedPropertyRow.test.tsx b/source/frontend/src/features/leases/shared/propertyPicker/selectedPropertyList/SelectedPropertyRow.test.tsx deleted file mode 100644 index a9210b8343..0000000000 --- a/source/frontend/src/features/leases/shared/propertyPicker/selectedPropertyList/SelectedPropertyRow.test.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { Formik } from 'formik'; -import { createMemoryHistory } from 'history'; -import noop from 'lodash/noop'; - -import { PropertyForm } from '@/features/mapSideBar/shared/models'; -import { getMockSelectedFeatureDataset } from '@/mocks/featureset.mock'; -import { mockLookups } from '@/mocks/lookups.mock'; -import { mapMachineBaseMock } from '@/mocks/mapFSM.mock'; -import { lookupCodesSlice } from '@/store/slices/lookupCodes'; -import { exists } from '@/utils'; -import { act, render, RenderOptions, screen, userEvent } from '@/utils/test-utils'; - -import SelectedPropertyRow, { ISelectedPropertyRowProps } from './SelectedPropertyRow'; - -const history = createMemoryHistory(); -const storeState = { - [lookupCodesSlice.name]: { lookupCodes: mockLookups }, -}; - -const onRemove = vi.fn(); - -describe('SelectedPropertyRow component', () => { - const setup = async ( - renderOptions: RenderOptions & { props?: Partial } = {}, - ) => { - // render component under test - const utils = render( - - {formikProps => ( - - )} - , - { - ...renderOptions, - store: storeState, - history, - mockMapMachine: renderOptions.mockMapMachine ?? mapMachineBaseMock, - }, - ); - - await act(async () => {}); - - return { ...utils }; - }; - - it('renders as expected', async () => { - const { asFragment } = await setup(); - expect(asFragment()).toMatchSnapshot(); - }); - - it('fires onRemove when remove button clicked', async () => { - await setup(); - const removeButton = screen.getByTitle('remove'); - await act(async () => userEvent.click(removeButton)); - expect(onRemove).toHaveBeenCalled(); - }); - - it('calls map machine when reposition button is clicked', async () => { - await setup({}); - await act(async () => {}); - const moveButton = screen.getByTitle('move-pin-location'); - userEvent.click(moveButton); - expect(mapMachineBaseMock.startReposition).toHaveBeenCalled(); - }); - - it('displays pid', async () => { - const mockFeatureSet = getMockSelectedFeatureDataset(); - mockFeatureSet.parcelFeature = {} as any; - mockFeatureSet.pimsFeature = { - ...mockFeatureSet.pimsFeature, - properties: { - ...mockFeatureSet.pimsFeature?.properties, - PID_PADDED: '111-111-111', - }, - }; - await setup({ props: { property: mockFeatureSet } }); - expect(screen.getByText('PID: 111-111-111')).toBeVisible(); - }); - - it('falls back to pin', async () => { - const mockFeatureSet = getMockSelectedFeatureDataset(); - mockFeatureSet.parcelFeature = {} as any; - mockFeatureSet.pimsFeature = { - ...mockFeatureSet.pimsFeature, - properties: { - ...mockFeatureSet.pimsFeature?.properties, - PID_PADDED: undefined, - PIN: 1234, - }, - }; - await setup({ props: { property: mockFeatureSet } }); - expect(screen.getByText('PIN: 1234')).toBeVisible(); - }); - - it('falls back to plan number', async () => { - const mockFeatureSet = getMockSelectedFeatureDataset(); - mockFeatureSet.parcelFeature = {} as any; - mockFeatureSet.pimsFeature = { - ...mockFeatureSet.pimsFeature, - properties: { - ...mockFeatureSet.pimsFeature?.properties, - SURVEY_PLAN_NUMBER: 'VIP123', - PID_PADDED: undefined, - PIN: undefined, - }, - }; - await setup({ props: { property: mockFeatureSet } }); - expect(screen.getByText('Plan #: VIP123')).toBeVisible(); - }); - - it('falls back to lat/lng', async () => { - const mockFeatureSet = getMockSelectedFeatureDataset(); - mockFeatureSet.pimsFeature = {} as any; - mockFeatureSet.parcelFeature = {} as any; - mockFeatureSet.location = { lat: 4, lng: 5 }; - - await setup({ props: { property: mockFeatureSet } }); - expect(screen.getByText('5.000000, 4.000000')).toBeVisible(); - }); - - it('falls back to address', async () => { - const mockFeatureSet = getMockSelectedFeatureDataset(); - mockFeatureSet.location = undefined; - mockFeatureSet.parcelFeature = {} as any; - mockFeatureSet.pimsFeature = { - ...mockFeatureSet.pimsFeature, - properties: { - ...mockFeatureSet.pimsFeature?.properties, - PID_PADDED: undefined, - PIN: undefined, - SURVEY_PLAN_NUMBER: undefined, - STREET_ADDRESS_1: 'a test address', - }, - }; - await setup({ props: { property: mockFeatureSet } }); - expect(screen.getByText('Address: a test address')).toBeVisible(); - }); -}); diff --git a/source/frontend/src/features/leases/shared/propertyPicker/selectedPropertyList/SelectedPropertyRow.tsx b/source/frontend/src/features/leases/shared/propertyPicker/selectedPropertyList/SelectedPropertyRow.tsx deleted file mode 100644 index 9b2423b4e0..0000000000 --- a/source/frontend/src/features/leases/shared/propertyPicker/selectedPropertyList/SelectedPropertyRow.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { FormikProps, getIn } from 'formik'; -import { Col, Row } from 'react-bootstrap'; -import { RiDragMove2Line } from 'react-icons/ri'; - -import { RemoveButton, StyledIconButton } from '@/components/common/buttons'; -import { InlineInput } from '@/components/common/form/styles'; -import { useMapStateMachine } from '@/components/common/mapFSM/MapStateMachineContext'; -import { SelectedFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; -import OverflowTip from '@/components/common/OverflowTip'; -import { ZoomIconType, ZoomToLocation } from '@/components/maps/ZoomToLocation'; -import AreaContainer from '@/components/measurements/AreaContainer'; -import DraftCircleNumber from '@/components/propertySelector/selectedPropertyList/DraftCircleNumber'; -import { FormLeaseProperty, LeaseFormModel } from '@/features/leases/models'; -import { withNameSpace } from '@/utils/formUtils'; -import { getPropertyNameFromSelectedFeatureSet, NameSourceType } from '@/utils/mapPropertyUtils'; - -export interface ISelectedPropertyRowProps { - index: number; - nameSpace?: string; - onRemove: () => void; - property: SelectedFeatureDataset; - formikProps: FormikProps; - showSeparator?: boolean; -} - -export const SelectedPropertyRow: React.FunctionComponent = ({ - nameSpace, - onRemove, - index, - property, - formikProps, - showSeparator = false, -}) => { - const mapMachine = useMapStateMachine(); - const propertyName = getPropertyNameFromSelectedFeatureSet(property); - let propertyIdentifier = ''; - switch (propertyName.label) { - case NameSourceType.PID: - case NameSourceType.PIN: - case NameSourceType.PLAN: - case NameSourceType.ADDRESS: - propertyIdentifier = `${propertyName.label}: ${propertyName.value}`; - break; - case NameSourceType.LOCATION: - propertyIdentifier = `${propertyName.value}`; - break; - default: - propertyIdentifier = ''; - break; - } - - const currentLeaseProperty: FormLeaseProperty = getIn( - formikProps.values, - withNameSpace(nameSpace), - ); - - return ( - <> - - - - - - - - - - { - mapMachine.startReposition(property, index); - }} - > - - - - - - - - - - - - - { - formikProps.setFieldValue(withNameSpace(nameSpace, 'landArea'), landArea); - formikProps.setFieldValue( - withNameSpace(nameSpace, 'areaUnitTypeCode'), - areaUnitTypeCode, - ); - }} - /> - - - {showSeparator &&
} - - ); -}; - -export default SelectedPropertyRow; diff --git a/source/frontend/src/features/mapSideBar/acquisition/AcquisitionContainer.test.tsx b/source/frontend/src/features/mapSideBar/acquisition/AcquisitionContainer.test.tsx index ec2c4fb678..c98f4e139d 100644 --- a/source/frontend/src/features/mapSideBar/acquisition/AcquisitionContainer.test.tsx +++ b/source/frontend/src/features/mapSideBar/acquisition/AcquisitionContainer.test.tsx @@ -23,6 +23,7 @@ import { import { SideBarContextProvider } from '../context/sidebarContext'; import { AcquisitionContainer, IAcquisitionContainerProps } from './AcquisitionContainer'; import { IAcquisitionViewProps } from './AcquisitionView'; +import { FileForm } from '../shared/models'; const history = createMemoryHistory(); const mockAxios = new MockAdapter(axios); @@ -253,7 +254,7 @@ describe('AcquisitionContainer component', () => { }); await act(async () => { - await viewProps.onUpdateProperties(mockAcquisitionFileResponse()); + await viewProps.onUpdateProperties(FileForm.fromApi(mockAcquisitionFileResponse())); }); expect( diff --git a/source/frontend/src/features/mapSideBar/acquisition/AcquisitionContainer.tsx b/source/frontend/src/features/mapSideBar/acquisition/AcquisitionContainer.tsx index b9b431f85f..f4c8fb2933 100644 --- a/source/frontend/src/features/mapSideBar/acquisition/AcquisitionContainer.tsx +++ b/source/frontend/src/features/mapSideBar/acquisition/AcquisitionContainer.tsx @@ -29,7 +29,7 @@ import { exists, isValidId, sortFileProperties, stripTrailingSlash } from '@/uti import { SideBarContext } from '../context/sidebarContext'; import { FileTabType } from '../shared/detail/FileTabs'; -import { PropertyForm } from '../shared/models'; +import { FileForm, PropertyForm } from '../shared/models'; import usePathGenerator from '../shared/sidebarPathGenerator'; import { IAcquisitionViewProps } from './AcquisitionView'; @@ -315,9 +315,8 @@ export const AcquisitionContainer: React.FunctionComponent => { + const onUpdateProperties = (fileForm: FileForm): Promise => { + const file = fileForm.toApi(); // The backend does not update the product or project so its safe to send nulls even if there might be data for those fields. return withUserOverride( (userOverrideCodes: UserOverrideCode[]) => { diff --git a/source/frontend/src/features/mapSideBar/acquisition/AcquisitionView.tsx b/source/frontend/src/features/mapSideBar/acquisition/AcquisitionView.tsx index f33cf296ca..cf30d9c483 100644 --- a/source/frontend/src/features/mapSideBar/acquisition/AcquisitionView.tsx +++ b/source/frontend/src/features/mapSideBar/acquisition/AcquisitionView.tsx @@ -29,10 +29,10 @@ import { InventoryTabNames } from '../property/InventoryTabs'; import FilePropertyRouter from '../router/FilePropertyRouter'; import { FileTabType } from '../shared/detail/FileTabs'; import FileMenuView from '../shared/FileMenuView'; -import { PropertyForm } from '../shared/models'; +import { FileForm, PropertyForm } from '../shared/models'; import SidebarFooter from '../shared/SidebarFooter'; import { StyledFormWrapper } from '../shared/styles'; -import UpdateProperties from '../shared/update/properties/UpdateProperties'; +import UpdatePropertiesContainer from '../shared/update/properties/UpdatePropertiesContainer'; import { AcquisitionContainerState } from './AcquisitionContainer'; import AcquisitionHeader from './common/AcquisitionHeader'; import AcquisitionGenerateContainer from './common/GenerateForm/AcquisitionGenerateContainer'; @@ -48,7 +48,7 @@ export interface IAcquisitionViewProps { onSelectProperty: (propertyId: number) => void; onEditProperties: () => void; onSuccess: () => void; - onUpdateProperties: (file: ApiGen_Concepts_File) => Promise; + onUpdateProperties: (file: FileForm) => Promise; confirmBeforeAdd: (propertyForm: PropertyForm) => Promise; canRemove: (propertyId: number) => Promise; isEditing: boolean; @@ -87,6 +87,7 @@ export const AcquisitionView: React.FunctionComponent = ( const acquisitionFile: ApiGen_Concepts_AcquisitionFile = { ...file, } as unknown as ApiGen_Concepts_AcquisitionFile; + const formFile = FileForm.fromApi(acquisitionFile); // match for property menu routes - eg /property/1/ltsa const fileMatch = matchPath>(location.pathname, `${match.path}/:tab`); @@ -117,14 +118,14 @@ export const AcquisitionView: React.FunctionComponent = ( return ( - {file && ( -

This property has already been added to one or more acquisition files.

Do you want to acknowledge and proceed?

@@ -132,6 +133,7 @@ export const AcquisitionView: React.FunctionComponent = ( } canRemove={canRemove} canUploadShapefiles={true} + canReposition={true} formikRef={formikRef} /> )} diff --git a/source/frontend/src/features/mapSideBar/acquisition/add/AcquisitionPropertiesSubForm.test.tsx b/source/frontend/src/features/mapSideBar/acquisition/add/AcquisitionPropertiesSubForm.test.tsx deleted file mode 100644 index 1c67378533..0000000000 --- a/source/frontend/src/features/mapSideBar/acquisition/add/AcquisitionPropertiesSubForm.test.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { Formik } from 'formik'; -import noop from 'lodash/noop'; -import configureMockStore from 'redux-mock-store'; -import thunk from 'redux-thunk'; - -import { getMockSelectedFeatureDataset } from '@/mocks/featureset.mock'; -import { mapMachineBaseMock } from '@/mocks/mapFSM.mock'; -import { act, render, RenderOptions, userEvent } from '@/utils/test-utils'; - -import { PropertyForm } from '../../shared/models'; -import { AcquisitionPropertiesSubForm } from './AcquisitionPropertiesSubForm'; -import { AcquisitionForm } from './models'; - -const mockStore = configureMockStore([thunk]); - -const customSetFilePropertyLocations = vi.fn(); - -describe('AcquisitionProperties component', () => { - // render component under test - const setup = async ( - props: { - initialForm: AcquisitionForm; - confirmBeforeAdd?: (propertyForm: PropertyForm) => Promise; - }, - renderOptions: RenderOptions = {}, - ) => { - const utils = render( - <> - - {formikProps => ( - - )} - - , - { - ...renderOptions, - store: mockStore({}), - claims: [], - mockMapMachine: { - ...mapMachineBaseMock, - setFilePropertyLocations: customSetFilePropertyLocations, - }, - }, - ); - - // Wait for any async effects to complete - await act(async () => {}); - - return { ...utils }; - }; - - let testForm: AcquisitionForm; - - beforeEach(() => { - const mockFeatureSet = getMockSelectedFeatureDataset(); - testForm = new AcquisitionForm(); - testForm.fileName = 'Test name'; - testForm.properties = [ - PropertyForm.fromFeatureDataset({ - ...mockFeatureSet, - pimsFeature: { - ...mockFeatureSet.pimsFeature, - properties: { - ...mockFeatureSet.pimsFeature?.properties, - PID_PADDED: '123-456-789', - }, - }, - }), - PropertyForm.fromFeatureDataset({ - ...mockFeatureSet, - pimsFeature: { - ...mockFeatureSet.pimsFeature, - properties: { - ...mockFeatureSet.pimsFeature?.properties, - PIN: 1111222, - }, - }, - }), - ]; - }); - - afterEach(() => { - vi.clearAllMocks(); - customSetFilePropertyLocations.mockReset(); - }); - - it('renders as expected', async () => { - const { asFragment } = await setup({ initialForm: testForm }); - await act(async () => {}); - expect(asFragment()).toMatchSnapshot(); - }); - - it('renders list of properties', async () => { - const { getByText } = await setup({ initialForm: testForm }); - - expect(getByText('PID: 123-456-789')).toBeVisible(); - expect(getByText('PIN: 1111222')).toBeVisible(); - }); - - it('should remove property from list when Remove button is clicked', async () => { - const { getAllByTitle, queryByText } = await setup({ initialForm: testForm }); - const pidRow = getAllByTitle('remove')[0]; - await act(async () => userEvent.click(pidRow)); - - expect(queryByText('PID: 123-456-789')).toBeNull(); - }); - - it('should display properties with svg prefixed with incrementing id', async () => { - const { getByTitle } = await setup({ initialForm: testForm }); - expect(getByTitle('1')).toBeInTheDocument(); - expect(getByTitle('2')).toBeInTheDocument(); - }); -}); diff --git a/source/frontend/src/features/mapSideBar/acquisition/add/AcquisitionPropertiesSubForm.tsx b/source/frontend/src/features/mapSideBar/acquisition/add/AcquisitionPropertiesSubForm.tsx deleted file mode 100644 index b990a551f3..0000000000 --- a/source/frontend/src/features/mapSideBar/acquisition/add/AcquisitionPropertiesSubForm.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import { FieldArray, FormikProps } from 'formik'; -import noop from 'lodash/noop'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; -import styled from 'styled-components'; - -import { Button } from '@/components/common/buttons'; -import { useMapStateMachine } from '@/components/common/mapFSM/MapStateMachineContext'; -import { SelectedFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; -import { Section } from '@/components/common/Section/Section'; -import SelectedPropertyHeaderRow from '@/components/propertySelector/selectedPropertyList/SelectedPropertyHeaderRow'; -import SelectedPropertyRow from '@/components/propertySelector/selectedPropertyList/SelectedPropertyRow'; -import { UploadResponseModel } from '@/features/properties/shapeUpload/models'; -import useDraftMarkerSynchronizer from '@/hooks/useDraftMarkerSynchronizer'; -import { useFeatureDatasetsWithAddresses } from '@/hooks/useFeatureDatasetsWithAddresses'; -import { exists, featuresetToLocationBoundaryDataset, firstOrNull } from '@/utils'; -import { addPropertiesToCurrentFile } from '@/utils/propertyUtils'; - -import { PropertyForm } from '../../shared/models'; -import AddPropertiesGuide from '../../shared/update/properties/AddPropertiesGuide'; -import { AcquisitionForm } from './models'; - -export interface IAcquisitionPropertiesProps { - formikProps: FormikProps; - confirmBeforeAdd: (propertyForm: PropertyForm) => Promise; -} - -export const AcquisitionPropertiesSubForm: React.FunctionComponent = ({ - formikProps, -}) => { - const localRef = useRef>(); - - const { selectedFeatures, processCreation, mapLocationFeatureDataset, prepareForCreation } = - useMapStateMachine(); - - useDraftMarkerSynchronizer( - formikProps.values.properties.map(p => - featuresetToLocationBoundaryDataset(p.toFeatureDataset()), - ), - ); - - const selectedFeatureDataset = useMemo(() => { - return { - selectingComponentId: mapLocationFeatureDataset?.selectingComponentId ?? null, - location: mapLocationFeatureDataset?.location, - fileLocation: mapLocationFeatureDataset?.fileLocation ?? null, - fileBoundary: null, - parcelFeature: firstOrNull(mapLocationFeatureDataset?.parcelFeatures), - pimsFeature: firstOrNull(mapLocationFeatureDataset?.pimsFeatures), - regionFeature: mapLocationFeatureDataset?.regionFeature ?? null, - districtFeature: mapLocationFeatureDataset?.districtFeature ?? null, - municipalityFeature: firstOrNull(mapLocationFeatureDataset?.municipalityFeatures), - isActive: true, - displayOrder: 0, - }; - }, [ - mapLocationFeatureDataset?.selectingComponentId, - mapLocationFeatureDataset?.location, - mapLocationFeatureDataset?.fileLocation, - mapLocationFeatureDataset?.parcelFeatures, - mapLocationFeatureDataset?.pimsFeatures, - mapLocationFeatureDataset?.regionFeature, - mapLocationFeatureDataset?.districtFeature, - mapLocationFeatureDataset?.municipalityFeatures, - ]); - - // Get PropertyForms with addresses for all selected features - const { featuresWithAddresses } = useFeatureDatasetsWithAddresses(selectedFeatures ?? []); - - // Convert SelectedFeatureDataset to PropertyForm - const propertyForms = useMemo( - () => - featuresWithAddresses.map(obj => { - const property = PropertyForm.fromFeatureDataset(obj.feature); - if (exists(obj.address)) { - property.address = obj.address; - } - return property; - }), - [featuresWithAddresses], - ); - - const handleAddToSelection = useCallback(() => { - prepareForCreation([selectedFeatureDataset]); - }, [prepareForCreation, selectedFeatureDataset]); - - useEffect(() => { - if (exists(localRef.current) && propertyForms.length > 0) { - addPropertiesToCurrentFile(localRef, 'properties', propertyForms, noop); - processCreation(); - } - }, [localRef, processCreation, propertyForms]); - - return ( - -
- Select one or more properties that you want to include in this acquisition. You can choose a - location from the map, or search by other criteria. -
- - - {({ remove, replace }) => ( -
- - {exists(selectedFeatureDataset?.parcelFeature) && ( - - - - )} - - - {formikProps.values.properties.map((property, index) => ( - remove(index)} - nameSpace={`properties.${index}`} - index={index} - property={property.toFeatureDataset()} - canUploadShapefile={true} - onUploadShapefile={(result: UploadResponseModel | null) => { - // Update the property boundary based on the uploaded shapefile - if (exists(result)) { - if (result.isSuccess && exists(result.boundary)) { - const updatedFormProperty = new PropertyForm(property); - updatedFormProperty.fileBoundary = result.boundary; - replace(index, updatedFormProperty); - } - } - }} - /> - ))} - {formikProps.values.properties.length === 0 && No Properties selected} -
- )} -
-
- ); -}; - -export default AcquisitionPropertiesSubForm; - -const StyledComponentWrapper = styled.div` - margin: 0 1.6rem 0 1.6rem; - padding: 0 1.6rem 0 1.6rem; - text-align: left; - text-underline-offset: 2px; - - button { - font-size: 14px; - } -`; - -const StyledButtonWrapper = styled.div` - margin: 0 1.6rem; - padding-left: 1.6rem; - text-align: left; - text-underline-offset: 2px; - - button { - font-size: 14px; - } -`; diff --git a/source/frontend/src/features/mapSideBar/acquisition/add/AddAcquisitionContainer.test.tsx b/source/frontend/src/features/mapSideBar/acquisition/add/AddAcquisitionContainer.test.tsx index 28760e19e2..f05af96a10 100644 --- a/source/frontend/src/features/mapSideBar/acquisition/add/AddAcquisitionContainer.test.tsx +++ b/source/frontend/src/features/mapSideBar/acquisition/add/AddAcquisitionContainer.test.tsx @@ -27,6 +27,7 @@ import { AcquisitionOwnerFormModel, OwnerAddressFormModel } from '../common/mode import { AddAcquisitionContainer, IAddAcquisitionContainerProps } from './AddAcquisitionContainer'; import AddAcquisitionForm from './AddAcquisitionForm'; import { AcquisitionForm } from './models'; +import { emptyFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; const history = createMemoryHistory(); @@ -255,13 +256,10 @@ describe('AddAcquisitionContainer component', () => { it('should pre-populate the region if a property is selected', async () => { const testMockMachine: IMapStateMachineContext = { ...mapMachineBaseMock, - selectedFeatures: [ + locationFeaturesForAddition: [ { + ...emptyFeatureDataset(), location: { lng: -120.69195885, lat: 50.25163372 }, - fileLocation: null, - fileBoundary: null, - pimsFeature: null, - parcelFeature: null, regionFeature: { type: 'Feature', properties: { ...emptyRegion, REGION_NUMBER: 1, REGION_NAME: 'South Coast Region' }, @@ -270,9 +268,6 @@ describe('AddAcquisitionContainer component', () => { coordinates: [[[-120.69195885, 50.25163372]]], }, }, - districtFeature: null, - municipalityFeature: null, - selectingComponentId: null, }, ], }; @@ -287,13 +282,10 @@ describe('AddAcquisitionContainer component', () => { it('should not pre-populate the region if a property is selected and the region cannot be determined', async () => { const testMockMachine: IMapStateMachineContext = { ...mapMachineBaseMock, - selectedFeatures: [ + locationFeaturesForAddition: [ { + ...emptyFeatureDataset(), location: { lng: -120.69195885, lat: 50.25163372 }, - fileLocation: null, - fileBoundary: null, - pimsFeature: null, - parcelFeature: null, regionFeature: { type: 'Feature', properties: { ...emptyRegion, REGION_NUMBER: 4 }, @@ -302,9 +294,6 @@ describe('AddAcquisitionContainer component', () => { coordinates: [[[-120.69195885, 50.25163372]]], }, }, - districtFeature: null, - municipalityFeature: null, - selectingComponentId: null, }, ], }; @@ -320,7 +309,6 @@ describe('AddAcquisitionContainer component', () => { let testObj: any = undefined; const testMockMachine: IMapStateMachineContext = { ...mapMachineBaseMock, - processCreation: vi.fn(), refreshMapProperties: vi.fn(), }; @@ -350,7 +338,6 @@ describe('AddAcquisitionContainer component', () => { const expectedValues = formValues.toApi(); expect(addAcquisitionFileApi.execute).toHaveBeenCalledWith(expectedValues, []); expect(onSuccess).toHaveBeenCalledWith(1); - expect(testMockMachine.processCreation).toHaveBeenCalled(); expect(testMockMachine.refreshMapProperties).toHaveBeenCalled(); }); @@ -425,51 +412,37 @@ describe('AddAcquisitionContainer component', () => { let testObj: any = undefined; const testMockMachine: IMapStateMachineContext = { ...mapMachineBaseMock, - selectedFeatures: [ + pendingLocationFeaturesAddition: true, + locationFeaturesForAddition: [ { + ...emptyFeatureDataset(), location: { lng: -120.69195885, lat: 50.25163372 }, - fileLocation: null, - fileBoundary: null, - pimsFeature: null, - parcelFeature: getMockFullyAttributedParcel('111-111-111'), + parcelFeatures: [getMockFullyAttributedParcel('111-111-111')], regionFeature: feature(getMockPolygon(), { ...emptyRegion, REGION_NUMBER: 1, REGION_NAME: 'South Coast Region', }), - districtFeature: null, - selectingComponentId: null, - municipalityFeature: null, }, { + ...emptyFeatureDataset(), location: { lng: -120.69195885, lat: 50.25163372 }, - fileLocation: null, - fileBoundary: null, - pimsFeature: null, - parcelFeature: getMockFullyAttributedParcel('222-222-222'), + parcelFeatures: [getMockFullyAttributedParcel('222-222-222')], regionFeature: feature(getMockPolygon(), { ...emptyRegion, REGION_NUMBER: 1, REGION_NAME: 'South Coast Region', }), - districtFeature: null, - selectingComponentId: null, - municipalityFeature: null, }, { + ...emptyFeatureDataset(), location: { lng: -120.69195885, lat: 50.25163372 }, - fileLocation: null, - fileBoundary: null, - pimsFeature: null, - parcelFeature: getMockFullyAttributedParcel('333-333-333'), + parcelFeatures: [getMockFullyAttributedParcel('333-333-333')], regionFeature: feature(getMockPolygon(), { ...emptyRegion, REGION_NUMBER: 1, REGION_NAME: 'South Coast Region', }), - districtFeature: null, - selectingComponentId: null, - municipalityFeature: null, }, ], }; diff --git a/source/frontend/src/features/mapSideBar/acquisition/add/AddAcquisitionContainer.tsx b/source/frontend/src/features/mapSideBar/acquisition/add/AddAcquisitionContainer.tsx index bbea929c89..6d93349a15 100644 --- a/source/frontend/src/features/mapSideBar/acquisition/add/AddAcquisitionContainer.tsx +++ b/source/frontend/src/features/mapSideBar/acquisition/add/AddAcquisitionContainer.tsx @@ -13,8 +13,8 @@ import { useAcquisitionProvider } from '@/hooks/repositories/useAcquisitionProvi import { usePropertyAssociations } from '@/hooks/repositories/usePropertyAssociations'; import { useQuery } from '@/hooks/use-query'; import useApiUserOverride from '@/hooks/useApiUserOverride'; -import { useEditPropertiesNotifier } from '@/hooks/useEditPropertiesNotifier'; import { useModalContext } from '@/hooks/useModalContext'; +import { usePropertyFormSyncronizer } from '@/hooks/usePropertyFormSyncronizer'; import { IApiError } from '@/interfaces/IApiError'; import { ApiGen_Concepts_AcquisitionFile } from '@/models/api/generated/ApiGen_Concepts_AcquisitionFile'; import { UserOverrideCode } from '@/models/api/UserOverrideCode'; @@ -41,13 +41,15 @@ export const AddAcquisitionContainer: React.FC = const { setModalContent, setDisplayModal } = useModalContext(); const { execute: getPropertyAssociations } = usePropertyAssociations(); - const [needsUserConfirmation, setNeedsUserConfirmation] = useState(true); + const [needsFirstTimeConfirmation, setNeedsFirstTimeConfirmation] = useState(true); const { getAcquisitionFile: { execute: getAcquisitionFile, response: parentAcquisitionFile }, addAcquisitionFile: { execute: addAcquisitionFile, loading: addAcquisitionFileLoading }, } = useAcquisitionProvider(); + const mapMachine = useMapStateMachine(); + // Check for parent acquisition file id for sub-files const params = useQuery(); const parentId = params.get('parentId'); @@ -63,16 +65,76 @@ export const AddAcquisitionContainer: React.FC = fetchParentFile(); }, [getAcquisitionFile, parentAcquisitionFile, parentId]); - const mapMachine = useMapStateMachine(); + //Verifies that the property does not belong to another acquisition file already + const confirmProperty = useCallback( + async (propertyForm: PropertyForm) => { + if (isValidId(propertyForm.apiId)) { + const response = await getPropertyAssociations(propertyForm.apiId); + const acquisitionAssociations = response?.acquisitionAssociations ?? []; + const otherAcqFiles = acquisitionAssociations.filter(a => exists(a.id)); + return otherAcqFiles.length > 0; + } else { + // the property is not in PIMS db -> no need to confirm + return false; + } + }, + [getPropertyAssociations], + ); + + // Require user confirmation before adding a property to file + const confirmBeforeAdd = useCallback( + async ( + newPropertyForms: PropertyForm[], + isValidCallback: (isValid: boolean, newProperties: PropertyForm[]) => void, + ) => { + const needsConfirmation = await Promise.all( + newPropertyForms.map(formProperty => confirmProperty(formProperty)), + ); + if (needsFirstTimeConfirmation && needsConfirmation.some(x => x === true)) { + // show the user confirmation modal only once when creating a file + setNeedsFirstTimeConfirmation(false); + setModalContent({ + variant: 'warning', + title: 'User Override Required', + message: ( + <> +

+ One or more properties have already been added to one or more acquisition files. +

+

Do you want to acknowledge and proceed?

+ + ), + okButtonText: 'Yes', + cancelButtonText: 'No', + handleOk: () => { + // allow the property to be added to the file being created + isValidCallback(true, newPropertyForms); + setDisplayModal(false); + }, + handleCancel: () => { + isValidCallback(false, []); + setDisplayModal(false); + }, + }); + setDisplayModal(true); + } else { + isValidCallback(true, newPropertyForms); + } + }, + [confirmProperty, needsFirstTimeConfirmation, setDisplayModal, setModalContent], + ); - const { featuresWithAddresses, bcaLoading } = useEditPropertiesNotifier(formikRef, 'properties'); + const { featuresWithAddresses, isLoading } = usePropertyFormSyncronizer( + formikRef, + confirmBeforeAdd, + ); useEffect(() => { if (featuresWithAddresses?.length > 0 && !isSubFile && !formikRef?.current?.values?.region) { const firstPropertyFeature = firstOrNull(featuresWithAddresses)?.feature; if (exists(firstPropertyFeature)) { - const firstProperty = PropertyForm.fromFeatureDataset(firstPropertyFeature); + const firstProperty = PropertyForm.fromLocationFeatureDataset(firstPropertyFeature); formikRef?.current?.setFieldValue( 'region', firstProperty.regionName !== 'Cannot determine' @@ -111,73 +173,6 @@ export const AddAcquisitionContainer: React.FC = } }, [isSubFile, onClose, parentId]); - // Warn user that property is part of an existing acquisition file - const confirmBeforeAdd = useCallback( - async (propertyForm: PropertyForm) => { - if (isValidId(propertyForm.apiId)) { - const response = await getPropertyAssociations(propertyForm.apiId); - const acquisitionAssociations = response?.acquisitionAssociations ?? []; - const otherAcqFiles = acquisitionAssociations.filter(a => exists(a.id)); - return otherAcqFiles.length > 0; - } else { - // the property is not in PIMS db -> no need to confirm - return false; - } - }, - [getPropertyAssociations], - ); - - // Require user confirmation before adding a property to file - // This is the flow for Map Marker -> right-click -> create Acquisition File - useEffect(() => { - const runAsync = async () => { - if (exists(initialForm) && exists(formikRef.current) && needsUserConfirmation) { - if (initialForm.properties.length > 0) { - // Check all properties for confirmation - const needsConfirmation = await Promise.all( - initialForm.properties.map(formProperty => confirmBeforeAdd(formProperty)), - ); - if (needsConfirmation.some(confirm => confirm)) { - setModalContent({ - variant: 'warning', - title: 'User Override Required', - message: ( - <> -

- One or more properties have already been added to one or more acquisition files. -

-

Do you want to acknowledge and proceed?

- - ), - okButtonText: 'Yes', - cancelButtonText: 'No', - handleOk: () => { - // allow the property to be added to the file being created - formikRef.current.resetForm(); - formikRef.current.setFieldValue('properties', initialForm.properties); - setDisplayModal(false); - // show the user confirmation modal only once when creating a file - setNeedsUserConfirmation(false); - }, - handleCancel: () => { - // clear out the properties array as the user did not agree to the popup - initialForm.properties.splice(0, initialForm.properties.length); - formikRef.current.resetForm(); - formikRef.current.setFieldValue('properties', initialForm.properties); - setDisplayModal(false); - // show the user confirmation modal only once when creating a file - setNeedsUserConfirmation(false); - }, - }); - setDisplayModal(true); - } - } - } - }; - - runAsync(); - }, [confirmBeforeAdd, initialForm, needsUserConfirmation, setDisplayModal, setModalContent]); - const checkState = useCallback(() => { return (isSubFile || formikRef?.current?.dirty) && !formikRef?.current?.isSubmitting; }, [formikRef, isSubFile]); @@ -214,12 +209,11 @@ export const AddAcquisitionContainer: React.FC = handleSuccess(response); } } finally { - mapMachine.processCreation(); formikHelpers?.setSubmitting(false); } }; - const loading = addAcquisitionFileLoading || bcaLoading; + const loading = addAcquisitionFileLoading || isLoading; return ( = ); }} validationSchema={AddAcquisitionFileYupSchema} - confirmBeforeAdd={confirmBeforeAdd} /> diff --git a/source/frontend/src/features/mapSideBar/acquisition/add/AddAcquisitionForm.test.tsx b/source/frontend/src/features/mapSideBar/acquisition/add/AddAcquisitionForm.test.tsx index a584748e96..550019915d 100644 --- a/source/frontend/src/features/mapSideBar/acquisition/add/AddAcquisitionForm.test.tsx +++ b/source/frontend/src/features/mapSideBar/acquisition/add/AddAcquisitionForm.test.tsx @@ -31,7 +31,7 @@ const history = createMemoryHistory(); const validationSchema = vi.fn().mockReturnValue(AddAcquisitionFileYupSchema); const onSubmit = vi.fn(); -type TestProps = Pick; +type TestProps = Pick; vi.mock('@/hooks/pims-api/useApiUsers'); vi.mocked(useApiUsers).mockReturnValue({ @@ -51,7 +51,6 @@ describe('AddAcquisitionForm component', () => { { }); it('renders as expected', async () => { - const { asFragment } = await setup({ initialValues, confirmBeforeAdd: vi.fn() }); + const { asFragment } = await setup({ initialValues }); expect(asFragment()).toMatchSnapshot(); }); @@ -108,7 +107,6 @@ describe('AddAcquisitionForm component', () => { const { getByText, getNameTextbox, getRegionDropdown, getSubfileInterestTypeDropdown } = await setup({ initialValues, - confirmBeforeAdd: vi.fn(), }); const formSection = getByText(/Acquisition Details/i); @@ -128,7 +126,7 @@ describe('AddAcquisitionForm component', () => { const apiProject = mockProjects()[0]; initialValues.project = { id: apiProject.id || 0, text: apiProject.description || '' }; initialValues.physicalFileDetails = 'mocked physical file details'; - const { getNameTextbox, getByText } = await setup({ initialValues, confirmBeforeAdd: vi.fn() }); + const { getNameTextbox, getByText } = await setup({ initialValues }); const input = getNameTextbox(); expect(input).toBeVisible(); @@ -139,7 +137,6 @@ describe('AddAcquisitionForm component', () => { it('should validate character limits', async () => { const { getFormikRef, findByText, getNameTextbox } = await setup({ initialValues, - confirmBeforeAdd: vi.fn(), }); // name cannot exceed 500 characters @@ -161,17 +158,17 @@ describe('AddAcquisitionForm component', () => { }); it('should display historical field input', async () => { - const { getByText } = await setup({ initialValues, confirmBeforeAdd: vi.fn() }); + const { getByText } = await setup({ initialValues }); expect(getByText(/Historical file number/i)).toBeVisible(); }); it('should display owner solicitor input', async () => { - const { getByText } = await setup({ initialValues, confirmBeforeAdd: vi.fn() }); + const { getByText } = await setup({ initialValues }); expect(getByText(/Owner solicitor/i)).toBeVisible(); }); it('should display owner representative input', async () => { - const { getByText } = await setup({ initialValues, confirmBeforeAdd: vi.fn() }); + const { getByText } = await setup({ initialValues }); expect(getByText(/Owner representative/i)).toBeVisible(); }); @@ -185,12 +182,12 @@ describe('AddAcquisitionForm component', () => { }); it('renders as expected', async () => { - const { asFragment } = await setup({ initialValues, parentId, confirmBeforeAdd: vi.fn() }); + const { asFragment } = await setup({ initialValues, parentId }); expect(asFragment()).toMatchSnapshot(); }); it('should display interest solicitor input', async () => { - const { getByText } = await setup({ initialValues, parentId, confirmBeforeAdd: vi.fn() }); + const { getByText } = await setup({ initialValues, parentId }); expect(getByText(/Sub-interest solicitor/i)).toBeVisible(); }); @@ -198,7 +195,6 @@ describe('AddAcquisitionForm component', () => { const { getSubfileInterestTypeDropdown } = await setup({ initialValues, parentId, - confirmBeforeAdd: vi.fn(), }); expect(getSubfileInterestTypeDropdown()).toBeInTheDocument(); }); @@ -207,7 +203,6 @@ describe('AddAcquisitionForm component', () => { const { getSubfileInterestTypeDropdown, getOtherSubfileInterestTypeTextbox } = await setup({ initialValues, parentId, - confirmBeforeAdd: vi.fn(), }); const subfileInterestTypeDropdown = getSubfileInterestTypeDropdown(); @@ -224,7 +219,7 @@ describe('AddAcquisitionForm component', () => { it('should validate OTHER Subfile interest type max length', async () => { const { findByText, getSubfileInterestTypeDropdown, getOtherSubfileInterestTypeTextbox } = - await setup({ initialValues, parentId, confirmBeforeAdd: vi.fn() }); + await setup({ initialValues, parentId }); const subfileInterestTypeDropdown = getSubfileInterestTypeDropdown(); expect(subfileInterestTypeDropdown).toBeInTheDocument(); @@ -249,12 +244,12 @@ describe('AddAcquisitionForm component', () => { }); it('should display interest representative input', async () => { - const { getByText } = await setup({ initialValues, parentId, confirmBeforeAdd: vi.fn() }); + const { getByText } = await setup({ initialValues, parentId }); expect(getByText(/Sub-interest representative/i)).toBeVisible(); }); it('should display project and product as read-only (with tooltip explaining why)', async () => { - await setup({ initialValues, parentId, confirmBeforeAdd: vi.fn() }); + await setup({ initialValues, parentId }); expect(screen.getByText('1111 - Test Project')).toBeVisible(); expect(screen.getByText('9999 Test Product')).toBeVisible(); diff --git a/source/frontend/src/features/mapSideBar/acquisition/add/AddAcquisitionForm.tsx b/source/frontend/src/features/mapSideBar/acquisition/add/AddAcquisitionForm.tsx index 6adda642d5..00642b0e67 100644 --- a/source/frontend/src/features/mapSideBar/acquisition/add/AddAcquisitionForm.tsx +++ b/source/frontend/src/features/mapSideBar/acquisition/add/AddAcquisitionForm.tsx @@ -30,13 +30,12 @@ import { UserOverrideCode } from '@/models/api/UserOverrideCode'; import { exists, isValidId, isValidString } from '@/utils'; import { formatApiPersonNames } from '@/utils/personUtils'; -import { PropertyForm } from '../../shared/models'; +import PropertiesListContainer from '../../shared/update/properties/PropertiesListContainer'; import { TeamMemberFormModal } from '../common/modals/AcquisitionFormModal'; import UpdateAcquisitionOwnersSubForm from '../common/update/acquisitionOwners/UpdateAcquisitionOwnersSubForm'; import { UpdateAcquisitionTeamSubForm } from '../common/update/acquisitionTeam/UpdateAcquisitionTeamSubForm'; import { ProgressStatusModel } from '../models/ProgressStatusModel'; import { TakingTypeStatusModel } from '../models/TakingTypeStatusModel'; -import { AcquisitionPropertiesSubForm } from './AcquisitionPropertiesSubForm'; import { AcquisitionForm } from './models'; export interface IAddAcquisitionFormProps { @@ -53,7 +52,6 @@ export interface IAddAcquisitionFormProps { formikHelpers: FormikHelpers, userOverrides: UserOverrideCode[], ) => void | Promise; - confirmBeforeAdd: (propertyForm: PropertyForm) => Promise; } export const AddAcquisitionForm: React.FunctionComponent = ({ @@ -61,7 +59,6 @@ export const AddAcquisitionForm: React.FunctionComponent { const [showDiffMinistryRegionModal, setShowDiffMinistryRegionModal] = @@ -95,18 +92,15 @@ export const AddAcquisitionForm: React.FunctionComponent - {formikProps => { - return ( - - ); - }} + {formikProps => ( + + )} ); }; @@ -121,22 +115,18 @@ const AddAcquisitionDetailSubForm: React.FC<{ ) => void | Promise; showDiffMinistryRegionModal: boolean; setShowDiffMinistryRegionModal: React.Dispatch>; - confirmBeforeAdd: (propertyForm: PropertyForm) => Promise; }> = ({ parentId, formikProps, onSubmit, showDiffMinistryRegionModal, setShowDiffMinistryRegionModal, - confirmBeforeAdd, }) => { const [projectProducts, setProjectProducts] = React.useState< ApiGen_Concepts_Product[] | undefined >(undefined); - const { values, setFieldValue } = formikProps; - - const ownerSolicitorContact = values?.ownerSolicitor.contact; + const ownerSolicitorContact = formikProps.values.ownerSolicitor.contact; const { retrieveProjectProducts } = useProjectProvider(); const { getOptionsByType, getByType } = useLookupCodeHelpers(); @@ -186,12 +176,12 @@ const AddAcquisitionDetailSubForm: React.FC<{ React.useEffect(() => { if (orgPersons?.length === 0) { - setFieldValue('ownerSolicitor.primaryContactId', null); + formikProps.setFieldValue('ownerSolicitor.primaryContactId', null); } if (orgPersons?.length === 1) { - setFieldValue('ownerSolicitor.primaryContactId', orgPersons[0].personId); + formikProps.setFieldValue('ownerSolicitor.primaryContactId', orgPersons[0].personId); } - }, [orgPersons, setFieldValue]); + }, [formikProps, formikProps.setFieldValue, orgPersons]); React.useEffect(() => { if (ownerSolicitorContact?.organizationId) { @@ -209,13 +199,13 @@ const AddAcquisitionDetailSubForm: React.FC<{ label="Ministry project" tooltip="Sub-file has the same project as the main file and it can only be updated from the main file" > - {values?.formattedProject ?? ''} + {formikProps.values?.formattedProject ?? ''} - {values?.formattedProduct ?? ''} + {formikProps.values?.formattedProduct ?? ''} ) : ( @@ -254,12 +244,12 @@ const AddAcquisitionDetailSubForm: React.FC<{ .call(e.target.selectedOptions) .map((option: HTMLOptionElement & number) => option.value)[0]; if (isValidString(selectedValue) && selectedValue !== 'OTHER') { - setFieldValue('fundingTypeOtherDescription', ''); + formikProps.setFieldValue('fundingTypeOtherDescription', ''); } }} /> - {values?.fundingTypeCode === 'OTHER' && ( + {formikProps.values?.fundingTypeCode === 'OTHER' && ( @@ -336,9 +326,10 @@ const AddAcquisitionDetailSubForm: React.FC<{ : 'Properties to include in this file:' } > - verifyCallback()} + canUploadShapefiles={true} /> @@ -390,7 +381,7 @@ const AddAcquisitionDetailSubForm: React.FC<{ ) { formikProps.setFieldValue('otherSubfileInterestType', null); } else { - formikProps.setFieldValue('otherSubfileInterestType', ''); + formikProps?.setFieldValue('otherSubfileInterestType', ''); } }} required @@ -399,7 +390,8 @@ const AddAcquisitionDetailSubForm: React.FC<{ )} {isSubFile && - values?.subfileInterestTypeCode === ApiGen_CodeTypes_SubfileInterestTypes.OTHER && ( + formikProps.values?.subfileInterestTypeCode === + ApiGen_CodeTypes_SubfileInterestTypes.OTHER && ( Sub-interest file > renders as expe margin-right: 0; } -.c32.c32.btn { +.c31.c31.btn { color: #aaaaaa; -webkit-text-decoration: none; text-decoration: none; line-height: unset; } -.c32.c32.btn .text { +.c31.c31.btn .text { display: none; } -.c32.c32.btn:hover, -.c32.c32.btn:active, -.c32.c32.btn:focus { +.c31.c31.btn:hover, +.c31.c31.btn:active, +.c31.c31.btn:focus { color: #d8292f; -webkit-text-decoration: none; text-decoration: none; @@ -247,9 +247,9 @@ exports[`AddAcquisitionContainer component > Sub-interest file > renders as expe flex-direction: row; } -.c32.c32.btn:hover .text, -.c32.c32.btn:active .text, -.c32.c32.btn:focus .text { +.c31.c31.btn:hover .text, +.c31.c31.btn:active .text, +.c31.c31.btn:focus .text { display: inline; line-height: 2rem; } @@ -286,7 +286,7 @@ exports[`AddAcquisitionContainer component > Sub-interest file > renders as expe margin-bottom: 2.4rem; } -.c33 { +.c32 { color: #474543; font-size: 1.6rem; -webkit-text-decoration: none; @@ -409,7 +409,7 @@ exports[`AddAcquisitionContainer component > Sub-interest file > renders as expe overflow-y: auto; } -.c35 { +.c34 { position: -webkit-sticky; position: sticky; padding-top: 2rem; @@ -419,7 +419,7 @@ exports[`AddAcquisitionContainer component > Sub-interest file > renders as expe z-index: 10; } -.c34 { +.c33 { position: relative; border-radius: 0.4rem; padding-top: 0.8rem; @@ -433,11 +433,11 @@ exports[`AddAcquisitionContainer component > Sub-interest file > renders as expe cursor: pointer; } -.c34.is-invalid { +.c33.is-invalid { border: #CE3E39 solid 0.1rem; } -.c30 { +.c29 { position: relative; border-radius: 0.4rem; padding-top: 0.8rem; @@ -451,11 +451,11 @@ exports[`AddAcquisitionContainer component > Sub-interest file > renders as expe cursor: default; } -.c30.is-invalid { +.c29.is-invalid { border: #CE3E39 solid 0.1rem; } -.c31.c31.btn { +.c30.c30.btn { position: absolute; top: calc(50% - 1.4rem); padding-top: 0.8rem; @@ -469,13 +469,13 @@ exports[`AddAcquisitionContainer component > Sub-interest file > renders as expe line-height: unset; } -.c31.c31.btn .text { +.c30.c30.btn .text { display: none; } -.c31.c31.btn:hover, -.c31.c31.btn:active, -.c31.c31.btn:focus { +.c30.c30.btn:hover, +.c30.c30.btn:active, +.c30.c30.btn:focus { color: #CE3E39; -webkit-text-decoration: none; text-decoration: none; @@ -508,7 +508,7 @@ exports[`AddAcquisitionContainer component > Sub-interest file > renders as expe font-weight: bold; } -.c28 { +.c27 { margin: 0; padding: 1.6rem; font-size: 1.6rem; @@ -519,18 +519,18 @@ exports[`AddAcquisitionContainer component > Sub-interest file > renders as expe font-family: 'BcSans-Bold'; } -.c20 { +.c19 { margin: 0 1.6rem 0 1.6rem; padding: 1.6rem; text-align: left; text-underline-offset: 2px; } -.c20 button { +.c19 button { font-size: 14px; } -.c21 { +.c20 { font-size: 16px; color: #1a5a96; font-style: italic; @@ -541,7 +541,7 @@ exports[`AddAcquisitionContainer component > Sub-interest file > renders as expe margin-bottom: 1rem; } -.c25 { +.c24 { padding: 1.5rem 1.8rem; border-radius: 4px; background-color: #f0f7fc; @@ -553,42 +553,31 @@ exports[`AddAcquisitionContainer component > Sub-interest file > renders as expe line-height: 24px; } -.c24 { +.c23 { float: right; cursor: pointer; color: #1a5a96; font-size: 2.8rem; } -.c23 { +.c22 { margin-right: 1.5rem; font-size: 1.3rem; } -.c22 { +.c21 { cursor: pointer; } -.c27 { +.c26 { font-weight: normal; } -.c26 { +.c25 { font-weight: bold; } -.c19 { - margin: 0 1.6rem 0 1.6rem; - padding: 0 1.6rem 0 1.6rem; - text-align: left; - text-underline-offset: 2px; -} - -.c19 button { - font-size: 14px; -} - -.c29 input.form-control { +.c28 input.form-control { min-width: 50rem; max-width: 100%; } @@ -1661,43 +1650,137 @@ exports[`AddAcquisitionContainer component > Sub-interest file > renders as expe class="c19" >
- Select one or more properties that you want to include in this acquisition. You can choose a location from the map, or search by other criteria. +
+
+ + + + New workflow +
+
+ + + + +
+
-

-
+
  • +
    + Find a Property +
    +
    + Navigate to an area of the map OR use + +
    +
  • +
  • +
    + Select a property +
    - Selected Properties + Click on the map and the selection will be highlighed
    -
  • -

    + +
  • +
    + Add it to this file +
    +
    + Click "Add selected" property button when it appears below +
    +
  • + +
    +
    +
    +

    -
    +
    + Sub-interest file > renders as expe d="M256 8C119.043 8 8 119.083 8 256c0 136.997 111.043 248 248 248s248-111.003 248-248C504 119.083 392.957 8 256 8zm0 110c23.196 0 42 18.804 42 42s-18.804 42-42 42-42-18.804-42-42 18.804-42 42-42zm56 254c0 6.627-5.373 12-12 12h-88c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h12v-64h-12c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h64c6.627 0 12 5.373 12 12v100h12c6.627 0 12 5.373 12 12v24z" /> - New workflow -
    -
    - - - - -
    +
    -
    -
      -
    1. -
      - Find a Property -
      -
      - Navigate to an area of the map OR use - -
      -
    2. -
    3. -
      - Select a property -
      -
      - Click on the map and the selection will be highlighed -
      -
    4. -
    5. -
      - Add it to this file -
      -
      - Click "Add selected" property button when it appears below -
      -
    6. -
    -
    +
    +

    +
    +
    -
    - Identifier -
    -
    +
    + Provide a descriptive name for this land + - Provide a descriptive name for this land - - - - - -
    + + +
    - - No Properties selected -
    + + No Properties selected +
    @@ -1866,7 +1878,7 @@ exports[`AddAcquisitionContainer component > Sub-interest file > renders as expe class="c16 required text-left col" >
    Sub-interest file > renders as expe class="c16 text-left col" >
    Sub-interest file > renders as expe class="col" >
    Bob Billy Smith
    -
    - + +
  • +
    + Select a property +
    +
    + Click on the map and the selection will be highlighed +
    +
  • +
  • +
    + Add it to this file +
    +
    + Click "Add selected" property button when it appears below +
    +
  • + +
    + +
    +

    -
    +
    + renders as expected 1`] = ` d="M256 8C119.043 8 8 119.083 8 256c0 136.997 111.043 248 248 248s248-111.003 248-248C504 119.083 392.957 8 256 8zm0 110c23.196 0 42 18.804 42 42s-18.804 42-42 42-42-18.804-42-42 18.804-42 42-42zm56 254c0 6.627-5.373 12-12 12h-88c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h12v-64h-12c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h64c6.627 0 12 5.373 12 12v100h12c6.627 0 12 5.373 12 12v24z" /> - New workflow -
    -
    - - - - -
    +
    -
    -
      -
    1. -
      - Find a Property -
      -
      - Navigate to an area of the map OR use - -
      -
    2. -
    3. -
      - Select a property -
      -
      - Click on the map and the selection will be highlighed -
      -
    4. -
    5. -
      - Add it to this file -
      -
      - Click "Add selected" property button when it appears below -
      -
    6. -
    -
    +
    +
    +

    +
    +
    +
    + Identifier
    -
    - Identifier -
    -
    - Provide a descriptive name for this land - - - - - -
    + + +
    - - No Properties selected -
    + + No Properties selected +
    @@ -4749,7 +4773,7 @@ exports[`AddAcquisitionContainer component > renders as expected 1`] = ` class="c16 required text-left col" >
    renders as expected 1`] = ` class="c16 text-left col" >
    renders as expected 1`] = ` class="collapse show" >

    Each property in this file should be owned by the owner(s) in this section

    @@ -5134,7 +5158,7 @@ exports[`AddAcquisitionContainer component > renders as expected 1`] = ` class="col" >
    Select from contacts
    @@ -5208,7 +5232,7 @@ exports[`AddAcquisitionContainer component > renders as expected 1`] = ` class="col" >
    Select from contacts
    @@ -5295,7 +5319,7 @@ exports[`AddAcquisitionContainer component > renders as expected 1`] = ` class="" >
    Sub-interest files > renders as expected class="Toastify" />
    - .c16.btn { + .c15.btn { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -35,17 +35,17 @@ exports[`AddAcquisitionForm component > Sub-interest files > renders as expected cursor: pointer; } -.c16.btn .Button__value { +.c15.btn .Button__value { width: auto; } -.c16.btn:hover { +.c15.btn:hover { -webkit-text-decoration: underline; text-decoration: underline; opacity: 0.8; } -.c16.btn:focus { +.c15.btn:focus { outline-width: 2px; outline-style: solid; outline-color: #2E5DD7; @@ -53,31 +53,31 @@ exports[`AddAcquisitionForm component > Sub-interest files > renders as expected box-shadow: none; } -.c16.btn.btn-primary { +.c15.btn.btn-primary { color: #FFFFFF; background-color: #013366; } -.c16.btn.btn-primary:hover, -.c16.btn.btn-primary:active, -.c16.btn.btn-primary:focus { +.c15.btn.btn-primary:hover, +.c15.btn.btn-primary:active, +.c15.btn.btn-primary:focus { background-color: #1E5189; } -.c16.btn.btn-secondary { +.c15.btn.btn-secondary { color: #013366; background: none; border-color: #013366; } -.c16.btn.btn-secondary:hover, -.c16.btn.btn-secondary:active, -.c16.btn.btn-secondary:focus { +.c15.btn.btn-secondary:hover, +.c15.btn.btn-secondary:active, +.c15.btn.btn-secondary:focus { color: #FFFFFF; background-color: #013366; } -.c16.btn.btn-info { +.c15.btn.btn-info { color: #9F9D9C; border: none; background: none; @@ -85,66 +85,66 @@ exports[`AddAcquisitionForm component > Sub-interest files > renders as expected padding-right: 0.6rem; } -.c16.btn.btn-info:hover, -.c16.btn.btn-info:active, -.c16.btn.btn-info:focus { +.c15.btn.btn-info:hover, +.c15.btn.btn-info:active, +.c15.btn.btn-info:focus { color: var(--surface-color-primary-button-hover); background: none; } -.c16.btn.btn-light { +.c15.btn.btn-light { color: #FFFFFF; background-color: #606060; border: none; } -.c16.btn.btn-light:hover, -.c16.btn.btn-light:active, -.c16.btn.btn-light:focus { +.c15.btn.btn-light:hover, +.c15.btn.btn-light:active, +.c15.btn.btn-light:focus { color: #FFFFFF; background-color: #606060; } -.c16.btn.btn-dark { +.c15.btn.btn-dark { color: #FFFFFF; background-color: #474543; border: none; } -.c16.btn.btn-dark:hover, -.c16.btn.btn-dark:active, -.c16.btn.btn-dark:focus { +.c15.btn.btn-dark:hover, +.c15.btn.btn-dark:active, +.c15.btn.btn-dark:focus { color: #FFFFFF; background-color: #474543; } -.c16.btn.btn-danger { +.c15.btn.btn-danger { color: #FFFFFF; background-color: #CE3E39; } -.c16.btn.btn-danger:hover, -.c16.btn.btn-danger:active, -.c16.btn.btn-danger:focus { +.c15.btn.btn-danger:hover, +.c15.btn.btn-danger:active, +.c15.btn.btn-danger:focus { color: #FFFFFF; background-color: #CE3E39; } -.c16.btn.btn-warning { +.c15.btn.btn-warning { color: #FFFFFF; background-color: #FCBA19; border-color: #FCBA19; } -.c16.btn.btn-warning:hover, -.c16.btn.btn-warning:active, -.c16.btn.btn-warning:focus { +.c15.btn.btn-warning:hover, +.c15.btn.btn-warning:active, +.c15.btn.btn-warning:focus { color: #FFFFFF; border-color: #FCBA19; background-color: #FCBA19; } -.c16.btn.btn-link { +.c15.btn.btn-link { font-size: 1.6rem; font-weight: 400; color: var(--surface-color-primary-button-default); @@ -168,9 +168,9 @@ exports[`AddAcquisitionForm component > Sub-interest files > renders as expected text-decoration: underline; } -.c16.btn.btn-link:hover, -.c16.btn.btn-link:active, -.c16.btn.btn-link:focus { +.c15.btn.btn-link:hover, +.c15.btn.btn-link:active, +.c15.btn.btn-link:focus { color: var(--surface-color-primary-button-hover); -webkit-text-decoration: underline; text-decoration: underline; @@ -180,15 +180,15 @@ exports[`AddAcquisitionForm component > Sub-interest files > renders as expected outline: none; } -.c16.btn.btn-link:disabled, -.c16.btn.btn-link.disabled { +.c15.btn.btn-link:disabled, +.c15.btn.btn-link.disabled { color: #9F9D9C; background: none; pointer-events: none; } -.c16.btn:disabled, -.c16.btn:disabled:hover { +.c15.btn:disabled, +.c15.btn:disabled:hover { color: #9F9D9C; background-color: #EDEBE9; box-shadow: none; @@ -200,15 +200,15 @@ exports[`AddAcquisitionForm component > Sub-interest files > renders as expected cursor: not-allowed; } -.c16.Button .Button__icon { +.c15.Button .Button__icon { margin-right: 1.6rem; } -.c16.Button--icon-only:focus { +.c15.Button--icon-only:focus { outline: none; } -.c16.Button--icon-only .Button__icon { +.c15.Button--icon-only .Button__icon { margin-right: 0; } @@ -234,14 +234,14 @@ exports[`AddAcquisitionForm component > Sub-interest files > renders as expected border-color: #d8292f !important; } -.c19 { +.c18 { color: #474543; font-size: 1.6rem; -webkit-text-decoration: none; text-decoration: none; } -.c20 { +.c19 { position: relative; border-radius: 0.4rem; padding-top: 0.8rem; @@ -255,7 +255,7 @@ exports[`AddAcquisitionForm component > Sub-interest files > renders as expected cursor: pointer; } -.c20.is-invalid { +.c19.is-invalid { border: #CE3E39 solid 0.1rem; } @@ -285,7 +285,7 @@ exports[`AddAcquisitionForm component > Sub-interest files > renders as expected font-weight: bold; } -.c17 { +.c16 { margin: 0; padding: 1.6rem; font-size: 1.6rem; @@ -296,18 +296,18 @@ exports[`AddAcquisitionForm component > Sub-interest files > renders as expected font-family: 'BcSans-Bold'; } -.c8 { +.c7 { margin: 0 1.6rem 0 1.6rem; padding: 1.6rem; text-align: left; text-underline-offset: 2px; } -.c8 button { +.c7 button { font-size: 14px; } -.c9 { +.c8 { font-size: 16px; color: #1a5a96; font-style: italic; @@ -318,7 +318,7 @@ exports[`AddAcquisitionForm component > Sub-interest files > renders as expected margin-bottom: 1rem; } -.c13 { +.c12 { padding: 1.5rem 1.8rem; border-radius: 4px; background-color: #f0f7fc; @@ -330,42 +330,31 @@ exports[`AddAcquisitionForm component > Sub-interest files > renders as expected line-height: 24px; } -.c12 { +.c11 { float: right; cursor: pointer; color: #1a5a96; font-size: 2.8rem; } -.c11 { +.c10 { margin-right: 1.5rem; font-size: 1.3rem; } -.c10 { +.c9 { cursor: pointer; } -.c15 { +.c14 { font-weight: normal; } -.c14 { +.c13 { font-weight: bold; } -.c7 { - margin: 0 1.6rem 0 1.6rem; - padding: 0 1.6rem 0 1.6rem; - text-align: left; - text-underline-offset: 2px; -} - -.c7 button { - font-size: 14px; -} - -.c18 input.form-control { +.c17 input.form-control { min-width: 50rem; max-width: 100%; } @@ -1328,43 +1317,137 @@ exports[`AddAcquisitionForm component > Sub-interest files > renders as expected class="c7" >
    - Select one or more properties that you want to include in this acquisition. You can choose a location from the map, or search by other criteria. +
    +
    + + + + New workflow +
    +
    + + + + +
    +
    -

    -
    +
  • +
    + Find a Property +
    - Selected Properties + Navigate to an area of the map OR use +
    -
  • -

    + +
  • +
    + Select a property +
    +
    + Click on the map and the selection will be highlighed +
    +
  • +
  • +
    + Add it to this file +
    +
    + Click "Add selected" property button when it appears below +
    +
  • + +
    +
    +
    +

    -
    +
    + Sub-interest files > renders as expected d="M256 8C119.043 8 8 119.083 8 256c0 136.997 111.043 248 248 248s248-111.003 248-248C504 119.083 392.957 8 256 8zm0 110c23.196 0 42 18.804 42 42s-18.804 42-42 42-42-18.804-42-42 18.804-42 42-42zm56 254c0 6.627-5.373 12-12 12h-88c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h12v-64h-12c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h64c6.627 0 12 5.373 12 12v100h12c6.627 0 12 5.373 12 12v24z" /> - New workflow -
    -
    - - - - -
    +
    -
    -
      -
    1. -
      - Find a Property -
      -
      - Navigate to an area of the map OR use - -
      -
    2. -
    3. -
      - Select a property -
      -
      - Click on the map and the selection will be highlighed -
      -
    4. -
    5. -
      - Add it to this file -
      -
      - Click "Add selected" property button when it appears below -
      -
    6. -
    -
    +
    +
    +

    +
    +
    +
    + Identifier
    -
    - Identifier -
    -
    - Provide a descriptive name for this land - - - - - -
    + + +
    - - No Properties selected -
    + + No Properties selected +
    @@ -1533,7 +1545,7 @@ exports[`AddAcquisitionForm component > Sub-interest files > renders as expected class="c4 required text-left col" >
    Sub-interest files > renders as expected class="c4 text-left col" >
    Sub-interest files > renders as expected class="collapse show" >
    -
    - + +
  • +
    + Select a property +
    +
    + Click on the map and the selection will be highlighed +
    +
  • +
  • +
    + Add it to this file +
    +
    + Click "Add selected" property button when it appears below +
    +
  • + +
    +
    +
    +

    -
    +
    + renders as expected 1`] = ` d="M256 8C119.043 8 8 119.083 8 256c0 136.997 111.043 248 248 248s248-111.003 248-248C504 119.083 392.957 8 256 8zm0 110c23.196 0 42 18.804 42 42s-18.804 42-42 42-42-18.804-42-42 18.804-42 42-42zm56 254c0 6.627-5.373 12-12 12h-88c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h12v-64h-12c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h64c6.627 0 12 5.373 12 12v100h12c6.627 0 12 5.373 12 12v24z" /> - New workflow -
    -
    - - - - -
    +
    -
    -
      -
    1. -
      - Find a Property -
      -
      - Navigate to an area of the map OR use - -
      -
    2. -
    3. -
      - Select a property -
      -
      - Click on the map and the selection will be highlighed -
      -
    4. -
    5. -
      - Add it to this file -
      -
      - Click "Add selected" property button when it appears below -
      -
    6. -
    -
    +
    +

    +
    +
    -
    - Identifier -
    -
    +
    + Provide a descriptive name for this land + - Provide a descriptive name for this land - - - - - -
    + + +
    - - No Properties selected -
    + + No Properties selected +
    @@ -3716,7 +3740,7 @@ exports[`AddAcquisitionForm component > renders as expected 1`] = ` class="c4 required text-left col" >
    renders as expected 1`] = ` class="c4 text-left col" >
    renders as expected 1`] = ` class="collapse show" >
    -
    - + +
  • +
    + Select a property +
    +
    + Click on the map and the selection will be highlighed +
    +
  • +
  • +
    + Add it to this file +
    +
    + Click "Add selected" property button when it appears below +
    +
  • + +
    + +
    +

    -
    +
    + matches snapshot 1`] = ` d="M256 8C119.043 8 8 119.083 8 256c0 136.997 111.043 248 248 248s248-111.003 248-248C504 119.083 392.957 8 256 8zm0 110c23.196 0 42 18.804 42 42s-18.804 42-42 42-42-18.804-42-42 18.804-42 42-42zm56 254c0 6.627-5.373 12-12 12h-88c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h12v-64h-12c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h64c6.627 0 12 5.373 12 12v100h12c6.627 0 12 5.373 12 12v24z" /> - New workflow -
    -
    - - - - -
    +
    -
    -
      -
    1. -
      - Find a Property -
      -
      - Navigate to an area of the map OR use - -
      -
    2. -
    3. -
      - Select a property -
      -
      - Click on the map and the selection will be highlighed -
      -
    4. -
    5. -
      - Add it to this file -
      -
      - Click "Add selected" property button when it appears below -
      -
    6. -
    -
    +
    +

    +
    +
    -
    - Identifier -
    -
    +
    + Provide a descriptive name for this land + - Provide a descriptive name for this land - - - - - -
    + + +
    - - No Properties selected -
    + + No Properties selected +
    @@ -1706,7 +1686,7 @@ exports[`Add Disposition Container View > matches snapshot 1`] = ` class="" >
    , ) => void | Promise; - confirmBeforeAdd: (propertyForm: PropertyForm) => Promise; } const DispositionForm: React.FC = props => { - const { initialValues, onSubmit, confirmBeforeAdd, formikRef } = props; + const { initialValues, onSubmit, formikRef } = props; const [projectProducts, setProjectProducts] = useState( undefined, @@ -125,9 +123,9 @@ const DispositionForm: React.FC = props => {
    - verifyCallback()} />
    diff --git a/source/frontend/src/features/mapSideBar/disposition/form/DispositionPropertiesSubForm.test.tsx b/source/frontend/src/features/mapSideBar/disposition/form/DispositionPropertiesSubForm.test.tsx deleted file mode 100644 index 18702774cd..0000000000 --- a/source/frontend/src/features/mapSideBar/disposition/form/DispositionPropertiesSubForm.test.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { Formik, FormikProps } from 'formik'; -import { createRef } from 'react'; -import configureMockStore from 'redux-mock-store'; -import thunk from 'redux-thunk'; - -import { getMockSelectedFeatureDataset } from '@/mocks/featureset.mock'; -import { mapMachineBaseMock } from '@/mocks/mapFSM.mock'; -import { act, render, RenderOptions, userEvent } from '@/utils/test-utils'; - -import { PropertyForm } from '../../shared/models'; -import { DispositionFormModel } from '../models/DispositionFormModel'; -import DispositionPropertiesSubForm from './DispositionPropertiesSubForm'; - -const mockStore = configureMockStore([thunk]); - -const customSetFilePropertyLocations = vi.fn(); - -const confirmBeforeAdd = vi.fn(); - -describe('DispositionPropertiesSubForm component', () => { - const setup = async ( - props: { initialForm: DispositionFormModel }, - renderOptions: RenderOptions = {}, - ) => { - const ref = createRef>(); - const utils = render( - - {formikProps => ( - - )} - , - { - ...renderOptions, - store: mockStore({}), - claims: [], - mockMapMachine: { - ...mapMachineBaseMock, - setFilePropertyLocations: customSetFilePropertyLocations, - }, - }, - ); - - // Wait for any async effects to complete - await act(async () => {}); - - return { - ...utils, - getFormikRef: () => ref, - }; - }; - - let testForm: DispositionFormModel; - - beforeEach(() => { - const mockFeatureSet = getMockSelectedFeatureDataset(); - testForm = new DispositionFormModel(); - testForm.fileProperties = [ - PropertyForm.fromFeatureDataset({ - ...mockFeatureSet, - pimsFeature: { - ...mockFeatureSet.pimsFeature, - properties: { - ...mockFeatureSet.pimsFeature?.properties, - PID_PADDED: '123-456-789', - }, - }, - }), - PropertyForm.fromFeatureDataset({ - ...mockFeatureSet, - pimsFeature: { - ...mockFeatureSet.pimsFeature, - properties: { - ...mockFeatureSet.pimsFeature?.properties, - PIN: 1111222, - }, - }, - }), - ]; - }); - - afterEach(() => { - vi.clearAllMocks(); - customSetFilePropertyLocations.mockReset(); - }); - - it('renders as expected', async () => { - const { asFragment } = await setup({ initialForm: testForm }); - await act(async () => {}); - expect(asFragment()).toMatchSnapshot(); - }); - - it('renders list of properties', async () => { - const { getByText } = await setup({ initialForm: testForm }); - - expect(getByText('PID: 123-456-789')).toBeVisible(); - expect(getByText('PIN: 1111222')).toBeVisible(); - }); - - it('should remove property from list when Remove button is clicked', async () => { - const { getAllByTitle, queryByText } = await setup({ initialForm: testForm }); - const pidRow = getAllByTitle('remove')[0]; - await act(async () => userEvent.click(pidRow)); - - expect(queryByText('PID: 123-456-789')).toBeNull(); - }); - - it('should display properties with svg prefixed with incrementing id', async () => { - const { getByTitle } = await setup({ initialForm: testForm }); - - expect(getByTitle('1')).toBeInTheDocument(); - expect(getByTitle('2')).toBeInTheDocument(); - }); -}); diff --git a/source/frontend/src/features/mapSideBar/disposition/form/DispositionPropertiesSubForm.tsx b/source/frontend/src/features/mapSideBar/disposition/form/DispositionPropertiesSubForm.tsx deleted file mode 100644 index 94e35c2633..0000000000 --- a/source/frontend/src/features/mapSideBar/disposition/form/DispositionPropertiesSubForm.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { FieldArray, FormikProps } from 'formik'; -import { noop } from 'lodash'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { Col, Row } from 'react-bootstrap'; -import styled from 'styled-components'; - -import { Button } from '@/components/common/buttons'; -import { useMapStateMachine } from '@/components/common/mapFSM/MapStateMachineContext'; -import { SelectedFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; -import { Section } from '@/components/common/Section/Section'; -import { ZoomIconType, ZoomToLocation } from '@/components/maps/ZoomToLocation'; -import SelectedPropertyHeaderRow from '@/components/propertySelector/selectedPropertyList/SelectedPropertyHeaderRow'; -import SelectedPropertyRow from '@/components/propertySelector/selectedPropertyList/SelectedPropertyRow'; -import useDraftMarkerSynchronizer from '@/hooks/useDraftMarkerSynchronizer'; -import { useFeatureDatasetsWithAddresses } from '@/hooks/useFeatureDatasetsWithAddresses'; -import { exists, featuresetToLocationBoundaryDataset, firstOrNull } from '@/utils'; -import { addPropertiesToCurrentFile } from '@/utils/propertyUtils'; - -import { PropertyForm } from '../../shared/models'; -import AddPropertiesGuide from '../../shared/update/properties/AddPropertiesGuide'; -import { DispositionFormModel } from '../models/DispositionFormModel'; - -export interface DispositionPropertiesSubFormProps { - formikProps: FormikProps; - confirmBeforeAdd: (propertyForm: PropertyForm) => Promise; -} - -const DispositionPropertiesSubForm: React.FunctionComponent = ({ - formikProps, -}) => { - const localRef = useRef>(); - - const { selectedFeatures, processCreation, mapLocationFeatureDataset, prepareForCreation } = - useMapStateMachine(); - - useDraftMarkerSynchronizer( - formikProps.values.fileProperties.map(p => - featuresetToLocationBoundaryDataset(p.toFeatureDataset()), - ), - ); - - const selectedFeatureDataset = useMemo(() => { - return { - selectingComponentId: mapLocationFeatureDataset?.selectingComponentId ?? null, - location: mapLocationFeatureDataset?.location, - fileLocation: mapLocationFeatureDataset?.fileLocation ?? null, - fileBoundary: null, - parcelFeature: firstOrNull(mapLocationFeatureDataset?.parcelFeatures), - pimsFeature: firstOrNull(mapLocationFeatureDataset?.pimsFeatures), - regionFeature: mapLocationFeatureDataset?.regionFeature ?? null, - districtFeature: mapLocationFeatureDataset?.districtFeature ?? null, - municipalityFeature: firstOrNull(mapLocationFeatureDataset?.municipalityFeatures), - isActive: true, - displayOrder: 0, - }; - }, [ - mapLocationFeatureDataset?.selectingComponentId, - mapLocationFeatureDataset?.location, - mapLocationFeatureDataset?.fileLocation, - mapLocationFeatureDataset?.parcelFeatures, - mapLocationFeatureDataset?.pimsFeatures, - mapLocationFeatureDataset?.regionFeature, - mapLocationFeatureDataset?.districtFeature, - mapLocationFeatureDataset?.municipalityFeatures, - ]); - - // Get PropertyForms with addresses for all selected features - const { featuresWithAddresses } = useFeatureDatasetsWithAddresses(selectedFeatures ?? []); - - // Convert SelectedFeatureDataset to PropertyForm - const propertyForms = useMemo( - () => - featuresWithAddresses.map(obj => { - const property = PropertyForm.fromFeatureDataset(obj.feature); - if (exists(obj.address)) { - property.address = obj.address; - } - return property; - }), - [featuresWithAddresses], - ); - - const handleAddToSelection = useCallback(() => { - prepareForCreation([selectedFeatureDataset]); - }, [prepareForCreation, selectedFeatureDataset]); - - useEffect(() => { - if (exists(localRef.current) && propertyForms.length > 0) { - addPropertiesToCurrentFile(localRef, 'fileProperties', propertyForms, noop); - processCreation(); - } - }, [localRef, processCreation, propertyForms]); - - return ( - -
    - Select one or more properties that you want to include in this disposition. You can choose a - location from the map, or search by other criteria. -
    - - - {({ remove }) => ( -
    - Selected Properties - - - - - } - > - - {exists(selectedFeatureDataset?.parcelFeature) && ( - - - - )} - - - {formikProps.values.fileProperties.map((property, index) => ( - remove(index)} - nameSpace={`fileProperties.${index}`} - index={index} - property={property.toFeatureDataset()} - /> - ))} - {formikProps.values.fileProperties.length === 0 && No Properties selected} -
    - )} -
    -
    - ); -}; - -export default DispositionPropertiesSubForm; - -const StyledComponentWrapper = styled.div` - margin: 0 1.6rem 0 1.6rem; - padding: 0 1.6rem 0 1.6rem; - text-align: left; - text-underline-offset: 2px; - - button { - font-size: 14px; - } -`; - -const StyledButtonWrapper = styled.div` - margin: 0 1.6rem; - padding-left: 1.6rem; - text-align: left; - text-underline-offset: 2px; - - button { - font-size: 14px; - } -`; diff --git a/source/frontend/src/features/mapSideBar/disposition/models/DispositionFormModel.ts b/source/frontend/src/features/mapSideBar/disposition/models/DispositionFormModel.ts index 7a3fa7f066..f2298792c9 100644 --- a/source/frontend/src/features/mapSideBar/disposition/models/DispositionFormModel.ts +++ b/source/frontend/src/features/mapSideBar/disposition/models/DispositionFormModel.ts @@ -6,14 +6,14 @@ import { applyDisplayOrder } from '@/utils'; import { emptyStringtoNullable, fromTypeCode, toTypeCodeNullable } from '@/utils/formUtils'; import { exists, isValidIsoDateTime } from '@/utils/utils'; -import { PropertyForm } from '../../shared/models'; +import { PropertyForm, WithFormProperties } from '../../shared/models'; import { ChecklistItemFormModel } from '../../shared/tabs/checklist/update/models'; import { DispositionOfferFormModel } from '../tabs/offersAndSale/dispositionOffer/models/DispositionOfferFormModel'; import { DispositionAppraisalFormModel } from './DispositionAppraisalFormModel'; import { DispositionSaleFormModel } from './DispositionSaleFormModel'; import { DispositionTeamSubFormModel, WithDispositionTeam } from './DispositionTeamSubFormModel'; -export class DispositionFormModel implements WithDispositionTeam { +export class DispositionFormModel implements WithDispositionTeam, WithFormProperties { fileName: string | null = ''; fileStatusTypeCode: string | null = null; referenceNumber: string | null = ''; @@ -31,7 +31,7 @@ export class DispositionFormModel implements WithDispositionTeam { initiatingDocumentTypeOther: string | null = ''; initiatingDocumentDate: string | null = null; regionCode: string | null = null; - fileProperties: PropertyForm[] = []; + properties: PropertyForm[] = []; team: DispositionTeamSubFormModel[] = []; offers: DispositionOfferFormModel[] = []; fileChecklist: ChecklistItemFormModel[] = []; @@ -52,7 +52,7 @@ export class DispositionFormModel implements WithDispositionTeam { } toApi(): ApiGen_Concepts_DispositionFile { - const fileProperties = this.fileProperties.map(x => this.toPropertyApi(x)); + const fileProperties = this.properties.map(x => this.toPropertyApi(x)); const sortedProperties = applyDisplayOrder(fileProperties); return { id: this.id ?? 0, @@ -135,7 +135,7 @@ export class DispositionFormModel implements WithDispositionTeam { dispositionForm.team = model.dispositionTeam?.map(x => DispositionTeamSubFormModel.fromApi(x)) || []; - dispositionForm.fileProperties = model.fileProperties?.map(x => PropertyForm.fromApi(x)) || []; + dispositionForm.properties = model.fileProperties?.map(x => PropertyForm.fromApi(x)) || []; return dispositionForm; } diff --git a/source/frontend/src/features/mapSideBar/layer/LayerTabContainer.tsx b/source/frontend/src/features/mapSideBar/layer/LayerTabContainer.tsx index 018227ec23..00eee7e2d4 100644 --- a/source/frontend/src/features/mapSideBar/layer/LayerTabContainer.tsx +++ b/source/frontend/src/features/mapSideBar/layer/LayerTabContainer.tsx @@ -89,29 +89,33 @@ export const LayerTabContainer: React.FC { - if (!composedProperty) return []; + if (!exists(composedProperty?.featureDataset)) { + return []; + } + + const featureDataset = composedProperty?.featureDataset; const crownFeaturesTotal = - (composedProperty.crownTenureFeatures?.length || 0) + - (composedProperty.crownLeaseFeatures?.length || 0) + - (composedProperty.crownInclusionFeatures?.length || 0) + - (composedProperty.crownInventoryFeatures?.length || 0) + - (composedProperty.crownLicenseFeatures?.length || 0); + (featureDataset.crownLandTenuresFeatures?.length || 0) + + (featureDataset.crownLandLeasesFeatures?.length || 0) + + (featureDataset.crownLandInclusionsFeatures?.length || 0) + + (featureDataset.crownLandInventoryFeatures?.length || 0) + + (featureDataset.crownLandLicensesFeatures?.length || 0); return [ ...buildLayerData({ - features: composedProperty.pimsGeoserverFeatureCollection?.features, + features: featureDataset?.pimsFeatures, titlePrefix: 'PIMS data', tab: InventoryTabNames.pims, config: pimsLayerConfig, }), ...buildLayerData({ - features: composedProperty.parcelMapFeatureCollection?.features, + features: featureDataset?.parcelFeatures, titlePrefix: 'LTSA ParcelMap data', tab: InventoryTabNames.pmbc, config: parcelLayerConfig, }), ...buildLayerData({ - features: composedProperty.crownTenureFeatures, + features: featureDataset?.crownLandTenuresFeatures, titlePrefix: 'Crown Land Tenures', tab: InventoryTabNames.crown, config: {}, @@ -119,7 +123,7 @@ export const LayerTabContainer: React.FC = ({ leaseId, onClos } = useContext(SideBarContext); const query = useQuery(); + const match = useRouteMatch(); const history = useHistory(); const [isValid, setIsValid] = useState(true); const { setModalContent, setDisplayModal } = useModalContext(); + const withUserOverride = useApiUserOverride< + (userOverrideCodes: UserOverrideCode[]) => Promise + >('Failed to update Lease File'); + const pathSolver = usePathGenerator(); const activeTab = containerState.activeTab; @@ -224,13 +236,13 @@ export const LeaseContainer: React.FC = ({ leaseId, onClos getLastUpdatedBy: { execute: getLastUpdatedBy, loading: getLastUpdatedByLoading }, } = useLeaseRepository(); + const { updateLeaseProperties } = usePropertyLeaseRepository(); + const lease: ApiGen_Concepts_Lease | null = isLeaseFile(file) ? file : null; - const locations: LocationBoundaryDataset[] = useMemo(() => { + const fileProperties: ApiGen_Concepts_FileProperty[] = useMemo(() => { if (exists(lease?.fileProperties)) { - return lease?.fileProperties - .map(leaseProp => filePropertyToLocationBoundaryDataset(leaseProp)) - .filter(exists); + return lease?.fileProperties.filter(exists); } else { return []; } @@ -328,8 +340,8 @@ export const LeaseContainer: React.FC = ({ leaseId, onClos }, [fetchLastUpdatedBy, lastUpdatedBy, leaseId, staleLastUpdatedBy]); useEffect(() => { - setFilePropertyLocations(locations); - }, [setFilePropertyLocations, locations]); + setFilePropertyLocations(fileProperties); + }, [setFilePropertyLocations, fileProperties]); const onSelectFileSummary = () => { if (!exists(lease)) { @@ -375,7 +387,6 @@ export const LeaseContainer: React.FC = ({ leaseId, onClos } else { query.delete('edit'); } - setContainerState({ isEditing: value, }); @@ -388,14 +399,64 @@ export const LeaseContainer: React.FC = ({ leaseId, onClos setIsPropertyEditing(query.get('edit') === 'true'); }, [query, setIsPropertyEditing]); - const onPropertyUpdate = () => { + const onSuccess = () => { setIsPropertyEditing(false); refresh(); }; + const closePropertySelector = () => { + onSuccess(); + history.push(`${match.url}`); + }; + + // Properties that are not in PIMS need confirmation + const confirmBeforeAdd = async (propertyForm: PropertyForm): Promise => { + return !isValidId(propertyForm.apiId); + }; + + const onUpdateProperties = async (updatedFormFile: LeaseFormModel) => { + return withUserOverride( + (userOverrideCodes: UserOverrideCode[]) => { + const updatedFile = LeaseFormModel.toApi(updatedFormFile); + return updateLeaseProperties + .execute(updatedFile, userOverrideCodes) + .then(async response => { + formikRef.current?.setSubmitting(false); + if (isValidId(response?.id)) { + if ( + updatedFile.fileProperties?.find(fp => !fp.property?.address && !fp.property?.id) + ) { + toast.warn( + 'Address could not be retrieved for this property, it will have to be provided manually in property details tab', + { autoClose: 15000 }, + ); + } + formikRef.current?.resetForm(); + closePropertySelector(); + } + }); + }, + [], + (axiosError: AxiosError) => { + setModalContent({ + variant: 'error', + title: 'Error', + message: axiosError?.response?.data.error, + okButtonText: 'Close', + handleOk: async () => { + formikRef.current?.resetForm(); + await getCompleteLease(); + setDisplayModal(false); + }, + }); + setDisplayModal(true); + }, + ); + }; + // UI components if (loading || getLastUpdatedByLoading) { - return ; + return ; } return ( @@ -407,7 +468,7 @@ export const LeaseContainer: React.FC = ({ leaseId, onClos onSelectFileSummary={onSelectFileSummary} onSelectProperty={onSelectProperty} onEditProperties={onEditProperties} - onPropertyUpdateSuccess={onPropertyUpdate} + onPropertyUpdateSuccess={onSuccess} onChildSuccess={onChildSuccess} refreshLease={refresh} setLease={setLease} @@ -416,7 +477,11 @@ export const LeaseContainer: React.FC = ({ leaseId, onClos setContainerState={setContainerState} isFormValid={isValid} lease={lease} - > + setIsShowingPropertySelector={closePropertySelector} + onUpdateProperties={onUpdateProperties} + confirmBeforeAddProperty={confirmBeforeAdd} + canRemoveProperty={() => Promise.resolve(true)} + /> ); }; diff --git a/source/frontend/src/features/mapSideBar/lease/LeaseView.test.tsx b/source/frontend/src/features/mapSideBar/lease/LeaseView.test.tsx index 399bc7508d..6ceca3cb72 100644 --- a/source/frontend/src/features/mapSideBar/lease/LeaseView.test.tsx +++ b/source/frontend/src/features/mapSideBar/lease/LeaseView.test.tsx @@ -35,6 +35,8 @@ import { usePropertyOperationRepository } from '@/hooks/repositories/useProperty import { IResponseWrapper } from '@/hooks/util/useApiRequestWrapper'; import { ApiGen_Concepts_PropertyOperation } from '@/models/api/generated/ApiGen_Concepts_PropertyOperation'; import { AxiosResponse } from 'axios'; +import { ApiGen_Concepts_File } from '@/models/api/generated/ApiGen_Concepts_File'; +import { FileForm, PropertyForm } from '../shared/models'; vi.mock('@/hooks/repositories/useNoteRepository'); vi.mock('@/hooks/pims-api/useApiNotes'); @@ -104,6 +106,11 @@ vi.mocked(usePropertyOperationRepository).mockReturnValue({ >, } as unknown as ReturnType); +const setIsShowingPropertySelectorMock = vi.fn(); +const onUpdatePropertiesMock = vi.fn(); +const confirmBeforeAddPropertyMock = vi.fn(); +const canRemovePropertyMock = vi.fn(); + const history = createMemoryHistory(); describe('LeaseView component', () => { @@ -142,6 +149,10 @@ describe('LeaseView component', () => { fileProperties: getMockLeaseProperties(1), } } + setIsShowingPropertySelector={setIsShowingPropertySelectorMock} + onUpdateProperties={onUpdatePropertiesMock} + confirmBeforeAddProperty={confirmBeforeAddPropertyMock} + canRemoveProperty={canRemovePropertyMock} /> , { diff --git a/source/frontend/src/features/mapSideBar/lease/LeaseView.tsx b/source/frontend/src/features/mapSideBar/lease/LeaseView.tsx index cf833f74a9..a429936876 100644 --- a/source/frontend/src/features/mapSideBar/lease/LeaseView.tsx +++ b/source/frontend/src/features/mapSideBar/lease/LeaseView.tsx @@ -4,9 +4,10 @@ import { Route, Switch, useRouteMatch } from 'react-router-dom'; import LeaseIcon from '@/assets/images/lease-icon.svg?react'; import { Claims, Roles } from '@/constants'; -import LeaseUpdatePropertySelector from '@/features/leases/shared/propertyPicker/LeaseUpdatePropertySelector'; +import { LeaseFormModel } from '@/features/leases/models'; import useKeycloakWrapper from '@/hooks/useKeycloakWrapper'; import { ApiGen_CodeTypes_FileTypes } from '@/models/api/generated/ApiGen_CodeTypes_FileTypes'; +import { ApiGen_Concepts_File } from '@/models/api/generated/ApiGen_Concepts_File'; import { ApiGen_Concepts_Lease } from '@/models/api/generated/ApiGen_Concepts_Lease'; import { exists, stripTrailingSlash } from '@/utils'; @@ -18,8 +19,10 @@ import MapSideBarLayout from '../layout/MapSideBarLayout'; import { InventoryTabNames } from '../property/InventoryTabs'; import FilePropertyRouter from '../router/FilePropertyRouter'; import FileMenuView from '../shared/FileMenuView'; +import { FileForm, PropertyForm } from '../shared/models'; import SidebarFooter from '../shared/SidebarFooter'; import { StyledFormWrapper } from '../shared/styles'; +import UpdatePropertiesContainer from '../shared/update/properties/UpdatePropertiesContainer'; import LeaseHeader from './common/LeaseHeader'; import { LeaseContainerState } from './LeaseContainer'; import LeaseGenerateContainer from './LeaseGenerateContainer'; @@ -42,6 +45,10 @@ export interface ILeaseViewProps { formikRef: React.RefObject>; isFormValid: boolean; lease?: ApiGen_Concepts_Lease; + setIsShowingPropertySelector: (isShowing: boolean) => void; + onUpdateProperties: (file: FileForm) => Promise; + confirmBeforeAddProperty: (propertyForm: PropertyForm) => Promise; + canRemoveProperty: (propertyId: number) => Promise; } export const LeaseView: React.FunctionComponent = ({ @@ -61,6 +68,10 @@ export const LeaseView: React.FunctionComponent = ({ formikRef, isFormValid, lease, + setIsShowingPropertySelector, + onUpdateProperties, + confirmBeforeAddProperty, + canRemoveProperty, }) => { // match for the current route const currentRoute = useRouteMatch(); @@ -74,7 +85,29 @@ export const LeaseView: React.FunctionComponent = ({ return ( - {exists(lease) && } + {exists(lease) && ( + +

    + You have selected a property not previously in the inventory. Do you want to add + this property to the lease? +

    +

    Do you want to acknowledge and proceed?

    + + } + /> + )}
    { await waitForElementToBeRemoved(spinner); await act(async () => { - await viewProps.onUpdateProperties(mockManagementFileApi); + await viewProps.updateFileProperties(ManagementFormModel.fromApi(mockManagementFileApi), []); }); expect(spinner).not.toBeVisible(); expect( @@ -186,7 +188,7 @@ describe('ManagementContainer component', () => { const errorMessage = 'You cannot add a property that is outside of your user account region'; mockAxios.onPut(new RegExp('managementfiles/1/properties')).reply(400, { error: errorMessage }); await act(async () => { - await viewProps.onUpdateProperties(mockManagementFileApi); + await viewProps.updateFileProperties(ManagementFormModel.fromApi(mockManagementFileApi), []); }); expect(spinner).not.toBeVisible(); expect(await screen.findByText(errorMessage)).toBeVisible(); @@ -333,7 +335,10 @@ describe('ManagementContainer component', () => { }); await act(async () => { - await viewProps.onUpdateProperties(mockManagementFileResponse()); + await viewProps.updateFileProperties( + ManagementFormModel.fromApi(mockManagementFileResponse()), + [], + ); }); expect( diff --git a/source/frontend/src/features/mapSideBar/management/ManagementContainer.tsx b/source/frontend/src/features/mapSideBar/management/ManagementContainer.tsx index 355650d1ad..471f2fc2d4 100644 --- a/source/frontend/src/features/mapSideBar/management/ManagementContainer.tsx +++ b/source/frontend/src/features/mapSideBar/management/ManagementContainer.tsx @@ -13,7 +13,6 @@ import { getCancelModalProps, useModalContext } from '@/hooks/useModalContext'; import { IApiError } from '@/interfaces/IApiError'; import { ApiGen_CodeTypes_FileTypes } from '@/models/api/generated/ApiGen_CodeTypes_FileTypes'; import { ApiGen_Concepts_File } from '@/models/api/generated/ApiGen_Concepts_File'; -import { ApiGen_Concepts_ManagementFile } from '@/models/api/generated/ApiGen_Concepts_ManagementFile'; import { UserOverrideCode } from '@/models/api/UserOverrideCode'; import { exists, isValidId, sortFileProperties, stripTrailingSlash } from '@/utils'; @@ -22,6 +21,7 @@ import { FileTabType } from '../shared/detail/FileTabs'; import { PropertyForm } from '../shared/models'; import usePathGenerator from '../shared/sidebarPathGenerator'; import { IManagementViewProps } from './ManagementView'; +import { ManagementFormModel } from './models/ManagementFormModel'; export interface IManagementContainerProps { managementFileId: number; @@ -241,7 +241,7 @@ export const ManagementContainer: React.FunctionComponent => { // The backend does not update the product or project so its safe to send nulls even if there might be data for those fields. return withUserOverride( @@ -249,7 +249,7 @@ export const ManagementContainer: React.FunctionComponent void; onEditProperties: () => void; onSuccess: (updateProperties?: boolean, updateFile?: boolean) => void; - onUpdateProperties: (file: ApiGen_Concepts_File) => Promise; + updateFileProperties: ( + file: WithFormProperties, + userOverrideCodes: UserOverrideCode[], + ) => Promise; confirmBeforeAdd: (propertyForm: PropertyForm) => Promise; canRemove: (propertyId: number) => Promise; isEditing: boolean; @@ -63,7 +68,7 @@ export const ManagementView: React.FunctionComponent = ({ onSelectProperty, onEditProperties, onSuccess, - onUpdateProperties, + updateFileProperties, confirmBeforeAdd, canRemove, isEditing, @@ -109,16 +114,16 @@ export const ManagementView: React.FunctionComponent = ({ {managementFile && ( -

    This property has already been added to one or more management files.

    Do you want to acknowledge and proceed?

    diff --git a/source/frontend/src/features/mapSideBar/management/add/AddManagementContainer.test.tsx b/source/frontend/src/features/mapSideBar/management/add/AddManagementContainer.test.tsx index 09cd6b9ee5..e0fd529025 100644 --- a/source/frontend/src/features/mapSideBar/management/add/AddManagementContainer.test.tsx +++ b/source/frontend/src/features/mapSideBar/management/add/AddManagementContainer.test.tsx @@ -4,7 +4,6 @@ import { createMemoryHistory } from 'history'; import { createRef } from 'react'; import { IMapStateMachineContext } from '@/components/common/mapFSM/MapStateMachineContext'; -import { SelectedFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; import { useManagementFileRepository } from '@/hooks/repositories/useManagementFileRepository'; import { getMockFullyAttributedParcel } from '@/mocks/faParcelLayerResponse.mock'; import { getMockPolygon } from '@/mocks/geometries.mock'; @@ -25,6 +24,10 @@ import { PropertyForm } from '../../shared/models'; import { ManagementFormModel } from '../models/ManagementFormModel'; import AddManagementContainer, { IAddManagementContainerProps } from './AddManagementContainer'; import { IAddManagementContainerViewProps } from './AddManagementContainerView'; +import { + emptyFeatureDataset, + LocationFeatureDataset, +} from '@/components/common/mapFSM/useLocationFeatureLoader'; const history = createMemoryHistory(); @@ -106,7 +109,6 @@ describe('Add Management Container component', () => { it('calls onSuccess when the Management is saved successfully', async () => { const testMockMachine: IMapStateMachineContext = { ...mapMachineBaseMock, - processCreation: vi.fn(), refreshMapProperties: vi.fn(), }; await setup({ mockMapMachine: testMockMachine }); @@ -119,7 +121,6 @@ describe('Add Management Container component', () => { }); expect(onSuccess).toHaveBeenCalled(); - expect(testMockMachine.processCreation).toHaveBeenCalled(); expect(testMockMachine.refreshMapProperties).toHaveBeenCalled(); }); @@ -127,58 +128,43 @@ describe('Add Management Container component', () => { const testMockMachine: IMapStateMachineContext = { ...mapMachineBaseMock, }; - const selectedFeatures: SelectedFeatureDataset[] = [ + const selectedFeatures: LocationFeatureDataset[] = [ { + ...emptyFeatureDataset(), location: { lng: -120.69195885, lat: 50.25163372 }, - fileLocation: null, - fileBoundary: null, - pimsFeature: null, - parcelFeature: getMockFullyAttributedParcel('111-111-111'), + parcelFeatures: [getMockFullyAttributedParcel('111-111-111')], regionFeature: feature(getMockPolygon(), { ...emptyRegion, REGION_NUMBER: 1, REGION_NAME: 'South Coast Region', }), - districtFeature: null, - selectingComponentId: null, - municipalityFeature: null, }, { + ...emptyFeatureDataset(), location: { lng: -120.69195885, lat: 50.25163372 }, - fileLocation: null, - fileBoundary: null, - pimsFeature: null, - parcelFeature: getMockFullyAttributedParcel('222-222-222'), + parcelFeatures: [getMockFullyAttributedParcel('222-222-222')], regionFeature: feature(getMockPolygon(), { ...emptyRegion, REGION_NUMBER: 1, REGION_NAME: 'South Coast Region', }), - districtFeature: null, - selectingComponentId: null, - municipalityFeature: null, }, { + ...emptyFeatureDataset(), location: { lng: -120.69195885, lat: 50.25163372 }, - fileLocation: null, - fileBoundary: null, - pimsFeature: null, - parcelFeature: getMockFullyAttributedParcel('333-333-333'), + parcelFeatures: [getMockFullyAttributedParcel('333-333-333')], regionFeature: feature(getMockPolygon(), { ...emptyRegion, REGION_NUMBER: 1, REGION_NAME: 'South Coast Region', }), - districtFeature: null, - selectingComponentId: null, - municipalityFeature: null, }, ]; await setup({ mockMapMachine: testMockMachine }); await act(async () => { const model = new ManagementFormModel(); - model.fileProperties = selectedFeatures?.map(sf => PropertyForm.fromFeatureDataset(sf)); + model.properties = selectedFeatures?.map(sf => PropertyForm.fromLocationFeatureDataset(sf)); await viewProps?.onSubmit(model, { setSubmitting: vi.fn(), resetForm: vi.fn(), diff --git a/source/frontend/src/features/mapSideBar/management/add/AddManagementContainer.tsx b/source/frontend/src/features/mapSideBar/management/add/AddManagementContainer.tsx index ab6823519b..2c1ec506aa 100644 --- a/source/frontend/src/features/mapSideBar/management/add/AddManagementContainer.tsx +++ b/source/frontend/src/features/mapSideBar/management/add/AddManagementContainer.tsx @@ -1,13 +1,13 @@ import { AxiosError } from 'axios'; import { FormikHelpers, FormikProps } from 'formik'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { useMapStateMachine } from '@/components/common/mapFSM/MapStateMachineContext'; import { useManagementFileRepository } from '@/hooks/repositories/useManagementFileRepository'; import { usePropertyAssociations } from '@/hooks/repositories/usePropertyAssociations'; import useApiUserOverride from '@/hooks/useApiUserOverride'; -import { useEditPropertiesNotifier } from '@/hooks/useEditPropertiesNotifier'; import { useModalContext } from '@/hooks/useModalContext'; +import { usePropertyFormSyncronizer } from '@/hooks/usePropertyFormSyncronizer'; import { IApiError } from '@/interfaces/IApiError'; import { ApiGen_Concepts_ManagementFile } from '@/models/api/generated/ApiGen_Concepts_ManagementFile'; import { UserOverrideCode } from '@/models/api/UserOverrideCode'; @@ -32,14 +32,16 @@ const AddManagementContainer: React.FC = ({ const formikRef = useRef>(null); const { setModalContent, setDisplayModal } = useModalContext(); const { execute: getPropertyAssociations } = usePropertyAssociations(); - const [needsUserConfirmation, setNeedsUserConfirmation] = useState(true); + const [needsFirstTimeConfirmation, setNeedsFirstTimeConfirmation] = useState(true); + const mapMachine = useMapStateMachine(); + const initialForm = new ManagementFormModel(); const { addManagementFileApi: { execute: addManagementFileApi, loading }, } = useManagementFileRepository(); // Warn user that property is part of an existing management file - const confirmBeforeAdd = useCallback( + const confirmProperty = useCallback( async (propertyForm: PropertyForm): Promise => { if (isValidId(propertyForm.apiId)) { const response = await getPropertyAssociations(propertyForm.apiId); @@ -54,76 +56,48 @@ const AddManagementContainer: React.FC = ({ [getPropertyAssociations], ); - const mapMachine = useMapStateMachine(); - - const { featuresWithAddresses, bcaLoading } = useEditPropertiesNotifier( - formikRef, - 'fileProperties', - ); - - const initialForm = useMemo(() => { - const managementForm = new ManagementFormModel(); - return managementForm; - }, []); - // Require user confirmation before adding a property to file - // This is the flow for Map Marker -> right-click -> create Management File - useEffect(() => { - const runAsync = async () => { - const incomingProperties = - featuresWithAddresses?.map(f => PropertyForm.fromFeatureDataset(f.feature)) ?? []; - if (exists(incomingProperties) && exists(formikRef.current) && needsUserConfirmation) { - if (incomingProperties.length > 0) { - // Check all properties for confirmation - const needsConfirmation = await Promise.all( - incomingProperties.map(formProperty => confirmBeforeAdd(formProperty)), - ); - if (needsConfirmation.some(confirm => confirm)) { - setModalContent({ - variant: 'warning', - title: 'User Override Required', - message: ( - <> -

    - One or more properties have already been added to one or more management files. -

    -

    Do you want to acknowledge and proceed?

    - - ), - okButtonText: 'Yes', - cancelButtonText: 'No', - handleOk: () => { - // allow the property to be added to the file being created - formikRef.current.resetForm(); - formikRef.current.setFieldValue('properties', incomingProperties); - setDisplayModal(false); - // show the user confirmation modal only once when creating a file - setNeedsUserConfirmation(false); - }, - handleCancel: () => { - // clear out the properties array as the user did not agree to the popup - incomingProperties.splice(0, incomingProperties.length); - formikRef.current.resetForm(); - formikRef.current.setFieldValue('properties', incomingProperties); - setDisplayModal(false); - // show the user confirmation modal only once when creating a file - setNeedsUserConfirmation(false); - }, - }); - setDisplayModal(true); - } - } + const confirmBeforeAdd = useCallback( + async ( + newPropertyForms: PropertyForm[], + isValidCallback: (isValid: boolean, newProperties: PropertyForm[]) => void, + ) => { + const needsConfirmation = await Promise.all( + newPropertyForms.map(formProperty => confirmProperty(formProperty)), + ); + if (needsFirstTimeConfirmation && needsConfirmation.some(x => x === true)) { + // show the user confirmation modal only once when creating a file + setNeedsFirstTimeConfirmation(false); + setModalContent({ + variant: 'warning', + title: 'User Override Required', + message: ( + <> +

    One or more properties have already been added to one or more management files.

    +

    Do you want to acknowledge and proceed?

    + + ), + okButtonText: 'Yes', + cancelButtonText: 'No', + handleOk: () => { + // allow the property to be added to the file being created + isValidCallback(true, newPropertyForms); + setDisplayModal(false); + }, + handleCancel: () => { + isValidCallback(false, []); + setDisplayModal(false); + }, + }); + setDisplayModal(true); + } else { + isValidCallback(true, newPropertyForms); } - }; + }, + [confirmProperty, needsFirstTimeConfirmation, setDisplayModal, setModalContent], + ); - runAsync(); - }, [ - confirmBeforeAdd, - featuresWithAddresses, - needsUserConfirmation, - setDisplayModal, - setModalContent, - ]); + const { isLoading } = usePropertyFormSyncronizer(formikRef, confirmBeforeAdd); const handleCancel = useCallback(() => onClose(), [onClose]); @@ -163,7 +137,6 @@ const AddManagementContainer: React.FC = ({ handleSuccess(response); } } finally { - mapMachine.processCreation(); formikHelpers?.setSubmitting(false); } }; @@ -172,9 +145,8 @@ const AddManagementContainer: React.FC = ({ { onCancel={onCancel} onSave={onSave} onSubmit={onSubmit} - confirmBeforeAdd={confirmBeforeAdd} />, { ...renderOptions, diff --git a/source/frontend/src/features/mapSideBar/management/add/AddManagementContainerView.tsx b/source/frontend/src/features/mapSideBar/management/add/AddManagementContainerView.tsx index a8a7d1ebe3..278724cdc0 100644 --- a/source/frontend/src/features/mapSideBar/management/add/AddManagementContainerView.tsx +++ b/source/frontend/src/features/mapSideBar/management/add/AddManagementContainerView.tsx @@ -7,7 +7,6 @@ import ConfirmNavigation from '@/components/common/ConfirmNavigation'; import LoadingBackdrop from '@/components/common/LoadingBackdrop'; import MapSideBarLayout from '../../layout/MapSideBarLayout'; -import { PropertyForm } from '../../shared/models'; import SidebarFooter from '../../shared/SidebarFooter'; import { StyledFormWrapper } from '../../shared/styles'; import ManagementForm from '../form/ManagementForm'; @@ -24,7 +23,6 @@ export interface IAddManagementContainerViewProps { ) => void | Promise; onCancel: () => void; onSave: () => void; - confirmBeforeAdd: (propertyForm: PropertyForm) => Promise; } const AddManagementContainerView: React.FunctionComponent = ({ @@ -35,7 +33,6 @@ const AddManagementContainerView: React.FunctionComponent { const history = useHistory(); @@ -64,7 +61,6 @@ const AddManagementContainerView: React.FunctionComponent diff --git a/source/frontend/src/features/mapSideBar/management/add/__snapshots__/AddManagementContainerView.test.tsx.snap b/source/frontend/src/features/mapSideBar/management/add/__snapshots__/AddManagementContainerView.test.tsx.snap index a99127aef7..e11a096634 100644 --- a/source/frontend/src/features/mapSideBar/management/add/__snapshots__/AddManagementContainerView.test.tsx.snap +++ b/source/frontend/src/features/mapSideBar/management/add/__snapshots__/AddManagementContainerView.test.tsx.snap @@ -333,7 +333,7 @@ exports[`Add Management Container View > matches snapshot 1`] = ` align-items: end; } -.c29 { +.c27 { position: -webkit-sticky; position: sticky; padding-top: 2rem; @@ -386,7 +386,7 @@ exports[`Add Management Container View > matches snapshot 1`] = ` font-weight: bold; } -.c27 { +.c26 { margin: 0; padding: 1.6rem; font-size: 1.6rem; @@ -397,18 +397,18 @@ exports[`Add Management Container View > matches snapshot 1`] = ` font-family: 'BcSans-Bold'; } -.c19 { +.c18 { margin: 0 1.6rem 0 1.6rem; padding: 1.6rem; text-align: left; text-underline-offset: 2px; } -.c19 button { +.c18 button { font-size: 14px; } -.c20 { +.c19 { font-size: 16px; color: #1a5a96; font-style: italic; @@ -419,7 +419,7 @@ exports[`Add Management Container View > matches snapshot 1`] = ` margin-bottom: 1rem; } -.c24 { +.c23 { padding: 1.5rem 1.8rem; border-radius: 4px; background-color: #f0f7fc; @@ -431,45 +431,30 @@ exports[`Add Management Container View > matches snapshot 1`] = ` line-height: 24px; } -.c23 { +.c22 { float: right; cursor: pointer; color: #1a5a96; font-size: 2.8rem; } -.c22 { +.c21 { margin-right: 1.5rem; font-size: 1.3rem; } -.c21 { +.c20 { cursor: pointer; } -.c26 { +.c25 { font-weight: normal; } -.c25 { +.c24 { font-weight: bold; } -.c18 { - margin: 0 1.6rem 0 1.6rem; - padding: 0 1.6rem 0 1.6rem; - text-align: left; - text-underline-offset: 2px; -} - -.c18 button { - font-size: 14px; -} - -.c28 { - margin: 0 1.6rem 0 1.6rem; -} - .c12 .form-section { margin: 0; padding-left: 0; @@ -830,43 +815,137 @@ exports[`Add Management Container View > matches snapshot 1`] = ` class="c18" >
    - Select one or more properties that you want to include in this management file. You can choose a location from the map, or search by other criteria. +
    +
    + + + + New workflow +
    +
    + + + + +
    +
    -

    -
    +
  • +
    + Find a Property +
    - Selected Properties + Navigate to an area of the map OR use +
    -
  • -

    + +
  • +
    + Select a property +
    +
    + Click on the map and the selection will be highlighed +
    +
  • +
  • +
    + Add it to this file +
    +
    + Click "Add selected" property button when it appears below +
    +
  • + +
    +
    +
    +

    -
    +
    + matches snapshot 1`] = ` d="M256 8C119.043 8 8 119.083 8 256c0 136.997 111.043 248 248 248s248-111.003 248-248C504 119.083 392.957 8 256 8zm0 110c23.196 0 42 18.804 42 42s-18.804 42-42 42-42-18.804-42-42 18.804-42 42-42zm56 254c0 6.627-5.373 12-12 12h-88c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h12v-64h-12c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h64c6.627 0 12 5.373 12 12v100h12c6.627 0 12 5.373 12 12v24z" /> - New workflow -
    -
    - - - - -
    +
    -
    -
      -
    1. -
      - Find a Property -
      -
      - Navigate to an area of the map OR use - -
      -
    2. -
    3. -
      - Select a property -
      -
      - Click on the map and the selection will be highlighed -
      -
    4. -
    5. -
      - Add it to this file -
      -
      - Click "Add selected" property button when it appears below -
      -
    6. -
    -
    +
    +

    +
    +
    -
    - Identifier -
    -
    - Provide a descriptive name for this land - - - - - -
    + Identifier
    -
    - - No Properties selected - -
    + + + +
    + + No Properties selected +
    @@ -1209,7 +1209,7 @@ exports[`Add Management Container View > matches snapshot 1`] = ` class="" >
    , ) => void | Promise; - confirmBeforeAdd: (propertyForm: PropertyForm) => Promise; } const ManagementForm: React.FC = props => { - const { initialValues, onSubmit, confirmBeforeAdd, formikRef } = props; + const { initialValues, onSubmit, formikRef } = props; const [projectProducts, setProjectProducts] = useState( undefined, @@ -107,9 +105,9 @@ const ManagementForm: React.FC = props => {
    - verifyCallback()} + properties={formikProps.values.properties} />
    diff --git a/source/frontend/src/features/mapSideBar/management/form/ManagementPropertiesSubForm.test.tsx b/source/frontend/src/features/mapSideBar/management/form/ManagementPropertiesSubForm.test.tsx deleted file mode 100644 index 707ed5fa03..0000000000 --- a/source/frontend/src/features/mapSideBar/management/form/ManagementPropertiesSubForm.test.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { Formik, FormikProps } from 'formik'; -import { createRef } from 'react'; -import configureMockStore from 'redux-mock-store'; -import thunk from 'redux-thunk'; - -import { getMockSelectedFeatureDataset } from '@/mocks/featureset.mock'; -import { mapMachineBaseMock } from '@/mocks/mapFSM.mock'; -import { act, render, RenderOptions, userEvent } from '@/utils/test-utils'; - -import { PropertyForm } from '../../shared/models'; -import { ManagementFormModel } from '../models/ManagementFormModel'; -import ManagementPropertiesSubForm from './ManagementPropertiesSubForm'; - -const mockStore = configureMockStore([thunk]); - -const customSetFilePropertyLocations = vi.fn(); - -const confirmBeforeAdd = vi.fn(); - -describe('ManagementPropertiesSubForm component', () => { - const setup = async ( - props: { initialForm: ManagementFormModel }, - renderOptions: RenderOptions = {}, - ) => { - const ref = createRef>(); - const utils = render( - - {formikProps => ( - - )} - , - { - ...renderOptions, - store: mockStore({}), - claims: [], - mockMapMachine: { - ...mapMachineBaseMock, - setFilePropertyLocations: customSetFilePropertyLocations, - }, - }, - ); - - // Wait for any async effects to complete - await act(async () => {}); - - return { - ...utils, - getFormikRef: () => ref, - }; - }; - - let testForm: ManagementFormModel; - - beforeEach(() => { - const mockFeatureSet = getMockSelectedFeatureDataset(); - testForm = new ManagementFormModel(); - testForm.fileProperties = [ - PropertyForm.fromFeatureDataset({ - ...mockFeatureSet, - pimsFeature: { - ...mockFeatureSet.pimsFeature, - properties: { - ...mockFeatureSet.pimsFeature?.properties, - PID_PADDED: '123-456-789', - }, - }, - }), - PropertyForm.fromFeatureDataset({ - ...mockFeatureSet, - pimsFeature: { - ...mockFeatureSet.pimsFeature, - properties: { - ...mockFeatureSet.pimsFeature?.properties, - PIN: 1111222, - }, - }, - }), - ]; - }); - - afterEach(() => { - vi.clearAllMocks(); - customSetFilePropertyLocations.mockReset(); - }); - - it('renders as expected', async () => { - const { asFragment } = await setup({ initialForm: testForm }); - await act(async () => {}); - expect(asFragment()).toMatchSnapshot(); - }); - - it('renders list of properties', async () => { - const { getByText } = await setup({ initialForm: testForm }); - expect(getByText('PID: 123-456-789')).toBeVisible(); - expect(getByText('PIN: 1111222')).toBeVisible(); - }); - - it('should remove property from list when Remove button is clicked', async () => { - const { getAllByTitle, queryByText } = await setup({ initialForm: testForm }); - const pidRow = getAllByTitle('remove')[0]; - await act(async () => userEvent.click(pidRow)); - - expect(queryByText('PID: 123-456-789')).toBeNull(); - }); - - it('should display properties with svg prefixed with incrementing id', async () => { - const { getByTitle } = await setup({ initialForm: testForm }); - expect(getByTitle('1')).toBeInTheDocument(); - expect(getByTitle('2')).toBeInTheDocument(); - }); -}); diff --git a/source/frontend/src/features/mapSideBar/management/form/ManagementPropertiesSubForm.tsx b/source/frontend/src/features/mapSideBar/management/form/ManagementPropertiesSubForm.tsx deleted file mode 100644 index 3381de7059..0000000000 --- a/source/frontend/src/features/mapSideBar/management/form/ManagementPropertiesSubForm.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { FieldArray, FormikProps } from 'formik'; -import { noop } from 'lodash'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { Col, Row } from 'react-bootstrap'; -import styled from 'styled-components'; - -import { Button } from '@/components/common/buttons'; -import { useMapStateMachine } from '@/components/common/mapFSM/MapStateMachineContext'; -import { SelectedFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; -import { Section } from '@/components/common/Section/Section'; -import SelectedPropertyHeaderRow from '@/components/propertySelector/selectedPropertyList/SelectedPropertyHeaderRow'; -import SelectedPropertyRow from '@/components/propertySelector/selectedPropertyList/SelectedPropertyRow'; -import useDraftMarkerSynchronizer from '@/hooks/useDraftMarkerSynchronizer'; -import { useFeatureDatasetsWithAddresses } from '@/hooks/useFeatureDatasetsWithAddresses'; -import { featuresetToLocationBoundaryDataset } from '@/utils'; -import { addPropertiesToCurrentFile } from '@/utils/propertyUtils'; -import { exists, firstOrNull } from '@/utils/utils'; - -import { PropertyForm } from '../../shared/models'; -import AddPropertiesGuide from '../../shared/update/properties/AddPropertiesGuide'; -import { ManagementFormModel } from '../models/ManagementFormModel'; - -export interface ManagementPropertiesSubFormProps { - formikProps: FormikProps; - confirmBeforeAdd: (propertyForm: PropertyForm) => Promise; -} - -const ManagementPropertiesSubForm: React.FunctionComponent = ({ - formikProps, -}) => { - const localRef = useRef>(null); - - const { selectedFeatures, processCreation, mapLocationFeatureDataset, prepareForCreation } = - useMapStateMachine(); - - useDraftMarkerSynchronizer( - formikProps.values.fileProperties.map(p => - featuresetToLocationBoundaryDataset(p.toFeatureDataset()), - ), - ); - - // Get PropertyForms with addresses for all selected features - const { featuresWithAddresses } = useFeatureDatasetsWithAddresses(selectedFeatures ?? []); - - const selectedFeatureDataset = useMemo(() => { - return { - selectingComponentId: mapLocationFeatureDataset?.selectingComponentId ?? null, - location: mapLocationFeatureDataset?.location, - fileLocation: mapLocationFeatureDataset?.fileLocation ?? null, - fileBoundary: null, - parcelFeature: firstOrNull(mapLocationFeatureDataset?.parcelFeatures), - pimsFeature: firstOrNull(mapLocationFeatureDataset?.pimsFeatures), - regionFeature: mapLocationFeatureDataset?.regionFeature ?? null, - districtFeature: mapLocationFeatureDataset?.districtFeature ?? null, - municipalityFeature: firstOrNull(mapLocationFeatureDataset?.municipalityFeatures), - isActive: true, - displayOrder: 0, - }; - }, [ - mapLocationFeatureDataset?.selectingComponentId, - mapLocationFeatureDataset?.location, - mapLocationFeatureDataset?.fileLocation, - mapLocationFeatureDataset?.parcelFeatures, - mapLocationFeatureDataset?.pimsFeatures, - mapLocationFeatureDataset?.regionFeature, - mapLocationFeatureDataset?.districtFeature, - mapLocationFeatureDataset?.municipalityFeatures, - ]); - - // Convert SelectedFeatureDataset to PropertyForm - const propertyForms = useMemo( - () => - featuresWithAddresses.map(obj => { - const property = PropertyForm.fromFeatureDataset(obj.feature); - if (exists(obj.address)) { - property.address = obj.address; - } - return property; - }), - [featuresWithAddresses], - ); - - const handleAddToSelection = useCallback(() => { - prepareForCreation([selectedFeatureDataset]); - }, [prepareForCreation, selectedFeatureDataset]); - - useEffect(() => { - if (exists(localRef.current) && propertyForms.length > 0) { - addPropertiesToCurrentFile(localRef, 'fileProperties', propertyForms, noop); - processCreation(); - } - }, [localRef, processCreation, propertyForms]); - - return ( - -
    - Select one or more properties that you want to include in this management file. You can - choose a location from the map, or search by other criteria. -
    - - - {({ remove }) => ( -
    - - {exists(selectedFeatureDataset?.parcelFeature) && ( - - - - )} - - {formikProps.values.fileProperties.map((property, index) => ( - remove(index)} - nameSpace={`fileProperties.${index}`} - index={index} - property={property.toFeatureDataset()} - /> - ))} - {formikProps.values.fileProperties.length === 0 && ( - - - No Properties selected - - - )} -
    - )} -
    -
    - ); -}; - -export default ManagementPropertiesSubForm; - -const StyledComponentWrapper = styled.div` - margin: 0 1.6rem 0 1.6rem; - padding: 0 1.6rem 0 1.6rem; - text-align: left; - text-underline-offset: 2px; - - button { - font-size: 14px; - } -`; - -const StyledButtonWrapper = styled.div` - margin: 0 1.6rem; - padding-left: 1.6rem; - text-align: left; - text-underline-offset: 2px; - - button { - font-size: 14px; - } -`; - -export const HeaderRow = styled(Row)` - margin: 0 1.6rem 0 1.6rem; -`; diff --git a/source/frontend/src/features/mapSideBar/management/models/ManagementFormModel.ts b/source/frontend/src/features/mapSideBar/management/models/ManagementFormModel.ts index e966a0bf10..c6b9593a19 100644 --- a/source/frontend/src/features/mapSideBar/management/models/ManagementFormModel.ts +++ b/source/frontend/src/features/mapSideBar/management/models/ManagementFormModel.ts @@ -6,10 +6,10 @@ import { applyDisplayOrder } from '@/utils'; import { fromTypeCode, toTypeCodeNullable } from '@/utils/formUtils'; import { exists } from '@/utils/utils'; -import { PropertyForm } from '../../shared/models'; +import { PropertyForm, WithFormProperties } from '../../shared/models'; import { ManagementTeamSubFormModel, WithManagementTeam } from './ManagementTeamSubFormModel'; -export class ManagementFormModel implements WithManagementTeam { +export class ManagementFormModel implements WithManagementTeam, WithFormProperties { fileName: string | null = ''; additionalDetails: string | null = ''; filePurpose: string | null = ''; @@ -19,7 +19,7 @@ export class ManagementFormModel implements WithManagementTeam { productId: string | null = null; fundingTypeCode: string | null = null; purposeTypeCode: string | null = null; - fileProperties: PropertyForm[] = []; + properties: PropertyForm[] = []; team: ManagementTeamSubFormModel[] = []; constructor( @@ -34,7 +34,7 @@ export class ManagementFormModel implements WithManagementTeam { } toApi(): ApiGen_Concepts_ManagementFile { - const fileProperties = this.fileProperties.map(x => this.toPropertyApi(x)); + const fileProperties = this.properties.map(x => this.toPropertyApi(x)); const sortedProperties = applyDisplayOrder(fileProperties); return { id: this.id ?? 0, @@ -87,7 +87,7 @@ export class ManagementFormModel implements WithManagementTeam { managementForm.fileName = model.fileName ?? ''; managementForm.team = model.managementTeam?.map(x => ManagementTeamSubFormModel.fromApi(x)) || []; - managementForm.fileProperties = model.fileProperties?.map(x => PropertyForm.fromApi(x)) || []; + managementForm.properties = model.fileProperties?.map(x => PropertyForm.fromApi(x)) || []; return managementForm; } diff --git a/source/frontend/src/features/mapSideBar/property/ComposedProperty.ts b/source/frontend/src/features/mapSideBar/property/ComposedProperty.ts index e46470ec09..00e93f17af 100644 --- a/source/frontend/src/features/mapSideBar/property/ComposedProperty.ts +++ b/source/frontend/src/features/mapSideBar/property/ComposedProperty.ts @@ -1,22 +1,8 @@ -import { Feature, FeatureCollection, GeoJsonProperties, Geometry } from 'geojson'; - +import { FeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; import { LtsaOrders, SpcpOrder } from '@/interfaces/ltsaModels'; import { ApiGen_Concepts_Property } from '@/models/api/generated/ApiGen_Concepts_Property'; import { ApiGen_Concepts_PropertyAssociations } from '@/models/api/generated/ApiGen_Concepts_PropertyAssociations'; -import { ADM_IndianReserveBands_Feature_Properties } from '@/models/layers/admIndianReserveBands'; -import { WHSE_AgriculturalLandReservePoly_Feature_Properties } from '@/models/layers/alcAgriculturalReserve'; import { IBcAssessmentSummary } from '@/models/layers/bcAssesment'; -import { - TANTALIS_CrownLandInclusions_Feature_Properties, - TANTALIS_CrownLandInventory_Feature_Properties, - TANTALIS_CrownLandLeases_Feature_Properties, - TANTALIS_CrownLandLicenses_Feature_Properties, - TANTALIS_CrownLandTenures_Feature_Properties, -} from '@/models/layers/crownLand'; -import { EBC_ELECTORAL_DISTS_BS10_SVW_Feature_Properties } from '@/models/layers/electoralBoundaries'; -import { WHSE_Municipalities_Feature_Properties } from '@/models/layers/municipalities'; -import { PMBC_FullyAttributed_Feature_Properties } from '@/models/layers/parcelMapBC'; -import { ISS_ProvincialPublicHighway } from '@/models/layers/pimsHighwayLayer'; export interface ComposedProperty { pid: string | undefined; @@ -27,19 +13,6 @@ export interface ComposedProperty { spcpOrder: SpcpOrder | undefined; pimsProperty: ApiGen_Concepts_Property | undefined; propertyAssociations: ApiGen_Concepts_PropertyAssociations | undefined; - parcelMapFeatureCollection: - | FeatureCollection - | undefined; - pimsGeoserverFeatureCollection: FeatureCollection | undefined; // TODO: These need to be strongly typed bcAssessmentSummary: IBcAssessmentSummary | undefined; - crownTenureFeatures: Feature[]; - crownLeaseFeatures: Feature[]; - crownLicenseFeatures: Feature[]; - crownInclusionFeatures: Feature[]; - crownInventoryFeatures: Feature[]; - highwayFeatures: Feature[]; - municipalityFeatures: Feature[]; - firstNationFeatures: Feature[]; - alrFeatures: Feature[]; - electoralFeatures: Feature[]; + featureDataset: FeatureDataset; } diff --git a/source/frontend/src/features/mapSideBar/property/MotiInventoryContainer.test.tsx b/source/frontend/src/features/mapSideBar/property/MotiInventoryContainer.test.tsx index ab806260b0..6c9a40fd1e 100644 --- a/source/frontend/src/features/mapSideBar/property/MotiInventoryContainer.test.tsx +++ b/source/frontend/src/features/mapSideBar/property/MotiInventoryContainer.test.tsx @@ -13,6 +13,7 @@ import { act, cleanup, render, RenderOptions, userEvent, waitFor } from '@/utils import MotiInventoryContainer, { IMotiInventoryContainerProps } from './MotiInventoryContainer'; import { mockFAParcelLayerResponse } from '@/mocks/faParcelLayerResponse.mock'; +import { emptyFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; const mockAxios = new MockAdapter(axios); const history = createMemoryHistory(); @@ -140,23 +141,10 @@ describe('MotiInventoryContainer component', () => { it('shows the crown tab when property has a TANTALIS record', async () => { const testMockMachine: IMapStateMachineContext = { ...mapMachineBaseMock, - isSelecting: true, - selectingComponentId: undefined, mapLocationFeatureDataset: { + ...emptyFeatureDataset(), location: { lng: -120.69195885, lat: 50.25163372 }, - fileLocation: null, - pimsFeatures: null, parcelFeatures: mockFAParcelLayerResponse.features as any, - regionFeature: null, - districtFeature: null, - municipalityFeatures: null, - highwayFeatures: null, - selectingComponentId: null, - crownLandLeasesFeatures: null, - crownLandLicensesFeatures: null, - crownLandTenuresFeatures: null, - crownLandInventoryFeatures: null, - crownLandInclusionsFeatures: null, }, }; diff --git a/source/frontend/src/features/mapSideBar/property/MotiInventoryContainer.tsx b/source/frontend/src/features/mapSideBar/property/MotiInventoryContainer.tsx index ada9717a9e..aebb9fcbbd 100644 --- a/source/frontend/src/features/mapSideBar/property/MotiInventoryContainer.tsx +++ b/source/frontend/src/features/mapSideBar/property/MotiInventoryContainer.tsx @@ -41,14 +41,14 @@ export const MotiInventoryContainer: React.FunctionComponent< const { setModalContent, setDisplayModal } = useModalContext(); const mapMachine = useMapStateMachine(); - const selectedFeatureData = mapMachine.mapLocationFeatureDataset; + const locationFeatureDataset = mapMachine.mapLocationFeatureDataset; const formikRef = useRef>(null); let boundary: Geometry = null; if (exists(props.id)) { - boundary = firstOrNull(selectedFeatureData?.pimsFeatures)?.geometry; + boundary = firstOrNull(locationFeatureDataset?.pimsFeatures)?.geometry; } else if (exists(props.pid || props.pin)) { - boundary = firstOrNull(selectedFeatureData?.parcelFeatures)?.geometry; + boundary = firstOrNull(locationFeatureDataset?.parcelFeatures)?.geometry; } else if (exists(props.location?.lng) && exists(props.location?.lat)) { boundary = point([props.location?.lng, props.location?.lat])?.geometry; } diff --git a/source/frontend/src/features/mapSideBar/property/MotiInventoryHeader.test.tsx b/source/frontend/src/features/mapSideBar/property/MotiInventoryHeader.test.tsx index 99c894232d..ebd673a920 100644 --- a/source/frontend/src/features/mapSideBar/property/MotiInventoryHeader.test.tsx +++ b/source/frontend/src/features/mapSideBar/property/MotiInventoryHeader.test.tsx @@ -9,6 +9,7 @@ import { act, render, RenderOptions, RenderResult } from '@/utils/test-utils'; import { ComposedProperty } from './ComposedProperty'; import { IMotiInventoryHeaderProps, MotiInventoryHeader } from './MotiInventoryHeader'; +import { emptyFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; const defaultComposedProperty: ComposedProperty = { pid: undefined, @@ -17,21 +18,12 @@ const defaultComposedProperty: ComposedProperty = { ltsaOrders: undefined, pimsProperty: undefined, propertyAssociations: undefined, - parcelMapFeatureCollection: undefined, - pimsGeoserverFeatureCollection: undefined, - bcAssessmentSummary: undefined, - crownTenureFeatures: undefined, + featureDataset: { + ...emptyFeatureDataset(), + }, planNumber: undefined, spcpOrder: undefined, - crownInclusionFeatures: undefined, - crownInventoryFeatures: undefined, - crownLeaseFeatures: undefined, - crownLicenseFeatures: undefined, - highwayFeatures: undefined, - municipalityFeatures: undefined, - firstNationFeatures: undefined, - alrFeatures: undefined, - electoralFeatures: undefined, + bcAssessmentSummary: undefined, }; vi.mock('@/hooks/repositories/useHistoricalNumberRepository'); @@ -143,9 +135,9 @@ describe('MotiInventoryHeader component', () => { const result = await setup({ composedProperty: { ...defaultComposedProperty, - pimsGeoserverFeatureCollection: { - type: 'FeatureCollection', - features: [testProperty], + featureDataset: { + ...emptyFeatureDataset(), + pimsFeatures: [testProperty], }, }, isLoading: false, diff --git a/source/frontend/src/features/mapSideBar/property/MotiInventoryHeader.tsx b/source/frontend/src/features/mapSideBar/property/MotiInventoryHeader.tsx index 55f6c2853f..05f01095c1 100644 --- a/source/frontend/src/features/mapSideBar/property/MotiInventoryHeader.tsx +++ b/source/frontend/src/features/mapSideBar/property/MotiInventoryHeader.tsx @@ -34,14 +34,14 @@ export interface IMotiInventoryHeaderProps { } export const MotiInventoryHeader: React.FunctionComponent = props => { - const parcelMapData = props.composedProperty.parcelMapFeatureCollection; - const geoserverMapData = props.composedProperty.pimsGeoserverFeatureCollection; + const parcelMapData = firstOrNull(props.composedProperty.featureDataset.parcelFeatures); + const geoserverMapData = firstOrNull(props.composedProperty.featureDataset.pimsFeatures); const apiProperty = props.composedProperty.pimsProperty; let pmbcParcel: Feature | null = null; - if (exists(parcelMapData?.features[0])) { - pmbcParcel = firstOrNull(parcelMapData?.features); + if (exists(parcelMapData)) { + pmbcParcel = parcelMapData; } const pid = pidFormatter(props.composedProperty.pid); @@ -61,15 +61,11 @@ export const MotiInventoryHeader: React.FunctionComponent { - if ( - geoserverMapData?.features?.length && - exists(geoserverMapData?.features[0]) && - geoserverMapData?.features[0]?.properties?.IS_DISPOSED - ) { + if (exists(geoserverMapData?.properties?.IS_DISPOSED)) { return true; } return false; - }, [geoserverMapData?.features]); + }, [geoserverMapData]); return ( diff --git a/source/frontend/src/features/mapSideBar/property/PropertyContainer.tsx b/source/frontend/src/features/mapSideBar/property/PropertyContainer.tsx index 3140a04f48..e5d1ef9fa0 100644 --- a/source/frontend/src/features/mapSideBar/property/PropertyContainer.tsx +++ b/source/frontend/src/features/mapSideBar/property/PropertyContainer.tsx @@ -182,16 +182,18 @@ export const PropertyContainer: React.FunctionComponent property={{ ...toFormValues(composedPropertyState?.apiWrapper?.response), electoralDistrict: firstOrNull( - composedPropertyState?.composedProperty?.electoralFeatures, + composedPropertyState?.composedProperty?.featureDataset?.electoralFeatures, ), - isALR: composedPropertyState?.composedProperty?.alrFeatures?.length > 0, + isALR: composedPropertyState?.composedProperty?.featureDataset?.alrFeatures?.length > 0, firstNations: { bandName: - firstOrNull(composedPropertyState?.composedProperty?.firstNationFeatures) - ?.properties.BAND_NAME || '', + firstOrNull( + composedPropertyState?.composedProperty?.featureDataset?.firstNationFeatures, + )?.properties.BAND_NAME || '', reserveName: - firstOrNull(composedPropertyState?.composedProperty?.firstNationFeatures) - ?.properties.ENGLISH_NAME || '', + firstOrNull( + composedPropertyState?.composedProperty?.featureDataset?.firstNationFeatures, + )?.properties.ENGLISH_NAME || '', }, }} loading={composedPropertyState.apiWrapper?.loading ?? false} @@ -241,7 +243,8 @@ export const PropertyContainer: React.FunctionComponent if (exists(composedPropertyState?.composedProperty)) { const composedProperty = composedPropertyState?.composedProperty; - if (composedProperty?.parcelMapFeatureCollection?.features?.length > 0) { + const featureDataset = composedPropertyState?.composedProperty?.featureDataset; + if (featureDataset?.parcelFeatures?.length > 0) { tabViews.push({ content: ( name: 'PMBC', }); } - if ( - composedProperty?.pimsGeoserverFeatureCollection?.features?.length > 0 && - !exists(composedProperty?.id) - ) { + if (featureDataset?.pimsFeatures?.length > 0 && !exists(composedProperty?.id)) { tabViews.push({ content: ( }); } if ( - composedProperty?.crownInclusionFeatures?.length + - composedProperty?.crownInventoryFeatures?.length + - composedProperty?.crownLeaseFeatures?.length + - composedProperty?.crownLeaseFeatures?.length + - composedProperty?.crownLicenseFeatures?.length + - composedProperty?.crownTenureFeatures?.length > + featureDataset?.crownLandInclusionsFeatures?.length + + featureDataset?.crownLandInventoryFeatures?.length + + featureDataset?.crownLandLeasesFeatures?.length + + featureDataset?.crownLandLicensesFeatures?.length + + featureDataset?.crownLandTenuresFeatures?.length > 0 ) { tabViews.push({ @@ -291,7 +290,7 @@ export const PropertyContainer: React.FunctionComponent name: 'Crown', }); } - if (composedProperty?.highwayFeatures?.length > 0) { + if (featureDataset?.highwayFeatures?.length > 0) { tabViews.push({ content: ( }); } if ( - composedProperty?.municipalityFeatures?.length > 0 || - composedProperty?.electoralFeatures?.length > 0 || - composedProperty?.alrFeatures?.length > 0 || - (composedProperty?.firstNationFeatures?.length > 0 && + featureDataset?.municipalityFeatures?.length > 0 || + featureDataset?.electoralFeatures?.length > 0 || + featureDataset?.alrFeatures?.length > 0 || + (featureDataset?.firstNationFeatures?.length > 0 && !composedPropertyState.alrLoading && !composedPropertyState.electoralLoading && !composedPropertyState.electoralLoading && diff --git a/source/frontend/src/features/mapSideBar/research/ResearchContainer.tsx b/source/frontend/src/features/mapSideBar/research/ResearchContainer.tsx index 9c36f4c9d4..4de2b123fb 100644 --- a/source/frontend/src/features/mapSideBar/research/ResearchContainer.tsx +++ b/source/frontend/src/features/mapSideBar/research/ResearchContainer.tsx @@ -20,6 +20,7 @@ import { exists, isValidId, sortFileProperties, stripTrailingSlash } from '@/uti import { SideBarContext } from '../context/sidebarContext'; import { PropertyForm } from '../shared/models'; import usePathGenerator from '../shared/sidebarPathGenerator'; +import { ResearchForm } from './add/models'; import { useGetResearch } from './hooks/useGetResearch'; import { useUpdateResearchProperties } from './hooks/useUpdateResearchProperties'; import { IResearchViewProps } from './ResearchView'; @@ -250,15 +251,10 @@ export const ResearchContainer: React.FunctionComponent [getPropertyAssociations, researchFileId], ); - const onUpdateProperties = ( - file: ApiGen_Concepts_File, - ): Promise => { + const onUpdateProperties = (file: ResearchForm): Promise => { return withUserOverride( (userOverrideCodes: UserOverrideCode[]) => { - return updateResearchFileProperties( - file as ApiGen_Concepts_ResearchFile, - userOverrideCodes, - ).then(response => { + return updateResearchFileProperties(file.toApi(), userOverrideCodes).then(response => { onSuccess(); return response; }); diff --git a/source/frontend/src/features/mapSideBar/research/ResearchView.tsx b/source/frontend/src/features/mapSideBar/research/ResearchView.tsx index eddd37e0ff..359c7f9eed 100644 --- a/source/frontend/src/features/mapSideBar/research/ResearchView.tsx +++ b/source/frontend/src/features/mapSideBar/research/ResearchView.tsx @@ -22,7 +22,8 @@ import FileMenuView from '../shared/FileMenuView'; import { PropertyForm } from '../shared/models'; import SidebarFooter from '../shared/SidebarFooter'; import { StyledFormWrapper } from '../shared/styles'; -import UpdateProperties from '../shared/update/properties/UpdateProperties'; +import UpdatePropertiesContainer from '../shared/update/properties/UpdatePropertiesContainer'; +import { ResearchForm } from './add/models'; import ResearchHeader from './common/ResearchHeader'; import ResearchGenerateContainer from './ResearchGenerateContainer'; import ResearchRouter from './ResearchRouter'; @@ -42,7 +43,7 @@ export interface IResearchViewProps { onSelectFileSummary: () => void; onSelectProperty: (propertyId: number) => void; onEditProperties: () => void; - onUpdateProperties: (file: ApiGen_Concepts_File) => Promise; + onUpdateProperties: (file: ResearchForm) => Promise; confirmBeforeAdd: (propertyForm: PropertyForm) => Promise; canRemove: (propertyId: number) => Promise; onSuccess: () => void; @@ -85,13 +86,13 @@ const ResearchView: React.FunctionComponent = ({ {exists(researchFile) && ( -

    This property has already been added to one or more research files.

    Do you want to acknowledge and proceed?

    diff --git a/source/frontend/src/features/mapSideBar/research/add/AddResearchContainer.test.tsx b/source/frontend/src/features/mapSideBar/research/add/AddResearchContainer.test.tsx index 3952cd94f3..b7e638fe72 100644 --- a/source/frontend/src/features/mapSideBar/research/add/AddResearchContainer.test.tsx +++ b/source/frontend/src/features/mapSideBar/research/add/AddResearchContainer.test.tsx @@ -24,6 +24,7 @@ import { SideBarContextProvider } from '../../context/sidebarContext'; import { useAddResearch } from '../hooks/useAddResearch'; import AddResearchContainer, { IAddResearchContainerProps } from './AddResearchContainer'; import AddResearchForm from './AddResearchForm'; +import { emptyFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; const mockStore = configureMockStore([thunk]); @@ -110,17 +111,12 @@ describe('AddResearchContainer component', () => { const { findByText } = await setup({ mockMapMachine: { ...mapMachineBaseMock, - selectedFeatures: [ + pendingLocationFeaturesAddition: true, + locationFeaturesForAddition: [ { + ...emptyFeatureDataset(), location: { lat: 0, lng: 0 }, - fileLocation: null, - fileBoundary: null, - pimsFeature: null, - parcelFeature: selectedFeature, - regionFeature: null, - districtFeature: null, - selectingComponentId: null, - municipalityFeature: undefined, + parcelFeatures: [selectedFeature], }, ], }, @@ -155,7 +151,7 @@ describe('AddResearchContainer component', () => { it('should save the form and navigate to details view when Save button is clicked', async () => { const testMockMachine: IMapStateMachineContext = { ...mapMachineBaseMock, - processCreation: vi.fn(), + processLocationFeaturesAddition: vi.fn(), refreshMapProperties: vi.fn(), }; @@ -169,7 +165,6 @@ describe('AddResearchContainer component', () => { await act(async () => userEvent.click(getSaveButton())); expect(onSuccess).toHaveBeenCalled(); - expect(testMockMachine.processCreation).toHaveBeenCalled(); expect(testMockMachine.refreshMapProperties).toHaveBeenCalled(); }); @@ -177,39 +172,22 @@ describe('AddResearchContainer component', () => { const { getNameTextbox, getSaveButton } = await setup({ mockMapMachine: { ...mapMachineBaseMock, - selectedFeatures: [ + pendingLocationFeaturesAddition: true, + locationFeaturesForAddition: [ { + ...emptyFeatureDataset(), location: { lng: -120.69195885, lat: 50.25163372 }, - fileLocation: null, - fileBoundary: null, - pimsFeature: null, - parcelFeature: getMockFullyAttributedParcel('111-111-111'), - regionFeature: null, - districtFeature: null, - selectingComponentId: null, - municipalityFeature: null, + parcelFeatures: [getMockFullyAttributedParcel('111-111-111')], }, { + ...emptyFeatureDataset(), location: { lng: -120.69195885, lat: 50.25163372 }, - fileLocation: null, - fileBoundary: null, - pimsFeature: null, - parcelFeature: getMockFullyAttributedParcel('222-222-222'), - regionFeature: null, - districtFeature: null, - selectingComponentId: null, - municipalityFeature: null, + parcelFeatures: [getMockFullyAttributedParcel('222-222-222')], }, { + ...emptyFeatureDataset(), location: { lng: -120.69195885, lat: 50.25163372 }, - fileLocation: null, - fileBoundary: null, - pimsFeature: null, - parcelFeature: getMockFullyAttributedParcel('333-333-333'), - regionFeature: null, - districtFeature: null, - selectingComponentId: null, - municipalityFeature: null, + parcelFeatures: [getMockFullyAttributedParcel('333-333-333')], }, ], }, diff --git a/source/frontend/src/features/mapSideBar/research/add/AddResearchContainer.tsx b/source/frontend/src/features/mapSideBar/research/add/AddResearchContainer.tsx index f1533369be..8647e500d1 100644 --- a/source/frontend/src/features/mapSideBar/research/add/AddResearchContainer.tsx +++ b/source/frontend/src/features/mapSideBar/research/add/AddResearchContainer.tsx @@ -1,5 +1,5 @@ import { Formik, FormikProps } from 'formik'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { useHistory } from 'react-router-dom'; import { toast } from 'react-toastify'; @@ -10,8 +10,8 @@ import { useMapStateMachine } from '@/components/common/mapFSM/MapStateMachineCo import MapSideBarLayout from '@/features/mapSideBar/layout/MapSideBarLayout'; import { usePropertyAssociations } from '@/hooks/repositories/usePropertyAssociations'; import useApiUserOverride from '@/hooks/useApiUserOverride'; -import { useEditPropertiesNotifier } from '@/hooks/useEditPropertiesNotifier'; import { useModalContext } from '@/hooks/useModalContext'; +import { usePropertyFormSyncronizer } from '@/hooks/usePropertyFormSyncronizer'; import { ApiGen_Concepts_ResearchFile } from '@/models/api/generated/ApiGen_Concepts_ResearchFile'; import { UserOverrideCode } from '@/models/api/UserOverrideCode'; import { exists, isValidId } from '@/utils'; @@ -21,13 +21,12 @@ import SidebarFooter from '../../shared/SidebarFooter'; import { StyledFormWrapper } from '../../shared/styles'; import { useAddResearch } from '../hooks/useAddResearch'; import { AddResearchFileYupSchema } from './AddResearchFileYupSchema'; -import { IAddResearchFormProps } from './AddResearchForm'; import { ResearchForm } from './models'; export interface IAddResearchContainerProps { onClose: () => void; onSuccess: (newResearchId: number) => void; - View: React.FC; + View: React.FC; } export const AddResearchContainer: React.FunctionComponent = props => { @@ -39,10 +38,12 @@ export const AddResearchContainer: React.FunctionComponent(true); + const [needsFirstTimeConfirmation, setNeedsFirstTimeConfirmation] = useState(true); + + const initialForm = new ResearchForm(); // Warn user that property is part of an existing research file - const confirmBeforeAdd = useCallback( + const confirmProperty = useCallback( async (propertyForm: PropertyForm): Promise => { if (isValidId(propertyForm.apiId)) { const response = await getPropertyAssociations(propertyForm.apiId); @@ -57,68 +58,54 @@ export const AddResearchContainer: React.FunctionComponent { - return new ResearchForm(); - }, []); - const withUserOverride = useApiUserOverride< (userOverrideCodes: UserOverrideCode[]) => Promise >('Failed to add Research File'); // Require user confirmation before adding a property to file - // This is the flow for Map Marker -> right-click -> create Research File - useEffect(() => { - const runAsync = async () => { - if (exists(initialForm) && exists(formikRef.current) && needsUserConfirmation) { - if (initialForm.properties.length > 0) { - // Check all properties for confirmation - const needsConfirmation = await Promise.all( - initialForm.properties.map(formProperty => confirmBeforeAdd(formProperty)), - ); - if (needsConfirmation.some(confirm => confirm)) { - setModalContent({ - variant: 'warning', - title: 'User Override Required', - message: ( - <> -

    - One or more properties have already been added to one or more research files. -

    -

    Do you want to acknowledge and proceed?

    - - ), - okButtonText: 'Yes', - cancelButtonText: 'No', - handleOk: () => { - // allow the properties to be added to the file being created - formikRef.current.resetForm(); - formikRef.current.setFieldValue('properties', initialForm.properties); - setDisplayModal(false); - // show the user confirmation modal only once when creating a file - setNeedsUserConfirmation(false); - }, - handleCancel: () => { - // clear out the properties array as the user did not agree to the popup - initialForm.properties.splice(0, initialForm.properties.length); - formikRef.current.resetForm(); - formikRef.current.setFieldValue('properties', initialForm.properties); - setDisplayModal(false); - // show the user confirmation modal only once when creating a file - setNeedsUserConfirmation(false); - }, - }); - setDisplayModal(true); - } - } + const confirmBeforeAdd = useCallback( + async ( + newPropertyForms: PropertyForm[], + isValidCallback: (isValid: boolean, newProperties: PropertyForm[]) => void, + ) => { + const needsConfirmation = await Promise.all( + newPropertyForms.map(formProperty => confirmProperty(formProperty)), + ); + if (needsFirstTimeConfirmation && needsConfirmation.some(x => x === true)) { + // show the user confirmation modal only once when creating a file + setNeedsFirstTimeConfirmation(false); + setModalContent({ + variant: 'warning', + title: 'User Override Required', + message: ( + <> +

    One or more properties have already been added to one or more research files.

    +

    Do you want to acknowledge and proceed?

    + + ), + okButtonText: 'Yes', + cancelButtonText: 'No', + handleOk: () => { + // allow the properties to be added to the file being created + isValidCallback(true, newPropertyForms); + setDisplayModal(false); + }, + handleCancel: () => { + // clear out the properties array as the user did not agree to the popup + isValidCallback(false, []); + setDisplayModal(false); + }, + }); + setDisplayModal(true); + } else { + isValidCallback(true, newPropertyForms); } - }; + }, + [confirmProperty, needsFirstTimeConfirmation, setDisplayModal, setModalContent], + ); - runAsync(); - }, [confirmBeforeAdd, initialForm, needsUserConfirmation, setDisplayModal, setModalContent]); + // Get PropertyForms with addresses for all selected features + const { isLoading } = usePropertyFormSyncronizer(formikRef, confirmBeforeAdd); const saveResearchFile = async ( researchFile: ApiGen_Concepts_ResearchFile, @@ -140,7 +127,6 @@ export const AddResearchContainer: React.FunctionComponent} footer={ - - + + diff --git a/source/frontend/src/features/mapSideBar/research/add/AddResearchForm.test.tsx b/source/frontend/src/features/mapSideBar/research/add/AddResearchForm.test.tsx index eadb74aed5..ba5bc6fe4a 100644 --- a/source/frontend/src/features/mapSideBar/research/add/AddResearchForm.test.tsx +++ b/source/frontend/src/features/mapSideBar/research/add/AddResearchForm.test.tsx @@ -32,9 +32,7 @@ describe('AddResearchForm component', () => { initialValues={renderOptions.initialValues} validationSchema={AddResearchFileYupSchema} > - {formikProps => ( - - )} + {formikProps => } , { ...renderOptions, diff --git a/source/frontend/src/features/mapSideBar/research/add/AddResearchForm.tsx b/source/frontend/src/features/mapSideBar/research/add/AddResearchForm.tsx index 3c90cbd041..cca1de0990 100644 --- a/source/frontend/src/features/mapSideBar/research/add/AddResearchForm.tsx +++ b/source/frontend/src/features/mapSideBar/research/add/AddResearchForm.tsx @@ -1,3 +1,4 @@ +import { useFormikContext } from 'formik'; import React from 'react'; import { Col, Row } from 'react-bootstrap'; import styled from 'styled-components'; @@ -5,16 +6,14 @@ import styled from 'styled-components'; import { Input } from '@/components/common/form/'; import { Section } from '@/components/common/Section/Section'; -import { PropertyForm } from '../../shared/models'; +import PropertiesListContainer from '../../shared/update/properties/PropertiesListContainer'; import { ResearchFileNameGuide } from '../common/ResearchFileNameGuide'; import { UpdateProjectsSubForm } from '../common/updateProjects/UpdateProjectsSubForm'; -import ResearchProperties from './ResearchProperties'; +import { ResearchForm } from './models'; -export interface IAddResearchFormProps { - confirmBeforeAdd: (propertyForm: PropertyForm) => Promise; -} +const AddResearchForm: React.FC = () => { + const { values } = useFormikContext(); -const AddResearchForm: React.FC = props => { return (
    @@ -35,7 +34,13 @@ const AddResearchForm: React.FC = props => {
    - + +
    + removeCallback()} + /> +
    ); }; diff --git a/source/frontend/src/features/mapSideBar/research/add/ResearchProperties.test.tsx b/source/frontend/src/features/mapSideBar/research/add/ResearchProperties.test.tsx deleted file mode 100644 index 9895fb905b..0000000000 --- a/source/frontend/src/features/mapSideBar/research/add/ResearchProperties.test.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { Formik } from 'formik'; -import noop from 'lodash/noop'; -import { act } from 'react-dom/test-utils'; -import configureMockStore from 'redux-mock-store'; -import thunk from 'redux-thunk'; - -import { IMapStateMachineContext } from '@/components/common/mapFSM/MapStateMachineContext'; -import { getMockSelectedFeatureDataset } from '@/mocks/featureset.mock'; -import { render, RenderOptions, userEvent } from '@/utils/test-utils'; - -import { PropertyForm } from '../../shared/models'; -import { ResearchForm } from './models'; -import ResearchProperties from './ResearchProperties'; - -const mockStore = configureMockStore([thunk]); - -let testForm: ResearchForm; - -const store = mockStore({}); -const setDraftProperties = vi.fn(); - -describe('ResearchProperties component', () => { - const setup = ( - renderOptions: RenderOptions & { - initialForm: ResearchForm; - confirmBeforeAdd?: (propertyForm: PropertyForm) => Promise; - } & Partial, - ) => { - // render component under test - const component = render( - - - , - { - ...renderOptions, - store: store, - claims: [], - useMockAuthentication: true, - }, - ); - - return { - store, - component, - }; - }; - - beforeEach(() => { - const mockFeatureSet = getMockSelectedFeatureDataset(); - testForm = new ResearchForm(); - testForm.name = 'Test name'; - testForm.properties = [ - PropertyForm.fromFeatureDataset({ - ...mockFeatureSet, - pimsFeature: { - ...mockFeatureSet.pimsFeature, - properties: { - ...mockFeatureSet.pimsFeature?.properties, - PID_PADDED: '123-456-789', - }, - }, - }), - PropertyForm.fromFeatureDataset({ - ...mockFeatureSet, - pimsFeature: { - ...mockFeatureSet.pimsFeature, - properties: { - ...mockFeatureSet.pimsFeature?.properties, - PIN: 1111222, - }, - }, - }), - ]; - }); - - afterEach(() => { - vi.clearAllMocks(); - setDraftProperties.mockReset(); - }); - - it('renders as expected when provided no properties', async () => { - const { component } = setup({ initialForm: testForm }); - await act(async () => {}); - expect(component.asFragment()).toMatchSnapshot(); - }); - - it('renders as expected when provided a list of properties', async () => { - const { - component: { getByText }, - } = await setup({ initialForm: testForm }); - - await act(async () => {}); - expect(getByText('PID: 123-456-789')).toBeVisible(); - expect(getByText('PIN: 1111222')).toBeVisible(); - }); - - it('properties can be removed', async () => { - const { - component: { getAllByTitle, queryByText }, - } = await setup({ initialForm: testForm }); - const pidRow = getAllByTitle('remove')[0]; - - await act(async () => { - userEvent.click(pidRow); - }); - - expect(queryByText('PID: 123-456-789')).toBeNull(); - }); - - it('properties are prefixed by svg with incrementing id', async () => { - const { - component: { getByTitle }, - } = await setup({ initialForm: testForm }); - - await act(async () => {}); - expect(getByTitle('1')).toBeInTheDocument(); - expect(getByTitle('2')).toBeInTheDocument(); - }); - - it.skip('properties with lat/lng are synchronized', async () => { - const formWithProperties = testForm; - formWithProperties.properties[0].latitude = 1; - formWithProperties.properties[0].longitude = 2; - await setup({ initialForm: formWithProperties }); - - //TODO: correct assertions. - }); - - it.skip('multiple properties with lat/lng are synchronized', async () => { - const formWithProperties = testForm; - formWithProperties.properties[0].latitude = 1; - formWithProperties.properties[0].longitude = 2; - formWithProperties.properties[1].latitude = 3; - formWithProperties.properties[1].longitude = 4; - - await setup({ initialForm: formWithProperties }); - - //TODO: correct assertions. - }); -}); diff --git a/source/frontend/src/features/mapSideBar/research/add/ResearchProperties.tsx b/source/frontend/src/features/mapSideBar/research/add/ResearchProperties.tsx deleted file mode 100644 index 42d4de0f7e..0000000000 --- a/source/frontend/src/features/mapSideBar/research/add/ResearchProperties.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { FieldArray, FormikProps, useFormikContext } from 'formik'; -import noop from 'lodash/noop'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; -import styled from 'styled-components'; - -import { Button } from '@/components/common/buttons'; -import { useMapStateMachine } from '@/components/common/mapFSM/MapStateMachineContext'; -import { SelectedFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; -import { Section } from '@/components/common/Section/Section'; -import SelectedPropertyHeaderRow from '@/components/propertySelector/selectedPropertyList/SelectedPropertyHeaderRow'; -import SelectedPropertyRow from '@/components/propertySelector/selectedPropertyList/SelectedPropertyRow'; -import useDraftMarkerSynchronizer from '@/hooks/useDraftMarkerSynchronizer'; -import { useEditPropertiesMode } from '@/hooks/useEditPropertiesMode'; -import { useFeatureDatasetsWithAddresses } from '@/hooks/useFeatureDatasetsWithAddresses'; -import { featuresetToLocationBoundaryDataset } from '@/utils'; -import { addPropertiesToCurrentFile } from '@/utils/propertyUtils'; -import { exists, firstOrNull } from '@/utils/utils'; - -import { PropertyForm } from '../../shared/models'; -import { AddPropertiesGuide } from '../../shared/update/properties/AddPropertiesGuide'; -import { ResearchForm } from './models'; - -export interface IResearchPropertiesProps { - confirmBeforeAdd: (propertyForm: PropertyForm) => Promise; -} - -const ResearchProperties: React.FC = () => { - const localRef = useRef>(); - const { values } = useFormikContext(); - useEditPropertiesMode(); - - const { selectedFeatures, processCreation, mapLocationFeatureDataset, prepareForCreation } = - useMapStateMachine(); - - useDraftMarkerSynchronizer( - values.properties.map(p => featuresetToLocationBoundaryDataset(p.toFeatureDataset())), - ); - - const selectedFeatureDataset = useMemo(() => { - return { - selectingComponentId: mapLocationFeatureDataset?.selectingComponentId ?? null, - location: mapLocationFeatureDataset?.location, - fileLocation: mapLocationFeatureDataset?.fileLocation ?? null, - fileBoundary: null, - parcelFeature: firstOrNull(mapLocationFeatureDataset?.parcelFeatures), - pimsFeature: firstOrNull(mapLocationFeatureDataset?.pimsFeatures), - regionFeature: mapLocationFeatureDataset?.regionFeature ?? null, - districtFeature: mapLocationFeatureDataset?.districtFeature ?? null, - municipalityFeature: firstOrNull(mapLocationFeatureDataset?.municipalityFeatures), - isActive: true, - displayOrder: 0, - }; - }, [ - mapLocationFeatureDataset?.selectingComponentId, - mapLocationFeatureDataset?.location, - mapLocationFeatureDataset?.fileLocation, - mapLocationFeatureDataset?.parcelFeatures, - mapLocationFeatureDataset?.pimsFeatures, - mapLocationFeatureDataset?.regionFeature, - mapLocationFeatureDataset?.districtFeature, - mapLocationFeatureDataset?.municipalityFeatures, - ]); - // Get PropertyForms with addresses for all selected features - const { featuresWithAddresses } = useFeatureDatasetsWithAddresses(selectedFeatures ?? []); - - // Convert SelectedFeatureDataset to PropertyForm - const propertyForms = useMemo( - () => - featuresWithAddresses.map(obj => { - const property = PropertyForm.fromFeatureDataset(obj.feature); - if (exists(obj.address)) { - property.address = obj.address; - } - return property; - }), - [featuresWithAddresses], - ); - - const handleAddToSelection = useCallback(() => { - prepareForCreation([selectedFeatureDataset]); - }, [prepareForCreation, selectedFeatureDataset]); - - useEffect(() => { - if (exists(localRef.current) && propertyForms.length > 0) { - addPropertiesToCurrentFile(localRef, 'properties', propertyForms, noop); - processCreation(); - } - }, [localRef, processCreation, propertyForms]); - - return ( -
    - - {exists(selectedFeatureDataset?.parcelFeature) && ( - - - - )} - - {({ remove }) => ( -
    - - {values.properties.map((property, index) => ( - remove(index)} - nameSpace={`properties.${index}`} - index={index} - property={property.toFeatureDataset()} - /> - ))} - {values.properties.length === 0 && No Properties selected} -
    - )} -
    -
    - ); -}; - -export default ResearchProperties; - -const StyledButtonWrapper = styled.div` - margin: 0 1.6rem; - padding-left: 1.6rem; - text-align: left; - text-underline-offset: 2px; - - button { - font-size: 14px; - } -`; diff --git a/source/frontend/src/features/mapSideBar/research/add/__snapshots__/AddResearchContainer.test.tsx.snap b/source/frontend/src/features/mapSideBar/research/add/__snapshots__/AddResearchContainer.test.tsx.snap index 879d652146..f0f97e60be 100644 --- a/source/frontend/src/features/mapSideBar/research/add/__snapshots__/AddResearchContainer.test.tsx.snap +++ b/source/frontend/src/features/mapSideBar/research/add/__snapshots__/AddResearchContainer.test.tsx.snap @@ -371,6 +371,17 @@ exports[`AddResearchContainer component > renders as expected 1`] = ` border-radius: 0.5rem; } +.c25 { + margin: 0; + padding: 1.6rem; + font-size: 1.6rem; + color: #9F9D9C; + border-bottom: 0.2rem solid #606060; + margin-bottom: 0.9rem; + padding-bottom: 0.25rem; + font-family: 'BcSans-Bold'; +} + .c15 { margin: 0 1.6rem 0 1.6rem; padding: 1.6rem; @@ -428,17 +439,6 @@ exports[`AddResearchContainer component > renders as expected 1`] = ` cursor: pointer; } -.c25 { - margin: 0; - padding: 1.6rem; - font-size: 1.6rem; - color: #9F9D9C; - border-bottom: 0.2rem solid #606060; - margin-bottom: 0.9rem; - padding-bottom: 0.25rem; - font-family: 'BcSans-Bold'; -} - .c24 { font-weight: normal; } @@ -892,7 +892,39 @@ exports[`AddResearchContainer component > renders as expected 1`] = `
    - Selected Properties +
    +
    + Selected Properties +
    +
    + + + + + +
    +
    diff --git a/source/frontend/src/features/mapSideBar/research/add/__snapshots__/AddResearchForm.test.tsx.snap b/source/frontend/src/features/mapSideBar/research/add/__snapshots__/AddResearchForm.test.tsx.snap index 5b6bdc7591..ca749b9cb0 100644 --- a/source/frontend/src/features/mapSideBar/research/add/__snapshots__/AddResearchForm.test.tsx.snap +++ b/source/frontend/src/features/mapSideBar/research/add/__snapshots__/AddResearchForm.test.tsx.snap @@ -227,6 +227,17 @@ exports[`AddResearchForm component > renders as expected 1`] = ` border-radius: 0.5rem; } +.c14 { + margin: 0; + padding: 1.6rem; + font-size: 1.6rem; + color: #9F9D9C; + border-bottom: 0.2rem solid #606060; + margin-bottom: 0.9rem; + padding-bottom: 0.25rem; + font-family: 'BcSans-Bold'; +} + .c3 { margin: 0 1.6rem 0 1.6rem; padding: 1.6rem; @@ -284,17 +295,6 @@ exports[`AddResearchForm component > renders as expected 1`] = ` cursor: pointer; } -.c14 { - margin: 0; - padding: 1.6rem; - font-size: 1.6rem; - color: #9F9D9C; - border-bottom: 0.2rem solid #606060; - margin-bottom: 0.9rem; - padding-bottom: 0.25rem; - font-family: 'BcSans-Bold'; -} - .c13 { font-weight: normal; } @@ -638,7 +638,39 @@ exports[`AddResearchForm component > renders as expected 1`] = `
    - Selected Properties +
    +
    + Selected Properties +
    +
    + + + + + +
    +
    diff --git a/source/frontend/src/features/mapSideBar/research/add/models.ts b/source/frontend/src/features/mapSideBar/research/add/models.ts index d51173ca82..2d355bd51f 100644 --- a/source/frontend/src/features/mapSideBar/research/add/models.ts +++ b/source/frontend/src/features/mapSideBar/research/add/models.ts @@ -3,10 +3,10 @@ import { ApiGen_Concepts_ResearchFileProperty } from '@/models/api/generated/Api import { getEmptyBaseAudit } from '@/models/defaultInitializers'; import { applyDisplayOrder } from '@/utils'; -import { PropertyForm } from '../../shared/models'; +import { PropertyForm, WithFormProperties } from '../../shared/models'; import { ResearchFileProjectFormModel } from '../common/models'; -export class ResearchForm { +export class ResearchForm implements WithFormProperties { public id?: number; public name: string; public properties: PropertyForm[]; diff --git a/source/frontend/src/features/mapSideBar/shared/detail/PropertyFileContainer.test.tsx b/source/frontend/src/features/mapSideBar/shared/detail/PropertyFileContainer.test.tsx index e34ee7bc9c..70490ee13a 100644 --- a/source/frontend/src/features/mapSideBar/shared/detail/PropertyFileContainer.test.tsx +++ b/source/frontend/src/features/mapSideBar/shared/detail/PropertyFileContainer.test.tsx @@ -161,13 +161,13 @@ describe('PropertyFileContainer component', () => { .reply(200, getMockCrownTenuresLayerResponse()); await setup(); - expect(viewProps?.tabViews).toHaveLength(6); expect(viewProps?.tabViews[0].key).toBe(InventoryTabNames.title); expect(viewProps?.tabViews[1].key).toBe(InventoryTabNames.value); expect(viewProps?.tabViews[2].key).toBe(InventoryTabNames.property); expect(viewProps?.tabViews[3].key).toBe(InventoryTabNames.pims); expect(viewProps?.tabViews[4].key).toBe(InventoryTabNames.crown); expect(viewProps?.tabViews[5].key).toBe(InventoryTabNames.highway); + expect(viewProps?.tabViews).toHaveLength(6); }); it('does not call lease endpoints when user does not have lease permissions', async () => { diff --git a/source/frontend/src/features/mapSideBar/shared/detail/PropertyFileContainer.tsx b/source/frontend/src/features/mapSideBar/shared/detail/PropertyFileContainer.tsx index f56bae30cc..474698d053 100644 --- a/source/frontend/src/features/mapSideBar/shared/detail/PropertyFileContainer.tsx +++ b/source/frontend/src/features/mapSideBar/shared/detail/PropertyFileContainer.tsx @@ -178,15 +178,19 @@ export const PropertyFileContainer: React.FunctionComponent< 0, + electoralDistrict: firstOrNull( + composedProperties?.composedProperty?.featureDataset?.electoralFeatures, + ), + isALR: composedProperties?.composedProperty?.featureDataset?.alrFeatures?.length > 0, firstNations: { bandName: - firstOrNull(composedProperties?.composedProperty?.firstNationFeatures)?.properties - .BAND_NAME || '', + firstOrNull( + composedProperties?.composedProperty?.featureDataset?.firstNationFeatures, + )?.properties.BAND_NAME || '', reserveName: - firstOrNull(composedProperties?.composedProperty?.firstNationFeatures)?.properties - .ENGLISH_NAME || '', + firstOrNull( + composedProperties?.composedProperty?.featureDataset?.firstNationFeatures, + )?.properties.ENGLISH_NAME || '', }, }} loading={composedProperties?.composedLoading ?? false} @@ -219,7 +223,8 @@ export const PropertyFileContainer: React.FunctionComponent< if (exists(composedProperties?.composedProperty)) { const composedProperty = composedProperties?.composedProperty; - if (composedProperty?.parcelMapFeatureCollection?.features?.length > 0) { + const featureDataset = composedProperty?.featureDataset; + if (featureDataset?.parcelFeatures?.length > 0) { tabViews.push({ content: ( 0 && - !exists(composedProperty?.id) - ) { + if (featureDataset?.pimsFeatures?.length > 0 && !exists(composedProperty?.id)) { tabViews.push({ content: ( + featureDataset?.crownLandInclusionsFeatures?.length + + featureDataset?.crownLandInventoryFeatures?.length + + featureDataset?.crownLandLeasesFeatures?.length + + featureDataset?.crownLandLicensesFeatures?.length + + featureDataset?.crownLandTenuresFeatures?.length > 0 ) { tabViews.push({ @@ -269,7 +270,7 @@ export const PropertyFileContainer: React.FunctionComponent< name: 'Crown', }); } - if (composedProperty?.highwayFeatures?.length > 0) { + if (composedProperty?.featureDataset?.highwayFeatures?.length > 0) { tabViews.push({ content: ( 0) { + if (composedProperty?.featureDataset?.municipalityFeatures?.length > 0) { tabViews.push({ content: ( { return { - parcelFeature: null, - selectingComponentId: null, - pimsFeature: { - properties: { - ...emptyPropertyLocation, - PROPERTY_ID: this.apiId, - PID: this.pid ? +this.pid.replaceAll(/-/g, '') : null, - PID_PADDED: this?.pid?.padStart(9, '0'), - PIN: this.pin ? +this.pin : null, - SURVEY_PLAN_NUMBER: this.planNumber, - REGION_CODE: this.region, - DISTRICT_CODE: this.district, - LAND_AREA: this.landArea, - PROPERTY_AREA_UNIT_TYPE_CODE: this.areaUnit, - STREET_ADDRESS_1: this.address?.streetAddress1 ?? this.formattedAddress, - STREET_ADDRESS_2: this.address?.streetAddress2, - STREET_ADDRESS_3: this.address?.streetAddress3, - MUNICIPALITY_NAME: this.address?.municipality, - POSTAL_CODE: this.address?.postalCode, - IS_RETIRED: this.isRetired, - LAND_LEGAL_DESCRIPTION: this.legalDescription, - }, - type: 'Feature', - geometry: this.polygon ? this.polygon : null, + properties: { + ...emptyPropertyLocation, + PROPERTY_ID: this.apiId, + PID: this.pid ? +this.pid.replaceAll(/-/g, '') : null, + PID_PADDED: this?.pid?.padStart(9, '0'), + PIN: this.pin ? +this.pin : null, + SURVEY_PLAN_NUMBER: this.planNumber, + REGION_CODE: this.region, + DISTRICT_CODE: this.district, + LAND_AREA: this.landArea, + PROPERTY_AREA_UNIT_TYPE_CODE: this.areaUnit, + STREET_ADDRESS_1: this.address?.streetAddress1 ?? this.formattedAddress, + STREET_ADDRESS_2: this.address?.streetAddress2, + STREET_ADDRESS_3: this.address?.streetAddress3, + MUNICIPALITY_NAME: this.address?.municipality, + POSTAL_CODE: this.address?.postalCode, + IS_RETIRED: this.isRetired, + LAND_LEGAL_DESCRIPTION: this.legalDescription, }, + type: 'Feature', + geometry: this.polygon ? this.polygon : null, + }; + } + + public toLocationFeatureDataset(): LocationFeatureDataset { + return { + ...emptyFeatureDataset(), location: { lat: this.latitude, lng: this.longitude }, - fileLocation: this.fileLocation ?? { lat: this.latitude, lng: this.longitude }, - fileBoundary: this.fileBoundary ?? null, + pimsFeatures: [ + { + ...this.toFeature(), + }, + ], regionFeature: { properties: { REGION_NAME: this.regionName, @@ -207,8 +227,6 @@ export class PropertyForm { type: 'Feature', geometry: null, }, - municipalityFeature: null, - isActive: this.isActive !== 'false', }; } @@ -351,6 +369,7 @@ export class AddressForm { public streetAddress3?: string; public municipality?: string; public postalCode?: string; + public province?: string; public static fromBcaAddress(model: IBcAssessmentSummary['ADDRESSES'][0]): AddressForm { const newForm = new AddressForm(); @@ -383,6 +402,7 @@ export class AddressForm { newForm.municipality = model.municipality ?? undefined; newForm.postalCode = model.postal ?? undefined; newForm.apiId = model?.id ?? undefined; + newForm.province = model?.province?.description ?? undefined; newForm.rowVersion = model.rowVersion ?? undefined; return newForm; diff --git a/source/frontend/src/features/mapSideBar/shared/operations/SelectedOperationProperty.tsx b/source/frontend/src/features/mapSideBar/shared/operations/SelectedOperationProperty.tsx index a65cce5e25..c8613e9b91 100644 --- a/source/frontend/src/features/mapSideBar/shared/operations/SelectedOperationProperty.tsx +++ b/source/frontend/src/features/mapSideBar/shared/operations/SelectedOperationProperty.tsx @@ -6,12 +6,13 @@ import OverflowTip from '@/components/common/OverflowTip'; import { ZoomIconType, ZoomToLocation } from '@/components/maps/ZoomToLocation'; import DraftCircleNumber from '@/components/propertySelector/selectedPropertyList/DraftCircleNumber'; import { ApiGen_CodeTypes_AreaUnitTypes } from '@/models/api/generated/ApiGen_CodeTypes_AreaUnitTypes'; -import { ApiGen_Concepts_Property } from '@/models/api/generated/ApiGen_Concepts_Property'; -import { convertArea, formatApiAddress, formatNumber, pidFormatter } from '@/utils'; +import { convertArea, formatFormAddress, formatNumber, pidFormatter } from '@/utils'; + +import { PropertyForm } from '../models'; interface ISelectedOperationPropertyProps { - property: ApiGen_Concepts_Property; - getMarkerIndex: (property: ApiGen_Concepts_Property) => number; + property: PropertyForm; + getMarkerIndex: (property: PropertyForm) => number; nameSpace: string; onRemove: () => void; isEditable?: boolean; @@ -41,12 +42,12 @@ export const SelectedOperationProperty: React.FunctionComponent< {isEditable ? ( ) : ( - getAreaValue(property.landArea ?? 0, property.areaUnit?.id ?? '') + getAreaValue(property.landArea ?? 0, property.areaUnit ?? '') )} - {formatApiAddress(property?.address) ?? ''} + {formatFormAddress(property?.address) ?? ''} - + diff --git a/source/frontend/src/features/mapSideBar/shared/update/properties/PropertiesListContainer.test.tsx b/source/frontend/src/features/mapSideBar/shared/update/properties/PropertiesListContainer.test.tsx new file mode 100644 index 0000000000..2d473ba31d --- /dev/null +++ b/source/frontend/src/features/mapSideBar/shared/update/properties/PropertiesListContainer.test.tsx @@ -0,0 +1,118 @@ +import { Formik, FormikProps } from 'formik'; +import { createRef } from 'react'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; + +import { mapMachineBaseMock } from '@/mocks/mapFSM.mock'; +import { act, render, RenderOptions, userEvent } from '@/utils/test-utils'; + +import { FileForm, PropertyForm, WithFormProperties } from '../../models'; +import PropertiesListContainer from './PropertiesListContainer'; +import { PIMS_Property_Location_View } from '@/models/layers/pimsPropertyLocationView'; +import { getMockLocationFeatureDataset } from '@/mocks/featureset.mock'; + +const mockStore = configureMockStore([thunk]); + +const customSetFilePropertyLocations = vi.fn(); + +const verifyCanRemove = vi.fn(); + +describe('PropertiesListContainer component', () => { + const setup = async ( + props: { properties: PropertyForm[] }, + renderOptions: RenderOptions = {}, + ) => { + const ref = createRef>(); + const utils = render( + + + , + { + ...renderOptions, + store: mockStore({}), + claims: [], + mockMapMachine: { + ...mapMachineBaseMock, + setFilePropertyLocations: customSetFilePropertyLocations, + }, + }, + ); + + // Wait for any async effects to complete + await act(async () => {}); + + return { + ...utils, + getFormikRef: () => ref, + }; + }; + + let testForm: FileForm; + + beforeEach(() => { + const mockFeatureSet = getMockLocationFeatureDataset(); + testForm = new FileForm(); + testForm.properties = [ + PropertyForm.fromLocationFeatureDataset({ + ...mockFeatureSet, + pimsFeatures: [ + { + ...mockFeatureSet.pimsFeatures[0], + properties: { + ...mockFeatureSet.pimsFeatures[0].properties, + PID_PADDED: '123-456-789', + }, + }, + ], + }), + PropertyForm.fromLocationFeatureDataset({ + ...mockFeatureSet, + pimsFeatures: [ + { + ...mockFeatureSet.pimsFeatures[0], + properties: { + ...mockFeatureSet.pimsFeatures[0]?.properties, + PIN: 1111222, + }, + }, + ], + }), + ]; + testForm.properties[0].pid = '123456789'; + testForm.properties[1].pin = '1111222'; + }); + + afterEach(() => { + vi.clearAllMocks(); + customSetFilePropertyLocations.mockReset(); + }); + + it('renders as expected', async () => { + const { asFragment } = await setup({ properties: testForm.properties }); + await act(async () => {}); + expect(asFragment()).toMatchSnapshot(); + }); + + it('renders list of properties', async () => { + const { getByText } = await setup({ properties: testForm.properties }); + + expect(getByText('PID: 123-456-789')).toBeVisible(); + expect(getByText('PIN: 1111222')).toBeVisible(); + }); + + it('should remove property from list when Remove button is clicked', async () => { + const { getAllByTitle } = await setup({ properties: testForm.properties }); + const pidRow = getAllByTitle('remove')[0]; + await act(async () => userEvent.click(pidRow)); + + const theCallbackFn = expect.any(Function); // Expect a function + expect(verifyCanRemove).toHaveBeenCalledWith(testForm.properties[0].apiId, theCallbackFn); + }); + + it('should display properties with svg prefixed with incrementing id', async () => { + const { getByTitle } = await setup({ properties: testForm.properties }); + + expect(getByTitle('1')).toBeInTheDocument(); + expect(getByTitle('2')).toBeInTheDocument(); + }); +}); diff --git a/source/frontend/src/features/mapSideBar/shared/update/properties/PropertiesListContainer.tsx b/source/frontend/src/features/mapSideBar/shared/update/properties/PropertiesListContainer.tsx new file mode 100644 index 0000000000..289f625a58 --- /dev/null +++ b/source/frontend/src/features/mapSideBar/shared/update/properties/PropertiesListContainer.tsx @@ -0,0 +1,193 @@ +import { FieldArray, useFormikContext } from 'formik'; +import { Fragment, useCallback, useState } from 'react'; +import { Col, Row } from 'react-bootstrap'; +import { toast } from 'react-toastify'; +import styled from 'styled-components'; + +import { Button } from '@/components/common/buttons'; +import { useMapStateMachine } from '@/components/common/mapFSM/MapStateMachineContext'; +import { LocationFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; +import { Section } from '@/components/common/Section/Section'; +import { ZoomIconType, ZoomToLocation } from '@/components/maps/ZoomToLocation'; +import AreaContainer from '@/components/measurements/AreaContainer'; +import MapClickMonitor from '@/components/propertySelector/MapClickMonitor'; +import SelectedPropertyHeaderRow from '@/components/propertySelector/selectedPropertyList/SelectedPropertyHeaderRow'; +import SelectedPropertyRow from '@/components/propertySelector/selectedPropertyList/SelectedPropertyRow'; +import { UploadResponseModel } from '@/features/properties/shapeUpload/models'; +import useDraftMarkerSynchronizer from '@/hooks/useDraftMarkerSynchronizer'; +import { exists, isEmptyOrNull, isLatLngInFeatureSetBoundary, isNumber } from '@/utils'; +import { withNameSpace } from '@/utils/formUtils'; + +import { PropertyForm } from '../../models'; +import AddPropertiesGuide from './AddPropertiesGuide'; + +export interface IPropertiesListContainerProps { + properties: PropertyForm[]; + verifyCanRemove: (propertyId: number, removeCallback: () => void) => void; + confirmBeforeAddMessage?: React.ReactNode; + showDisabledProperties?: boolean; + canUploadShapefiles?: boolean; + canReposition?: boolean; + showArea?: boolean; +} + +export const PropertiesListContainer: React.FunctionComponent< + IPropertiesListContainerProps +> = props => { + const { setFieldValue } = useFormikContext(); + const [repositionPropertyIndex, setRepositionPropertyIndex] = useState(null); + + useDraftMarkerSynchronizer(props.properties); + + const { + mapLocationFeatureDataset, + requestLocationFeatureAddition, + isRepositioning, + startReposition, + finishReposition, + } = useMapStateMachine(); + + const handleAddToSelection = useCallback(() => { + requestLocationFeatureAddition([mapLocationFeatureDataset]); + }, [requestLocationFeatureAddition, mapLocationFeatureDataset]); + + const onRepositionClick = useCallback( + (propertyIndex: number) => { + setRepositionPropertyIndex(propertyIndex); + const formProperty = props.properties[propertyIndex]; + startReposition(formProperty.toFeature()); + }, + [props.properties, startReposition], + ); + + return ( + <> + + {!isEmptyOrNull(mapLocationFeatureDataset?.parcelFeatures) && ( + + + + )} + + + {({ remove, replace }) => ( +
    + Selected Properties + + + + + } + > + { + if ( + props.canReposition && + isRepositioning && + isNumber(repositionPropertyIndex) && + repositionPropertyIndex >= 0 && + !hasMultipleProperties + ) { + // As long as the marker is repositioned within the boundary of the originally selected property simply reposition the marker without further notification. + const formProperty = props.properties[repositionPropertyIndex]; + + if ( + isLatLngInFeatureSetBoundary( + locationDataSet.location, + formProperty.toLocationFeatureDataset(), + ) + ) { + const updatedFormProperty = new PropertyForm(formProperty); + updatedFormProperty.fileLocation = locationDataSet.location; + + // Find property within formik values and reposition it based on incoming file marker position + replace(repositionPropertyIndex, updatedFormProperty); + + // Reset the reposition state + finishReposition(); + setRepositionPropertyIndex(null); + } else { + toast.warn( + 'Please choose a location that is within the (highlighted) boundary of this property.', + ); + } + } + }} + /> + + {props.properties?.map((property, index) => { + const namespace = `properties.${index}`; + + return ( + + { + const removeCallback = () => { + remove(index); + }; + await props.verifyCanRemove(property?.apiId, removeCallback); + }} + canReposition={props.canReposition} + onReposition={onRepositionClick} + nameSpace={namespace} + index={index} + property={property} + showDisable={props.showDisabledProperties} + canUploadShapefile={props.canUploadShapefiles} + onUploadShapefile={(result: UploadResponseModel | null) => { + // Update the property boundary based on the uploaded shapefile + if (exists(result)) { + if (result.isSuccess && exists(result.boundary)) { + const updatedFormProperty = new PropertyForm(property); + updatedFormProperty.fileBoundary = result.boundary; + replace(index, updatedFormProperty); + } + } + }} + /> + {props.showArea && ( + + + { + setFieldValue(withNameSpace(namespace, 'landArea'), landArea); + setFieldValue(withNameSpace(namespace, 'areaUnit'), areaUnitTypeCode); + }} + /> + + + )} + + ); + })} + {props.properties?.length === 0 && No Properties selected} +
    + )} +
    + + ); +}; + +export default PropertiesListContainer; + +const StyledButtonWrapper = styled.div` + margin: 0 1.6rem; + padding-left: 1.6rem; + text-align: left; + text-underline-offset: 2px; + + button { + font-size: 14px; + } +`; diff --git a/source/frontend/src/features/mapSideBar/shared/update/properties/UpdateProperties.tsx b/source/frontend/src/features/mapSideBar/shared/update/properties/UpdateProperties.tsx deleted file mode 100644 index 18aed9e88d..0000000000 --- a/source/frontend/src/features/mapSideBar/shared/update/properties/UpdateProperties.tsx +++ /dev/null @@ -1,358 +0,0 @@ -import axios, { AxiosError } from 'axios'; -import { FieldArray, Formik, FormikProps } from 'formik'; -import { LatLngLiteral } from 'leaflet'; -import noop from 'lodash/noop'; -import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; -import { Col, Row } from 'react-bootstrap'; -import { toast } from 'react-toastify'; -import styled from 'styled-components'; - -import { Button } from '@/components/common/buttons'; -import GenericModal from '@/components/common/GenericModal'; -import LoadingBackdrop from '@/components/common/LoadingBackdrop'; -import { useMapStateMachine } from '@/components/common/mapFSM/MapStateMachineContext'; -import { SelectedFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; -import { Section } from '@/components/common/Section/Section'; -import { ZoomIconType, ZoomToLocation } from '@/components/maps/ZoomToLocation'; -import MapClickMonitor from '@/components/propertySelector/MapClickMonitor'; -import SelectedPropertyHeaderRow from '@/components/propertySelector/selectedPropertyList/SelectedPropertyHeaderRow'; -import SelectedPropertyRow from '@/components/propertySelector/selectedPropertyList/SelectedPropertyRow'; -import { SideBarContext } from '@/features/mapSideBar/context/sidebarContext'; -import MapSideBarLayout from '@/features/mapSideBar/layout/MapSideBarLayout'; -import { UploadResponseModel } from '@/features/properties/shapeUpload/models'; -import { useEditPropertiesMode } from '@/hooks/useEditPropertiesMode'; -import { useFeatureDatasetsWithAddresses } from '@/hooks/useFeatureDatasetsWithAddresses'; -import { getCancelModalProps, useModalContext } from '@/hooks/useModalContext'; -import { ApiGen_Concepts_File } from '@/models/api/generated/ApiGen_Concepts_File'; -import { UserOverrideCode } from '@/models/api/UserOverrideCode'; -import { exists, firstOrNull, isLatLngInFeatureSetBoundary, isNumber, isValidId } from '@/utils'; -import { addPropertiesToCurrentFile } from '@/utils/propertyUtils'; - -import { FileForm, PropertyForm } from '../../models'; -import SidebarFooter from '../../SidebarFooter'; -import AddPropertiesGuide from './AddPropertiesGuide'; -import { UpdatePropertiesYupSchema } from './UpdatePropertiesYupSchema'; - -export interface IUpdatePropertiesProps { - file: ApiGen_Concepts_File; - setIsShowingPropertySelector: (isShowing: boolean) => void; - onSuccess: (updateProperties?: boolean, updateFile?: boolean) => void; - updateFileProperties: ( - file: ApiGen_Concepts_File, - userOverrideCodes: UserOverrideCode[], - ) => Promise; - canRemove: (propertyId: number) => Promise; - confirmBeforeAdd: (propertyForm: PropertyForm) => Promise; - confirmBeforeAddMessage?: React.ReactNode; - formikRef?: React.RefObject>; - disableProperties?: boolean; - canUploadShapefiles?: boolean; -} - -export const UpdateProperties: React.FunctionComponent = props => { - const localRef = useRef>(null); - const formikRef = props.formikRef ? props.formikRef : localRef; - const formFile = FileForm.fromApi(props.file); - - const [showSaveConfirmModal, setShowSaveConfirmModal] = useState(false); - const [showAssociatedEntityWarning, setShowAssociatedEntityWarning] = useState(false); - const [isValid, setIsValid] = useState(true); - const { setModalContent, setDisplayModal } = useModalContext(); - const { resetFilePropertyLocations } = useContext(SideBarContext); - const { selectedFeatures, processCreation, mapLocationFeatureDataset, prepareForCreation } = - useMapStateMachine(); - - useEditPropertiesMode(); - - // Get PropertyForms with addresses for all selected features - const { featuresWithAddresses, bcaLoading } = useFeatureDatasetsWithAddresses( - selectedFeatures ?? [], - ); - - // Convert SelectedFeatureDataset to PropertyForm - const propertyForms = useMemo( - () => - featuresWithAddresses.map(obj => { - const property = PropertyForm.fromFeatureDataset(obj.feature); - if (exists(obj.address)) { - property.address = obj.address; - } - return property; - }), - [featuresWithAddresses], - ); - - // This effect is used to update the file properties when "add to open file" is clicked in the worklist. - useEffect(() => { - if (exists(formikRef.current) && propertyForms.length > 0) { - addPropertiesToCurrentFile(formikRef, 'properties', propertyForms, noop); - processCreation(); - } - }, [formikRef, processCreation, propertyForms]); - - const handleSaveClick = async () => { - await formikRef?.current?.validateForm(); - if (!formikRef?.current?.isValid) { - setIsValid(false); - } else { - setIsValid(true); - } - setShowSaveConfirmModal(true); - }; - - const handleCancelClick = () => { - if (formikRef !== undefined) { - if (formikRef.current?.dirty) { - setModalContent({ - ...getCancelModalProps(), - handleOk: () => { - handleCancelConfirm(); - setDisplayModal(false); - }, - handleCancel: () => setDisplayModal(false), - }); - setDisplayModal(true); - } else { - handleCancelConfirm(); - } - } else { - handleCancelConfirm(); - } - }; - - const handleSaveConfirm = async () => { - if (formikRef !== undefined) { - formikRef.current?.setSubmitting(true); - formikRef.current?.submitForm(); - } - }; - - const handleCancelConfirm = () => { - if (formikRef !== undefined) { - formikRef.current?.resetForm(); - } - resetFilePropertyLocations(); - props.setIsShowingPropertySelector(false); - }; - - const saveFile = async (file: ApiGen_Concepts_File) => { - try { - const response = await props.updateFileProperties(file, []); - - formikRef.current?.setSubmitting(false); - if (isValidId(response?.id)) { - if (file.fileProperties?.find(fp => !fp.property?.address && !fp.property?.id)) { - toast.warn( - 'Address could not be retrieved for this property, it will have to be provided manually in property details tab', - { autoClose: 15000 }, - ); - } - formikRef.current?.resetForm(); - props.setIsShowingPropertySelector(false); - props.onSuccess(true); - } - } catch (e) { - if (axios.isAxiosError(e) && (e as AxiosError).code === '409') { - setShowAssociatedEntityWarning(true); - } - } - }; - - const selectedFeatureDataset = useMemo(() => { - return { - selectingComponentId: mapLocationFeatureDataset?.selectingComponentId ?? null, - location: mapLocationFeatureDataset?.location, - fileLocation: mapLocationFeatureDataset?.fileLocation ?? null, - fileBoundary: null, - parcelFeature: firstOrNull(mapLocationFeatureDataset?.parcelFeatures), - pimsFeature: firstOrNull(mapLocationFeatureDataset?.pimsFeatures), - regionFeature: mapLocationFeatureDataset?.regionFeature ?? null, - districtFeature: mapLocationFeatureDataset?.districtFeature ?? null, - municipalityFeature: firstOrNull(mapLocationFeatureDataset?.municipalityFeatures), - isActive: true, - displayOrder: 0, - }; - }, [ - mapLocationFeatureDataset?.selectingComponentId, - mapLocationFeatureDataset?.location, - mapLocationFeatureDataset?.fileLocation, - mapLocationFeatureDataset?.parcelFeatures, - mapLocationFeatureDataset?.pimsFeatures, - mapLocationFeatureDataset?.regionFeature, - mapLocationFeatureDataset?.districtFeature, - mapLocationFeatureDataset?.municipalityFeatures, - ]); - - const handleAddToSelection = useCallback(() => { - prepareForCreation([selectedFeatureDataset]); - }, [prepareForCreation, selectedFeatureDataset]); - - return ( - <> - - - } - > - <> - - {exists(selectedFeatureDataset?.parcelFeature) && ( - - - - )} - - innerRef={formikRef} - initialValues={formFile} - validationSchema={UpdatePropertiesYupSchema} - onSubmit={async (values: FileForm) => { - const file: ApiGen_Concepts_File = values.toApi(); - await saveFile(file); - }} - > - {formikProps => ( - - {({ remove, replace }) => ( -
    - Selected Properties - - - - - } - > - - - { - // As long as the marker is repositioned within the boundary of the originally selected property simply reposition the marker without further notification. - if ( - isNumber(index) && - index >= 0 && - isLatLngInFeatureSetBoundary(latLng, featureset) - ) { - const formProperty = formikProps.values.properties[index]; - const updatedFormProperty = new PropertyForm(formProperty); - updatedFormProperty.fileLocation = latLng; - - // Find property within formik values and reposition it based on incoming file marker position - replace(index, updatedFormProperty); - } else if (!isLatLngInFeatureSetBoundary(latLng, featureset)) { - toast.warn( - 'Please choose a location that is within the (highlighted) boundary of this property.', - ); - } - }} - modifiedProperties={formikProps.values.properties.map(p => - p.toFeatureDataset(), - )} - /> - - - - {formikProps.values.properties.map((property, index) => ( - { - if (!property.apiId || (await props.canRemove(property.apiId))) { - remove(index); - } else { - setShowAssociatedEntityWarning(true); - } - }} - nameSpace={`properties.${index}`} - index={index} - property={property.toFeatureDataset()} - showDisable={props.disableProperties} - canUploadShapefile={props.canUploadShapefiles} - onUploadShapefile={(result: UploadResponseModel | null) => { - // Update the property boundary based on the uploaded shapefile - if (exists(result)) { - if (result.isSuccess && exists(result.boundary)) { - const updatedFormProperty = new PropertyForm(property); - updatedFormProperty.fileBoundary = result.boundary; - replace(index, updatedFormProperty); - } - } - }} - /> - ))} - {formikProps.values.properties.length === 0 && ( - No Properties selected - )} -
    - )} -
    - )} - - -
    - -
    You have made changes to the properties in this file.
    -
    - Do you want to save these changes? - - } - handleOk={handleSaveConfirm} - handleCancel={() => setShowSaveConfirmModal(false)} - okButtonText="Save" - cancelButtonText="Cancel" - show - /> - -
    - This property can not be removed from the file. This property is related to one or - more entities in the file, only properties that are not linked to any entities in the - file can be removed. -
    - - } - handleOk={() => setShowAssociatedEntityWarning(false)} - okButtonText="Close" - show - /> - - ); -}; - -export default UpdateProperties; - -const StyledButtonWrapper = styled.div` - margin: 0 1.6rem; - padding-left: 1.6rem; - text-align: left; - text-underline-offset: 2px; - - button { - font-size: 14px; - } -`; diff --git a/source/frontend/src/features/mapSideBar/shared/update/properties/UpdateProperties.test.tsx b/source/frontend/src/features/mapSideBar/shared/update/properties/UpdatePropertiesContainer.test.tsx similarity index 73% rename from source/frontend/src/features/mapSideBar/shared/update/properties/UpdateProperties.test.tsx rename to source/frontend/src/features/mapSideBar/shared/update/properties/UpdatePropertiesContainer.test.tsx index d3087ecaf9..d128e81576 100644 --- a/source/frontend/src/features/mapSideBar/shared/update/properties/UpdateProperties.test.tsx +++ b/source/frontend/src/features/mapSideBar/shared/update/properties/UpdatePropertiesContainer.test.tsx @@ -20,7 +20,16 @@ import { waitFor, } from '@/utils/test-utils'; -import UpdateProperties, { IUpdatePropertiesProps } from './UpdateProperties'; +import UpdatePropertiesContainer, { + IUpdatePropertiesContainerProps, +} from './UpdatePropertiesContainer'; +import { IPropertiesListContainerProps } from './PropertiesListContainer'; +import { emptyFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; +import { ResearchForm } from '@/features/mapSideBar/research/add/models'; +import React from 'react'; +import { FormikProps, getIn } from 'formik'; +import { PropertyForm, WithFormProperties } from '../../models'; +import { map } from 'lodash'; const mockAxios = new MockAdapter(axios); @@ -40,22 +49,24 @@ const setIsShowingPropertySelector = vi.fn(); const onSuccess = vi.fn(); const updateFileProperties = vi.fn(); -describe('UpdateProperties component', () => { +describe('UpdatePropertiesContainer component', () => { // render component under test const setup = async ( - props: Partial, + props: Partial, renderOptions: RenderOptions = {}, ) => { + const formikRef = React.createRef>(); const utils = render( - , { @@ -72,6 +83,7 @@ describe('UpdateProperties component', () => { return { ...utils, + formikRef, }; }; @@ -92,7 +104,7 @@ describe('UpdateProperties component', () => { it('renders a row with an address', async () => { const { getByText } = await setup({ - file: { + formFile: ResearchForm.fromApi({ ...getMockResearchFile(), fileProperties: [ { @@ -145,9 +157,14 @@ describe('UpdateProperties component', () => { location: null, boundary: null, file: null, + isLegalOpinionRequired: false, + isLegalOpinionObtained: false, + documentReference: '', + researchSummary: '', + propertyResearchPurposeTypes: [], }, ], - }, + }), }); expect(getByText(/45 - 904 Ho/, { exact: false })).toBeVisible(); }); @@ -178,61 +195,55 @@ describe('UpdateProperties component', () => { it('should preserve the order of properties when saving', async () => { updateFileProperties.mockResolvedValue(getMockResearchFile()); - const { getByText } = await setup( + const { getByText, formikRef } = await setup({ + formFile: ResearchForm.fromApi({ + ...getMockResearchFile(), + fileProperties: [ + { + ...getMockApiPropertyFile(), + // existing property + property: { ...getMockApiProperty(), pid: 123456789, id: 1 }, + isLegalOpinionRequired: false, + isLegalOpinionObtained: false, + documentReference: '', + researchSummary: '', + propertyResearchPurposeTypes: [], + file: null, + }, + ], + }), + }); + + // properties to be added to the current file via the map state machine (ie working list, etc) + const formPropertiesForAddition = [ { - file: { - ...getMockResearchFile(), - fileProperties: [ - { - ...getMockApiPropertyFile(), - // existing property - property: { ...getMockApiProperty(), pid: 123456789, id: 1 }, - }, - ], - }, + ...emptyFeatureDataset(), + location: { lng: -120.69195885, lat: 50.25163372 }, + parcelFeatures: [getMockFullyAttributedParcel('111-111-111')], }, { - mockMapMachine: { - ...mapMachineBaseMock, - // properties to be added to the current file via the map state machine (ie working list, etc) - selectedFeatures: [ - { - location: { lng: -120.69195885, lat: 50.25163372 }, - fileLocation: null, - fileBoundary: null, - pimsFeature: null, - parcelFeature: getMockFullyAttributedParcel('111-111-111'), - regionFeature: null, - districtFeature: null, - selectingComponentId: null, - municipalityFeature: null, - }, - { - location: { lng: -120.69195885, lat: 50.25163372 }, - fileLocation: null, - fileBoundary: null, - pimsFeature: null, - parcelFeature: getMockFullyAttributedParcel('222-222-222'), - regionFeature: null, - districtFeature: null, - selectingComponentId: null, - municipalityFeature: null, - }, - { - location: { lng: -120.69195885, lat: 50.25163372 }, - fileLocation: null, - fileBoundary: null, - pimsFeature: null, - parcelFeature: getMockFullyAttributedParcel('333-333-333'), - regionFeature: null, - districtFeature: null, - selectingComponentId: null, - municipalityFeature: null, - }, - ], - }, + ...emptyFeatureDataset(), + location: { lng: -120.69195885, lat: 50.25163372 }, + parcelFeatures: [getMockFullyAttributedParcel('222-222-222')], }, - ); + { + ...emptyFeatureDataset(), + location: { lng: -120.69195885, lat: 50.25163372 }, + parcelFeatures: [getMockFullyAttributedParcel('333-333-333')], + }, + ].map(x => PropertyForm.fromLocationFeatureDataset(x)); + + const fieldName = 'properties'; + const existingProperties = getIn(formikRef?.current?.values, fieldName) ?? []; + + await act(async () => { + formikRef.current?.setFieldValue(fieldName, [ + ...existingProperties, + ...formPropertiesForAddition, + ]); + await formikRef.current?.setFieldTouched(fieldName, true); + }); + const saveButton = getByText('Save'); await act(async () => userEvent.click(saveButton)); @@ -241,23 +252,11 @@ describe('UpdateProperties component', () => { expect(updateFileProperties).toHaveBeenCalledWith( expect.objectContaining({ - fileProperties: expect.arrayContaining([ - expect.objectContaining({ - property: expect.objectContaining({ id: 1 }), - displayOrder: 0, - }), - expect.objectContaining({ - property: expect.objectContaining({ pid: 111111111 }), - displayOrder: 1, - }), - expect.objectContaining({ - property: expect.objectContaining({ pid: 222222222 }), - displayOrder: 2, - }), - expect.objectContaining({ - property: expect.objectContaining({ pid: 333333333 }), - displayOrder: 3, - }), + properties: expect.arrayContaining([ + expect.objectContaining({ id: 1 }), + expect.objectContaining({ pid: '111-111-111' }), + expect.objectContaining({ pid: '222-222-222' }), + expect.objectContaining({ pid: '333-333-333' }), ]), }), expect.any(Array), diff --git a/source/frontend/src/features/mapSideBar/shared/update/properties/UpdatePropertiesContainer.tsx b/source/frontend/src/features/mapSideBar/shared/update/properties/UpdatePropertiesContainer.tsx new file mode 100644 index 0000000000..3315ac8ac9 --- /dev/null +++ b/source/frontend/src/features/mapSideBar/shared/update/properties/UpdatePropertiesContainer.tsx @@ -0,0 +1,239 @@ +import axios, { AxiosError } from 'axios'; +import { Formik, FormikProps } from 'formik'; +import { useCallback, useContext, useRef, useState } from 'react'; +import { toast } from 'react-toastify'; + +import GenericModal from '@/components/common/GenericModal'; +import LoadingBackdrop from '@/components/common/LoadingBackdrop'; +import { SideBarContext } from '@/features/mapSideBar/context/sidebarContext'; +import MapSideBarLayout from '@/features/mapSideBar/layout/MapSideBarLayout'; +import { getCancelModalProps, useModalContext } from '@/hooks/useModalContext'; +import { usePropertyFormSyncronizer } from '@/hooks/usePropertyFormSyncronizer'; +import { ApiGen_Concepts_File } from '@/models/api/generated/ApiGen_Concepts_File'; +import { UserOverrideCode } from '@/models/api/UserOverrideCode'; +import { isValidId } from '@/utils'; + +import { FileForm, PropertyForm, WithFormProperties } from '../../models'; +import SidebarFooter from '../../SidebarFooter'; +import PropertiesListContainer from './PropertiesListContainer'; +import { UpdatePropertiesYupSchema } from './UpdatePropertiesYupSchema'; + +export interface IUpdatePropertiesContainerProps { + formFile: WithFormProperties; + setIsShowingPropertySelector: (isShowing: boolean) => void; + onSuccess: (updateProperties?: boolean, updateFile?: boolean) => void; + updateFileProperties: ( + file: WithFormProperties, + userOverrideCodes: UserOverrideCode[], + ) => Promise; + canRemove: (propertyId: number) => Promise; + canAdd: (propertyForm: PropertyForm) => Promise; + confirmAddMessage: React.ReactNode; + formikRef?: React.RefObject>; + showDisabledProperties?: boolean; + canUploadShapefiles?: boolean; + canReposition?: boolean; + showArea?: boolean; +} + +export const UpdatePropertiesContainer: React.FunctionComponent< + IUpdatePropertiesContainerProps +> = props => { + const localRef = useRef>(null); + const formikRef = props.formikRef ? props.formikRef : localRef; + const canAdd = props.canAdd; + const confirmAddMessage = props.confirmAddMessage; + + const [showSaveConfirmModal, setShowSaveConfirmModal] = useState(false); + const [showAssociatedEntityWarning, setShowAssociatedEntityWarning] = useState(false); + const [isValid, setIsValid] = useState(true); + const { setModalContent, setDisplayModal, modalProps } = useModalContext(); + const { resetFilePropertyLocations } = useContext(SideBarContext); + + // Require user confirmation before adding a property to file + const confirmBeforeAdd = useCallback( + async ( + newPropertyForms: PropertyForm[], + isValidCallback: (isValid: boolean, newProperties: PropertyForm[]) => void, + ) => { + const needsConfirmation = await Promise.all( + newPropertyForms.map(formProperty => canAdd(formProperty)), + ); + if (needsConfirmation.some(x => x === true) && !modalProps.display) { + setModalContent({ + variant: 'warning', + title: 'User Override Required', + message: confirmAddMessage, + okButtonText: 'Yes', + cancelButtonText: 'No', + handleOk: () => { + // allow the property to be added to the file being created + isValidCallback(true, newPropertyForms); + setDisplayModal(false); + }, + handleCancel: () => { + // clear out the properties array as the user did not agree to the popup + isValidCallback(false, []); + setDisplayModal(false); + }, + }); + setDisplayModal(true); + } else { + isValidCallback(true, newPropertyForms); + } + }, + [modalProps.display, canAdd, setModalContent, confirmAddMessage, setDisplayModal], + ); + + const { isLoading } = usePropertyFormSyncronizer(formikRef, confirmBeforeAdd); + + const handleSaveClick = async () => { + await formikRef?.current?.validateForm(); + if (!formikRef?.current?.isValid) { + setIsValid(false); + } else { + setIsValid(true); + } + setShowSaveConfirmModal(true); + }; + + const handleCancelClick = () => { + if (formikRef !== undefined) { + if (formikRef.current?.dirty) { + setModalContent({ + ...getCancelModalProps(), + handleOk: () => { + handleCancelConfirm(); + setDisplayModal(false); + }, + handleCancel: () => setDisplayModal(false), + }); + setDisplayModal(true); + } else { + handleCancelConfirm(); + } + } else { + handleCancelConfirm(); + } + }; + + const handleSaveConfirm = async () => { + if (formikRef !== undefined) { + formikRef.current?.setSubmitting(true); + formikRef.current?.submitForm(); + } + }; + + const handleCancelConfirm = () => { + if (formikRef !== undefined) { + formikRef.current?.resetForm(); + } + resetFilePropertyLocations(); + props.setIsShowingPropertySelector(false); + }; + + const saveFile = async (fileForm: FileForm) => { + try { + const response = await props.updateFileProperties(fileForm, []); + + formikRef?.current?.setSubmitting(false); + if (isValidId(response?.id)) { + if (response.fileProperties?.find(fp => !fp.property?.address && !fp.property?.id)) { + toast.warn( + 'Address could not be retrieved for this property, it will have to be provided manually in property details tab', + { autoClose: 15000 }, + ); + } + formikRef?.current?.resetForm(); + props.setIsShowingPropertySelector(false); + props.onSuccess(true); + } + } catch (e) { + if (axios.isAxiosError(e) && (e as AxiosError).code === '409') { + setShowAssociatedEntityWarning(true); + } + } + }; + + const onRemoveClick = async (propertyApiId: number, removeCallback: () => void) => { + if (await props.canRemove(propertyApiId)) { + removeCallback(); + } else { + setShowAssociatedEntityWarning(true); + } + }; + + return ( + <> + + + } + > + + innerRef={formikRef} + initialValues={props.formFile} + validationSchema={UpdatePropertiesYupSchema} + onSubmit={async (values: FileForm) => { + await saveFile(values); + }} + > + {formikProps => ( + + )} + + + +
    + This property can not be removed from the file. This property is related to one or + more entities in the file, only properties that are not linked to any entities in the + file can be removed. +
    + + } + handleOk={() => setShowAssociatedEntityWarning(false)} + okButtonText="Close" + show + /> + +
    You have made changes to the properties in this file.
    +
    + Do you want to save these changes? + + } + handleOk={handleSaveConfirm} + handleCancel={() => setShowSaveConfirmModal(false)} + okButtonText="Save" + cancelButtonText="Cancel" + show + /> + + ); +}; + +export default UpdatePropertiesContainer; diff --git a/source/frontend/src/features/mapSideBar/shared/update/properties/__snapshots__/PropertiesListContainer.test.tsx.snap b/source/frontend/src/features/mapSideBar/shared/update/properties/__snapshots__/PropertiesListContainer.test.tsx.snap new file mode 100644 index 0000000000..92dcf39401 --- /dev/null +++ b/source/frontend/src/features/mapSideBar/shared/update/properties/__snapshots__/PropertiesListContainer.test.tsx.snap @@ -0,0 +1,1152 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`PropertiesListContainer component > renders as expected 1`] = ` + +
    +
    + .c8.btn { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + padding: 0.4rem 1.2rem; + border: 0.2rem solid transparent; + border-radius: 0.4rem; + text-align: center; + -webkit-text-decoration: none; + text-decoration: none; + font-size: 1.8rem; + font-family: 'BCSans','Noto Sans',Verdana,Arial,sans-serif; + font-weight: 700; + -webkit-letter-spacing: 0.1rem; + -moz-letter-spacing: 0.1rem; + -ms-letter-spacing: 0.1rem; + letter-spacing: 0.1rem; + cursor: pointer; +} + +.c8.btn .Button__value { + width: auto; +} + +.c8.btn:hover { + -webkit-text-decoration: underline; + text-decoration: underline; + opacity: 0.8; +} + +.c8.btn:focus { + outline-width: 2px; + outline-style: solid; + outline-color: #2E5DD7; + outline-offset: 2px; + box-shadow: none; +} + +.c8.btn.btn-primary { + color: #FFFFFF; + background-color: #013366; +} + +.c8.btn.btn-primary:hover, +.c8.btn.btn-primary:active, +.c8.btn.btn-primary:focus { + background-color: #1E5189; +} + +.c8.btn.btn-secondary { + color: #013366; + background: none; + border-color: #013366; +} + +.c8.btn.btn-secondary:hover, +.c8.btn.btn-secondary:active, +.c8.btn.btn-secondary:focus { + color: #FFFFFF; + background-color: #013366; +} + +.c8.btn.btn-info { + color: #9F9D9C; + border: none; + background: none; + padding-left: 0.6rem; + padding-right: 0.6rem; +} + +.c8.btn.btn-info:hover, +.c8.btn.btn-info:active, +.c8.btn.btn-info:focus { + color: var(--surface-color-primary-button-hover); + background: none; +} + +.c8.btn.btn-light { + color: #FFFFFF; + background-color: #606060; + border: none; +} + +.c8.btn.btn-light:hover, +.c8.btn.btn-light:active, +.c8.btn.btn-light:focus { + color: #FFFFFF; + background-color: #606060; +} + +.c8.btn.btn-dark { + color: #FFFFFF; + background-color: #474543; + border: none; +} + +.c8.btn.btn-dark:hover, +.c8.btn.btn-dark:active, +.c8.btn.btn-dark:focus { + color: #FFFFFF; + background-color: #474543; +} + +.c8.btn.btn-danger { + color: #FFFFFF; + background-color: #CE3E39; +} + +.c8.btn.btn-danger:hover, +.c8.btn.btn-danger:active, +.c8.btn.btn-danger:focus { + color: #FFFFFF; + background-color: #CE3E39; +} + +.c8.btn.btn-warning { + color: #FFFFFF; + background-color: #FCBA19; + border-color: #FCBA19; +} + +.c8.btn.btn-warning:hover, +.c8.btn.btn-warning:active, +.c8.btn.btn-warning:focus { + color: #FFFFFF; + border-color: #FCBA19; + background-color: #FCBA19; +} + +.c8.btn.btn-link { + font-size: 1.6rem; + font-weight: 400; + color: var(--surface-color-primary-button-default); + background: none; + border: none; + -webkit-text-decoration: none; + text-decoration: none; + min-height: 2.5rem; + line-height: 3rem; + -webkit-box-pack: left; + -webkit-justify-content: left; + -ms-flex-pack: left; + justify-content: left; + -webkit-letter-spacing: unset; + -moz-letter-spacing: unset; + -ms-letter-spacing: unset; + letter-spacing: unset; + text-align: left; + padding: 0; + -webkit-text-decoration: underline; + text-decoration: underline; +} + +.c8.btn.btn-link:hover, +.c8.btn.btn-link:active, +.c8.btn.btn-link:focus { + color: var(--surface-color-primary-button-hover); + -webkit-text-decoration: underline; + text-decoration: underline; + border: none; + background: none; + box-shadow: none; + outline: none; +} + +.c8.btn.btn-link:disabled, +.c8.btn.btn-link.disabled { + color: #9F9D9C; + background: none; + pointer-events: none; +} + +.c8.btn:disabled, +.c8.btn:disabled:hover { + color: #9F9D9C; + background-color: #EDEBE9; + box-shadow: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; + cursor: not-allowed; +} + +.c8.Button .Button__icon { + margin-right: 1.6rem; +} + +.c8.Button--icon-only:focus { + outline: none; +} + +.c8.Button--icon-only .Button__icon { + margin-right: 0; +} + +.c0 { + margin: 0 1.6rem 0 1.6rem; + padding: 1.6rem; + text-align: left; + text-underline-offset: 2px; +} + +.c0 button { + font-size: 14px; +} + +.c1 { + font-size: 16px; + color: #1a5a96; + font-style: italic; + font-weight: bold; + -webkit-text-decoration: none solid rgb(26,90,150); + text-decoration: none solid rgb(26,90,150); + line-height: 24px; + margin-bottom: 1rem; +} + +.c5 { + padding: 1.5rem 1.8rem; + border-radius: 4px; + background-color: #f0f7fc; + margin-bottom: 2rem; + font-size: 14px; + color: #053662; + -webkit-text-decoration: none solid rgb(49,49,50); + text-decoration: none solid rgb(49,49,50); + line-height: 24px; +} + +.c4 { + float: right; + cursor: pointer; + color: #1a5a96; + font-size: 2.8rem; +} + +.c3 { + margin-right: 1.5rem; + font-size: 1.3rem; +} + +.c2 { + cursor: pointer; +} + +.c7 { + font-weight: normal; +} + +.c6 { + font-weight: bold; +} + +
    +
    +
    +
    + + + + New workflow +
    +
    + + + + +
    +
    +
    +
    +
      +
    1. +
      + Find a Property +
      +
      + Navigate to an area of the map OR use + +
      +
    2. +
    3. +
      + Select a property +
      +
      + Click on the map and the selection will be highlighed +
      +
    4. +
    5. +
      + Add it to this file +
      +
      + Click "Add selected" property button when it appears below +
      +
    6. +
    +
    +
    + .c2.btn { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + padding: 0.4rem 1.2rem; + border: 0.2rem solid transparent; + border-radius: 0.4rem; + text-align: center; + -webkit-text-decoration: none; + text-decoration: none; + font-size: 1.8rem; + font-family: 'BCSans','Noto Sans',Verdana,Arial,sans-serif; + font-weight: 700; + -webkit-letter-spacing: 0.1rem; + -moz-letter-spacing: 0.1rem; + -ms-letter-spacing: 0.1rem; + letter-spacing: 0.1rem; + cursor: pointer; +} + +.c2.btn .Button__value { + width: auto; +} + +.c2.btn:hover { + -webkit-text-decoration: underline; + text-decoration: underline; + opacity: 0.8; +} + +.c2.btn:focus { + outline-width: 2px; + outline-style: solid; + outline-color: #2E5DD7; + outline-offset: 2px; + box-shadow: none; +} + +.c2.btn.btn-primary { + color: #FFFFFF; + background-color: #013366; +} + +.c2.btn.btn-primary:hover, +.c2.btn.btn-primary:active, +.c2.btn.btn-primary:focus { + background-color: #1E5189; +} + +.c2.btn.btn-secondary { + color: #013366; + background: none; + border-color: #013366; +} + +.c2.btn.btn-secondary:hover, +.c2.btn.btn-secondary:active, +.c2.btn.btn-secondary:focus { + color: #FFFFFF; + background-color: #013366; +} + +.c2.btn.btn-info { + color: #9F9D9C; + border: none; + background: none; + padding-left: 0.6rem; + padding-right: 0.6rem; +} + +.c2.btn.btn-info:hover, +.c2.btn.btn-info:active, +.c2.btn.btn-info:focus { + color: var(--surface-color-primary-button-hover); + background: none; +} + +.c2.btn.btn-light { + color: #FFFFFF; + background-color: #606060; + border: none; +} + +.c2.btn.btn-light:hover, +.c2.btn.btn-light:active, +.c2.btn.btn-light:focus { + color: #FFFFFF; + background-color: #606060; +} + +.c2.btn.btn-dark { + color: #FFFFFF; + background-color: #474543; + border: none; +} + +.c2.btn.btn-dark:hover, +.c2.btn.btn-dark:active, +.c2.btn.btn-dark:focus { + color: #FFFFFF; + background-color: #474543; +} + +.c2.btn.btn-danger { + color: #FFFFFF; + background-color: #CE3E39; +} + +.c2.btn.btn-danger:hover, +.c2.btn.btn-danger:active, +.c2.btn.btn-danger:focus { + color: #FFFFFF; + background-color: #CE3E39; +} + +.c2.btn.btn-warning { + color: #FFFFFF; + background-color: #FCBA19; + border-color: #FCBA19; +} + +.c2.btn.btn-warning:hover, +.c2.btn.btn-warning:active, +.c2.btn.btn-warning:focus { + color: #FFFFFF; + border-color: #FCBA19; + background-color: #FCBA19; +} + +.c2.btn.btn-link { + font-size: 1.6rem; + font-weight: 400; + color: var(--surface-color-primary-button-default); + background: none; + border: none; + -webkit-text-decoration: none; + text-decoration: none; + min-height: 2.5rem; + line-height: 3rem; + -webkit-box-pack: left; + -webkit-justify-content: left; + -ms-flex-pack: left; + justify-content: left; + -webkit-letter-spacing: unset; + -moz-letter-spacing: unset; + -ms-letter-spacing: unset; + letter-spacing: unset; + text-align: left; + padding: 0; + -webkit-text-decoration: underline; + text-decoration: underline; +} + +.c2.btn.btn-link:hover, +.c2.btn.btn-link:active, +.c2.btn.btn-link:focus { + color: var(--surface-color-primary-button-hover); + -webkit-text-decoration: underline; + text-decoration: underline; + border: none; + background: none; + box-shadow: none; + outline: none; +} + +.c2.btn.btn-link:disabled, +.c2.btn.btn-link.disabled { + color: #9F9D9C; + background: none; + pointer-events: none; +} + +.c2.btn:disabled, +.c2.btn:disabled:hover { + color: #9F9D9C; + background-color: #EDEBE9; + box-shadow: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; + cursor: not-allowed; +} + +.c2.Button .Button__icon { + margin-right: 1.6rem; +} + +.c2.Button--icon-only:focus { + outline: none; +} + +.c2.Button--icon-only .Button__icon { + margin-right: 0; +} + +.c11.c11.btn { + font-size: 1.4rem; + color: #aaaaaa; + -webkit-text-decoration: none; + text-decoration: none; + line-height: unset; +} + +.c11.c11.btn .text { + display: none; +} + +.c11.c11.btn:hover, +.c11.c11.btn:active, +.c11.c11.btn:focus { + color: #d8292f; + -webkit-text-decoration: none; + text-decoration: none; + opacity: unset; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; +} + +.c11.c11.btn:hover .text, +.c11.c11.btn:active .text, +.c11.c11.btn:focus .text { + display: inline; + line-height: 2rem; +} + +.c8 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: baseline; + -webkit-box-align: baseline; + -ms-flex-align: baseline; + align-items: baseline; + gap: 0.8rem; +} + +.c8 .form-label { + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; +} + +.c1 { + font-weight: bold; + color: var(--theme-blue-100); + border-bottom: 0.2rem var(--theme-blue-90) solid; + margin-bottom: 2.4rem; +} + +.c0 { + margin: 1.6rem; + padding: 1.6rem; + background-color: white; + text-align: left; + border-radius: 0.5rem; +} + +.c3 { + margin: 0; + padding: 1.6rem; + font-size: 1.6rem; + color: #9F9D9C; + border-bottom: 0.2rem solid #606060; + margin-bottom: 0.9rem; + padding-bottom: 0.25rem; + font-family: 'BcSans-Bold'; +} + +.c7 { + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; + margin: 0.15rem; + witdh: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + word-break: break-all; + line-break: break-all; +} + +.c5 { + min-width: 3rem; + min-height: 4.5rem; +} + +.c6 { + font-size: 1.2rem; +} + +.c4 { + min-height: 4.5rem; +} + +.c9 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: end; + -webkit-justify-content: flex-end; + -ms-flex-pack: end; + justify-content: flex-end; +} + +.c10 { + padding-left: 1.2rem; +} + +
    +

    +
    +
    +
    +
    + Selected Properties +
    +
    + +
    +
    +
    +
    +

    +
    +
    +
    + Identifier +
    +
    + Provide a descriptive name for this land + + + + + +
    +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + 1 + + + + 1 + + +
    + PID: 123-456-789 +
    +
    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + 2 + + + + 2 + + +
    + PIN: 1111222 +
    +
    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    +
    +
    + +`; diff --git a/source/frontend/src/features/mapSideBar/shared/update/properties/__snapshots__/UpdateProperties.test.tsx.snap b/source/frontend/src/features/mapSideBar/shared/update/properties/__snapshots__/UpdateProperties.test.tsx.snap index 0954714743..099425343a 100644 --- a/source/frontend/src/features/mapSideBar/shared/update/properties/__snapshots__/UpdateProperties.test.tsx.snap +++ b/source/frontend/src/features/mapSideBar/shared/update/properties/__snapshots__/UpdateProperties.test.tsx.snap @@ -207,54 +207,7 @@ exports[`UpdateProperties component > renders as expected 1`] = ` margin-right: 0; } -.c27.c27.btn { - background-color: unset; - border: none; -} - -.c27.c27.btn:hover, -.c27.c27.btn:focus, -.c27.c27.btn:active { - background-color: unset; - outline: none; - box-shadow: none; -} - -.c27.c27.btn svg { - -webkit-transition: all 0.3s ease-out; - transition: all 0.3s ease-out; -} - -.c27.c27.btn svg:hover { - -webkit-transition: all 0.3s ease-in; - transition: all 0.3s ease-in; -} - -.c27.c27.btn.btn-primary svg { - color: #013366; -} - -.c27.c27.btn.btn-primary svg:hover { - color: #013366; -} - -.c27.c27.btn.btn-light svg { - color: var(--surface-color-primary-button-default); -} - -.c27.c27.btn.btn-light svg:hover { - color: #CE3E39; -} - -.c27.c27.btn.btn-info svg { - color: var(--surface-color-primary-button-default); -} - -.c27.c27.btn.btn-info svg:hover { - color: var(--surface-color-primary-button-hover); -} - -.c29.c29.btn { +.c28.c28.btn { font-size: 1.4rem; color: #aaaaaa; -webkit-text-decoration: none; @@ -262,13 +215,13 @@ exports[`UpdateProperties component > renders as expected 1`] = ` line-height: unset; } -.c29.c29.btn .text { +.c28.c28.btn .text { display: none; } -.c29.c29.btn:hover, -.c29.c29.btn:active, -.c29.c29.btn:focus { +.c28.c28.btn:hover, +.c28.c28.btn:active, +.c28.c28.btn:focus { color: #d8292f; -webkit-text-decoration: none; text-decoration: none; @@ -282,9 +235,9 @@ exports[`UpdateProperties component > renders as expected 1`] = ` flex-direction: row; } -.c29.c29.btn:hover .text, -.c29.c29.btn:active .text, -.c29.c29.btn:focus .text { +.c28.c28.btn:hover .text, +.c28.c28.btn:active .text, +.c28.c28.btn:focus .text { display: inline; line-height: 2rem; } @@ -329,84 +282,6 @@ exports[`UpdateProperties component > renders as expected 1`] = ` border-bottom: solid 0.5rem #3470B1; } -.c19 { - font-weight: bold; - color: var(--theme-blue-100); - border-bottom: 0.2rem var(--theme-blue-90) solid; - margin-bottom: 2.4rem; -} - -.c18 { - margin: 1.6rem; - padding: 1.6rem; - background-color: white; - text-align: left; - border-radius: 0.5rem; -} - -.c20 { - margin: 0; - padding: 1.6rem; - font-size: 1.6rem; - color: #9F9D9C; - border-bottom: 0.2rem solid #606060; - margin-bottom: 0.9rem; - padding-bottom: 0.25rem; - font-family: 'BcSans-Bold'; -} - -.c24 { - -webkit-box-flex: 1; - -webkit-flex-grow: 1; - -ms-flex-positive: 1; - flex-grow: 1; - margin: 0.15rem; - witdh: 100%; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 1; - -webkit-box-orient: vertical; - word-break: break-all; - line-break: break-all; -} - -.c22 { - min-width: 3rem; - min-height: 4.5rem; -} - -.c23 { - font-size: 1.2rem; -} - -.c21 { - min-height: 4.5rem; -} - -.c26 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: row; - -ms-flex-direction: row; - flex-direction: row; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: end; - -webkit-justify-content: flex-end; - -ms-flex-pack: end; - justify-content: flex-end; -} - -.c28 { - padding-left: 1.2rem; -} - .c5.btn.btn-light.Button { padding: 0; border: 0.1rem solid #9F9D9C; @@ -489,7 +364,7 @@ exports[`UpdateProperties component > renders as expected 1`] = ` align-items: end; } -.c30 { +.c29 { position: -webkit-sticky; position: sticky; padding-top: 2rem; @@ -499,6 +374,84 @@ exports[`UpdateProperties component > renders as expected 1`] = ` z-index: 10; } +.c19 { + font-weight: bold; + color: var(--theme-blue-100); + border-bottom: 0.2rem var(--theme-blue-90) solid; + margin-bottom: 2.4rem; +} + +.c18 { + margin: 1.6rem; + padding: 1.6rem; + background-color: white; + text-align: left; + border-radius: 0.5rem; +} + +.c20 { + margin: 0; + padding: 1.6rem; + font-size: 1.6rem; + color: #9F9D9C; + border-bottom: 0.2rem solid #606060; + margin-bottom: 0.9rem; + padding-bottom: 0.25rem; + font-family: 'BcSans-Bold'; +} + +.c24 { + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; + margin: 0.15rem; + witdh: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + word-break: break-all; + line-break: break-all; +} + +.c22 { + min-width: 3rem; + min-height: 4.5rem; +} + +.c23 { + font-size: 1.2rem; +} + +.c21 { + min-height: 4.5rem; +} + +.c26 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: end; + -webkit-justify-content: flex-end; + -ms-flex-pack: end; + justify-content: flex-end; +} + +.c27 { + padding-left: 1.2rem; +} + .c10 { margin: 0 1.6rem 0 1.6rem; padding: 1.6rem; @@ -800,13 +753,6 @@ exports[`UpdateProperties component > renders as expected 1`] = `
    -
    -
    -
    @@ -972,35 +918,11 @@ exports[`UpdateProperties component > renders as expected 1`] = `
    -
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + + New workflow +
    +
    + + + + +
    +
    +
    +
    +
      +
    1. +
      + Find a Property +
      +
      + Navigate to an area of the map OR use + + +
      +
    2. +
    3. +
      + Select a property +
      +
      + Click on the map and the selection will be highlighed +
      +
    4. +
    5. +
      + Add it to this file +
      +
      + Click "Add selected" property button when it appears below +
      +
    6. +
    +
    +
    +
    +

    +
    +
    +
    +
    + Selected Properties +
    +
    + +
    +
    +
    +
    +

    +
    +
    +
    + Identifier +
    +
    + Provide a descriptive name for this land + + + + + +
    +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + 1 + + + + 1 + + +
    + PID: 123-456-789 +
    +
    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    + +`; diff --git a/source/frontend/src/features/mapSideBar/subdivision/AddSubdivisionContainer.test.tsx b/source/frontend/src/features/mapSideBar/subdivision/AddSubdivisionContainer.test.tsx index 7dd355a804..e5b2107ae1 100644 --- a/source/frontend/src/features/mapSideBar/subdivision/AddSubdivisionContainer.test.tsx +++ b/source/frontend/src/features/mapSideBar/subdivision/AddSubdivisionContainer.test.tsx @@ -20,6 +20,7 @@ import AddSubdivisionContainer from './AddSubdivisionContainer'; import { ApiGen_Concepts_PropertyOperation } from '@/models/api/generated/ApiGen_Concepts_PropertyOperation'; import { ApiGen_Concepts_Property } from '@/models/api/generated/ApiGen_Concepts_Property'; import { Input } from '@/components/common/form'; +import { PropertyForm } from '../shared/models'; const history = createMemoryHistory(); @@ -137,7 +138,7 @@ describe('Add Subdivision Container component', () => { mockAddPropertyOperation.execute.mockResolvedValue([{}]); const model = new SubdivisionFormModel(); - model.destinationProperties = [{} as ApiGen_Concepts_Property]; + model.destinationProperties = [new PropertyForm()]; await act(async () => { viewProps?.onSubmit(model, {} as any); }); @@ -148,10 +149,12 @@ describe('Add Subdivision Container component', () => { it('Calls onSuccess when the submit operation completes successfully when the response contains a viable property to navigate', async () => { await setup({}); - mockAddPropertyOperation.execute.mockResolvedValue([{}]); + mockAddPropertyOperation.execute.mockResolvedValue([{ sourceProperty: { id: 1 } }]); const model = new SubdivisionFormModel(); - model.destinationProperties = [{} as ApiGen_Concepts_Property]; - model.sourceProperty = { id: 1 } as ApiGen_Concepts_Property; + model.destinationProperties = [new PropertyForm()]; + model.sourceProperty = new PropertyForm(); + model.sourceProperty.id = 1; + await act(async () => { viewProps?.onSubmit(model, {} as any); }); diff --git a/source/frontend/src/features/mapSideBar/subdivision/AddSubdivisionContainer.tsx b/source/frontend/src/features/mapSideBar/subdivision/AddSubdivisionContainer.tsx index db790e02b6..83c7233328 100644 --- a/source/frontend/src/features/mapSideBar/subdivision/AddSubdivisionContainer.tsx +++ b/source/frontend/src/features/mapSideBar/subdivision/AddSubdivisionContainer.tsx @@ -34,9 +34,10 @@ const AddSubdivisionContainer: React.FC = ({ const [initialForm, setInitialForm] = useState(new SubdivisionFormModel()); const formikRef = useRef>(null); const mapMachine = useMapStateMachine(); - const selectedFeatureDataset = firstOrNull(mapMachine.selectedFeatures); + const selectedFeatureDataset = firstOrNull(mapMachine.locationFeaturesForAddition); const { setModalContent, setDisplayModal } = useModalContext(); const { getPrimaryAddressByPid, bcaLoading } = useBcaAddress(); + const [isFirstLoad, setIsFirstLoad] = useState(true); const { addPropertyOperationApi: { execute: addPropertyOperation, loading }, @@ -58,22 +59,23 @@ const AddSubdivisionContainer: React.FC = ({ async function loadInitialProperty() { // support creating a new subdivision from the map popup - if (selectedFeatureDataset !== null) { - const propertyForm = PropertyForm.fromFeatureDataset(selectedFeatureDataset); + if (selectedFeatureDataset !== null && isFirstLoad) { + const propertyForm = PropertyForm.fromLocationFeatureDataset(selectedFeatureDataset); if (isValidString(propertyForm.pid)) { // TODO: This should work with multiple properties - const pimsFeature = selectedFeatureDataset.pimsFeature; + const pimsFeature = firstOrNull(selectedFeatureDataset.pimsFeatures); propertyForm.address = pimsFeature?.properties ? AddressForm.fromPimsView(pimsFeature?.properties) : undefined; const subdivisionFormModel = new SubdivisionFormModel(); - subdivisionFormModel.sourceProperty = propertyForm.toApi(); + subdivisionFormModel.sourceProperty = propertyForm; subdivisionFormModel.sourceProperty.isOwned = pimsFeature.properties.IS_OWNED; setInitialForm(subdivisionFormModel); } } + setIsFirstLoad(false); } - }, [selectedFeatureDataset, getAddress]); + }, [selectedFeatureDataset, getAddress, isFirstLoad]); useEffect(() => { if (exists(initialForm) && exists(formikRef.current)) { @@ -136,10 +138,10 @@ const AddSubdivisionContainer: React.FC = ({ const response = await addPropertyOperation(propertyOperations, userOverrideCodes); if (response?.length) { - handleSuccess(propertyOperations); + handleSuccess(response); } } finally { - mapMachine.processCreation(); + mapMachine.processLocationFeaturesAddition(); formikHelpers?.setSubmitting(false); } }; diff --git a/source/frontend/src/features/mapSideBar/subdivision/AddSubdivisionMarkerSynchronizer.tsx b/source/frontend/src/features/mapSideBar/subdivision/AddSubdivisionMarkerSynchronizer.tsx index d1b4741304..5f9252fefb 100644 --- a/source/frontend/src/features/mapSideBar/subdivision/AddSubdivisionMarkerSynchronizer.tsx +++ b/source/frontend/src/features/mapSideBar/subdivision/AddSubdivisionMarkerSynchronizer.tsx @@ -1,5 +1,4 @@ import useDraftMarkerSynchronizer from '@/hooks/useDraftMarkerSynchronizer'; -import { propertyToLocationBoundaryDataset } from '@/utils/mapPropertyUtils'; import { SubdivisionFormModel } from './AddSubdivisionModel'; @@ -11,8 +10,8 @@ const AddSubdivisionMarkerSynchronizer: React.FunctionComponent< IAddSubdivisionMarkerSynchronizerProps > = ({ values }) => { useDraftMarkerSynchronizer([ - ...(values.sourceProperty ? [propertyToLocationBoundaryDataset(values.sourceProperty)] : []), - ...values.destinationProperties.map(dp => propertyToLocationBoundaryDataset(dp)), + ...(values.sourceProperty ? [values.sourceProperty] : []), + ...values.destinationProperties, ]); return null; }; diff --git a/source/frontend/src/features/mapSideBar/subdivision/AddSubdivisionModel.ts b/source/frontend/src/features/mapSideBar/subdivision/AddSubdivisionModel.ts index 76e77654c7..f96c5dd0a5 100644 --- a/source/frontend/src/features/mapSideBar/subdivision/AddSubdivisionModel.ts +++ b/source/frontend/src/features/mapSideBar/subdivision/AddSubdivisionModel.ts @@ -1,13 +1,14 @@ import { ApiGen_Base_CodeType } from '@/models/api/generated/ApiGen_Base_CodeType'; import { ApiGen_CodeTypes_PropertyOperationTypes } from '@/models/api/generated/ApiGen_CodeTypes_PropertyOperationTypes'; -import { ApiGen_Concepts_Property } from '@/models/api/generated/ApiGen_Concepts_Property'; import { ApiGen_Concepts_PropertyOperation } from '@/models/api/generated/ApiGen_Concepts_PropertyOperation'; import { getEmptyBaseAudit } from '@/models/defaultInitializers'; import { exists } from '@/utils'; +import { PropertyForm } from '../shared/models'; + export class SubdivisionFormModel { - sourceProperty: ApiGen_Concepts_Property | null = null; - destinationProperties: ApiGen_Concepts_Property[] = []; + sourceProperty: PropertyForm | null = null; + destinationProperties: PropertyForm[] = []; propertyOperationNo: number | null = null; propertyOperationTypeCode: ApiGen_Base_CodeType | null = { id: ApiGen_CodeTypes_PropertyOperationTypes.SUBDIVIDE, @@ -19,12 +20,12 @@ export class SubdivisionFormModel { pid = ''; toApi(): ApiGen_Concepts_PropertyOperation[] { - return this.destinationProperties?.map(dp => ({ + return this.destinationProperties?.map(dp => ({ ...getEmptyBaseAudit(0), id: 0, sourcePropertyId: this.sourceProperty?.id ?? 0, - sourceProperty: this.sourceProperty, - destinationProperty: dp, + sourceProperty: this.sourceProperty?.toApi(), + destinationProperty: dp?.toApi(), destinationPropertyId: dp?.id ?? 0, operationDt: null, isDisabled: false, @@ -42,9 +43,10 @@ export class SubdivisionFormModel { subdivisionForm.propertyOperationNo = null; subdivisionForm.propertyOperationTypeCode = null; } - subdivisionForm.sourceProperty = operations[0].sourceProperty; + subdivisionForm.sourceProperty = PropertyForm.fromPropertyApi(operations[0].sourceProperty); subdivisionForm.destinationProperties = - operations.map(op => op.destinationProperty).filter(exists) ?? []; + operations.map(op => PropertyForm.fromPropertyApi(op.destinationProperty)).filter(exists) ?? + []; subdivisionForm.propertyOperationNo = operations[0].propertyOperationNo; subdivisionForm.propertyOperationTypeCode = operations[0].propertyOperationTypeCode; return subdivisionForm; diff --git a/source/frontend/src/features/mapSideBar/subdivision/AddSubdivisionView.test.tsx b/source/frontend/src/features/mapSideBar/subdivision/AddSubdivisionView.test.tsx index c6a88b4a5c..f57e93875c 100644 --- a/source/frontend/src/features/mapSideBar/subdivision/AddSubdivisionView.test.tsx +++ b/source/frontend/src/features/mapSideBar/subdivision/AddSubdivisionView.test.tsx @@ -5,13 +5,15 @@ import { createRef } from 'react'; import { IMapSelectorContainerProps } from '@/components/propertySelector/MapSelectorContainer'; import { PropertySelectorPidSearchContainerProps } from '@/components/propertySelector/search/PropertySelectorPidSearchContainer'; import Claims from '@/constants/claims'; -import { getMockSelectedFeatureDataset } from '@/mocks/featureset.mock'; import { mockLookups } from '@/mocks/lookups.mock'; import { getMockApiProperty } from '@/mocks/properties.mock'; import { lookupCodesSlice } from '@/store/slices/lookupCodes/lookupCodesSlice'; import { act, render, RenderOptions, screen, userEvent } from '@/utils/test-utils'; import { SubdivisionFormModel } from './AddSubdivisionModel'; import AddSubdivisionView, { IAddSubdivisionViewProps } from './AddSubdivisionView'; +import { PropertyForm } from '../shared/models'; +import { getMockLocationFeatureDataset } from '@/mocks/featureset.mock'; +import { pidFormatter } from '@/utils'; const history = createMemoryHistory(); @@ -93,19 +95,21 @@ describe('Add Subdivision View', () => { }); it('calls getPrimaryAddressByPid when destination property is activated', async () => { - const mockFeatureSet = getMockSelectedFeatureDataset(); + const mockFeatureSet = getMockLocationFeatureDataset(); await setup(); await act(async () => { mapSelectorProps.addSelectedProperties([ { ...mockFeatureSet, - pimsFeature: { - ...mockFeatureSet.pimsFeature, - properties: { - ...mockFeatureSet.pimsFeature?.properties, - PID_PADDED: '123-456-789', + pimsFeatures: [ + { + ...mockFeatureSet.pimsFeatures[0], + properties: { + ...mockFeatureSet.pimsFeatures[0]?.properties, + PID_PADDED: '123-456-789', + }, }, - }, + ], }, ]); }); @@ -113,19 +117,21 @@ describe('Add Subdivision View', () => { }); it('does not call for address if property has no pid', async () => { - const mockFeatureSet = getMockSelectedFeatureDataset(); + const mockFeatureSet = getMockLocationFeatureDataset(); await setup(); await act(async () => { mapSelectorProps.addSelectedProperties([ { ...mockFeatureSet, - pimsFeature: { - ...mockFeatureSet.pimsFeature, - properties: { - ...mockFeatureSet.pimsFeature?.properties, - PID_PADDED: undefined, + pimsFeatures: [ + { + ...mockFeatureSet.pimsFeatures[0], + properties: { + ...mockFeatureSet.pimsFeatures[0]?.properties, + PID_PADDED: undefined, + }, }, - }, + ], }, ]); }); @@ -139,13 +145,16 @@ describe('Add Subdivision View', () => { await act(async () => { pidSelectorProps.setSelectProperty(property); }); - const text = await screen.findByText(property.pid?.toString() ?? ''); + const text = await screen.findByText(pidFormatter(property?.pid.toString())); expect(text).toBeVisible(); }); it('selected source property can be removed', async () => { const initialFormModel = new SubdivisionFormModel(); - initialFormModel.sourceProperty = { ...getMockApiProperty(), pid: 111111111 }; + initialFormModel.sourceProperty = PropertyForm.fromPropertyApi({ + ...getMockApiProperty(), + pid: 111111111, + }); initialFormModel.destinationProperties = []; const { getByTitle, queryByText } = await setup({ @@ -163,7 +172,9 @@ describe('Add Subdivision View', () => { it('selected destination properties can be removed', async () => { const initialFormModel = new SubdivisionFormModel(); - initialFormModel.destinationProperties = [{ ...getMockApiProperty(), pid: 111111111 }]; + initialFormModel.destinationProperties = [ + PropertyForm.fromPropertyApi({ ...getMockApiProperty(), pid: 111111111 }), + ]; const { getByTitle, queryByText } = await setup({ props: { @@ -179,7 +190,7 @@ describe('Add Subdivision View', () => { }); it('property area only has at most 4 digits', async () => { - const mockFeatureSet = getMockSelectedFeatureDataset(); + const mockFeatureSet = getMockLocationFeatureDataset(); const initialFormModel = new SubdivisionFormModel(); getPrimaryAddressByPid.mockImplementation(() => Promise.resolve(undefined)); @@ -193,14 +204,16 @@ describe('Add Subdivision View', () => { mapSelectorProps.addSelectedProperties([ { ...mockFeatureSet, - pimsFeature: { - ...mockFeatureSet.pimsFeature, - properties: { - ...mockFeatureSet.pimsFeature?.properties, - PID_PADDED: '123-456-789', - LAND_AREA: 1.12345, + pimsFeatures: [ + { + ...mockFeatureSet.pimsFeatures[0], + properties: { + ...mockFeatureSet.pimsFeatures[0]?.properties, + PID_PADDED: '123-456-789', + LAND_AREA: 1.12345, + }, }, - }, + ], }, ]); }); diff --git a/source/frontend/src/features/mapSideBar/subdivision/AddSubdivisionView.tsx b/source/frontend/src/features/mapSideBar/subdivision/AddSubdivisionView.tsx index a8ad71869d..8acc264182 100644 --- a/source/frontend/src/features/mapSideBar/subdivision/AddSubdivisionView.tsx +++ b/source/frontend/src/features/mapSideBar/subdivision/AddSubdivisionView.tsx @@ -1,5 +1,4 @@ import { FieldArray, Formik, FormikHelpers, FormikProps } from 'formik'; -import noop from 'lodash/noop'; import { useCallback } from 'react'; import { Col, Row, Tab } from 'react-bootstrap'; import { FaInfoCircle } from 'react-icons/fa'; @@ -21,7 +20,6 @@ import { StyledTabView } from '@/components/propertySelector/PropertySelectorTab import { PropertySelectorPidSearchContainerProps } from '@/components/propertySelector/search/PropertySelectorPidSearchContainer'; import PropertySearchSelectorPidFormView from '@/components/propertySelector/search/PropertySelectorPidSearchView'; import { ApiGen_CodeTypes_AreaUnitTypes } from '@/models/api/generated/ApiGen_CodeTypes_AreaUnitTypes'; -import { ApiGen_Concepts_Property } from '@/models/api/generated/ApiGen_Concepts_Property'; import { convertArea } from '@/utils/convertUtils'; import MapSideBarLayout from '../layout/MapSideBarLayout'; @@ -131,7 +129,10 @@ const AddSubdivisionView: React.FunctionComponent< - setFieldValue('sourceProperty', selectedProperty) + setFieldValue( + 'sourceProperty', + PropertyForm.fromPropertyApi(selectedProperty), + ) } PropertySelectorPidSearchView={PropertySearchSelectorPidFormView} /> @@ -159,7 +160,7 @@ const AddSubdivisionView: React.FunctionComponent< const allProperties = [...values.destinationProperties]; await properties.reduce(async (promise, property) => { return promise.then(async () => { - const formProperty = PropertyForm.fromFeatureDataset(property); + const formProperty = PropertyForm.fromLocationFeatureDataset(property); formProperty.landArea = formProperty.landArea && formProperty.areaUnit ? getAreaValue(formProperty.landArea, formProperty.areaUnit) @@ -167,7 +168,7 @@ const AddSubdivisionView: React.FunctionComponent< formProperty.areaUnit = ApiGen_CodeTypes_AreaUnitTypes.M2; if (formProperty.pid) { formProperty.address = await getPrimaryAddressByPid(formProperty.pid); - allProperties.push(formProperty.toApi()); + allProperties.push(formProperty); } else { toast.error('Selected property must have a PID'); } @@ -175,11 +176,9 @@ const AddSubdivisionView: React.FunctionComponent< }, Promise.resolve()); setFieldValue('destinationProperties', allProperties); }} - selectedComponentId="destination-property-selector" modifiedProperties={values.destinationProperties.map(dp => - PropertyForm.fromPropertyApi(dp).toFeatureDataset(), + dp.toLocationFeatureDataset(), )} - repositionSelectedProperty={noop} /> {({ remove }) => ( @@ -190,7 +189,7 @@ const AddSubdivisionView: React.FunctionComponent< @@ -225,10 +224,7 @@ const AddSubdivisionView: React.FunctionComponent< ); }; -const getDraftMarkerIndex = ( - property: ApiGen_Concepts_Property, - form: SubdivisionFormModel, -): number => { +const getDraftMarkerIndex = (property: PropertyForm, form: SubdivisionFormModel): number => { let index = form.destinationProperties.findIndex( p => p.latitude === property.latitude && diff --git a/source/frontend/src/features/properties/parcelList/ParcelItem.tsx b/source/frontend/src/features/properties/parcelList/ParcelItem.tsx index 06e2d67002..8281dcefc7 100644 --- a/source/frontend/src/features/properties/parcelList/ParcelItem.tsx +++ b/source/frontend/src/features/properties/parcelList/ParcelItem.tsx @@ -10,26 +10,26 @@ import ManagementIcon from '@/assets/images/management-icon.svg?react'; import ResearchIcon from '@/assets/images/research-icon.svg?react'; import { RemoveIconButton } from '@/components/common/buttons'; import { useMapStateMachine } from '@/components/common/mapFSM/MapStateMachineContext'; -import { WorklistLocationFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; +import { LocationFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; import MoreOptionsMenu, { MenuOption } from '@/components/common/MoreOptionsMenu'; import OverflowTip from '@/components/common/OverflowTip'; import { ZoomIconType, ZoomToLocation } from '@/components/maps/ZoomToLocation'; import { Claims } from '@/constants'; import usePathGenerator from '@/features/mapSideBar/shared/sidebarPathGenerator'; import useKeycloakWrapper from '@/hooks/useKeycloakWrapper'; -import { exists, getPropertyNameFromSelectedFeatureSet, NameSourceType } from '@/utils'; +import { exists, getPropertyNameFromLocationFeatureSet, NameSourceType } from '@/utils'; -import { ParcelDataset } from './models'; +import { LocationDatasetWithId } from '../worklist/context/WorklistContext'; export interface IParcelItemProps { canAddToWorklist: boolean; - parcel: ParcelDataset; + parcel: LocationDatasetWithId; onRemove: (id: string) => void | null; parcelIndex: number; } export function ParcelItem({ parcel, onRemove, canAddToWorklist, parcelIndex }: IParcelItemProps) { - const propertyName = getPropertyNameFromSelectedFeatureSet(parcel.toSelectedFeatureDataset()); + const propertyName = getPropertyNameFromLocationFeatureSet(parcel); let propertyIdentifier = ''; switch (propertyName.label) { case NameSourceType.PID: @@ -45,70 +45,60 @@ export function ParcelItem({ parcel, onRemove, canAddToWorklist, parcelIndex }: break; } - const { prepareForCreation, isEditPropertiesMode, worklistAdd, setSelectedLocation } = - useMapStateMachine(); + const { + requestLocationFeatureAddition: prepareForCreation, + isEditPropertiesMode, + worklistAdd, + setSelectedLocation, + } = useMapStateMachine(); const canAddToOpenFile = isEditPropertiesMode; const pathGenerator = usePathGenerator(); const handleSelect = useCallback(() => { - setSelectedLocation(parcel.toLocationFeatureDataset()); + setSelectedLocation(parcel); }, [parcel, setSelectedLocation]); const onAddToWorklist = useCallback(() => { - const featuresSet = parcel.toSelectedFeatureDataset(); + const featuresSet = parcel; - const worklistDataSet: WorklistLocationFeatureDataset = { + const worklistDataSet: LocationFeatureDataset = { ...featuresSet, - fullyAttributedFeatures: null, }; - if (exists(featuresSet.parcelFeature)) { - worklistDataSet.fullyAttributedFeatures = { - type: 'FeatureCollection', - features: [featuresSet.parcelFeature], - }; - } - worklistAdd(worklistDataSet); }, [parcel, worklistAdd]); const handleCreateResearchFile = useCallback(() => { - const featuresSet = parcel.toSelectedFeatureDataset(); - prepareForCreation([featuresSet]); + prepareForCreation([parcel]); pathGenerator.newFile('research'); }, [parcel, pathGenerator, prepareForCreation]); const handleCreateAcquisitionFile = useCallback(() => { - const featuresSet = parcel.toSelectedFeatureDataset(); - prepareForCreation([featuresSet]); + prepareForCreation([parcel]); pathGenerator.newFile('acquisition'); }, [parcel, pathGenerator, prepareForCreation]); const handleCreateDispositionFile = useCallback(() => { - const featuresSet = parcel.toSelectedFeatureDataset(); - prepareForCreation([featuresSet]); + prepareForCreation([parcel]); pathGenerator.newFile('disposition'); }, [parcel, pathGenerator, prepareForCreation]); const handleCreateLeaseFile = useCallback(() => { - const featuresSet = parcel.toSelectedFeatureDataset(); - prepareForCreation([featuresSet]); + prepareForCreation([parcel]); pathGenerator.newFile('lease'); }, [parcel, pathGenerator, prepareForCreation]); const handleCreateManagementFile = useCallback(() => { - const featuresSet = parcel.toSelectedFeatureDataset(); - prepareForCreation([featuresSet]); + prepareForCreation([parcel]); pathGenerator.newFile('management'); }, [parcel, pathGenerator, prepareForCreation]); const handleAddToOpenFile = useCallback(() => { // If in edit properties mode, prepare the parcels for addition to an open file if (isEditPropertiesMode) { - const featuresSet = parcel.toSelectedFeatureDataset(); - prepareForCreation([featuresSet]); + prepareForCreation([parcel]); } }, [isEditPropertiesMode, parcel, prepareForCreation]); @@ -197,11 +187,11 @@ export function ParcelItem({ parcel, onRemove, canAddToWorklist, parcelIndex }: - + {exists(onRemove) && ( { e.preventDefault(); e.stopPropagation(); diff --git a/source/frontend/src/features/properties/parcelList/ParcelListContainer.tsx b/source/frontend/src/features/properties/parcelList/ParcelListContainer.tsx index a8972b42a3..62db0942a5 100644 --- a/source/frontend/src/features/properties/parcelList/ParcelListContainer.tsx +++ b/source/frontend/src/features/properties/parcelList/ParcelListContainer.tsx @@ -1,9 +1,10 @@ -import { ParcelDataset } from './models'; -import { IParcelListViewProps } from './ParcelListView'; +import { LocationFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; + +import { ISearchItemListViewProps } from './ParcelListView'; export interface IParcelListContainwerProps { - View: React.FC; - parcels: ParcelDataset[]; + View: React.FC; + parcels: LocationFeatureDataset[]; } export const ParcelListContainer: React.FC = ({ parcels, View }) => { diff --git a/source/frontend/src/features/properties/parcelList/ParcelListView.tsx b/source/frontend/src/features/properties/parcelList/ParcelListView.tsx index 9a8bf4865d..5e5199c640 100644 --- a/source/frontend/src/features/properties/parcelList/ParcelListView.tsx +++ b/source/frontend/src/features/properties/parcelList/ParcelListView.tsx @@ -1,16 +1,25 @@ +import { useMemo } from 'react'; import styled from 'styled-components'; +import { v4 as uuidv4 } from 'uuid'; +import { LocationFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; import { Section } from '@/components/common/Section/Section'; -import { ParcelDataset } from './models'; +import { LocationDatasetWithId } from '../worklist/context/WorklistContext'; import ParcelItem from './ParcelItem'; -export interface IParcelListViewProps { - parcels: ParcelDataset[]; +export interface ISearchItemListViewProps { + parcels: LocationFeatureDataset[]; } -export const ParcelListView: React.FC = ({ parcels }) => { - if (parcels.length === 0) { +export const SearchItemListView: React.FC = ({ parcels }) => { + //TODO: This is kind of awkward. The Id is only used for the react key + const parcelsWithId = useMemo( + () => parcels.map(x => ({ ...x, id: uuidv4() })), + [parcels], + ); + + if (parcelsWithId.length === 0) { return No properties to show; } @@ -18,11 +27,11 @@ export const ParcelListView: React.FC = ({ parcels }) => { - {parcels.length} - {parcels.length > 1 ? ' properties' : ' property'} + {parcelsWithId.length} + {parcelsWithId.length > 1 ? ' properties' : ' property'} - {parcels.map((p, index) => ( + {parcelsWithId.map((p, index) => ( | null; - public pimsFeature: Feature | null; - public regionFeature: Feature | null; - public districtFeature: Feature | null; - - public constructor(id?: string) { - this.id = id ?? uuidv4(); - this.name = ''; - this.location = null; - this.pmbcFeature = null; - this.pimsFeature = null; - this.regionFeature = null; - this.districtFeature = null; - } - - public static fromFullyAttributedFeature( - feature: Feature | null, - ) { - const parcel = new ParcelDataset(); - parcel.pmbcFeature = feature; - return parcel; - } - - public static fromPimsFeature(feature: Feature | null) { - const parcel = new ParcelDataset(); - parcel.pimsFeature = feature; - return parcel; - } - - public static fromSelectedFeatureDataset(featureSet: SelectedFeatureDataset): ParcelDataset { - const parcel = new ParcelDataset(); - parcel.id = featureSet.id ?? uuidv4(); - parcel.location = featureSet.location; - parcel.pmbcFeature = featureSet.parcelFeature; - parcel.pimsFeature = featureSet.pimsFeature; - parcel.regionFeature = featureSet.regionFeature; - parcel.districtFeature = featureSet.districtFeature; - return parcel; - } - - public static fromPropertyApi(apiProperty: ApiGen_Concepts_Property): ParcelDataset { - const parcel = new ParcelDataset(); - parcel.pimsFeature = apiPropertyToPimsFeature(apiProperty); - parcel.location = getLatLng(apiProperty?.location); - return parcel; - } - - public toSelectedFeatureDataset(): SelectedFeatureDataset { - return { - selectingComponentId: null, - location: this.location ?? { lat: 0, lng: 0 }, - fileLocation: this.location ?? null, - fileBoundary: null, - id: this.id, - parcelFeature: this.pmbcFeature ?? null, - pimsFeature: this.pimsFeature ?? null, - regionFeature: this.regionFeature ?? null, - districtFeature: this.districtFeature ?? null, - municipalityFeature: null, - isActive: true, - displayOrder: 0, - }; - } - - public toLocationFeatureDataset(): LocationFeatureDataset { - const locationDataset: LocationFeatureDataset = { - parcelFeatures: exists(this.pmbcFeature) ? [this.pmbcFeature] : null, - pimsFeatures: exists(this.pimsFeature) ? [this.pimsFeature] : null, - regionFeature: this.regionFeature ?? null, - districtFeature: this.districtFeature ?? null, - municipalityFeatures: null, - highwayFeatures: null, - crownLandLeasesFeatures: null, - crownLandLicensesFeatures: null, - crownLandTenuresFeatures: null, - crownLandInventoryFeatures: null, - crownLandInclusionsFeatures: null, - selectingComponentId: null, - location: this.location, - fileLocation: null, - }; - return locationDataset; - } -} diff --git a/source/frontend/src/features/properties/worklist/CopyToWorklist.tsx b/source/frontend/src/features/properties/worklist/CopyToWorklist.tsx index c7359bee9c..11f0b641ff 100644 --- a/source/frontend/src/features/properties/worklist/CopyToWorklist.tsx +++ b/source/frontend/src/features/properties/worklist/CopyToWorklist.tsx @@ -2,9 +2,13 @@ import React from 'react'; import { LiaFileExportSolid } from 'react-icons/lia'; import { LinkButton } from '@/components/common/buttons'; +import { + emptyFeatureDataset, + LocationFeatureDataset, +} from '@/components/common/mapFSM/useLocationFeatureLoader'; import { ApiGen_Concepts_FileProperty } from '@/models/api/generated/ApiGen_Concepts_FileProperty'; +import { apiFilePropertyToPimsFeature, getLatLng } from '@/utils'; -import { ParcelDataset } from '../parcelList/models'; import { useWorklistContext } from './context/WorklistContext'; export interface ICopyToWorklistProps { @@ -17,11 +21,15 @@ export const CopyToWorklist: React.FC = ({ fileProperties, const handleCopy = () => { const parcelItems = fileProperties.map(fp => { - return ParcelDataset.fromPropertyApi(fp.property); + const featureSet: LocationFeatureDataset = { + ...emptyFeatureDataset(), + pimsFeatures: [apiFilePropertyToPimsFeature(fp)], + location: getLatLng(fp?.location), + }; + return featureSet; }); addRange(parcelItems); }; - return ( { }); // Parcel list mock -let mockParcels: ParcelDataset[] = []; +let mockParcels: LocationDatasetWithId[] = []; // Mocks const select = vi.fn(); diff --git a/source/frontend/src/features/properties/worklist/WorklistContainer.tsx b/source/frontend/src/features/properties/worklist/WorklistContainer.tsx index 15d5e1177e..a4b3a63bd8 100644 --- a/source/frontend/src/features/properties/worklist/WorklistContainer.tsx +++ b/source/frontend/src/features/properties/worklist/WorklistContainer.tsx @@ -12,7 +12,7 @@ export interface IWorklistContainerProps { export const WorklistContainer: React.FC = ({ View }) => { const { parcels, remove, clearAll } = useWorklistContext(); - const { prepareForCreation, isEditPropertiesMode } = useMapStateMachine(); + const { requestLocationFeatureAddition, isEditPropertiesMode } = useMapStateMachine(); const pathGenerator = usePathGenerator(); // Handle creating a research file from the worklist @@ -20,58 +20,53 @@ export const WorklistContainer: React.FC = ({ View }) = if (parcels.length === 0) { return; } - const featuresSets = parcels.map(p => p.toSelectedFeatureDataset()); - prepareForCreation(featuresSets); + requestLocationFeatureAddition(parcels); pathGenerator.newFile('research'); - }, [parcels, pathGenerator, prepareForCreation]); + }, [parcels, pathGenerator, requestLocationFeatureAddition]); // Handle creating a acquisition file from the worklist const handleCreateAcquisitionFile = useCallback(() => { + debugger; if (parcels.length === 0) { return; } - const featuresSets = parcels.map(p => p.toSelectedFeatureDataset()); - prepareForCreation(featuresSets); + requestLocationFeatureAddition(parcels); pathGenerator.newFile('acquisition'); - }, [parcels, pathGenerator, prepareForCreation]); + }, [parcels, pathGenerator, requestLocationFeatureAddition]); // Handle creating a disposition file from the worklist const handleCreateDispositionFile = useCallback(() => { if (parcels.length === 0) { return; } - const featuresSets = parcels.map(p => p.toSelectedFeatureDataset()); - prepareForCreation(featuresSets); + requestLocationFeatureAddition(parcels); pathGenerator.newFile('disposition'); - }, [parcels, pathGenerator, prepareForCreation]); + }, [parcels, pathGenerator, requestLocationFeatureAddition]); // Handle creating a lease file from the worklist const handleCreateLeaseFile = useCallback(() => { if (parcels.length === 0) { return; } - const featuresSets = parcels.map(p => p.toSelectedFeatureDataset()); - prepareForCreation(featuresSets); + requestLocationFeatureAddition(parcels); pathGenerator.newFile('lease'); - }, [parcels, pathGenerator, prepareForCreation]); + }, [parcels, pathGenerator, requestLocationFeatureAddition]); // Handle creating a management file from the worklist const handleCreateManagementFile = useCallback(() => { if (parcels.length === 0) { return; } - const featuresSets = parcels.map(p => p.toSelectedFeatureDataset()); - prepareForCreation(featuresSets); + requestLocationFeatureAddition(parcels); pathGenerator.newFile('management'); - }, [parcels, pathGenerator, prepareForCreation]); + }, [parcels, pathGenerator, requestLocationFeatureAddition]); const handleAddToOpenFile = useCallback(() => { // If in edit properties mode, prepare the parcels for addition to an open file if (parcels.length > 0 && isEditPropertiesMode) { - const featuresSets = parcels.map(p => p.toSelectedFeatureDataset()); - prepareForCreation(featuresSets); + requestLocationFeatureAddition(parcels); } - }, [isEditPropertiesMode, parcels, prepareForCreation]); + }, [isEditPropertiesMode, parcels, requestLocationFeatureAddition]); return ( { const setup = (renderOptions: RenderOptions = {}) => { @@ -47,11 +50,8 @@ describe('WorklistMapClickMonitor', () => { const testMockMachine: IMapStateMachineContext = { ...mapMachineBaseMock, worklistLocationFeatureDataset: { + ...emptyFeatureDataset(), location: { lat: 49, lng: -123 }, - districtFeature: null, - regionFeature: null, - fullyAttributedFeatures: null, - pimsFeature: null, }, }; @@ -62,24 +62,20 @@ describe('WorklistMapClickMonitor', () => { it('adds range when new dataset has valid features', () => { mockPrevious = { + ...emptyFeatureDataset(), location: { lat: 49, lng: -123 }, - districtFeature: null, - regionFeature: null, - fullyAttributedFeatures: null, - pimsFeature: null, }; const testMockMachine: IMapStateMachineContext = { ...mapMachineBaseMock, worklistLocationFeatureDataset: { + ...emptyFeatureDataset(), location: { lat: 49.2, lng: -123.1 }, - districtFeature: null, - regionFeature: null, - fullyAttributedFeatures: turf.featureCollection([ + + parcelFeatures: turf.featureCollection([ turf.point([-123.1, 49.2], { ...emptyPmbcParcel }), turf.point([-75.3, 39.9], { ...emptyPmbcParcel }), - ]), - pimsFeature: null, + ]).features, }, }; @@ -90,27 +86,22 @@ describe('WorklistMapClickMonitor', () => { it('adds single parcel by lat/lng when no features exist', () => { mockPrevious = { + ...emptyFeatureDataset(), location: { lat: 49, lng: -123 }, - districtFeature: null, - regionFeature: null, - fullyAttributedFeatures: null, - pimsFeature: null, }; const testMockMachine: IMapStateMachineContext = { ...mapMachineBaseMock, worklistLocationFeatureDataset: { + ...emptyFeatureDataset(), location: { lat: 50, lng: -123 }, - districtFeature: null, - regionFeature: null, - fullyAttributedFeatures: turf.featureCollection([]), - pimsFeature: null, + parcelFeatures: [], }, }; setup({ mockMapMachine: testMockMachine }); expect(add).toHaveBeenCalledWith( - expect.objectContaining>({ + expect.objectContaining>({ location: { lat: 50, lng: -123 }, }), ); @@ -118,14 +109,11 @@ describe('WorklistMapClickMonitor', () => { }); it('does not re-add if same dataset is passed again', () => { - const dataset: WorklistLocationFeatureDataset = { + const dataset: LocationFeatureDataset = { + ...emptyFeatureDataset(), location: { lat: 49, lng: -123 }, - districtFeature: null, - regionFeature: null, - fullyAttributedFeatures: turf.featureCollection([ - turf.point([-123, 49], { ...emptyPmbcParcel }), - ]), - pimsFeature: null, + parcelFeatures: turf.featureCollection([turf.point([-123, 49], { ...emptyPmbcParcel })]) + .features, }; mockPrevious = dataset; diff --git a/source/frontend/src/features/properties/worklist/WorklistMapClickMonitor.tsx b/source/frontend/src/features/properties/worklist/WorklistMapClickMonitor.tsx index e8a0a0a8e9..93d3dcf661 100644 --- a/source/frontend/src/features/properties/worklist/WorklistMapClickMonitor.tsx +++ b/source/frontend/src/features/properties/worklist/WorklistMapClickMonitor.tsx @@ -1,9 +1,12 @@ import { useMapStateMachine } from '@/components/common/mapFSM/MapStateMachineContext'; +import { + emptyFeatureDataset, + LocationFeatureDataset, +} from '@/components/common/mapFSM/useLocationFeatureLoader'; import { usePrevious } from '@/hooks/usePrevious'; import useDeepCompareEffect from '@/hooks/util/useDeepCompareEffect'; import { exists } from '@/utils'; -import { ParcelDataset } from '../parcelList/models'; import { useWorklistContext } from './context/WorklistContext'; export const WorklistMapClickMonitor: React.FunctionComponent = () => { @@ -18,16 +21,20 @@ export const WorklistMapClickMonitor: React.FunctionComponent = () => { previous !== undefined ) { // Loop over the location featurecollection, adding it to the worklist if the parcelFeature is not there already - const worklistParcels: ParcelDataset[] = []; + const worklistParcels: LocationFeatureDataset[] = []; - if (exists(worklistLocationFeatureDataset.fullyAttributedFeatures)) { + if (exists(worklistLocationFeatureDataset.parcelFeatures)) { const pmbcParcels = - worklistLocationFeatureDataset.fullyAttributedFeatures?.features + worklistLocationFeatureDataset.parcelFeatures ?.map(pmbcFeature => { - const newParcel = ParcelDataset.fromFullyAttributedFeature(pmbcFeature); - newParcel.location = worklistLocationFeatureDataset.location; - newParcel.regionFeature = worklistLocationFeatureDataset.regionFeature; - newParcel.districtFeature = worklistLocationFeatureDataset.districtFeature; + const newParcel: LocationFeatureDataset = { + ...emptyFeatureDataset(), + parcelFeatures: [pmbcFeature], + location: worklistLocationFeatureDataset.location, + regionFeature: worklistLocationFeatureDataset.regionFeature, + districtFeature: worklistLocationFeatureDataset.districtFeature, + }; + return newParcel; }) .filter(exists) ?? []; @@ -35,24 +42,34 @@ export const WorklistMapClickMonitor: React.FunctionComponent = () => { worklistParcels.push(...pmbcParcels); } - if (exists(worklistLocationFeatureDataset.pimsFeature)) { - const pimsParcel = ParcelDataset.fromPimsFeature( - worklistLocationFeatureDataset.pimsFeature, - ); - pimsParcel.location = worklistLocationFeatureDataset.location; - pimsParcel.regionFeature = worklistLocationFeatureDataset.regionFeature; - pimsParcel.districtFeature = worklistLocationFeatureDataset.districtFeature; + if (exists(worklistLocationFeatureDataset.pimsFeatures)) { + const pimsParcels = + worklistLocationFeatureDataset.pimsFeatures + ?.map(pimsFeature => { + const newParcel: LocationFeatureDataset = { + ...emptyFeatureDataset(), + pimsFeatures: [pimsFeature], + location: worklistLocationFeatureDataset.location, + regionFeature: worklistLocationFeatureDataset.regionFeature, + districtFeature: worklistLocationFeatureDataset.districtFeature, + }; + + return newParcel; + }) + .filter(exists) ?? []; - worklistParcels.push(pimsParcel); + worklistParcels.push(...pimsParcels); } if (worklistParcels.length > 0) { addRange(worklistParcels); } else { // We didn't find any parcel-map properties - add a lat/long location to the worklist - const latLongParcel = new ParcelDataset(); - latLongParcel.location = worklistLocationFeatureDataset.location; - latLongParcel.pmbcFeature = null; + const latLongParcel: LocationFeatureDataset = { + ...emptyFeatureDataset(), + location: worklistLocationFeatureDataset.location, + }; + add(latLongParcel); } } diff --git a/source/frontend/src/features/properties/worklist/WorklistView.tsx b/source/frontend/src/features/properties/worklist/WorklistView.tsx index da4fd59e0b..9c5de5ca5c 100644 --- a/source/frontend/src/features/properties/worklist/WorklistView.tsx +++ b/source/frontend/src/features/properties/worklist/WorklistView.tsx @@ -14,11 +14,11 @@ import { Section } from '@/components/common/Section/Section'; import { Claims } from '@/constants'; import useKeycloakWrapper from '@/hooks/useKeycloakWrapper'; -import { ParcelDataset } from '../parcelList/models'; import ParcelItem from '../parcelList/ParcelItem'; +import { LocationDatasetWithId } from './context/WorklistContext'; export interface IWorklistViewProps { - parcels: ParcelDataset[]; + parcels: LocationDatasetWithId[]; canAddToOpenFile?: boolean; onRemove: (id: string) => void; onClearAll: () => void; diff --git a/source/frontend/src/features/properties/worklist/context/WorklistContext.test.tsx b/source/frontend/src/features/properties/worklist/context/WorklistContext.test.tsx index a001093733..e60992b1e9 100644 --- a/source/frontend/src/features/properties/worklist/context/WorklistContext.test.tsx +++ b/source/frontend/src/features/properties/worklist/context/WorklistContext.test.tsx @@ -3,8 +3,13 @@ import { renderHook } from '@testing-library/react-hooks'; import { act } from '@/utils/test-utils'; import { getMockWorklistParcel } from '@/mocks/worklistParcel.mock'; -import { IWorklistNotifier, useWorklistContext, WorklistContextProvider } from './WorklistContext'; -import { ParcelDataset } from '../../parcelList/models'; +import { + IWorklistNotifier, + LocationDatasetWithId, + useWorklistContext, + WorklistContextProvider, +} from './WorklistContext'; +import { firstOrNull } from '@/utils'; // Mock notification service const mockNotifier: IWorklistNotifier = { @@ -18,10 +23,10 @@ describe('WorklistContextProvider', () => { vi.clearAllMocks(); }); - const renderWorklistHook = (initial: ParcelDataset[] = []) => + const renderWorklistHook = (initial: LocationDatasetWithId[] = []) => renderHook(() => useWorklistContext(), { wrapper: ({ children }) => ( - + {children} ), @@ -62,27 +67,14 @@ describe('WorklistContextProvider', () => { it('add() adds a unique parcel', () => { const parcel = getMockWorklistParcel('1', { PID: '123456789' }); - const { result } = renderWorklistHook(); - act(() => result.current.add(parcel)); - - expect(result.current.parcels).toHaveLength(1); - expect(result.current.parcels[0].id).toBe('1'); - expect(mockNotifier.error).not.toHaveBeenCalled(); - }); - - it('add() prevents duplicate by internal ID and calls notifier.error', () => { - const parcel = getMockWorklistParcel('1'); - const duplicate = getMockWorklistParcel('1'); - const { result } = renderWorklistHook(); + expect(result.current.parcels).toHaveLength(0); act(() => result.current.add(parcel)); - act(() => result.current.add(duplicate)); expect(result.current.parcels).toHaveLength(1); - expect(mockNotifier.error).toHaveBeenCalledWith( - 'Duplicate parcel detected. Add to worklist skipped.', - ); + expect(result.current.parcels[0].parcelFeatures[0].properties.PID).toBe('123456789'); + expect(mockNotifier.error).not.toHaveBeenCalled(); }); it('add() prevents duplicate by PID and calls notifier.error', () => { @@ -155,11 +147,9 @@ describe('WorklistContextProvider', () => { act(() => result.current.addRange([p1, dupe, p2])); - expect(result.current.parcels.map(p => p.pmbcFeature?.properties?.PID)).toEqual([ - '111', - '222', - '333', - ]); + expect(result.current.parcels.map(p => firstOrNull(p.parcelFeatures)?.properties?.PID)).toEqual( + ['111', '222', '333'], + ); expect(mockNotifier.success).toHaveBeenCalledWith('Added 2 new parcel(s).'); expect(mockNotifier.warn).toHaveBeenCalledWith('1 duplicate parcel(s) were skipped.'); }); diff --git a/source/frontend/src/features/properties/worklist/context/WorklistContext.tsx b/source/frontend/src/features/properties/worklist/context/WorklistContext.tsx index 25a2609203..dc7ca88ed6 100644 --- a/source/frontend/src/features/properties/worklist/context/WorklistContext.tsx +++ b/source/frontend/src/features/properties/worklist/context/WorklistContext.tsx @@ -1,10 +1,13 @@ import { createContext, ReactNode, useCallback, useContext, useState } from 'react'; import { toast } from 'react-toastify'; +import { v4 as uuidv4 } from 'uuid'; -import { exists, pidFromFeatureSet, pinFromFeatureSet, planFromFeatureSet } from '@/utils'; - -import { ParcelDataset } from '../../parcelList/models'; +import { LocationFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; +import { areLocationFeatureDatasetsEqual } from '@/utils'; +export interface LocationDatasetWithId extends LocationFeatureDataset { + id; +} export interface IWorklistNotifier { error: (msg: string) => void; success: (msg: string) => void; @@ -12,12 +15,12 @@ export interface IWorklistNotifier { } export interface IWorklistContext { - parcels: ParcelDataset[]; + parcels: LocationDatasetWithId[]; selectedId: string | null; select: (id: string) => void; remove: (id: string) => void; - add: (parcel: ParcelDataset) => void; - addRange: (parcel: ParcelDataset[]) => void; + add: (parcel: LocationFeatureDataset) => void; + addRange: (parcel: LocationFeatureDataset[]) => void; clearAll: () => void; } @@ -33,17 +36,17 @@ export function useWorklistContext() { export interface IWorklistContextProviderProps { children: ReactNode; - parcels?: ParcelDataset[]; + initialParcels?: LocationDatasetWithId[]; //Only used for Testing /** Override the default react‑toastify notifier in tests or other environments */ notifier?: IWorklistNotifier; } export function WorklistContextProvider({ children, - parcels: initialParcels, + initialParcels, notifier = toast, // default is react‑toastify’s toast object }: IWorklistContextProviderProps) { - const [parcels, setParcels] = useState(initialParcels ?? []); + const [parcels, setParcels] = useState(initialParcels ?? []); const [selectedId, setSelectedId] = useState(null); const select = useCallback((id: string) => setSelectedId(id), []); @@ -51,27 +54,31 @@ export function WorklistContextProvider({ // The worklist should not allow duplicate property (using pid/pin/globalUID, lat/lng) const add = useCallback( - (parcel: ParcelDataset) => { + (parcel: LocationFeatureDataset) => { + const newParcelWithId = generateId(parcel); setParcels(prev => { - const alreadyExists = prev.some(p => areParcelsEqual(p, parcel)); + const alreadyExists = prev.some(p => areLocationFeatureDatasetsEqual(p, newParcelWithId)); if (alreadyExists) { notifier.error('Duplicate parcel detected. Add to worklist skipped.'); return prev; } - return [...prev, parcel]; + return [...prev, newParcelWithId]; }); }, [notifier], ); const addRange = useCallback( - (newParcels: ParcelDataset[]) => { + (newParcels: LocationFeatureDataset[]) => { + const newParcelsWithId = newParcels.map(generateId); setParcels(prev => { - const uniqueParcels = newParcels.filter(newParcel => { - return !prev.some(existingParcel => areParcelsEqual(existingParcel, newParcel)); + const uniqueParcels = newParcelsWithId.filter(newParcel => { + return !prev.some(existingParcel => + areLocationFeatureDatasetsEqual(existingParcel, newParcel), + ); }); - const duplicatesSkipped = newParcels.length - uniqueParcels.length; + const duplicatesSkipped = newParcelsWithId.length - uniqueParcels.length; if (uniqueParcels.length > 0) { notifier.success(`Added ${uniqueParcels.length} new parcel(s).`); @@ -98,64 +105,9 @@ export function WorklistContextProvider({ ); } -function areParcelsEqual(p1: ParcelDataset, p2: ParcelDataset): boolean { - if (!exists(p1) || !exists(p2)) { - return false; - } - - if (p1.id === p2.id) { - return true; - } - const fs1 = p1.toSelectedFeatureDataset(); - const fs2 = p2.toSelectedFeatureDataset(); - - const pid1 = pidFromFeatureSet(fs1) ?? null; - const pid2 = pidFromFeatureSet(fs2) ?? null; - if (exists(pid1) && pid1 === pid2) { - return true; - } - - if (exists(pid1) && pid1 === pid2) { - return true; - } - - const pin1 = pinFromFeatureSet(fs1) ?? null; - const pin2 = pinFromFeatureSet(fs2) ?? null; - - if (exists(pin1) && pin1 === pin2) { - return true; - } - - // Some parcels are only identified by their plan-number (e.g. common strata, parks) - // Only consider plan-number as an identifier when there are no PID/PIN - const planNumber1 = planFromFeatureSet(fs1) ?? null; - const planNumber2 = planFromFeatureSet(fs2) ?? null; - if ( - exists(planNumber1) && - !exists(pid1) && - !exists(pin1) && - !exists(pid2) && - !exists(pin2) && - planNumber1 === planNumber2 - ) { - return true; - } - - // Only consider lat/long when there are no PID/PIN - const location1 = p1.location; - const location2 = p2.location; - if ( - exists(location1) && - exists(location2) && - !exists(pid1) && - !exists(pin1) && - !exists(pid2) && - !exists(pin2) && - location1.lat === location2.lat && - location1.lng === location2.lng - ) { - return true; - } - - return false; -} +const generateId = (dataset: LocationFeatureDataset): LocationDatasetWithId => { + return { + ...dataset, + id: uuidv4(), + }; +}; diff --git a/source/frontend/src/hooks/layer-api/useGeoServer.ts b/source/frontend/src/hooks/layer-api/useGeoServer.ts index 3cf766a3c4..621c1f6099 100644 --- a/source/frontend/src/hooks/layer-api/useGeoServer.ts +++ b/source/frontend/src/hooks/layer-api/useGeoServer.ts @@ -3,6 +3,10 @@ import { Feature, FeatureCollection, Geometry, Point } from 'geojson'; import { useCallback, useContext } from 'react'; import CustomAxios from '@/customAxios'; +import { + PIMS_Property_Boundary_View, + PIMS_Property_Location_View, +} from '@/models/layers/pimsPropertyLocationView'; import { TenantContext } from '@/tenants'; import { useAxiosErrorHandler } from '@/utils'; @@ -28,7 +32,9 @@ export const useGeoServer = () => { return null; } const wfsUrl = buildUrl(baseUrl, { PROPERTY_ID: id }); - const { data } = await CustomAxios().get(wfsUrl); + const { data } = await CustomAxios().get< + FeatureCollection + >(wfsUrl); return data.features?.length ? (data.features[0] as Feature) : null; }, [baseUrl], @@ -37,7 +43,9 @@ export const useGeoServer = () => { requestFunction: useCallback( async (id: number) => { const wfsUrl = buildUrl(baseUrl, { PROPERTY_ID: id }); - return await CustomAxios().get(wfsUrl); + return await CustomAxios().get>( + wfsUrl, + ); }, [baseUrl], ), @@ -47,7 +55,7 @@ export const useGeoServer = () => { const getPropertyByBoundaryWfsWrapper = useApiRequestWrapper({ requestFunction: useCallback( async (boundary: Geometry) => { - return await CustomAxios().get( + return await CustomAxios().get>( `${baseBoundaryUrl}&cql_filter=INTERSECTS(GEOMETRY,SRID=4326;${geojsonToWKT(boundary)})`, ); }, diff --git a/source/frontend/src/hooks/repositories/useComposedProperties.ts b/source/frontend/src/hooks/repositories/useComposedProperties.ts index 3832499714..8e67026b28 100644 --- a/source/frontend/src/hooks/repositories/useComposedProperties.ts +++ b/source/frontend/src/hooks/repositories/useComposedProperties.ts @@ -3,6 +3,7 @@ import { AxiosResponse } from 'axios'; import { Feature, FeatureCollection, GeoJsonProperties, Geometry } from 'geojson'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { emptyFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; import { ComposedProperty } from '@/features/mapSideBar/property/ComposedProperty'; import { LtsaOrders, SpcpOrder } from '@/interfaces/ltsaModels'; import { ApiGen_Concepts_Property } from '@/models/api/generated/ApiGen_Concepts_Property'; @@ -151,11 +152,14 @@ export const useComposedProperties = ({ findMultipleCrownLandLeaseLoading, } = useCrownLandLayer(); const [crownResponse, setCrownResponse] = useState<{ - crownTenureFeatures: Feature[]; - crownLeaseFeatures: Feature[]; - crownLicenseFeatures: Feature[]; - crownInclusionFeatures: Feature[]; - crownInventoryFeatures: Feature[]; + crownLandTenuresFeatures: Feature[]; + crownLandLeasesFeatures: Feature[]; + crownLandLicensesFeatures: Feature[]; + crownLandInclusionsFeatures: Feature< + Geometry, + TANTALIS_CrownLandInclusions_Feature_Properties + >[]; + crownLandInventoryFeatures: Feature[]; }>(); const [highwayResponse, setHighwayResponse] = useState< Feature[] | undefined @@ -227,19 +231,8 @@ export const useComposedProperties = ({ spcpOrder: undefined, pimsProperty: undefined, propertyAssociations: undefined, - parcelMapFeatureCollection: undefined, - pimsGeoserverFeatureCollection: undefined, bcAssessmentSummary: undefined, - crownTenureFeatures: undefined, - crownLeaseFeatures: undefined, - crownLicenseFeatures: undefined, - crownInclusionFeatures: undefined, - crownInventoryFeatures: undefined, - highwayFeatures: undefined, - municipalityFeatures: undefined, - firstNationFeatures: undefined, - electoralFeatures: undefined, - alrFeatures: undefined, + featureDataset: emptyFeatureDataset(), }); const typeCheckWrapper = useDeepCompareCallback( @@ -349,11 +342,11 @@ export const useComposedProperties = ({ : Promise.resolve(undefined), ]); setCrownResponse({ - crownTenureFeatures: crownTenures ?? [], - crownLeaseFeatures: crownLeases ?? [], - crownLicenseFeatures: crownLicenses ?? [], - crownInclusionFeatures: crownInclusions ?? [], - crownInventoryFeatures: crownInventory ?? [], + crownLandTenuresFeatures: crownTenures ?? [], + crownLandLeasesFeatures: crownLeases ?? [], + crownLandLicensesFeatures: crownLicenses ?? [], + crownLandInclusionsFeatures: crownInclusions ?? [], + crownLandInventoryFeatures: crownInventory ?? [], }); setHighwayResponse(highway); setMunicipalityResponse(municipality); @@ -397,16 +390,19 @@ export const useComposedProperties = ({ spcpOrder: getStrataPlanCommonProperty.response, pimsProperty: getPropertyWrapper.response, propertyAssociations: getPropertyAssociationsWrapper.response, - parcelMapFeatureCollection: parcelResponse, - pimsGeoserverFeatureCollection: - getPropertyWfsWrapper.response ?? getPropertyByBoundaryWfsWrapper.response, bcAssessmentSummary: getSummaryWrapper.response, - ...crownResponse, - highwayFeatures: highwayResponse, - municipalityFeatures: municipalityResponse, - electoralFeatures: electoralResponse, - alrFeatures: alrResponse, - firstNationFeatures: firstNationsResponse, + featureDataset: { + ...emptyFeatureDataset(), + ...crownResponse, + pimsFeatures: getPropertyWfsWrapper.response?.features, + pimsBoundaryFeatures: getPropertyByBoundaryWfsWrapper.response?.features, + parcelFeatures: parcelResponse?.features, + highwayFeatures: highwayResponse, + municipalityFeatures: municipalityResponse, + electoralFeatures: electoralResponse, + alrFeatures: alrResponse, + firstNationFeatures: firstNationsResponse, + }, }); }, [ setComposedProperty, diff --git a/source/frontend/src/hooks/useDraftMarkerSynchronizer.ts b/source/frontend/src/hooks/useDraftMarkerSynchronizer.ts index 04d47610d1..4fec59f896 100644 --- a/source/frontend/src/hooks/useDraftMarkerSynchronizer.ts +++ b/source/frontend/src/hooks/useDraftMarkerSynchronizer.ts @@ -1,16 +1,17 @@ import debounce from 'lodash/debounce'; -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useRef } from 'react'; import { useMapStateMachine } from '@/components/common/mapFSM/MapStateMachineContext'; -import { LocationBoundaryDataset } from '@/components/common/mapFSM/models'; -import useDeepCompareEffect from '@/hooks/util/useDeepCompareEffect'; +import { PropertyForm } from '@/features/mapSideBar/shared/models'; import useIsMounted from '@/hooks/util/useIsMounted'; +import useDeepCompareEffect from './util/useDeepCompareEffect'; + /** * A hook that automatically syncs any updates to the lat/lngs of the parcel form with the map. * @param modifiedProperties array that contains the properties to be drawn. */ -const useDraftMarkerSynchronizer = (modifiedProperties: LocationBoundaryDataset[]) => { +const useDraftMarkerSynchronizer = (modifiedProperties: PropertyForm[]) => { const isMounted = useIsMounted(); const { setFilePropertyLocations } = useMapStateMachine(); @@ -20,9 +21,9 @@ const useDraftMarkerSynchronizer = (modifiedProperties: LocationBoundaryDataset[ * @param modifiedProperties the current properties */ const synchronizeMarkers = useCallback( - (modifiedProperties: LocationBoundaryDataset[]) => { + (modifiedProperties: PropertyForm[]) => { if (isMounted()) { - const filePropertyLocations = modifiedProperties; + const filePropertyLocations = modifiedProperties.map(x => x.toFilePropertyApi()); if (filePropertyLocations.length > 0) { setFilePropertyLocations(filePropertyLocations); } else { @@ -34,7 +35,7 @@ const useDraftMarkerSynchronizer = (modifiedProperties: LocationBoundaryDataset[ ); const synchronize = useRef( - debounce((modifiedProperties: LocationBoundaryDataset[]) => { + debounce((modifiedProperties: PropertyForm[]) => { synchronizeMarkers(modifiedProperties); }, 400), ).current; @@ -42,10 +43,6 @@ const useDraftMarkerSynchronizer = (modifiedProperties: LocationBoundaryDataset[ useDeepCompareEffect(() => { synchronize(modifiedProperties); }, [modifiedProperties, synchronize]); - - useEffect(() => { - return () => setFilePropertyLocations([]); - }, [setFilePropertyLocations]); }; export default useDraftMarkerSynchronizer; diff --git a/source/frontend/src/hooks/useEditPropertiesNotifier.ts b/source/frontend/src/hooks/useEditPropertiesNotifier.ts deleted file mode 100644 index a2f5ce821a..0000000000 --- a/source/frontend/src/hooks/useEditPropertiesNotifier.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { FormikProps } from 'formik'; -import { useEffect, useMemo } from 'react'; - -import { useMapStateMachine } from '@/components/common/mapFSM/MapStateMachineContext'; -import { SelectedFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; -import { PropertyForm } from '@/features/mapSideBar/shared/models'; -import { addPropertiesToCurrentFile } from '@/utils/propertyUtils'; -import { exists } from '@/utils/utils'; - -import { useEditPropertiesMode } from './useEditPropertiesMode'; -import { useFeatureDatasetsWithAddresses } from './useFeatureDatasetsWithAddresses'; - -/** - * Notifies the map state machine to enter/exit edit properties mode. - * Use this hook in any component that needs to toggle edit properties mode on mount/unmount. - */ -export function useEditPropertiesNotifier( - formikRef: React.RefObject>, - fieldName: keyof T, - overrideFeatures?: SelectedFeatureDataset[], -) { - const { selectedFeatures, processCreation } = useMapStateMachine(); - // Get PropertyForms with addresses for all selected features - const { featuresWithAddresses, bcaLoading } = useFeatureDatasetsWithAddresses( - overrideFeatures ?? selectedFeatures ?? [], - ); - - useEditPropertiesMode(); // if we are listening to property add notifications we must tell the state machine we are in edit mode. - - // Convert SelectedFeatureDataset to PropertyForm - const propertyForms = useMemo( - () => - featuresWithAddresses.map(obj => { - const property = PropertyForm.fromFeatureDataset(obj.feature); - if (exists(obj.address)) { - property.address = obj.address; - } - return property; - }), - [featuresWithAddresses], - ); - // This effect is used to update the file properties when "add to open file" is clicked in the worklist. - useEffect(() => { - if (exists(formikRef?.current) && propertyForms.length > 0) { - addPropertiesToCurrentFile(formikRef, fieldName, propertyForms, processCreation); - } - }, [fieldName, formikRef, processCreation, propertyForms]); - - return { featuresWithAddresses, bcaLoading }; -} diff --git a/source/frontend/src/hooks/useEnrichWithPimsFeatures.ts b/source/frontend/src/hooks/useEnrichWithPimsFeatures.ts index 8e3ef55da5..bd4b8c750c 100644 --- a/source/frontend/src/hooks/useEnrichWithPimsFeatures.ts +++ b/source/frontend/src/hooks/useEnrichWithPimsFeatures.ts @@ -1,32 +1,32 @@ import { Feature, Geometry } from 'geojson'; import { useCallback, useState } from 'react'; -import { SelectedFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; +import { LocationFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; import { PIMS_Property_Boundary_View } from '@/models/layers/pimsPropertyLocationView'; -import { exists, isValidString, pidFromFeatureSet, pinFromFeatureSet } from '@/utils'; +import { isEmptyOrNull, isValidString, pidFromFeatureSet, pinFromFeatureSet } from '@/utils'; import { usePimsPropertyLayer } from './repositories/mapLayer/usePimsPropertyLayer'; interface UseEnrichWithPimsFeaturesResult { - datasets: SelectedFeatureDataset[]; + datasets: LocationFeatureDataset[]; loading: boolean; error: string | null; - enrichWithPimsFeatures: (inputDatasets: SelectedFeatureDataset[]) => Promise; + enrichWithPimsFeatures: (inputDatasets: LocationFeatureDataset[]) => Promise; } /** * Hook that enriches SelectedFeatureDataset[] with PIMS features using findOneByPidOrPin. */ export function useEnrichWithPimsFeatures(): UseEnrichWithPimsFeaturesResult { - const [datasets, setDatasets] = useState([]); + const [datasets, setDatasets] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const { findOneByPidOrPin } = usePimsPropertyLayer(); const enrichWithPimsFeatures = useCallback( - async (inputDatasets: SelectedFeatureDataset[]) => { - if (!exists(inputDatasets) || inputDatasets.length === 0) { + async (inputDatasets: LocationFeatureDataset[]) => { + if (isEmptyOrNull(inputDatasets)) { setDatasets([]); return; } @@ -36,8 +36,12 @@ export function useEnrichWithPimsFeatures(): UseEnrichWithPimsFeaturesResult { try { const updatedPropertyPromises = inputDatasets.map(async dataset => { - if (!exists(dataset.parcelFeature)) return dataset; - if (exists(dataset.pimsFeature)) return dataset; // already enriched + if (isEmptyOrNull(dataset.parcelFeatures)) { + return dataset; + } + if (!isEmptyOrNull(dataset.pimsFeatures)) { + return dataset; // already enriched + } const pid = pidFromFeatureSet(dataset); const pin = pinFromFeatureSet(dataset); @@ -47,7 +51,7 @@ export function useEnrichWithPimsFeatures(): UseEnrichWithPimsFeaturesResult { try { const pimsFeature: Feature | undefined = await findOneByPidOrPin(pid, pin); - dataset.pimsFeature = pimsFeature ?? null; + dataset.pimsFeatures = [pimsFeature]; return dataset; } catch (innerErr) { return dataset; // leave dataset unchanged if API fails diff --git a/source/frontend/src/hooks/useInitialMapSelectorProperties.ts b/source/frontend/src/hooks/useInitialMapSelectorProperties.ts index 3271cb85ef..fb323c98a1 100644 --- a/source/frontend/src/hooks/useInitialMapSelectorProperties.ts +++ b/source/frontend/src/hooks/useInitialMapSelectorProperties.ts @@ -1,11 +1,11 @@ import { useEffect, useState } from 'react'; -import { SelectedFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; +import { LocationFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; import { AddressForm, PropertyForm } from '@/features/mapSideBar/shared/models'; import { useBcaAddress } from '@/features/properties/map/hooks/useBcaAddress'; import { pidFromFeatureSet } from '@/utils/mapPropertyUtils'; -export const useInitialMapSelectorProperties = (selectedFeature: SelectedFeatureDataset | null) => { +export const useInitialMapSelectorProperties = (selectedFeature: LocationFeatureDataset | null) => { const { getPrimaryAddressByPid, bcaLoading } = useBcaAddress(); const [bcaAddress, setBcaAddress] = useState(); @@ -24,7 +24,7 @@ export const useInitialMapSelectorProperties = (selectedFeature: SelectedFeature initialProperty: selectedFeature !== null ? { - ...PropertyForm.fromFeatureDataset(selectedFeature), + ...PropertyForm.fromLocationFeatureDataset(selectedFeature), address: bcaAddress, } : null, diff --git a/source/frontend/src/hooks/useFeatureDatasetsWithAddresses.ts b/source/frontend/src/hooks/useLocationFeatureDatasetsWithAddresses.ts similarity index 71% rename from source/frontend/src/hooks/useFeatureDatasetsWithAddresses.ts rename to source/frontend/src/hooks/useLocationFeatureDatasetsWithAddresses.ts index b27f4e26d8..368327679e 100644 --- a/source/frontend/src/hooks/useFeatureDatasetsWithAddresses.ts +++ b/source/frontend/src/hooks/useLocationFeatureDatasetsWithAddresses.ts @@ -1,24 +1,26 @@ import { useEffect, useState } from 'react'; -import { SelectedFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; +import { LocationFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; import { AddressForm } from '@/features/mapSideBar/shared/models'; import { useBcaAddress } from '@/features/properties/map/hooks/useBcaAddress'; -import { exists } from '@/utils'; +import { isEmptyOrNull } from '@/utils'; import { pidFromFeatureSet } from '@/utils/mapPropertyUtils'; -export interface FeatureDatasetWithAddress { - feature: SelectedFeatureDataset; +export interface LocationFeatureDatasetWithAddress { + feature: LocationFeatureDataset; address?: AddressForm; } -export const useFeatureDatasetsWithAddresses = (features: SelectedFeatureDataset[] | null) => { +export const useLocationFeatureDatasetsWithAddresses = ( + features: LocationFeatureDataset[] | null, +) => { const { getPrimaryAddressByPid, bcaLoading } = useBcaAddress(); - const [results, setResults] = useState([]); + const [results, setResults] = useState([]); useEffect(() => { let isMounted = true; const fetchAddresses = async () => { - if (!exists(features) || features.length === 0) { + if (isEmptyOrNull(features)) { setResults([]); return; } @@ -46,5 +48,5 @@ export const useFeatureDatasetsWithAddresses = (features: SelectedFeatureDataset }; }, [features, getPrimaryAddressByPid]); - return { featuresWithAddresses: results, bcaLoading }; + return { locationFeaturesWithAddresses: results, bcaLoading }; }; diff --git a/source/frontend/src/hooks/useEditPropertiesNotifier.test.ts b/source/frontend/src/hooks/usePropertyFormSyncronizer.test.ts similarity index 50% rename from source/frontend/src/hooks/useEditPropertiesNotifier.test.ts rename to source/frontend/src/hooks/usePropertyFormSyncronizer.test.ts index a103c8265e..e1178e13b8 100644 --- a/source/frontend/src/hooks/useEditPropertiesNotifier.test.ts +++ b/source/frontend/src/hooks/usePropertyFormSyncronizer.test.ts @@ -3,32 +3,34 @@ import { FormikProps } from 'formik'; import React from 'react'; import * as MapStateMachineContext from '@/components/common/mapFSM/MapStateMachineContext'; -import * as FeatureDatasetsHook from './useFeatureDatasetsWithAddresses'; +import * as FeatureDatasetsHook from './useLocationFeatureDatasetsWithAddresses'; import * as EditPropertiesModeHook from './useEditPropertiesMode'; -import { useEditPropertiesNotifier } from './useEditPropertiesNotifier'; import { PropertyForm } from '@/features/mapSideBar/shared/models'; import { toast } from 'react-toastify'; -import { getMockSelectedFeatureDataset } from '@/mocks/featureset.mock'; -import { FeatureDatasetWithAddress } from './useFeatureDatasetsWithAddresses'; +import { LocationFeatureDatasetWithAddress } from './useLocationFeatureDatasetsWithAddresses'; import { vi, Mock } from 'vitest'; +import { getMockLocationFeatureDataset } from '@/mocks/featureset.mock'; +import { usePropertyFormSyncronizer } from './usePropertyFormSyncronizer'; vi.mock('./useEditPropertiesMode', () => ({ useEditPropertiesMode: vi.fn(), })); -vi.mock('./useFeatureDatasetsWithAddresses', () => ({ - useFeatureDatasetsWithAddresses: vi.fn(), +vi.mock('./useLocationFeatureDatasetsWithAddresses', () => ({ + useLocationFeatureDatasetsWithAddresses: vi.fn(), })); const toastSuccessSpy = vi.spyOn(toast, 'success'); const toastWarnSpy = vi.spyOn(toast, 'warn'); -describe('useEditPropertiesNotifier', () => { - let mockProcessCreation: Mock; +describe('usePropertyFormSyncronizer', () => { + let mockProcessLocationAddition: Mock; + let validateNewProperties: Mock; + let mockFormikRef: React.RefObject>; beforeEach(() => { vi.clearAllMocks(); - mockProcessCreation = vi.fn(); + mockProcessLocationAddition = vi.fn(); mockFormikRef = { current: { @@ -38,49 +40,65 @@ describe('useEditPropertiesNotifier', () => { }, } as unknown as React.RefObject>; + validateNewProperties = vi.fn(); + vi.spyOn(MapStateMachineContext, 'useMapStateMachine').mockReturnValue({ - selectedFeatures: [getMockSelectedFeatureDataset()], - processCreation: mockProcessCreation, + processLocationFeaturesAddition: mockProcessLocationAddition, + pendingLocationFeaturesAddition: true, } as any); - (FeatureDatasetsHook.useFeatureDatasetsWithAddresses as any).mockReturnValue({ - featuresWithAddresses: [ - { feature: getMockSelectedFeatureDataset() } as FeatureDatasetWithAddress, + (FeatureDatasetsHook.useLocationFeatureDatasetsWithAddresses as any).mockReturnValue({ + locationFeaturesWithAddresses: [ + { feature: getMockLocationFeatureDataset() } as LocationFeatureDatasetWithAddress, ], bcaLoading: false, }); }); - it('calls useEditPropertiesMode and returns featuresWithAddresses and bcaLoading', () => { - const { result } = renderHook(() => useEditPropertiesNotifier(mockFormikRef, 'properties')); - expect(EditPropertiesModeHook.useEditPropertiesMode).toHaveBeenCalled(); - expect(result.current.featuresWithAddresses).toEqual([ - { feature: getMockSelectedFeatureDataset() }, - ]); - expect(result.current.bcaLoading).toBe(false); - }); - it('uses overrideFeatures if provided', () => { const overrideFeatures = [{ feature: { id: 2 }, address: '456 Side St' }]; renderHook(() => - useEditPropertiesNotifier(mockFormikRef, 'properties', overrideFeatures as any), + usePropertyFormSyncronizer(mockFormikRef, validateNewProperties, overrideFeatures as any), ); - expect(FeatureDatasetsHook.useFeatureDatasetsWithAddresses).toHaveBeenCalledWith( + expect(FeatureDatasetsHook.useLocationFeatureDatasetsWithAddresses).toHaveBeenCalledWith( overrideFeatures, ); }); it('calls setFieldValue, setFieldTouched, and shows success toast when unique properties are added', () => { mockFormikRef.current.values = { properties: [] }; - (FeatureDatasetsHook.useFeatureDatasetsWithAddresses as any).mockReturnValue({ - featuresWithAddresses: [ + (FeatureDatasetsHook.useLocationFeatureDatasetsWithAddresses as any).mockReturnValue({ + locationFeaturesWithAddresses: [ { feature: { id: 1 }, address: 'A' }, { feature: { id: 2 }, address: 'B' }, ], bcaLoading: false, }); - renderHook(() => useEditPropertiesNotifier(mockFormikRef, 'properties')); + // mocked validator + validateNewProperties.mockImplementation( + ( + newProperties: PropertyForm[], + validateCallback: (isValid: boolean, newProperties: PropertyForm[]) => void, + ) => { + validateCallback(true, newProperties); + }, + ); + + renderHook(() => usePropertyFormSyncronizer(mockFormikRef, validateNewProperties)); + + const callBackFn = expect.any(Function); + expect(validateNewProperties).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + address: 'A', + }), + expect.objectContaining({ + address: 'B', + }), + ]), + callBackFn, + ); expect(mockFormikRef.current.setFieldValue).toHaveBeenCalledWith('properties', [ expect.objectContaining({ address: 'A' }), @@ -89,59 +107,58 @@ describe('useEditPropertiesNotifier', () => { expect(mockFormikRef.current.setFieldTouched).toHaveBeenCalledWith('properties', true); expect(toastSuccessSpy).toHaveBeenCalledWith('Added 2 new property(s) to the file.'); expect(toastWarnSpy).not.toHaveBeenCalled(); - expect(mockProcessCreation).toHaveBeenCalled(); + expect(mockProcessLocationAddition).toHaveBeenCalled(); }); it('shows warning toast when duplicates are skipped', () => { - const mockPropertyForms = [PropertyForm.fromFeatureDataset(getMockSelectedFeatureDataset())]; + const mockPropertyForms = [ + PropertyForm.fromLocationFeatureDataset(getMockLocationFeatureDataset()), + ]; mockFormikRef.current.values = { properties: mockPropertyForms }; - (FeatureDatasetsHook.useFeatureDatasetsWithAddresses as any).mockReturnValue({ - featuresWithAddresses: [ - { feature: getMockSelectedFeatureDataset() } as FeatureDatasetWithAddress, - { feature: { id: 999 }, address: 'Unique' } as unknown as FeatureDatasetWithAddress, + (FeatureDatasetsHook.useLocationFeatureDatasetsWithAddresses as any).mockReturnValue({ + locationFeaturesWithAddresses: [ + { feature: getMockLocationFeatureDataset() } as LocationFeatureDatasetWithAddress, + { feature: { id: 999 }, address: 'Unique' } as unknown as LocationFeatureDatasetWithAddress, ], bcaLoading: false, }); - renderHook(() => useEditPropertiesNotifier(mockFormikRef, 'properties')); + renderHook(() => usePropertyFormSyncronizer(mockFormikRef, validateNewProperties)); - expect(mockFormikRef.current.setFieldValue).toHaveBeenCalled(); - expect(mockFormikRef.current.setFieldTouched).toHaveBeenCalledWith('properties', true); - expect(toastSuccessSpy).toHaveBeenCalledWith('Added 1 new property(s) to the file.'); expect(toastWarnSpy).toHaveBeenCalledWith('Skipped 1 duplicate property(s).'); - expect(mockProcessCreation).toHaveBeenCalled(); + expect(mockProcessLocationAddition).toHaveBeenCalled(); }); it('shows only warning toast if all properties are duplicates', () => { mockFormikRef.current.values = { - properties: [PropertyForm.fromFeatureDataset(getMockSelectedFeatureDataset())], + properties: [PropertyForm.fromLocationFeatureDataset(getMockLocationFeatureDataset())], }; - (FeatureDatasetsHook.useFeatureDatasetsWithAddresses as any).mockReturnValue({ - featuresWithAddresses: [ - { feature: getMockSelectedFeatureDataset() } as FeatureDatasetWithAddress, + (FeatureDatasetsHook.useLocationFeatureDatasetsWithAddresses as any).mockReturnValue({ + locationFeaturesWithAddresses: [ + { feature: getMockLocationFeatureDataset() } as LocationFeatureDatasetWithAddress, ], bcaLoading: false, }); - renderHook(() => useEditPropertiesNotifier(mockFormikRef, 'properties')); + renderHook(() => usePropertyFormSyncronizer(mockFormikRef, validateNewProperties)); expect(mockFormikRef.current.setFieldValue).not.toHaveBeenCalled(); expect(mockFormikRef.current.setFieldTouched).not.toHaveBeenCalled(); expect(toastSuccessSpy).not.toHaveBeenCalled(); expect(toastWarnSpy).toHaveBeenCalledWith('Skipped 1 duplicate property(s).'); - expect(mockProcessCreation).toHaveBeenCalled(); + expect(mockProcessLocationAddition).toHaveBeenCalled(); }); it('handles empty propertyForms gracefully', () => { mockFormikRef.current.values = { properties: [{ id: 1 }] }; - (FeatureDatasetsHook.useFeatureDatasetsWithAddresses as any).mockReturnValue({ - featuresWithAddresses: [], + (FeatureDatasetsHook.useLocationFeatureDatasetsWithAddresses as any).mockReturnValue({ + locationFeaturesWithAddresses: [], bcaLoading: false, }); - renderHook(() => useEditPropertiesNotifier(mockFormikRef, 'properties')); + renderHook(() => usePropertyFormSyncronizer(mockFormikRef, validateNewProperties)); expect(mockFormikRef.current.setFieldValue).not.toHaveBeenCalled(); expect(mockFormikRef.current.setFieldTouched).not.toHaveBeenCalled(); @@ -152,7 +169,7 @@ describe('useEditPropertiesNotifier', () => { it('handles undefined formikRef.current gracefully', () => { const emptyRef = { current: undefined } as React.RefObject>; - renderHook(() => useEditPropertiesNotifier(emptyRef, 'properties')); + renderHook(() => usePropertyFormSyncronizer(emptyRef, validateNewProperties)); expect(toastSuccessSpy).not.toHaveBeenCalled(); expect(toastWarnSpy).not.toHaveBeenCalled(); diff --git a/source/frontend/src/hooks/usePropertyFormSyncronizer.ts b/source/frontend/src/hooks/usePropertyFormSyncronizer.ts new file mode 100644 index 0000000000..955634dc41 --- /dev/null +++ b/source/frontend/src/hooks/usePropertyFormSyncronizer.ts @@ -0,0 +1,98 @@ +import { FormikProps, getIn } from 'formik'; +import { useCallback, useEffect } from 'react'; +import { toast } from 'react-toastify'; + +import { useMapStateMachine } from '@/components/common/mapFSM/MapStateMachineContext'; +import { LocationFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; +import { PropertyForm, WithFormProperties } from '@/features/mapSideBar/shared/models'; +import { exists, isEmptyOrNull } from '@/utils'; +import { arePropertyFormsEqual } from '@/utils/mapPropertyUtils'; + +import { useEditPropertiesMode } from './useEditPropertiesMode'; +import { useLocationFeatureDatasetsWithAddresses } from './useLocationFeatureDatasetsWithAddresses'; + +/** + * Notifies the map state machine to enter/exit edit properties mode. + * Use this hook in any component that needs to toggle edit properties mode on mount/unmount. + */ +export function usePropertyFormSyncronizer( + formikRef: React.RefObject>, + validateNewProperties: ( + newProperties: PropertyForm[], + validateCallback: (isValid: boolean, newProperties: PropertyForm[]) => void, + ) => void, + overrideFeatures?: LocationFeatureDataset[], +) { + const { + locationFeaturesForAddition, + pendingLocationFeaturesAddition, + processLocationFeaturesAddition, + } = useMapStateMachine(); + + // Get PropertyForms with addresses for all selected features + const { locationFeaturesWithAddresses: featuresWithAddresses, bcaLoading } = + useLocationFeatureDatasetsWithAddresses(overrideFeatures ?? locationFeaturesForAddition); + + // if we are listening to property add notifications we must tell the state machine we are in edit mode. + useEditPropertiesMode(); + + const fieldName = 'properties'; + + const validationCallback = useCallback( + (isValid: boolean, newProperties: PropertyForm[]) => { + if (isValid && !isEmptyOrNull(newProperties)) { + const existingProperties = getIn(formikRef?.current?.values, fieldName) ?? []; + formikRef.current?.setFieldValue(fieldName, [...existingProperties, ...newProperties]); + formikRef.current?.setFieldTouched(fieldName, true); + toast.success(`Added ${newProperties.length} new property(s) to the file.`); + } + }, + [fieldName, formikRef], + ); + + // This effect willbe called whenever there are new locations pending addition. + useEffect(() => { + if ( + exists(formikRef?.current) && + pendingLocationFeaturesAddition && + featuresWithAddresses.length > 0 + ) { + // Convert LocationFeatureDataset to PropertyForm + const newPropertyForms = featuresWithAddresses.map(obj => { + const property = PropertyForm.fromLocationFeatureDataset(obj.feature); + if (exists(obj.address)) { + property.address = obj.address; + } + return property; + }); + + const existingProperties = getIn(formikRef?.current?.values, fieldName as string) ?? []; + const uniqueNewProperties = newPropertyForms.filter(newProperty => { + return !existingProperties.some((existingProperty: PropertyForm) => + arePropertyFormsEqual(existingProperty, newProperty), + ); + }); + + const duplicatesSkipped = newPropertyForms.length - uniqueNewProperties.length; + + // If there are unique properties request a confirmation + if (uniqueNewProperties.length > 0) { + validateNewProperties(uniqueNewProperties, validationCallback); + } + if (duplicatesSkipped > 0) { + toast.warn(`Skipped ${duplicatesSkipped} duplicate property(s).`); + } + processLocationFeaturesAddition(); + } + }, [ + featuresWithAddresses, + fieldName, + formikRef, + pendingLocationFeaturesAddition, + processLocationFeaturesAddition, + validateNewProperties, + validationCallback, + ]); + + return { featuresWithAddresses, isLoading: bcaLoading }; +} diff --git a/source/frontend/src/mocks/featureset.mock.ts b/source/frontend/src/mocks/featureset.mock.ts index 952205784a..69276a7272 100644 --- a/source/frontend/src/mocks/featureset.mock.ts +++ b/source/frontend/src/mocks/featureset.mock.ts @@ -1,19 +1,16 @@ import { + emptyFeatureDataset, LocationFeatureDataset, - SelectedFeatureDataset, } from '@/components/common/mapFSM/useLocationFeatureLoader'; import getMockISSResult from './mockISSResult'; export const getMockLocationFeatureDataset = (): LocationFeatureDataset => ({ + ...emptyFeatureDataset(), location: { lat: 48.432802005, lng: -123.310041775, }, - fileLocation: { - lat: 48.432802005, - lng: -123.310041775, - }, pimsFeatures: [ { properties: null, @@ -140,25 +137,4 @@ export const getMockLocationFeatureDataset = (): LocationFeatureDataset => ({ }, ], highwayFeatures: getMockISSResult().features, - selectingComponentId: '', - crownLandLeasesFeatures: [], - crownLandLicensesFeatures: [], - crownLandTenuresFeatures: [], - crownLandInventoryFeatures: [], - crownLandInclusionsFeatures: [], }); - -export const getMockSelectedFeatureDataset = (): SelectedFeatureDataset => { - const locationFeatureDataset = getMockLocationFeatureDataset(); - return { - selectingComponentId: locationFeatureDataset.selectingComponentId, - location: locationFeatureDataset.location, - fileLocation: locationFeatureDataset.fileLocation, - fileBoundary: null, - parcelFeature: locationFeatureDataset.parcelFeatures[0], - pimsFeature: locationFeatureDataset.pimsFeatures[0], - regionFeature: locationFeatureDataset.regionFeature, - districtFeature: locationFeatureDataset.districtFeature, - municipalityFeature: locationFeatureDataset.municipalityFeatures[0], - }; -}; diff --git a/source/frontend/src/mocks/mapFSM.mock.ts b/source/frontend/src/mocks/mapFSM.mock.ts index f26a549c3d..5041f088cf 100644 --- a/source/frontend/src/mocks/mapFSM.mock.ts +++ b/source/frontend/src/mocks/mapFSM.mock.ts @@ -43,10 +43,7 @@ export const mapMachineBaseMock: IMapStateMachineContext = { mapMarkerSelected: null, mapLocationSelected: null, mapLocationFeatureDataset: null, - repositioningFeatureDataset: null, - repositioningPropertyIndex: null, - selectingComponentId: null, - selectedFeatures: [], + locationFeaturesForAddition: [], showPopup: false, isLoading: false, mapSearchCriteria: null, @@ -67,6 +64,8 @@ export const mapMachineBaseMock: IMapStateMachineContext = { advancedSearchCriteria: new PropertyFilterFormModel(), isMapVisible: true, currentMapBounds: defaultBounds, + pendingLocationFeaturesAddition: false, + repositioningFeature: null, requestFlyToLocation: vi.fn(), requestCenterToLocation: vi.fn(), @@ -80,7 +79,7 @@ export const mapMachineBaseMock: IMapStateMachineContext = { mapMarkerClick: vi.fn(), setMapSearchCriteria: vi.fn(), refreshMapProperties: vi.fn(), - prepareForCreation: vi.fn(), + requestLocationFeatureAddition: vi.fn(), startSelection: vi.fn(), finishSelection: vi.fn(), startReposition: vi.fn(), @@ -114,7 +113,7 @@ export const mapMachineBaseMock: IMapStateMachineContext = { closeQuickInfo: vi.fn(), minimizeQuickInfo: vi.fn(), isEditPropertiesMode: false, - processCreation: vi.fn(), + processLocationFeaturesAddition: vi.fn(), setEditPropertiesMode: vi.fn(), worklistAdd: vi.fn(), setSelectedLocation: vi.fn(), diff --git a/source/frontend/src/mocks/worklistParcel.mock.ts b/source/frontend/src/mocks/worklistParcel.mock.ts index 4407c6c0b0..00f1dd3f52 100644 --- a/source/frontend/src/mocks/worklistParcel.mock.ts +++ b/source/frontend/src/mocks/worklistParcel.mock.ts @@ -1,27 +1,32 @@ import * as turf from '@turf/turf'; import { LatLngLiteral } from 'leaflet'; -import { ParcelDataset } from '@/features/properties/parcelList/models'; +import { emptyFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; +import { LocationDatasetWithId } from '@/features/properties/worklist/context/WorklistContext'; import { emptyPmbcParcel, PMBC_FullyAttributed_Feature_Properties, } from '@/models/layers/parcelMapBC'; import { exists } from '@/utils'; -// Factory for ParcelFeature using fromFullyAttributedFeature +// Factory for ParcelFeature using export const getMockWorklistParcel = ( - id: string, + customId: string, props: Partial = {}, coords?: LatLngLiteral, -): ParcelDataset => { +): LocationDatasetWithId => { const geometry = exists(coords) ? [coords.lng, coords.lat] : [0, 0]; const geoFeature = turf.point(geometry, { ...emptyPmbcParcel, ...props, }); - const parcel = ParcelDataset.fromFullyAttributedFeature(geoFeature); - parcel.id = id; + const parcel: LocationDatasetWithId = { + ...emptyFeatureDataset(), + id: customId, + location: coords, + parcelFeatures: [geoFeature], + }; if (exists(coords)) { parcel.location = coords; diff --git a/source/frontend/src/models/generate/GenerateForm12.ts b/source/frontend/src/models/generate/GenerateForm12.ts index 9ef4523a4b..8407ed2e07 100644 --- a/source/frontend/src/models/generate/GenerateForm12.ts +++ b/source/frontend/src/models/generate/GenerateForm12.ts @@ -1,4 +1,4 @@ -import { FeatureCollection, Geometry } from 'geojson'; +import { Feature, Geometry } from 'geojson'; import moment from 'moment'; import { ComposedProperty } from '@/features/mapSideBar/property/ComposedProperty'; @@ -16,12 +16,10 @@ export class Api_GenerateForm12 { constructor(composedProperties: ComposedProperty[]) { this.properties = composedProperties.filter(exists).map(p => { const parcelMapFeatures = - ( - p.parcelMapFeatureCollection as FeatureCollection< - Geometry, - PMBC_FullyAttributed_Feature_Properties - > - )?.features ?? []; + (p.featureDataset.parcelFeatures as Feature< + Geometry, + PMBC_FullyAttributed_Feature_Properties + >[]) ?? []; return new Api_GenerateProperty(p.pimsProperty, firstOrNull(parcelMapFeatures)?.properties); }); diff --git a/source/frontend/src/models/generate/GenerateNotice.ts b/source/frontend/src/models/generate/GenerateNotice.ts index b901d70a7d..00e987a34d 100644 --- a/source/frontend/src/models/generate/GenerateNotice.ts +++ b/source/frontend/src/models/generate/GenerateNotice.ts @@ -1,4 +1,4 @@ -import { FeatureCollection, Geometry } from 'geojson'; +import { Feature, Geometry } from 'geojson'; import moment from 'moment'; import { ComposedProperty } from '@/features/mapSideBar/property/ComposedProperty'; @@ -61,12 +61,10 @@ export class Api_GenerateNotice { this.properties = properties.filter(exists).map(p => { const parcelMapFeatures = - ( - p.parcelMapFeatureCollection as FeatureCollection< - Geometry, - PMBC_FullyAttributed_Feature_Properties - > - )?.features ?? []; + (p.featureDataset.parcelFeatures as Feature< + Geometry, + PMBC_FullyAttributed_Feature_Properties + >[]) ?? []; return new Api_GenerateProperty(p.pimsProperty, firstOrNull(parcelMapFeatures)?.properties); }); diff --git a/source/frontend/src/utils/TestCommonWrapper.tsx b/source/frontend/src/utils/TestCommonWrapper.tsx index 3eb9541ae7..cacb61e0a1 100644 --- a/source/frontend/src/utils/TestCommonWrapper.tsx +++ b/source/frontend/src/utils/TestCommonWrapper.tsx @@ -10,8 +10,10 @@ import { vi } from 'vitest'; import css from '@/assets/scss/_variables.module.scss'; import ModalContainer from '@/components/common/ModalContainer'; import { ModalContextProvider } from '@/contexts/modalContext'; -import { ParcelDataset } from '@/features/properties/parcelList/models'; -import { WorklistContextProvider } from '@/features/properties/worklist/context/WorklistContext'; +import { + LocationDatasetWithId, + WorklistContextProvider, +} from '@/features/properties/worklist/context/WorklistContext'; import { ApiGen_Concepts_Organization } from '@/models/api/generated/ApiGen_Concepts_Organization'; import { TenantConsumer, TenantProvider } from '@/tenants'; @@ -24,7 +26,7 @@ interface TestProviderWrapperParams { claims?: string[]; roles?: string[]; history?: MemoryHistory; - worklistParcels?: ParcelDataset[]; + worklistParcels?: LocationDatasetWithId[]; } /** @@ -58,7 +60,7 @@ const TestCommonWrapper: React.FunctionComponent< - + { it.each([ [{}, { label: NameSourceType.NONE, value: '' }], [ { - ...getMockSelectedFeatureDataset(), + ...emptyFeatureDataset(), location: null, - pimsFeature: {} as any, - parcelFeature: { properties: { PID: undefined } } as any, + pimsFeatures: [] as any, + parcelFeatures: [{ properties: { PID: undefined } }] as any, }, { label: NameSourceType.NONE, value: '' }, ], [ { - ...getMockSelectedFeatureDataset(), + ...emptyFeatureDataset(), location: null, - pimsFeature: {} as any, - parcelFeature: { properties: { PID: '' } } as any, + pimsFeatures: [] as any, + parcelFeatures: [{ properties: { PID: '' } }] as any, }, { label: NameSourceType.NONE, value: '' }, ], [ { - ...getMockSelectedFeatureDataset(), - pimsFeature: {} as any, - parcelFeature: { properties: { PID: '000-000-001' } } as any, + ...emptyFeatureDataset(), + pimsFeatures: [] as any, + parcelFeatures: [{ properties: { PID: '000-000-001' } }] as any, }, { label: NameSourceType.PID, value: '000-000-001' }, ], [ { - ...getMockSelectedFeatureDataset(), - pimsFeature: {} as any, - parcelFeature: { - properties: { - PID: '000-000-001', - PIN: 1, - PLAN_NUMBER: 'PB1000', + ...emptyFeatureDataset(), + pimsFeatures: [] as any, + parcelFeatures: [ + { + properties: { + PID: '000-000-001', + PIN: 1, + PLAN_NUMBER: 'PB1000', + }, }, - } as any, + ] as any, }, { label: NameSourceType.PID, value: '000-000-001' }, ], [ { - ...getMockSelectedFeatureDataset(), + ...emptyFeatureDataset(), location: null, - pimsFeature: {} as any, - parcelFeature: { properties: { PIN: undefined } } as any, + pimsFeatures: [] as any, + parcelFeatures: [{ properties: { PIN: undefined } }] as any, }, { label: NameSourceType.NONE, value: '' }, ], [ { - ...getMockSelectedFeatureDataset(), + ...emptyFeatureDataset(), location: null, - pimsFeature: {} as any, - parcelFeature: { properties: { PIN: '' } } as any, + pimsFeatures: [] as any, + parcelFeatures: [{ properties: { PIN: '' } }] as any, }, { label: NameSourceType.NONE, value: '' }, ], [ { - ...getMockSelectedFeatureDataset(), - pimsFeature: {} as any, - parcelFeature: { properties: { PIN: 111112 } } as any, + ...emptyFeatureDataset(), + pimsFeatures: [] as any, + parcelFeatures: [{ properties: { PIN: 111112 } }] as any, }, { label: NameSourceType.PIN, value: '111112' }, ], [ { - ...getMockSelectedFeatureDataset(), - pimsFeature: {} as any, - parcelFeature: { - properties: { - PIN: 1, - PLAN_NUMBER: 'PB1000', + ...emptyFeatureDataset(), + pimsFeatures: [] as any, + parcelFeatures: [ + { + properties: { + PIN: 1, + PLAN_NUMBER: 'PB1000', + }, }, - } as any, + ] as any, }, { label: NameSourceType.PIN, value: '1' }, ], [ { - ...getMockSelectedFeatureDataset(), + ...emptyFeatureDataset(), location: null, - pimsFeature: {} as any, - parcelFeature: { properties: { PLAN_NUMBER: undefined } } as any, + pimsFeatures: [] as any, + parcelFeatures: [{ properties: { PLAN_NUMBER: undefined } }] as any, }, { label: NameSourceType.NONE, value: '' }, ], [ { - ...getMockSelectedFeatureDataset(), + ...emptyFeatureDataset(), location: null, - pimsFeature: {} as any, - parcelFeature: { properties: { PLAN_NUMBER: '' } } as any, + pimsFeatures: [] as any, + parcelFeatures: [{ properties: { PLAN_NUMBER: '' } }] as any, }, { label: NameSourceType.NONE, value: '' }, ], [ { - ...getMockSelectedFeatureDataset(), - pimsFeature: {} as any, - parcelFeature: { properties: { PLAN_NUMBER: '1' } } as any, + ...emptyFeatureDataset(), + pimsFeatures: [] as any, + parcelFeatures: [{ properties: { PLAN_NUMBER: '1' } }] as any, }, { label: NameSourceType.PLAN, value: '1' }, ], [ { - ...getMockSelectedFeatureDataset(), - pimsFeature: {} as any, - parcelFeature: { properties: { PLAN_NUMBER: 'PB1000' } } as any, + ...emptyFeatureDataset(), + pimsFeatures: [] as any, + parcelFeatures: [{ properties: { PLAN_NUMBER: 'PB1000' } }] as any, }, { label: NameSourceType.PLAN, value: 'PB1000' }, ], [ { - ...getMockSelectedFeatureDataset(), + ...emptyFeatureDataset(), location: { lat: 1, lng: 2 }, - fileLocation: null, - pimsFeature: {} as any, - parcelFeature: {} as any, + pimsFeatures: [] as any, + parcelFeatures: [] as any, }, { label: NameSourceType.LOCATION, value: '2.000000, 1.000000' }, ], [ { - ...getMockSelectedFeatureDataset(), + ...emptyFeatureDataset(), location: null, - pimsFeature: { properties: { STREET_ADDRESS_1: undefined } } as any, - parcelFeature: {} as any, + pimsFeature: [{ properties: { STREET_ADDRESS_1: undefined } }] as any, + parcelFeature: [] as any, }, { label: NameSourceType.NONE, value: '' }, ], [ { - ...getMockSelectedFeatureDataset(), + ...emptyFeatureDataset(), location: null, - pimsFeature: { properties: { STREET_ADDRESS_1: '' } } as any, - parcelFeature: {} as any, + pimsFeatures: [{ properties: { STREET_ADDRESS_1: '' } }] as any, + parcelFeatures: [] as any, }, { label: NameSourceType.NONE, value: '' }, ], [ { - ...getMockSelectedFeatureDataset(), + ...emptyFeatureDataset(), location: null, - pimsFeature: { properties: { STREET_ADDRESS_1: '1234 fake st' } } as any, - parcelFeature: {} as any, + pimsFeatures: [{ properties: { STREET_ADDRESS_1: '1234 fake st' } }] as any, + parcelFeatures: [] as any, }, { label: NameSourceType.ADDRESS, value: '1234 fake st' }, ], ])( 'getPropertyNameFromSelectedFeatureSet test with source %o expecting %o', - (featureSet: SelectedFeatureDataset, expectedName: PropertyName) => { - const actualName = getPropertyNameFromSelectedFeatureSet(featureSet); + (featureSet: LocationFeatureDataset, expectedName: PropertyName) => { + const actualName = getPropertyNameFromLocationFeatureSet(featureSet); expect(actualName.label).toEqual(expectedName.label); expect(actualName.value).toEqual(expectedName.value); }, @@ -251,30 +255,30 @@ describe('mapPropertyUtils', () => { it.each([ [ { - ...getMockSelectedFeatureDataset(), - pimsFeature: { properties: { PID_PADDED: '123-456-789' } } as any, + ...getMockLocationFeatureDataset(), + pimsFeatures: [{ properties: { PID_PADDED: '123-456-789' } }] as any, }, '123-456-789', ], [ { - ...getMockSelectedFeatureDataset(), - pimsFeature: {} as any, - parcelFeature: { properties: { PID: '9999' } } as any, + ...getMockLocationFeatureDataset(), + pimsFeatures: [] as any, + parcelFeatures: [{ properties: { PID: '9999' } }] as any, }, '9999', ], [ { - ...getMockSelectedFeatureDataset(), - pimsFeature: {} as any, - parcelFeature: {} as any, + ...getMockLocationFeatureDataset(), + pimsFeatures: [] as any, + parcelFeatures: [] as any, }, null, ], ])( 'pidFromFeatureSet test with feature set %o - expected %s', - (featureSet: SelectedFeatureDataset, expectedValue: string | null) => { + (featureSet: LocationFeatureDataset, expectedValue: string | null) => { const pid = pidFromFeatureSet(featureSet); expect(pid).toEqual(expectedValue); }, @@ -282,35 +286,36 @@ describe('mapPropertyUtils', () => { it.each([ [ - { ...getMockSelectedFeatureDataset(), pimsFeature: { properties: { PIN: 1234 } } as any }, + { ...getMockLocationFeatureDataset(), pimsFeatures: [{ properties: { PIN: 1234 } }] as any }, '1234', ], [ { - ...getMockSelectedFeatureDataset(), - pimsFeature: {} as any, - parcelFeature: { properties: { PIN: 9999 } } as any, + ...getMockLocationFeatureDataset(), + pimsFeatures: [] as any, + parcelFeatures: [{ properties: { PIN: 9999 } }] as any, }, '9999', ], [ { - ...getMockSelectedFeatureDataset(), - pimsFeature: {} as any, - parcelFeature: {} as any, + ...getMockLocationFeatureDataset(), + pimsFeatures: [] as any, + parcelFeatures: [] as any, }, null, ], [ { - pimsFeature: { properties: { pin: '4321' } } as any, - parcelFeature: { properties: { pid: 1234 } } as any, + ...getMockLocationFeatureDataset(), + pimsFeatures: [{ properties: { pin: '4321' } }] as any, + parcelFeatures: [{ properties: { pid: 1234 } }] as any, }, null, ], ])( 'pinFromFeatureSet test with feature set %o - expected %s', - (featureSet: SelectedFeatureDataset, expectedValue: string | null) => { + (featureSet: LocationFeatureDataset, expectedValue: string | null) => { const pid = pinFromFeatureSet(featureSet); expect(pid).toEqual(expectedValue); }, @@ -338,35 +343,6 @@ describe('mapPropertyUtils', () => { }, ); - it.each([ - [ - { ...getEmptyFileProperty(), location: getMockLocation() }, - { location: getMockLatLng(), boundary: null, isActive: null, fileBoundary: null }, - ], - [ - { - ...getEmptyFileProperty(), - location: null, - property: { - ...getEmptyProperty(), - location: getMockLocation(), - boundary: getMockPolygon(), - }, - }, - { location: getMockLatLng(), boundary: getMockPolygon(), isActive: null, fileBoundary: null }, - ], - [{ ...getEmptyFileProperty(), location: null }, null], - ])( - 'filePropertyToLocationBoundaryDataset test with file property %o - expected %o', - ( - fileProperty: ApiGen_Concepts_FileProperty | undefined | null, - expectedValue: LocationBoundaryDataset | null, - ) => { - const dataset = filePropertyToLocationBoundaryDataset(fileProperty); - expect(dataset).toEqual(expectedValue); - }, - ); - it.each([ [4, 5, { ...getMockLocation(4, 5) }], [null, null, null], @@ -386,73 +362,81 @@ describe('mapPropertyUtils', () => { [ { lat: 44, lng: -77 }, { - ...getMockSelectedFeatureDataset(), - pimsFeature: polygon([ - [ - [-81, 41], - [-81, 47], - [-72, 47], - [-72, 41], - [-81, 41], - ], - ]) as Feature | null, + ...getMockLocationFeatureDataset(), + pimsFeatures: [ + polygon([ + [ + [-81, 41], + [-81, 47], + [-72, 47], + [-72, 41], + [-81, 41], + ], + ]) as Feature | null, + ], }, true, ], [ { lat: 44, lng: 80 }, { - ...getMockSelectedFeatureDataset(), - pimsFeature: polygon([ - [ - [-81, 41], - [-81, 47], - [-72, 47], - [-72, 41], - [-81, 41], - ], - ]) as Feature | null, + ...getMockLocationFeatureDataset(), + pimsFeatures: [ + polygon([ + [ + [-81, 41], + [-81, 47], + [-72, 47], + [-72, 41], + [-81, 41], + ], + ]) as Feature | null, + ], }, false, ], [ { lat: 44, lng: -77 }, { - ...getMockSelectedFeatureDataset(), - pimsFeature: null, - parcelFeature: polygon([ - [ - [-81, 41], - [-81, 47], - [-72, 47], - [-72, 41], - [-81, 41], - ], - ]) as Feature | null, + ...getMockLocationFeatureDataset(), + pimsFeatures: null, + parcelFeatures: [ + polygon([ + [ + [-81, 41], + [-81, 47], + [-72, 47], + [-72, 41], + [-81, 41], + ], + ]) as Feature | null, + ], }, true, ], [ { lat: 44, lng: 80 }, { - ...getMockSelectedFeatureDataset(), - pimsFeature: null, - parcelFeature: polygon([ - [ - [-81, 41], - [-81, 47], - [-72, 47], - [-72, 41], - [-81, 41], - ], - ]) as Feature | null, + ...getMockLocationFeatureDataset(), + pimsFeatures: null, + parcelFeatures: [ + polygon([ + [ + [-81, 41], + [-81, 47], + [-72, 47], + [-72, 41], + [-81, 41], + ], + ]) as Feature | null, + ], }, false, ], [ { lat: 44, lng: -77 }, { - ...getMockSelectedFeatureDataset(), + ...getMockLocationFeatureDataset(), location: null, fileLocation: null, pimsFeature: null, @@ -462,7 +446,7 @@ describe('mapPropertyUtils', () => { ], ])( 'isLatLngInFeatureSetBoundary test with lat/long %o, feature set %o - expected %o', - (latLng: LatLngLiteral, featureset: SelectedFeatureDataset, expectedValue: boolean) => { + (latLng: LatLngLiteral, featureset: LocationFeatureDataset, expectedValue: boolean) => { const result = isLatLngInFeatureSetBoundary(latLng, featureset); expect(result).toEqual(expectedValue); }, diff --git a/source/frontend/src/utils/mapPropertyUtils.ts b/source/frontend/src/utils/mapPropertyUtils.ts index 41d7373a78..485e7d252e 100644 --- a/source/frontend/src/utils/mapPropertyUtils.ts +++ b/source/frontend/src/utils/mapPropertyUtils.ts @@ -13,8 +13,11 @@ import { chain, compact, isNumber } from 'lodash'; import polylabel from 'polylabel'; import { toast } from 'react-toastify'; -import { LocationBoundaryDataset, MapFeatureData } from '@/components/common/mapFSM/models'; -import { SelectedFeatureDataset } from '@/components/common/mapFSM/useLocationFeatureLoader'; +import { MapFeatureData } from '@/components/common/mapFSM/models'; +import { + FeatureDataset, + LocationFeatureDataset, +} from '@/components/common/mapFSM/useLocationFeatureLoader'; import { ONE_HUNDRED_METER_PRECISION } from '@/components/maps/constants'; import { AddressForm, PropertyForm } from '@/features/mapSideBar/shared/models'; import { ApiGen_CodeTypes_GeoJsonTypes } from '@/models/api/generated/ApiGen_CodeTypes_GeoJsonTypes'; @@ -28,7 +31,9 @@ import { emptyPropertyLocation, PIMS_Property_Location_View, } from '@/models/layers/pimsPropertyLocationView'; -import { exists, formatApiAddress, pidFormatter } from '@/utils'; + +import { formatApiAddress, formatSplitAddress, pidFormatter } from './propertyUtils'; +import { exists, firstOrNull } from './utils'; export enum NameSourceType { PID = 'PID', @@ -45,8 +50,8 @@ export interface PropertyName { value: string; } -export const getPropertyNameFromSelectedFeatureSet = ( - selectedFeature: SelectedFeatureDataset | null, +export const getPropertyNameFromLocationFeatureSet = ( + selectedFeature: LocationFeatureDataset | null, ): PropertyName => { if (!exists(selectedFeature)) { return { label: NameSourceType.NONE, value: '' }; @@ -76,6 +81,46 @@ export const getPropertyNameFromSelectedFeatureSet = ( return { label: NameSourceType.NONE, value: '' }; }; +export const getPropertyNameFromForm = (propertyForm: PropertyForm | null): PropertyName => { + if (!exists(propertyForm)) { + return { label: NameSourceType.NONE, value: '' }; + } + + const pid = propertyForm.pid; + const pin = propertyForm.pin; + const planNumber = propertyForm.planNumber; + const address = propertyForm.address; + const location = propertyForm.fileLocation; + + if (exists(pid) && pid?.toString()?.length > 0 && pid !== '0') { + return { label: NameSourceType.PID, value: pidFormatter(pid.toString()) }; + } else if (exists(pin) && pin?.toString()?.length > 0 && pin !== '0') { + return { label: NameSourceType.PIN, value: pin.toString() }; + } else if (exists(planNumber) && planNumber?.toString()?.length > 0) { + return { label: NameSourceType.PLAN, value: planNumber.toString() }; + } else if (exists(location?.lat) && exists(location?.lng)) { + return { + label: NameSourceType.LOCATION, + value: compact([location.lng?.toFixed(6), location.lat?.toFixed(6)]).join(', '), + }; + } else if (exists(address)) { + return { + label: NameSourceType.ADDRESS, + value: + formatSplitAddress( + address.streetAddress1, + address.streetAddress2, + address.streetAddress3, + address.municipality, + address.province, + address.postalCode, + ) ?? '', + }; + } + + return { label: NameSourceType.NONE, value: '' }; +}; + export const getPrettyLatLng = (location: ApiGen_Concepts_Geometry | undefined | null) => compact([ location?.coordinate?.x?.toFixed(6) ?? 0, @@ -174,17 +219,6 @@ export const getFeatureBoundedCenter = (feature: Feature { - return { - location: featureSet?.fileLocation ?? featureSet?.location, - boundary: featureSet?.pimsFeature?.geometry ?? featureSet?.parcelFeature?.geometry ?? null, - fileBoundary: featureSet?.fileBoundary ?? null, - isActive: featureSet.isActive, - }; -}; - export function pidFromFullyAttributedFeature( parcelFeature: Feature | null, ): string | null { @@ -205,40 +239,45 @@ export function planFromFullyAttributedFeature( : null; } -export function pidFromFeatureSet(featureset: SelectedFeatureDataset): string | null { - if (exists(featureset?.pimsFeature?.properties)) { - return exists(featureset?.pimsFeature?.properties?.PID_PADDED) - ? featureset?.pimsFeature?.properties?.PID_PADDED?.toString() +export function pidFromFeatureSet(featureset: FeatureDataset): string | null { + const pimsFeature = firstOrNull(featureset?.pimsFeatures); + if (exists(pimsFeature?.properties)) { + return exists(pimsFeature?.properties?.PID_PADDED) + ? pimsFeature?.properties?.PID_PADDED?.toString() : null; } - return pidFromFullyAttributedFeature(featureset?.parcelFeature); + const parcelFeature = firstOrNull(featureset?.parcelFeatures); + return pidFromFullyAttributedFeature(parcelFeature); } -export function pinFromFeatureSet(featureset: SelectedFeatureDataset): string | null { - if (exists(featureset?.pimsFeature?.properties)) { - return exists(featureset?.pimsFeature?.properties?.PIN) - ? featureset?.pimsFeature?.properties?.PIN?.toString() - : null; +export function pinFromFeatureSet(featureset: FeatureDataset): string | null { + const pimsFeature = firstOrNull(featureset?.pimsFeatures); + if (exists(pimsFeature?.properties)) { + return exists(pimsFeature?.properties?.PIN) ? pimsFeature?.properties?.PIN?.toString() : null; } - return pinFromFullyAttributedFeature(featureset?.parcelFeature); + const parcelFeature = firstOrNull(featureset?.parcelFeatures); + return pinFromFullyAttributedFeature(parcelFeature); } -export function planFromFeatureSet(featureset: SelectedFeatureDataset): string | null { - if (exists(featureset?.pimsFeature?.properties)) { - return exists(featureset?.pimsFeature?.properties?.SURVEY_PLAN_NUMBER) - ? featureset?.pimsFeature?.properties?.SURVEY_PLAN_NUMBER?.toString() +export function planFromFeatureSet(featureset: FeatureDataset): string | null { + const pimsFeature = firstOrNull(featureset?.pimsFeatures); + if (exists(pimsFeature?.properties)) { + return exists(pimsFeature?.properties?.SURVEY_PLAN_NUMBER) + ? pimsFeature?.properties?.SURVEY_PLAN_NUMBER?.toString() : null; } - return planFromFullyAttributedFeature(featureset?.parcelFeature); + const parcelFeature = firstOrNull(featureset?.parcelFeatures); + return planFromFullyAttributedFeature(parcelFeature); } -export function addressFromFeatureSet(featureset: SelectedFeatureDataset): string | null { - if (exists(featureset?.pimsFeature?.properties)) { - return exists(featureset?.pimsFeature?.properties?.STREET_ADDRESS_1) - ? formatApiAddress(AddressForm.fromPimsView(featureset?.pimsFeature?.properties).toApi()) +export function addressFromFeatureSet(featureset: FeatureDataset): string | null { + const pimsFeature = firstOrNull(featureset?.pimsFeatures); + if (exists(pimsFeature?.properties)) { + return exists(pimsFeature?.properties?.STREET_ADDRESS_1) + ? formatApiAddress(AddressForm.fromPimsView(pimsFeature?.properties).toApi()) : null; } @@ -277,34 +316,6 @@ export function pimsGeomeryToGeometry( return null; } -export function filePropertyToLocationBoundaryDataset( - fileProperty: ApiGen_Concepts_FileProperty | undefined | null, -): LocationBoundaryDataset | null { - const geom = locationFromFileProperty(fileProperty); - const location = getLatLng(geom); - return exists(location) - ? { - location, - boundary: fileProperty?.property?.boundary ?? null, - fileBoundary: fileProperty?.boundary ?? null, - isActive: fileProperty.isActive, - } - : null; -} - -export function propertyToLocationBoundaryDataset( - property: ApiGen_Concepts_Property | undefined | null, -): LocationBoundaryDataset | null { - const location = getLatLng(property.location); - return exists(location) - ? { - location, - boundary: property?.boundary ?? null, - fileBoundary: null, - } - : null; -} - /** * Takes a (Lat, Long) value and a FeatureSet and determines if the point resides inside the polygon. * The polygon can be convex or concave. The function accounts for holes. @@ -315,12 +326,14 @@ export function propertyToLocationBoundaryDataset( */ export function isLatLngInFeatureSetBoundary( latLng: LatLngLiteral, - featureset: SelectedFeatureDataset, + featureset: FeatureDataset, ): boolean { + // TODO: Make this work with the all of the features instead of just the first one + const pimsFeature = firstOrNull(featureset?.pimsFeatures); + const parcelFeature = firstOrNull(featureset?.parcelFeatures); + const location = point([latLng.lng, latLng.lat]); - const boundary = (featureset?.pimsFeature?.geometry ?? featureset?.parcelFeature?.geometry) as - | Polygon - | MultiPolygon; + const boundary = (pimsFeature?.geometry ?? parcelFeature?.geometry) as Polygon | MultiPolygon; return exists(boundary) && booleanPointInPolygon(location, boundary); } @@ -352,24 +365,76 @@ export function sortFileProperties( return null; } -export const areSelectedFeaturesEqual = ( - lhs: SelectedFeatureDataset, - rhs: SelectedFeatureDataset, -) => { - const lhsName = getPropertyNameFromSelectedFeatureSet(lhs); - const rhsName = getPropertyNameFromSelectedFeatureSet(rhs); +export function areFeatureDatasetsEqual(p1: FeatureDataset, p2: FeatureDataset): boolean { + if (!exists(p1) || !exists(p2)) { + return false; + } + + const pid1 = pidFromFeatureSet(p1) ?? null; + const pid2 = pidFromFeatureSet(p2) ?? null; + if (exists(pid1) && pid1 === pid2) { + return true; + } + + const pin1 = pinFromFeatureSet(p1) ?? null; + const pin2 = pinFromFeatureSet(p2) ?? null; + + if (exists(pin1) && pin1 === pin2) { + return true; + } + + // Some parcels are only identified by their plan-number (e.g. common strata, parks) + // Only consider plan-number as an identifier when there are no PID/PIN + const planNumber1 = planFromFeatureSet(p1) ?? null; + const planNumber2 = planFromFeatureSet(p2) ?? null; if ( - (lhsName.label === rhsName.label && - lhsName.label !== NameSourceType.NONE && - lhsName.label !== NameSourceType.PLAN) || - (lhsName.label === NameSourceType.PLAN && - lhs.location.lat === rhs.location.lat && - lhs.location.lng === rhs.location.lng) + exists(planNumber1) && + !exists(pid1) && + !exists(pin1) && + !exists(pid2) && + !exists(pin2) && + planNumber1 === planNumber2 ) { - return lhsName.value === rhsName.value; + return true; } + return false; -}; +} + +export function areLocationFeatureDatasetsEqual( + p1: LocationFeatureDataset, + p2: LocationFeatureDataset, +): boolean { + if (!exists(p1) || !exists(p2)) { + return false; + } + + if (areFeatureDatasetsEqual(p1, p2)) { + return true; + } + + // Only consider lat/long when there are no PID/PIN + const location1 = p1.location; + const location2 = p2.location; + const pid1 = pidFromFeatureSet(p1) ?? null; + const pid2 = pidFromFeatureSet(p2) ?? null; + const pin1 = pinFromFeatureSet(p1) ?? null; + const pin2 = pinFromFeatureSet(p2) ?? null; + if ( + exists(location1) && + exists(location2) && + !exists(pid1) && + !exists(pin1) && + !exists(pid2) && + !exists(pin2) && + location1.lat === location2.lat && + location1.lng === location2.lng + ) { + return true; + } + + return false; +} export const isEmptyFeatureCollection = (collection: FeatureCollection) => { return !(exists(collection?.features) && collection.features.length > 0); @@ -390,7 +455,10 @@ export const arePropertyFormsEqual = (lhs: PropertyForm, rhs: PropertyForm): boo if (!exists(lhs) || !exists(rhs)) { return exists(lhs) === exists(rhs); } - return areSelectedFeaturesEqual(lhs.toFeatureDataset(), rhs.toFeatureDataset()); + return areLocationFeatureDatasetsEqual( + lhs.toLocationFeatureDataset(), + rhs.toLocationFeatureDataset(), + ); }; export interface RegionDistrictResult { @@ -398,14 +466,9 @@ export interface RegionDistrictResult { districtResult: Feature; } -export function featureSetToLatLngKey(featureSet: SelectedFeatureDataset | null | undefined) { - if (exists(featureSet.location)) { - const latLng: LatLngLiteral = { - lat: featureSet.location.lat, - lng: featureSet.location.lng, - }; - - const key = `${latLng.lat}-${latLng.lng}`; +export function latLngToKey(location: LatLngLiteral | null | undefined) { + if (exists(location)) { + const key = `${location.lat}-${location.lng}`; return key; } @@ -420,7 +483,7 @@ export function featureSetToLatLngKey(featureSet: SelectedFeatureDataset | null * @returns A Map where keys are lat-lng strings and values are RegionDistrictResult objects. */ export async function getRegionAndDistrictsResults( - properties: SelectedFeatureDataset[], + properties: LocationFeatureDataset[], regionSearch: ( latlng: LatLngLiteral, geometryName?: string | undefined, @@ -439,15 +502,10 @@ export async function getRegionAndDistrictsResults( continue; } - const latLng: LatLngLiteral = { - lat: property.location.lat, - lng: property.location.lng, - }; - - const key = featureSetToLatLngKey(property); + const key = latLngToKey(property.location); if (!latLngMap.has(key)) { - latLngMap.set(key, latLng); + latLngMap.set(key, property.location); } } @@ -471,9 +529,10 @@ export async function getRegionAndDistrictsResults( return new Map(results); } -export function apiPropertyToPimsFeature( - property: ApiGen_Concepts_Property | undefined | null, +export function apiFilePropertyToPimsFeature( + fileProperty: ApiGen_Concepts_FileProperty | undefined | null, ): Feature | null { + const property = fileProperty?.property; if (!exists(property)) { return null; } diff --git a/source/frontend/src/utils/propertyUtils.ts b/source/frontend/src/utils/propertyUtils.ts index 7b0b1cdb1a..8b0a976fe7 100644 --- a/source/frontend/src/utils/propertyUtils.ts +++ b/source/frontend/src/utils/propertyUtils.ts @@ -1,13 +1,11 @@ -import { FormikProps, getIn } from 'formik'; import { Feature, Geometry } from 'geojson'; -import { toast } from 'react-toastify'; -import { PropertyForm } from '@/features/mapSideBar/shared/models'; +import { AddressForm } from '@/features/mapSideBar/shared/models'; import { ApiGen_Concepts_Address } from '@/models/api/generated/ApiGen_Concepts_Address'; import { ApiGen_Concepts_PropertyManagement } from '@/models/api/generated/ApiGen_Concepts_PropertyManagement'; import { IBcAssessmentSummary } from '@/models/layers/bcAssesment'; import { PMBC_FullyAttributed_Feature_Properties } from '@/models/layers/parcelMapBC'; -import { arePropertyFormsEqual, firstOrNull } from '@/utils'; +import { firstOrNull } from '@/utils'; import { exists, isNumber, isValidString } from './utils'; @@ -78,6 +76,17 @@ export const formatApiAddress = (address: ApiGen_Concepts_Address | null | undef ); }; +export const formatFormAddress = (address: AddressForm | null | undefined) => { + return formatSplitAddress( + address?.streetAddress1 ?? '', + address?.streetAddress2 ?? '', + address?.streetAddress3 ?? '', + address?.municipality ?? '', + address?.province ?? '', + address?.postalCode ?? '', + ); +}; + /** * Provides a formatted address as a string. * @returns Civic address string value. @@ -152,34 +161,3 @@ export function isStrataCommonProperty( feature.properties.OWNER_TYPE === 'Unclassified' ); } - -export const addPropertiesToCurrentFile = ( - formikRef: React.RefObject>, - fieldName: keyof T, - propertyForms: PropertyForm[], - notifyAddComplete: () => void, -) => { - const existingProperties = getIn(formikRef?.current?.values, fieldName as string) ?? []; - const uniqueProperties = propertyForms.filter(newProperty => { - return !existingProperties.some((existingProperty: PropertyForm) => - arePropertyFormsEqual(existingProperty, newProperty), - ); - }); - - const duplicatesSkipped = propertyForms.length - uniqueProperties.length; - - // If there are unique properties, add them to the formik values - if (uniqueProperties.length > 0) { - formikRef.current?.setFieldValue(fieldName as string, [ - ...existingProperties, - ...uniqueProperties, - ]); - formikRef.current?.setFieldTouched(fieldName as string, true); - toast.success(`Added ${uniqueProperties.length} new property(s) to the file.`); - } - - if (duplicatesSkipped > 0) { - toast.warn(`Skipped ${duplicatesSkipped} duplicate property(s).`); - } - notifyAddComplete(); -}; diff --git a/source/frontend/src/utils/test-utils.tsx b/source/frontend/src/utils/test-utils.tsx index a6450a8df4..266cbb0eef 100644 --- a/source/frontend/src/utils/test-utils.tsx +++ b/source/frontend/src/utils/test-utils.tsx @@ -18,7 +18,7 @@ import { vi } from 'vitest'; import { IMapStateMachineContext } from '@/components/common/mapFSM/MapStateMachineContext'; import { FilterProvider } from '@/components/maps/providers/FilterProvider'; -import { ParcelDataset } from '@/features/properties/parcelList/models'; +import { LocationDatasetWithId } from '@/features/properties/worklist/context/WorklistContext'; import { IApiError } from '@/interfaces/IApiError'; import { mapMachineBaseMock } from '@/mocks/mapFSM.mock'; @@ -282,7 +282,7 @@ export interface RenderOptions extends RtlRenderOptions { roles?: string[]; mockMapMachine?: IMapStateMachineContext; keycloakMock?: any; - worklistParcels?: ParcelDataset[]; + worklistParcels?: LocationDatasetWithId[]; } function render( diff --git a/source/frontend/src/utils/utils.ts b/source/frontend/src/utils/utils.ts index 485e8eeddb..1a1b7435c1 100644 --- a/source/frontend/src/utils/utils.ts +++ b/source/frontend/src/utils/utils.ts @@ -170,6 +170,10 @@ export function exists(value: T | null | undefined): value is T { return value === (value ?? !value); } +export function isEmptyOrNull(array: Array | null | undefined): array is null { + return !(exists(array) && !isEmpty(array)); +} + /** * Returns true id an identifier belongs to an existing entry on the backend * @param value the paraneter to be assessed