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-1826 Fuzzy Search Options #2511

Merged
merged 6 commits into from
Jul 9, 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
10 changes: 5 additions & 5 deletions express-api/src/services/properties/propertiesServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,11 @@ const propertiesFuzzySearch = async (keyword: string, limit?: number, agencyIds?
.leftJoinAndSelect('parcel.Classification', 'classification')
.where(
new Brackets((qb) => {
qb.where(`parcel.pid::text like '%${keyword}%'`)
.orWhere(`parcel.pin::text like '%${keyword}%'`)
.orWhere(`agency.name like '%${keyword}%'`)
.orWhere(`adminArea.name like '%${keyword}%'`)
.orWhere(`parcel.address1 like '%${keyword}%'`);
qb.where(`LPAD(parcel.pid::text, 9, '0') ILIKE '%${keyword.replaceAll('-', '')}%'`)
.orWhere(`parcel.pin::text ILIKE '%${keyword}%'`)
.orWhere(`agency.name ILIKE '%${keyword}%'`)
.orWhere(`adminArea.name ILIKE '%${keyword}%'`)
.orWhere(`parcel.address1 ILIKE '%${keyword}%'`);
}),
)
.andWhere(`classification.Name in ('Surplus Encumbered', 'Surplus Active')`);
Expand Down
4 changes: 2 additions & 2 deletions express-api/src/utilities/helperFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ export const TimestampComparisonWrapper = (tsValue: string, operator: TimestampO
//or "Project".project_number (correct table alias plus non-aliased column access)
//Thankfully, it's not too difficult to manually format this.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const fixColumnAlias = (str: string) => {
export const fixColumnAlias = (str: string) => {
const [tableAlias, columnAlias] = str.split('.');
const fixedColumn = columnAlias
.split(/\.?(?=[A-Z])/)
Expand All @@ -158,7 +158,7 @@ const fixColumnAlias = (str: string) => {
* @param date JS Date object
* @returns string
*/
const toPostgresTimestamp = (date: Date) => {
export const toPostgresTimestamp = (date: Date) => {
const pad = (num: number, size = 2) => {
let s = String(num);
while (s.length < size) s = '0' + s;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { PropertyClassification } from '@/typeorm/Entities/PropertyClassificatio
import { User } from '@/typeorm/Entities/User';
import { MapProperties } from '@/typeorm/Entities/views/MapPropertiesView';
import { PropertyUnion } from '@/typeorm/Entities/views/PropertyUnionView';
import logger from '@/utilities/winstonLogger';
import {
produceParcel,
produceBuilding,
Expand Down Expand Up @@ -199,7 +200,7 @@ describe('UNIT - Property Services', () => {

describe('fuzzySearchProperties', () => {
it('should return an object with parcels and buildings', async () => {
const result = await propertyServices.propertiesFuzzySearch('123', 3);
const result = await propertyServices.propertiesFuzzySearch('123', 3, [3]);
expect(Array.isArray(result.Parcels)).toBe(true);
expect(Array.isArray(result.Buildings)).toBe(true);
});
Expand All @@ -222,6 +223,7 @@ describe('UNIT - Property Services', () => {
quantity: 2,
page: 1,
updatedOn: 'after,' + new Date(),
quickFilter: 'contains,someWord',
});
expect(Array.isArray(result));
expect(result.at(0)).toHaveProperty('PropertyType');
Expand All @@ -232,6 +234,15 @@ describe('UNIT - Property Services', () => {
expect(result.at(0)).toHaveProperty('Classification');
expect(result.at(0)).toHaveProperty('AdministrativeArea');
});

it('should log an invalid sort key if the key is invalid', async () => {
const loggerErrorSpy = jest.spyOn(logger, 'error');
await propertyServices.getPropertiesUnion({
sortKey: 'aaaaa',
sortOrder: 'DESC',
});
expect(loggerErrorSpy).toHaveBeenCalledWith('PropertyUnion Service - Invalid Sort Key');
});
});

describe('getPropertiesForMap', () => {
Expand Down
183 changes: 183 additions & 0 deletions express-api/tests/unit/utilities/helperFunctions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import {
constructFindOptionFromQuery,
constructFindOptionFromQueryPid,
fixColumnAlias,
ILikeWrapper,
TimestampComparisonWrapper,
toPostgresTimestamp,
} from '@/utilities/helperFunctions';
import { EqualOperator, FindOperator } from 'typeorm';

describe('UNIT - helperFunctions', () => {
describe('toPostgresTimestamp', () => {
it('should return the reformatted timestamp', () => {
const date = new Date('2024-07-09');
const result = toPostgresTimestamp(date);
expect(result).toBe('2024-07-09 00:00:00');
});
});

describe('fixColumnAlias', () => {
it('should return a fixed column alias e.g. table.fixed_column', () => {
const tableColumnPair = 'user.FirstName';
const result = fixColumnAlias(tableColumnPair);
expect(result).toBe('"user".first_name');
});
});

describe('TimestampComparisonWrapper', () => {
it('should return an equals query', () => {
const result = TimestampComparisonWrapper(new Date('2024-07-09').toUTCString(), '=');
expect(result).toBeInstanceOf(FindOperator);
expect(result.getSql('date')).toBe(`(date)::DATE = '2024-07-09 00:00:00'::DATE`);
});
it('should return a not equals query', () => {
const result = TimestampComparisonWrapper(new Date('2024-07-09').toUTCString(), '!=');
expect(result).toBeInstanceOf(FindOperator);
expect(result.getSql('date')).toBe(`(date)::DATE != '2024-07-09 00:00:00'::DATE`);
});
it('should return other query comparisons', () => {
const result = TimestampComparisonWrapper(new Date('2024-07-09').toUTCString(), '>=');
expect(result).toBeInstanceOf(FindOperator);
expect(result.getSql('date')).toBe(`date >= '2024-07-09 00:00:00'`);
});
});

describe('ILikeWrapper', () => {
it('should return undefined when the query is undefined', () => {
expect(ILikeWrapper(undefined)).toBeUndefined();
});

it('should return _____% in query when the mode is startsWith', () => {
const result = ILikeWrapper('test', 'startsWith');
expect(result.getSql('query')).toBe(`(query)::TEXT ILIKE 'test%'`);
});

it('should return %_____ in query when the mode is endsWith', () => {
const result = ILikeWrapper('test', 'endsWith');
expect(result.getSql('query')).toBe(`(query)::TEXT ILIKE '%test'`);
});

it('should return %_____% in query when the mode is contains or undefined', () => {
let result = ILikeWrapper('test', 'contains');
expect(result.getSql('query')).toBe(`(query)::TEXT ILIKE '%test%'`);
result = ILikeWrapper('test');
expect(result.getSql('query')).toBe(`(query)::TEXT ILIKE '%test%'`);
});
});

describe('constructFindOptionFromQuery', () => {
it('should return undefined when the pair is null-ish', () => {
const result = constructFindOptionFromQuery('test', undefined);
expect(result.test).toBeUndefined();
});

it('should produce and equal operator when operator is "equals', () => {
const result = constructFindOptionFromQuery('test', 'equals,query');
expect(result.test).toBeInstanceOf(EqualOperator);
});

it('should produce %____% query when operator is "contains"', () => {
const result = constructFindOptionFromQuery('test', 'contains,query');
expect(result.test.getSql('query')).toBe(`(query)::TEXT ILIKE '%query%'`);
});
it('should produce ____% query when operator is "startsWith"', () => {
const result = constructFindOptionFromQuery('test', 'startsWith,query');
expect(result.test.getSql('query')).toBe(`(query)::TEXT ILIKE 'query%'`);
});
it('should produce %____ query when operator is "endsWith"', () => {
const result = constructFindOptionFromQuery('test', 'endsWith,query');
expect(result.test.getSql('query')).toBe(`(query)::TEXT ILIKE '%query'`);
});

it('should produce = DATE query when operator is "is"', () => {
const result = constructFindOptionFromQuery(
'test',
`is,${new Date('2024-07-09').toUTCString()}`,
);
expect(result.test.getSql('query')).toBe(`(query)::DATE = '2024-07-09 00:00:00'::DATE`);
});

it('should produce != DATE query when operator is "not"', () => {
const result = constructFindOptionFromQuery(
'test',
`not,${new Date('2024-07-09').toUTCString()}`,
);
expect(result.test.getSql('query')).toBe(`(query)::DATE != '2024-07-09 00:00:00'::DATE`);
});

it('should produce > DATE query when operator is "after"', () => {
const result = constructFindOptionFromQuery(
'test',
`after,${new Date('2024-07-09').toUTCString()}`,
);
expect(result.test.getSql('query')).toBe(`query > '2024-07-09 00:00:00'`);
});

it('should produce < DATE query when operator is "before"', () => {
const result = constructFindOptionFromQuery(
'test',
`before,${new Date('2024-07-09').toUTCString()}`,
);
expect(result.test.getSql('query')).toBe(`query < '2024-07-09 00:00:00'`);
});

it('should produce >= DATE query when operator is "onOrAfter"', () => {
const result = constructFindOptionFromQuery(
'test',
`onOrAfter,${new Date('2024-07-09').toUTCString()}`,
);
expect(result.test.getSql('query')).toBe(`query >= '2024-07-09 00:00:00'`);
});

it('should produce <= DATE query when operator is "onOrBefore"', () => {
const result = constructFindOptionFromQuery(
'test',
`onOrBefore,${new Date('2024-07-09').toUTCString()}`,
);
expect(result.test.getSql('query')).toBe(`query <= '2024-07-09 00:00:00'`);
});

it('should produce NOT NULL query when operator is "isNotEmpty"', () => {
const result = constructFindOptionFromQuery('test', `isNotEmpty,`);
expect(result.test?.type).toBe('not');
});

it('should produce NULL query when operator is "isEmpty"', () => {
const result = constructFindOptionFromQuery('test', `isEmpty,`);
expect(result.test?.type).toBe('isNull');
});

it('should produce undefined query when operator is an unexpected string', () => {
const result = constructFindOptionFromQuery('test', `wow,`);
expect(result.test).toBeUndefined();
});
});

describe('constructFindOptionFromQueryPid', () => {
it('should return "equals" query when the operator is "equals', () => {
const result = constructFindOptionFromQueryPid('test', 'equals,3');
expect(result.test).toBeInstanceOf(EqualOperator);
});

it('should return %____% query when the operator is "contains', () => {
const result = constructFindOptionFromQueryPid('test', 'contains,3');
expect(result.test.getSql('query')).toBe(`LPAD( (query)::TEXT, 9, '0') ILIKE '%3%'`);
});

it('should return ____% query when the operator is "startsWith', () => {
const result = constructFindOptionFromQueryPid('test', 'startsWith,3');
expect(result.test.getSql('query')).toBe(`LPAD( (query)::TEXT, 9, '0') ILIKE '3%'`);
});

it('should return %____ query when the operator is "endsWith', () => {
const result = constructFindOptionFromQueryPid('test', 'endsWith,3');
expect(result.test.getSql('query')).toBe(`LPAD( (query)::TEXT, 9, '0') ILIKE '%3'`);
});

it('should return the default response from constructFindOptionQuery if the operator does not match', () => {
const result = constructFindOptionFromQueryPid('test', 'wow,3');
expect(result.test).toBeUndefined();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,25 @@ import { Building, BuildingEvaluation } from '@/hooks/api/useBuildingsApi';
import { Parcel, ParcelEvaluation } from '@/hooks/api/useParcelsApi';
import usePimsApi from '@/hooks/usePimsApi';
import { Delete, Search } from '@mui/icons-material';
import { IconButton, Box, Autocomplete, TextField, InputAdornment, useTheme } from '@mui/material';
import {
IconButton,
Box,
Autocomplete,
TextField,
InputAdornment,
useTheme,
ListItem,
ListItemText,
ListItemAvatar,
Grid,
} from '@mui/material';
import { GridColDef, DataGrid } from '@mui/x-data-grid';
import { useState, ReactNode } from 'react';
import { Link } from 'react-router-dom';
import React from 'react';
import { pidFormatter } from '@/utilities/formatters';
import { ClassificationIcon } from '@/components/property/ClassificationIcon';
import { useClassificationStyle } from '@/components/property/PropertyTable';

interface IDisposalProjectSearch {
rows: any[];
Expand All @@ -30,23 +43,33 @@ const DisposalProjectSearch = (props: IDisposalProjectSearch) => {
const [loadingOptions, setLoadingOptions] = useState(false);
const api = usePimsApi();
const theme = useTheme();
const classification = useClassificationStyle();

const getAutoCompleteLabel = (input: ParcelWithType | BuildingWithType | string) => {
const getPidPinLabel = (input: ParcelWithType | BuildingWithType | string) => {
if (typeof input === 'string') {
return '';
}

if (input.PID) {
return `${input.Type} - PID: ${pidFormatter(input.PID)}`;
return `PID: ${pidFormatter(input.PID)}`;
} else if (input.PIN) {
return `${input.Type} - PIN: ${input.PIN}`;
} else if (input.Address1) {
return `${input.Type} - Address: ${input.Address1}`;
return `PIN: ${input.PIN}`;
} else {
return `${input.Type} - No identifying info.`;
return `No PID/PIN.`;
}
};

const getAddressLabel = (input: ParcelWithType | BuildingWithType) => {
const address = [];
if (input.Address1) {
address.push(input.Address1);
}
if (input.AdministrativeArea) {
address.push(input.AdministrativeArea.Name);
}
return address.join(', ');
};

const columns: GridColDef[] = [
{
field: 'Type',
Expand All @@ -61,7 +84,7 @@ const DisposalProjectSearch = (props: IDisposalProjectSearch) => {
renderCell: (params) => {
return (
<Link
to={`/properties/${params.row.Type.toLowerCase()}/${params.row.Id}`}
to={`/properties/${params.row.Type?.toLowerCase()}/${params.row.Id}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: theme.palette.primary.main }}
Expand Down Expand Up @@ -134,22 +157,57 @@ const DisposalProjectSearch = (props: IDisposalProjectSearch) => {
setFuzzySearchOptions([]);
}
}}
getOptionLabel={(option) => getAutoCompleteLabel(option)}
getOptionLabel={() => ''} // No label, because field should clear on select.
renderOption={(props, option) => {
const classificationColour =
option.ClassificationId != null
? classification[option.ClassificationId].textColor
: theme.palette.black.main;
return (
<ListItem {...props} key={`${option.Id},${option.PropertyTypeId}`}>
<Grid container>
<Grid item xs={1} pt={1} mr={1}>
<ListItemAvatar>
<ClassificationIcon
iconType={option.PropertyTypeId}
textColor={theme.palette.text.primary}
badgeColor={classificationColour}
scale={1.2}
badgeScale={1}
/>
</ListItemAvatar>
</Grid>
<Grid item xs={5}>
<ListItemText
primary={getPidPinLabel(option)}
secondary={getAddressLabel(option)}
/>
</Grid>
<Grid item xs={5}>
<ListItemText
primary={option.Agency?.Name ?? 'No Agency'}
secondary={option.Name && option.Name !== '' ? option.Name : 'No Name'}
/>
</Grid>
</Grid>
</ListItem>
);
}}
getOptionKey={(option) => option.Id + option.Type}
onInputChange={(_event, value) => {
setLoadingOptions(true);
api.properties.propertiesFuzzySearch(value).then((response) => {
setLoadingOptions(false);
setFuzzySearchOptions([
...response.Parcels.map((a) => ({
...response.Parcels?.map((a) => ({
...a,
Type: 'Parcel',
EvaluationYears: a.Evaluations.map((e) => e.Year),
EvaluationYears: a.Evaluations?.map((e) => e.Year),
})),
...response.Buildings.map((a) => ({
...response.Buildings?.map((a) => ({
...a,
Type: 'Building',
EvaluationYears: a.Evaluations.map((e) => e.Year),
EvaluationYears: a.Evaluations?.map((e) => e.Year),
})),
]);
});
Expand Down
Loading