diff --git a/frontend/.dockerignore b/frontend/.dockerignore
index c63854cec2..ab767a5734 100644
--- a/frontend/.dockerignore
+++ b/frontend/.dockerignore
@@ -8,3 +8,4 @@ dist/
build/
node_modules/
.vscode/
+public/videos
diff --git a/frontend/.gitignore b/frontend/.gitignore
index ff3b20c2a2..367196c69f 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -11,6 +11,7 @@ test-report.xml
# production
build/
+public/videos
# misc
.DS_Store
diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json
index 53fc5b2ae2..cef1d40622 100644
--- a/frontend/.vscode/settings.json
+++ b/frontend/.vscode/settings.json
@@ -21,5 +21,10 @@
"jest.autoRun": {
"onStartup": ["all-tests"]
},
- "cSpell.words": ["Formik", "PIMS", "siteminder"]
+ "cSpell.words": [
+ "bbox",
+ "Formik",
+ "PIMS",
+ "siteminder"
+ ]
}
diff --git a/frontend/src/components/maps/leaflet/InfoSlideOut/InfoSlideOut.tsx b/frontend/src/components/maps/leaflet/InfoSlideOut/InfoSlideOut.tsx
index 753c837a7d..81a0ba08dd 100644
--- a/frontend/src/components/maps/leaflet/InfoSlideOut/InfoSlideOut.tsx
+++ b/frontend/src/components/maps/leaflet/InfoSlideOut/InfoSlideOut.tsx
@@ -192,8 +192,12 @@ const InfoControl: React.FC = ({ open, setOpen, onHeaderAction
const keycloak = useKeycloakWrapper();
const dispatch = useAppDispatch();
- const canViewProperty = keycloak.canUserViewProperty(propertyInfo);
- const canEditProperty = keycloak.canUserEditProperty(propertyInfo);
+ const canViewProperty =
+ keycloak.canUserViewProperty(propertyInfo) &&
+ popUpContext.propertyTypeID !== PropertyTypes.GEOCODER;
+ const canEditProperty =
+ keycloak.canUserEditProperty(propertyInfo) &&
+ popUpContext.propertyTypeID !== PropertyTypes.GEOCODER;
const renderContent = () => {
if (popUpContext.propertyInfo) {
@@ -218,6 +222,7 @@ const InfoControl: React.FC = ({ open, setOpen, onHeaderAction
>
);
} else if (canViewProperty) {
+ if (popUpContext.propertyTypeID === PropertyTypes.GEOCODER) return null;
if (isBuilding) {
return (
@@ -304,23 +309,26 @@ const InfoControl: React.FC = ({ open, setOpen, onHeaderAction
- {open && popUpContext.propertyInfo && canViewProperty && (
-
- {
- setGeneralInfoOpen(false);
- }}
+ {open &&
+ popUpContext.propertyInfo &&
+ canViewProperty &&
+ popUpContext.propertyTypeID !== PropertyTypes.GEOCODER && (
+
- {isBuilding ? : }
-
-
- )}
+ {
+ setGeneralInfoOpen(false);
+ }}
+ >
+ {isBuilding ? : }
+
+
+ )}
{open && {renderContent()}}
diff --git a/frontend/src/components/maps/leaflet/InventoryLayer.tsx b/frontend/src/components/maps/leaflet/InventoryLayer.tsx
index 107109ef8a..1f634db5f7 100644
--- a/frontend/src/components/maps/leaflet/InventoryLayer.tsx
+++ b/frontend/src/components/maps/leaflet/InventoryLayer.tsx
@@ -1,7 +1,8 @@
import { IBuilding, IParcel, IPropertyDetail } from 'actions/parcelsActions';
import { IGeoSearchParams } from 'constants/API';
import { PropertyTypes } from 'constants/propertyTypes';
-import { BBox } from 'geojson';
+import { BBox, Point } from 'geojson';
+import { useApiGeocoder } from 'hooks/api';
import { useApi } from 'hooks/useApi';
import useDeepCompareEffect from 'hooks/useDeepCompareEffect';
import useKeycloakWrapper from 'hooks/useKeycloakWrapper';
@@ -127,6 +128,8 @@ export const defaultBounds = new LatLngBounds(
[48.78370426, -139.35937554],
);
+var counter = 1000000;
+
/**
* Displays the search results onto a layer with clustering.
* This component makes a request to the PIMS API properties search WFS endpoint.
@@ -148,6 +151,7 @@ export const InventoryLayer: React.FC = ({
const { loadProperties } = useApi();
const { changed: filterChanged } = useFilterContext();
const municipalitiesService = useLayerQuery(MUNICIPALITY_LAYER_URL);
+ const geocoder = useApiGeocoder();
const draftProperties: PointFeature[] = useAppSelector(store => store.parcel.draftProperties);
@@ -195,7 +199,33 @@ export const InventoryLayer: React.FC = ({
}, [filter]);
const loadTile = async (filter: IGeoSearchParams) => {
- return loadProperties(filter);
+ const inventory = await loadProperties(filter);
+
+ // Make a request to geocoder for properties with the specified address.
+ if (!!filter.address) {
+ var geo = await geocoder.addresses(filter.address, filter.bbox, filter.administrativeArea);
+ var features = geo.data.features
+ .filter(f => f?.properties?.locationDescriptor !== 'provincePoint')
+ .map(f => ({
+ ...f,
+ properties: {
+ id: !!f.properties?.blockID ? f.properties?.blockID : counter++,
+ propertyTypeId: PropertyTypes.GEOCODER,
+ isSensitive: false,
+ statusId: 0,
+ name: f.properties?.siteName,
+ address: f.properties?.fullAddress,
+ administrativeArea: f.properties?.localityName,
+ province: 'British Columbia',
+ geocoder: f.properties,
+ longitude: (f.geometry as Point).coordinates[0],
+ latitude: (f.geometry as Point).coordinates[1],
+ },
+ }));
+ return inventory.concat(features);
+ }
+
+ return inventory;
};
const search = async (filters: IGeoSearchParams[]) => {
diff --git a/frontend/src/components/maps/leaflet/Map.tsx b/frontend/src/components/maps/leaflet/Map.tsx
index a508122a2f..5451a8e81d 100644
--- a/frontend/src/components/maps/leaflet/Map.tsx
+++ b/frontend/src/components/maps/leaflet/Map.tsx
@@ -157,7 +157,7 @@ const getQueryParams = (filter: IPropertyFilter): IGeoSearchParams => {
const defaultBounds = new LatLngBounds([60.09114547, -119.49609429], [48.78370426, -139.35937554]);
/**
- * Creates a Leaflet map and by default includes a number of preconfigured layers.
+ * Creates a Leaflet map and by default includes a number of pre-configured layers.
* @param param0
*/
const Map: React.FC = ({
diff --git a/frontend/src/components/maps/leaflet/PointClusterer.tsx b/frontend/src/components/maps/leaflet/PointClusterer.tsx
index add95bf850..68a7a602f1 100644
--- a/frontend/src/components/maps/leaflet/PointClusterer.tsx
+++ b/frontend/src/components/maps/leaflet/PointClusterer.tsx
@@ -85,6 +85,20 @@ export const convertToProperty = (
[PropertyTypes.DRAFT_BUILDING, PropertyTypes.DRAFT_PARCEL].includes(property.propertyTypeId)
) {
return property;
+ } else if (property.propertyTypeId === PropertyTypes.GEOCODER) {
+ return {
+ ...property,
+ evaluations: [],
+ fiscals: [],
+ latitude: latitude,
+ longitude: longitude,
+ address: {
+ line1: property.address,
+ administrativeArea: property.administrativeArea,
+ province: property.province,
+ postal: property.postal,
+ } as IAddress,
+ } as IParcel;
}
return null;
};
@@ -262,8 +276,8 @@ export const PointClusterer: React.FC = ({
const { getParcel, getBuilding } = useApi();
const fetchProperty = React.useCallback(
(propertyTypeId: number, id: number) => {
- popUpContext.setLoading(true);
if ([PropertyTypes.PARCEL, PropertyTypes.SUBDIVISION].includes(propertyTypeId)) {
+ popUpContext.setLoading(true);
getParcel(id as number)
.then(parcel => {
popUpContext.setPropertyInfo(parcel);
@@ -275,13 +289,14 @@ export const PointClusterer: React.FC = ({
popUpContext.setLoading(false);
});
} else if (propertyTypeId === PropertyTypes.BUILDING) {
+ popUpContext.setLoading(true);
getBuilding(id as number)
.then(building => {
popUpContext.setPropertyInfo(building);
if (!!building.parcels.length) {
dispatch(
storePropertyDetail({
- propertyTypeId: 1,
+ propertyTypeId: PropertyTypes.BUILDING,
parcelDetail: building,
}),
);
@@ -293,6 +308,9 @@ export const PointClusterer: React.FC = ({
.finally(() => {
popUpContext.setLoading(false);
});
+ } else {
+ toast.warn('This property does not exist in inventory.');
+ Promise.resolve();
}
},
[getParcel, popUpContext, getBuilding, dispatch],
@@ -336,7 +354,7 @@ export const PointClusterer: React.FC = ({
}
return (
- // render single marker, not in a cluster
+ //render single marker, not in a cluster
= ({
longitude,
);
//sets this pin as currently selected
- if (
- cluster.properties.propertyTypeId === PropertyTypes.PARCEL ||
- cluster.properties.propertyTypeId === PropertyTypes.SUBDIVISION
- ) {
+ if (cluster.properties.propertyTypeId === PropertyTypes.BUILDING) {
dispatch(
storePropertyDetail({
- propertyTypeId: (convertedProperty as IParcel)
- ?.propertyTypeId as PropertyTypes,
- parcelDetail: convertedProperty as IParcel,
+ propertyTypeId: cluster.properties.propertyTypeId,
+ parcelDetail: convertedProperty as IBuilding,
}),
);
} else {
dispatch(
storePropertyDetail({
- propertyTypeId: 1,
- parcelDetail: convertedProperty as IBuilding,
+ propertyTypeId: cluster.properties.propertyTypeId,
+ parcelDetail: convertedProperty as IParcel,
}),
);
}
onMarkerClick(); //open information slideout
- if (keycloak.canUserViewProperty(cluster.properties as IProperty)) {
+ if (
+ keycloak.canUserViewProperty(cluster.properties as IProperty) &&
+ cluster.properties.propertyTypeId !== PropertyTypes.GEOCODER
+ ) {
fetchProperty(cluster.properties.propertyTypeId, cluster.properties.id);
} else {
popUpContext.setPropertyInfo(convertedProperty);
@@ -418,7 +435,10 @@ export const PointClusterer: React.FC = ({
);
}
onMarkerClick(); //open information slideout
- if (keycloak.canUserViewProperty(m.properties as IProperty)) {
+ if (
+ keycloak.canUserViewProperty(m.properties as IProperty) &&
+ m.properties.propertyTypeId !== PropertyTypes.GEOCODER
+ ) {
fetchProperty(m.properties.propertyTypeId, m.properties.id);
} else {
popUpContext.setPropertyInfo(
diff --git a/frontend/src/components/maps/leaflet/mapUtils.tsx b/frontend/src/components/maps/leaflet/mapUtils.tsx
index e77e2d01fa..17498c8a19 100644
--- a/frontend/src/components/maps/leaflet/mapUtils.tsx
+++ b/frontend/src/components/maps/leaflet/mapUtils.tsx
@@ -73,6 +73,16 @@ export const subdivisionIconSelect = L.icon({
shadowSize: [41, 41],
});
+export const geocoderIcon = L.icon({
+ iconUrl:
+ require('assets/images/pins/marker-green.png').default ?? 'assets/images/pins/marker-green.png',
+ shadowUrl: require('assets/images/pins/marker-shadow.png').default ?? 'marker-shadow.png',
+ iconSize: [25, 41],
+ iconAnchor: [12, 41],
+ popupAnchor: [1, -34],
+ shadowSize: [41, 41],
+});
+
// draft parcel icon (green)
export const draftParcelIcon = L.icon({
iconUrl:
@@ -305,6 +315,8 @@ export const getMarkerIcon = (feature: ICluster, selected?: boolean) => {
return parcelIconSelect;
} else if (propertyTypeId === PropertyTypes.SUBDIVISION) {
return subdivisionIconSelect;
+ } else if (propertyTypeId === PropertyTypes.GEOCODER) {
+ return geocoderIcon;
} else {
return buildingIconSelect;
}
@@ -336,6 +348,8 @@ export const getMarkerIcon = (feature: ICluster, selected?: boolean) => {
return parcelIcon;
} else if (propertyTypeId === PropertyTypes.SUBDIVISION) {
return subdivisionIcon;
+ } else if (propertyTypeId === PropertyTypes.GEOCODER) {
+ return geocoderIcon;
} else {
return buildingIcon;
}
diff --git a/frontend/src/constants/propertyTypes.ts b/frontend/src/constants/propertyTypes.ts
index 3bf4baeaf2..e7a3c2e8e7 100644
--- a/frontend/src/constants/propertyTypes.ts
+++ b/frontend/src/constants/propertyTypes.ts
@@ -4,4 +4,5 @@ export enum PropertyTypes {
SUBDIVISION = 2,
DRAFT_PARCEL = 3,
DRAFT_BUILDING = 4,
+ GEOCODER = 5,
}
diff --git a/frontend/src/features/help/components/text/TutorialHelpText.tsx b/frontend/src/features/help/components/text/TutorialHelpText.tsx
new file mode 100644
index 0000000000..c6b2ab291d
--- /dev/null
+++ b/frontend/src/features/help/components/text/TutorialHelpText.tsx
@@ -0,0 +1,55 @@
+import * as React from 'react';
+
+/**
+ * Help Text for the tutorial videos.
+ */
+export const TutorialHelpText = () => {
+ return (
+
+ The following videos have been created to assist in the most common tasks.
+
+
+ );
+};
diff --git a/frontend/src/features/help/constants/HelpText.tsx b/frontend/src/features/help/constants/HelpText.tsx
index 86298bfca0..0407c588d4 100644
--- a/frontend/src/features/help/constants/HelpText.tsx
+++ b/frontend/src/features/help/constants/HelpText.tsx
@@ -12,6 +12,7 @@ import InventoryNavigationHelpText from '../components/text/InventoryNavigationH
import LandingFilterHelpText from '../components/text/LandingFilterHelpText';
import LandingMapHelpText from '../components/text/LandingMapHelpText';
import LandingNavigationHelpText from '../components/text/LandingNavigationHelpText';
+import { TutorialHelpText } from '../components/text/TutorialHelpText';
import BugForm from '../forms/BugForm';
import FeatureRequestForm from '../forms/FeatureRequestForm';
import QuestionForm from '../forms/QuestionForm';
@@ -24,6 +25,7 @@ export const landingPageTopics = new Map([
[Topics.LANDING_MAP, ],
[Topics.LANDING_FILTER, ],
[Topics.LANDING_NAVIGATION, ],
+ [Topics.TUTORIALS, ],
]);
/**
diff --git a/frontend/src/features/help/interfaces.tsx b/frontend/src/features/help/interfaces.tsx
index 4d680c6761..8bf4817c85 100644
--- a/frontend/src/features/help/interfaces.tsx
+++ b/frontend/src/features/help/interfaces.tsx
@@ -31,4 +31,5 @@ export enum Topics {
CREATE_PROJECT_NAVIGATION = 'Navigation',
CREATE_PROJECT_STEPS = 'Steps',
ASSESS_PROJECT = 'Assess',
+ TUTORIALS = 'Tutorials',
}
diff --git a/frontend/src/features/properties/hooks/useGeocoder.tsx b/frontend/src/features/properties/hooks/useGeocoder.tsx
index 9319fbb764..401b858c32 100644
--- a/frontend/src/features/properties/hooks/useGeocoder.tsx
+++ b/frontend/src/features/properties/hooks/useGeocoder.tsx
@@ -116,7 +116,7 @@ const useGeocoder = ({ formikRef, fetchPimsOrLayerParcel }: IUseGeocoderProps) =
} as LatLng)
.then(response => {
const pid = getIn(response, 'features.0.properties.PID');
- //it is possible the geocoder will fail to get the pid but the parcel layer service request will succeed. In that case, double check that the pid doesn't exist within pims.
+ // it is possible the geocoder will fail to get the pid but the parcel layer service request will succeed. In that case, double check that the pid doesn't exist within pims.
if (pid) {
const parcelLayerSearchCallback = () => {
const response = parcelsService.findByPid(pid);
diff --git a/frontend/src/hooks/api/geocoder/useApiGeocoder.ts b/frontend/src/hooks/api/geocoder/useApiGeocoder.ts
index 9606538ff7..6affa909a1 100644
--- a/frontend/src/hooks/api/geocoder/useApiGeocoder.ts
+++ b/frontend/src/hooks/api/geocoder/useApiGeocoder.ts
@@ -1,8 +1,14 @@
+import Axios from 'axios';
+import { FeatureCollection } from 'geojson';
import { useApi } from 'hooks/api';
import React from 'react';
import { IGeoAddressModel, ISitePidsModel } from '.';
+/**
+ * Provides a simple api to communicate with geocoder endpoints.
+ * @returns API controller with endpoints to Geocoder.
+ */
export const useApiGeocoder = () => {
const api = useApi();
@@ -14,6 +20,39 @@ export const useApiGeocoder = () => {
findAddresses: async (address: string) => {
return api.get(`/tools/geocoder/addresses?address=${address}`);
},
+ addresses: async (address: string, bbox?: string, locality?: string) => {
+ const axios = Axios.create({
+ baseURL: 'https://geocoder.api.gov.bc.ca',
+ });
+ // TODO: Figure out how to only return good results, Geocoder returns a ton of junk.
+ var params: any = {
+ ver: '1.2',
+ addressString: `"${address}"`,
+ outputSRS: 4326,
+ // maxResults: 10,
+ minScore: 60,
+ setBack: 0,
+ echo: true,
+ brief: true,
+ autoComplete: true,
+ locationDescriptor: 'parcelPoint',
+ // interpolation: 'adaptive',
+ // matchPrecision: 'CIVIC_NUMBER,STREET',
+ provinceCode: 'BC',
+ };
+ if (!!locality) params.localities = locality;
+ if (!!bbox) {
+ const values = bbox.split(',');
+ params.bbox = `${values[0]},${values[2]},${values[1]},${values[3]}`;
+ }
+ return axios.get(`/addresses.json`, {
+ headers: {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'GET,PUT,POST,DELETE,PATCH,OPTIONS',
+ },
+ params,
+ });
+ },
}),
[api],
);
diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts
index a706965efa..932a0d2da6 100644
--- a/frontend/src/hooks/useApi.ts
+++ b/frontend/src/hooks/useApi.ts
@@ -4,6 +4,7 @@ import { IGeoSearchParams } from 'constants/API';
import { ENVIRONMENT } from 'constants/environment';
import CustomAxios, { LifecycleToasts } from 'customAxios';
import { IApiProperty } from 'features/projects/interfaces';
+import { GeoJsonObject } from 'geojson';
import * as _ from 'lodash';
import queryString from 'query-string';
import { useCallback } from 'react';
@@ -38,7 +39,7 @@ export interface PimsAPI extends AxiosInstance {
) => Promise<{ available: boolean }>;
searchAddress: (text: string) => Promise;
getSitePids: (siteId: string) => Promise;
- loadProperties: (params?: IGeoSearchParams) => Promise;
+ loadProperties: (params?: IGeoSearchParams) => Promise;
getBuilding: (id: number) => Promise;
getParcel: (id: number) => Promise;
updateBuilding: (id: number, data: IApiProperty) => Promise;
@@ -131,9 +132,9 @@ export const useApi = (props?: IApiProps): PimsAPI => {
);
axios.loadProperties = useCallback(
- async (params?: IGeoSearchParams): Promise => {
+ async (params?: IGeoSearchParams): Promise => {
try {
- const { data } = await axios.get(
+ const { data } = await axios.get(
`${ENVIRONMENT.apiUrl}/properties/search/wfs?${
params ? queryString.stringify(params) : ''
}`,
@@ -141,7 +142,7 @@ export const useApi = (props?: IApiProps): PimsAPI => {
return data;
} catch (error) {
throw new Error(
- `${(error as any).message}: An error occured while fetching properties in inventory.`,
+ `${(error as any).message}: An error occurred while fetching properties in inventory.`,
);
}
},
diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts
index de678d1f98..e6ca20d990 100644
--- a/frontend/src/utils/utils.ts
+++ b/frontend/src/utils/utils.ts
@@ -224,7 +224,6 @@ export const getYear = (date?: Date | string): number => {
} else {
momentDate = moment(date);
}
- console.debug(date);
return momentDate.year();
};
diff --git a/openshift/4.0/templates/api/deploy-routes.yaml b/openshift/4.0/templates/api/deploy-routes.yaml
index 4e0b1a08a3..32e2940e63 100644
--- a/openshift/4.0/templates/api/deploy-routes.yaml
+++ b/openshift/4.0/templates/api/deploy-routes.yaml
@@ -60,6 +60,8 @@ objects:
app: ${APP_NAME}
role: ${ROLE_NAME}
env: ${ENV_NAME}
+ annotations:
+ haproxy.router.openshift.io/timeout: 60s
spec:
host: ${APP_DOMAIN}
path: ${API_PATH}
diff --git a/openshift/4.0/templates/app/deploy.yaml b/openshift/4.0/templates/app/deploy.yaml
index 01a073a5e8..95ceca153c 100644
--- a/openshift/4.0/templates/app/deploy.yaml
+++ b/openshift/4.0/templates/app/deploy.yaml
@@ -266,6 +266,9 @@ objects:
items:
- key: "environment.json"
path: "environment.json"
+ - name: file-storage
+ persistentVolumeClaim:
+ claimName: file-storage
containers:
- name: ${SOLUTION_NAME}-${APP_NAME}
image: ""
@@ -296,6 +299,8 @@ objects:
- name: "${SOLUTION_NAME}-${APP_NAME}-envvars"
mountPath: "${FILE_CONFIG_MOUNT_PATH}environment.json"
subPath: "environment.json"
+ - name: file-storage
+ mountPath: /tmp/app/dist/videos
livenessProbe:
httpGet:
path: "/nginx_status"
diff --git a/openshift/4.0/templates/app/pvc.yaml b/openshift/4.0/templates/app/pvc.yaml
new file mode 100644
index 0000000000..dcfd24dadf
--- /dev/null
+++ b/openshift/4.0/templates/app/pvc.yaml
@@ -0,0 +1,20 @@
+---
+kind: PersistentVolumeClaim
+apiVersion: v1
+metadata:
+ name: file-storage
+ labels:
+ name: app
+ part-of: pims
+ version: 1.0.0
+ component: app
+ created-by: jeremy.foster
+spec:
+ # Storage class name is the type of storage [netapp-file-standard, netapp-file-extended, netapp-file-backup, netapp-block-standard, netapp-block-extended]
+ storageClassName: netapp-file-standard
+ # Storage access mode [ReadWriteOnce, ReadWriteMany]
+ accessModes:
+ - ReadWriteMany
+ resources:
+ requests:
+ storage: 10Gi