Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PIMS-1967: Create link to project from property page #2617

Merged
merged 17 commits into from
Aug 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading