Skip to content

Commit

Permalink
PIMS-1967: Create link to project from property page (#2617)
Browse files Browse the repository at this point in the history
Co-authored-by: Dylan Barkowsky <[email protected]>
  • Loading branch information
LawrenceLau2020 and dbarkowsky authored Aug 15, 2024
1 parent 604f054 commit 5672997
Show file tree
Hide file tree
Showing 7 changed files with 197 additions and 0 deletions.
17 changes: 17 additions & 0 deletions express-api/src/controllers/properties/propertiesController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,23 @@ export const getPropertiesFuzzySearch = async (req: Request, res: Response) => {
return res.status(200).send(result);
};

/**
* @description Search for a single keyword across multiple different fields in both parcels and buildings.
* @param {Request} req Incoming request
* @param {Response} res Outgoing response
* @returns {Response} A 200 status with a list of properties.
*/
export const getLinkedProjects = async (req: Request, res: Response) => {
const buildingId = req.query.buildingId
? parseInt(req.query.buildingId as string, 10)
: undefined;
const parcelId = req.query.parcelId ? parseInt(req.query.parcelId as string, 10) : undefined;

const linkedProjects = await propertyServices.findLinkedProjectsForProperty(buildingId, parcelId);

return res.status(200).send(linkedProjects);
};

/**
* @description Used to retrieve all property geolocation information.
* @param {Request} req Incoming request
Expand Down
3 changes: 3 additions & 0 deletions express-api/src/routes/propertiesRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,15 @@ const {
getPropertiesFuzzySearch,
getPropertyUnion,
getImportResults,
getLinkedProjects,
} = controllers;

router.route('/search/fuzzy').get(activeUserCheck, catchErrors(getPropertiesFuzzySearch));

router.route('/search/geo').get(activeUserCheck, catchErrors(getPropertiesForMap)); // Formerly wfs route

router.route('/search/linkedProjects').get(activeUserCheck, catchErrors(getLinkedProjects));

const upload = multer({
dest: 'uploads/',
fileFilter: (req, file, cb) => {
Expand Down
35 changes: 35 additions & 0 deletions express-api/src/services/properties/propertiesServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,40 @@ const propertiesFuzzySearch = async (keyword: string, limit?: number, agencyIds?
Buildings: buildings,
};
};
/**
* Finds associated projects based on the provided building ID or parcel ID.
*
* This function queries the `ProjectProperty` repository to find projects linked
* to either a building or a parcel. It returns an empty array if neither ID is provided.
*
* @param buildingId - Optional ID of the building to find associated projects for.
* @param parcelId - Optional ID of the parcel to find associated projects for.
* @returns A promise that resolves to an array of `ProjectProperty` objects.
* If neither `buildingId` nor `parcelId` is provided, an empty array is returned.
*/
const findLinkedProjectsForProperty = async (buildingId?: number, parcelId?: number) => {
const whereCondition = buildingId
? { BuildingId: buildingId }
: parcelId
? { ParcelId: parcelId }
: {}; // Return an empty condition if neither ID is provided

const query = AppDataSource.getRepository(ProjectProperty)
.createQueryBuilder('pp')
.leftJoinAndSelect('pp.Project', 'p')
.leftJoinAndSelect('p.Status', 'ps')
.where(whereCondition)
.select(['p.*', 'ps.Name AS status_name']);

const associatedProjects = buildingId || parcelId ? await query.getRawMany() : []; // Return an empty array if no ID is provided

return associatedProjects.map((result) => ({
ProjectNumber: result.project_number,
Id: result.id,
StatusName: result.status_name,
Description: result.description,
}));
};

/**
* Retrieves properties based on the provided filter criteria to render map markers.
Expand Down Expand Up @@ -841,6 +875,7 @@ const propertyServices = {
getImportResults,
getPropertiesForExport,
processFile,
findLinkedProjectsForProperty,
};

export default propertyServices;
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
getImportResults,
getPropertyUnion,
importProperties,
getLinkedProjects,
} from '@/controllers/properties/propertiesController';
import { ImportResult } from '@/typeorm/Entities/ImportResult';
import xlsx, { WorkBook } from 'xlsx';
Expand All @@ -65,6 +66,10 @@ const _getPropertyUnion = jest.fn().mockImplementation(async () => [producePrope

const _getImportResults = jest.fn().mockImplementation(async () => [produceImportResult()]);

const _findLinkedProjectsForProperty = jest.fn().mockImplementation(async () => {
return [{ id: 1, name: 'Linked Project 1', buildingId: 1 }];
});

jest.spyOn(xlsx, 'readFile').mockImplementation(() => {
const wb: WorkBook = {
Sheets: {},
Expand All @@ -82,6 +87,7 @@ jest.mock('@/services/properties/propertiesServices', () => ({
getPropertiesForMap: () => _getPropertiesForMap(),
getPropertiesUnion: () => _getPropertyUnion(),
getImportResults: () => _getImportResults(),
findLinkedProjectsForProperty: () => _findLinkedProjectsForProperty(),
}));

const _getAgencies = jest.fn().mockImplementation(async () => [1, 2, 3]);
Expand Down Expand Up @@ -207,4 +213,13 @@ describe('UNIT - Properties', () => {
expect(mockResponse.statusValue).toBe(400);
});
});

describe('GET /properties/search/linkedProjects', () => {
it('should return 200 with linked projects for a building ID', async () => {
mockRequest.query.buildingId = '1';
await getLinkedProjects(mockRequest, mockResponse);
expect(mockResponse.statusValue).toBe(200);
expect(mockResponse.sendValue).toEqual([{ id: 1, name: 'Linked Project 1', buildingId: 1 }]);
});
});
});
78 changes: 78 additions & 0 deletions react-app/src/components/property/AssociatedProjectsTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import React from 'react';
import { DataGrid, GridCellParams, GridColDef, GridRowsProp } from '@mui/x-data-grid';
import { useTheme } from '@mui/material';
import { Link } from 'react-router-dom';

interface Project {
Id: number;
ProjectNumber: string;
StatusName: string;
Description: string;
}

interface AssociatedProjectsTableProps {
linkedProjects: Project[];
}

const AssociatedProjectsTable: React.FC<AssociatedProjectsTableProps> = ({ linkedProjects }) => {
const theme = useTheme();
const columns: GridColDef[] = [
{
field: 'ProjectNumber',
headerName: 'Project Number',
width: 150,
renderCell: (params: GridCellParams) => (
<Link
to={`/projects/${params.row.id}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: theme.palette.primary.main, textDecoration: 'none' }}
>
{String(params.value)}
</Link>
),
},
{ field: 'StatusName', headerName: 'Status Name', width: 150 },
{
field: 'Description',
headerName: 'Description',
width: 550,
renderCell: (params: GridCellParams) => (
<div style={{ whiteSpace: 'normal', wordWrap: 'break-word', overflow: 'visible' }}>
{String(params.value)}
</div>
),
},
];

const rows: GridRowsProp = linkedProjects.map((project) => ({
id: project.Id,
ProjectNumber: project.ProjectNumber,
StatusName: project.StatusName,
Description: project.Description,
}));

return (
<DataGrid
rows={rows}
columns={columns}
autoHeight
hideFooter
sx={{
borderStyle: 'none',
'& .MuiDataGrid-columnHeaders': {
borderBottom: 'none',
},
'& div div div div >.MuiDataGrid-cell': {
borderBottom: 'none',
borderTop: '1px solid rgba(224, 224, 224, 1)',
},
'& .MuiDataGrid-row:hover': {
backgroundColor: 'transparent',
},
}}
/>
);
};

export default AssociatedProjectsTable;
27 changes: 27 additions & 0 deletions react-app/src/components/property/PropertyDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import useDataSubmitter from '@/hooks/useDataSubmitter';
import { AuthContext } from '@/contexts/authContext';
import { Roles } from '@/constants/roles';
import { LookupContext } from '@/contexts/lookupContext';
import AssociatedProjectsTable from './AssociatedProjectsTable';

interface IPropertyDetail {
onClose: () => void;
Expand All @@ -44,6 +45,7 @@ const PropertyDetail = (props: IPropertyDetail) => {
const buildingId = isNaN(Number(params.buildingId)) ? null : Number(params.buildingId);
const api = usePimsApi();
const deletionBroadcastChannel = useMemo(() => new BroadcastChannel('property'), []);
const [linkedProjects, setLinkedProjects] = useState<any[]>([]);
const {
data: parcel,
refreshData: refreshParcel,
Expand Down Expand Up @@ -82,6 +84,17 @@ const PropertyDetail = (props: IPropertyDetail) => {
api.buildings.getBuildings({ pid: parcel?.parsedBody?.PID, includeRelations: true }),
);

useEffect(() => {
const fetchLinkedProjects = async () => {
const projects = await api.properties.getLinkedProjectsToProperty({
parcelId,
buildingId,
});
setLinkedProjects(projects);
};
fetchLinkedProjects();
}, [parcelId, buildingId]);

const isAuditor = keycloak.hasRoles([Roles.AUDITOR]);

const refreshEither = () => {
Expand Down Expand Up @@ -278,6 +291,7 @@ const PropertyDetail = (props: IPropertyDetail) => {
];

if (buildingOrParcel === 'Parcel') sideBarItems.splice(3, 0, { title: 'LTSA Information' });
if (linkedProjects.length > 0) sideBarItems.splice(4, 0, { title: 'Associated Projects' });

return (
<CollapsibleSidebar items={sideBarItems}>
Expand Down Expand Up @@ -359,6 +373,19 @@ const PropertyDetail = (props: IPropertyDetail) => {
/>
</DataCard>
)}
{linkedProjects.length > 0 && (
<>
<DataCard
id={'Associated Projects'}
title={'Associated Projects'}
values={undefined}
onEdit={undefined}
disableEdit={true}
>
<AssociatedProjectsTable linkedProjects={linkedProjects} />
</DataCard>
</>
)}
</Box>
<>
{buildingOrParcel === 'Parcel' ? (
Expand Down
22 changes: 22 additions & 0 deletions react-app/src/hooks/api/usePropertiesApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ export interface PropertiesUnionResponse {
totalCount: number;
}

export interface PropertyId {
buildingId?: number;
parcelId?: number;
}

const usePropertiesApi = (absoluteFetch: IFetch) => {
const config = useContext(ConfigContext);
const keycloak = useSSO();
Expand Down Expand Up @@ -179,6 +184,22 @@ const usePropertiesApi = (absoluteFetch: IFetch) => {
return parsedBody as ImportResult[];
};

const getLinkedProjectsToProperty = async (propertyId: PropertyId) => {
try {
const params: Record<string, any> = {};
if (propertyId.buildingId !== undefined && propertyId.buildingId !== null) {
params.buildingId = propertyId.buildingId.toString();
}
if (propertyId.parcelId !== undefined && propertyId.parcelId !== null) {
params.parcelId = propertyId.parcelId.toString();
}
const { parsedBody } = await absoluteFetch.get('/properties/search/linkedProjects', params);
return parsedBody as any[];
} catch (error) {
return [];
}
};

return {
propertiesFuzzySearch,
propertiesGeoSearch,
Expand All @@ -187,6 +208,7 @@ const usePropertiesApi = (absoluteFetch: IFetch) => {
getImportResults,
propertiesDataSource,
getPropertiesForExcelExport,
getLinkedProjectsToProperty,
};
};

Expand Down

0 comments on commit 5672997

Please sign in to comment.