diff --git a/client/app/controllers/query-parameters/show-geography.js b/client/app/controllers/query-parameters/show-geography.js index 8f9f087e..78a1ab54 100644 --- a/client/app/controllers/query-parameters/show-geography.js +++ b/client/app/controllers/query-parameters/show-geography.js @@ -145,6 +145,10 @@ export const projectParams = new QueryParams({ defaultValue: '', refresh: true, }, + blocks_in_radius: { + defaultValue: [], + refresh: true, + }, dcp_ulurp_nonulurp: { defaultValue: [], refresh: true, diff --git a/client/tests/acceptance/filter-checkbox-test.js b/client/tests/acceptance/filter-checkbox-test.js index 9f12dc5a..31f4debb 100644 --- a/client/tests/acceptance/filter-checkbox-test.js +++ b/client/tests/acceptance/filter-checkbox-test.js @@ -30,6 +30,15 @@ module('Acceptance | filter checkbox', function(hooks) { assert.equal(currentURL().includes('dcp_femafloodzonea=true'), true); }); + test('User clicks radius filter checkbox', async function(assert) { + server.createList('project', 20); + await visit('/'); + await click('[data-test-filter-section="filter-section-radius-filter"] .switch-paddle'); + + assert.equal(currentURL().includes('distance_from_point'), true); + assert.equal(currentURL().includes('radius_from_point'), true); + }); + test('User clicks community district box, fills in community district name, selects CD', async function(assert) { server.createList('project', 20); await visit('/'); diff --git a/server/src/crm/crm.utilities.ts b/server/src/crm/crm.utilities.ts index 11f06038..475ee549 100644 --- a/server/src/crm/crm.utilities.ts +++ b/server/src/crm/crm.utilities.ts @@ -122,7 +122,33 @@ export function containsAnyOf(propertyName, strings = [], options?) { return `(${not ? 'not ' : ''}${lambdaQueryPrefix}(${containsQuery}))`; } -export const dateParser = function(key, value) { +export function startsWithString(propertyName, string) { + return `startswith(${propertyName}, '${string}')`; +} + +export function startsWithAnyOf(propertyName, strings = [], options?) { + const { + childEntity = '', + comparisonStrategy = startsWithString, + not = false, + } = options || {}; + + const containsQuery = strings + .map((string, i) => { + // in odata syntax, this character o is a variable for scoping + // logic for related entities. it needs to only appear once. + const lambdaScope = (childEntity && i === 0) ? `${childEntity}:` : ''; + const lambdaScopedProperty = childEntity ? `${childEntity}/${propertyName}` : propertyName; + + return `${lambdaScope}${comparisonStrategy(lambdaScopedProperty, string)}`; + }) + .join(' or '); + const lambdaQueryPrefix = childEntity ? `${childEntity}/any` : ''; + + return `(${not ? 'not ' : ''}${lambdaQueryPrefix}(${containsQuery}))`; +} + +export const dateParser = function (key, value) { if (typeof value === 'string') { // YYYY-MM-DDTHH:mm:ss.sssZ => parsed as UTC // YYYY-MM-DD => parsed as local date diff --git a/server/src/project/geometry/geometry.service.ts b/server/src/project/geometry/geometry.service.ts index 7b3747fc..a0b60216 100644 --- a/server/src/project/geometry/geometry.service.ts +++ b/server/src/project/geometry/geometry.service.ts @@ -186,6 +186,9 @@ const QUERY_TEMPLATES = { "dcp_project" ), + blocks_in_radius: (queryParamValue) => + containsString('dcp_bblnumber', [queryParamValue], 'dcp_projectbbl'), + dcp_femafloodzonev: queryParamValue => comparisonOperator( "dcp_femafloodzonev", @@ -360,8 +363,10 @@ export class GeometryService { const blocks = await this.carto.fetchCarto(distinctBlocks, "json", "post"); // note: DTM stores blocks with the borough - return blocks.map(block => `${block.block.substring(1)}`); - } + + return blocks + .map(block => `${block.block}`); + } // Warning! Returns either null or an Object async getBblsGeometry(bbls = []) { diff --git a/server/src/project/project.service.ts b/server/src/project/project.service.ts index 4f052a70..8a456014 100644 --- a/server/src/project/project.service.ts +++ b/server/src/project/project.service.ts @@ -30,6 +30,8 @@ import { containsString, equalsAnyOf, containsAnyOf, + startsWithAnyOf, + startsWithString, overwriteCodesWithLabels } from "../crm/crm.utilities"; import { ArtifactService } from "../artifact/artifact.service"; @@ -214,8 +216,9 @@ const QUERY_TEMPLATES = { } ) ), - blocks_in_radius: queryParamValue => - containsAnyOf("dcp_validatedblock", queryParamValue, { + + blocks_in_radius: queryParamValue => + startsWithAnyOf("dcp_bblnumber", queryParamValue, { childEntity: "dcp_dcp_project_dcp_projectbbl_project" }) }; @@ -232,11 +235,11 @@ export const ALLOWED_FILTERS = [ "dcp_publicstatus", // 'Noticed', 'Filed', 'In Public Review', 'Completed', 'Unknown' "dcp_certifiedreferred", "project_applicant_text", - "block", "distance_from_point", "radius_from_point", "zoning-resolutions", - "dcp_applicability" + "dcp_applicability", + "blocks_in_radius", ]; export const generateFromTemplate = (query, template) => { @@ -247,11 +250,6 @@ export const generateFromTemplate = (query, template) => { }; function generateProjectsFilterString(query) { - // Special handling for 'block' query, which must be explicitly ignored if empty - // otherwise, unmapped projects will be excluded from the results - if (!query.block) delete query.block; - - // optional params // apply only those that appear in the query object const requestedFiltersQuery = generateFromTemplate(query, QUERY_TEMPLATES); return all( @@ -263,7 +261,7 @@ function generateProjectsFilterString(query) { ), // optional params ...requestedFiltersQuery - ); + );; } function generateQueryObject(query, overrides?) { @@ -583,11 +581,7 @@ export class ProjectService { return this.serialize(transformedProject); } - async blocksWithinRadius(query) { - let { distance_from_point, radius_from_point } = query; - - if (!distance_from_point || !radius_from_point) return {}; - + async blocksWithinRadius(distance_from_point, radius_from_point) { // search cannot support more than 1000 because of URI Too Large errors // if (radius_from_point > 1000) radius_from_point = 1000; @@ -601,31 +595,36 @@ export class ProjectService { } async queryProjects(query, itemsPerPage = ITEMS_PER_PAGE) { - const blocks = await this.blocksWithinRadius(query); - - // adds in the blocks filter for use across various query types - const normalizedQuery = { + const radiusFilterOn = query.radius_from_point && query.distance_from_point; + const blocks = radiusFilterOn ? await this.blocksWithinRadius(query.distance_from_point, query.radius_from_point) : []; + + const normalizedQuery = blocks.length == 0 ? {...query} : { blocks_in_radius: blocks, ...query - - // this information is sent as separate filters but must be represented as one - // to work correctly with the query template system. - // ...blocks }; const queryObject = generateQueryObject(normalizedQuery); const spatialInfo = await this.geometryService.createAnonymousMapWithFilters( normalizedQuery ); + + // Return empty projects when radius filter is on and Carto returns 0 blocks + // otherwise, send the OData query const { records: projects, skipTokenParams: nextPageSkipTokenParams, count - } = await this.crmService.queryFromObject( - "dcp_projects", - queryObject, - itemsPerPage - ); + } = blocks.length == 0 && radiusFilterOn ? + { + records: [], + skipTokenParams: undefined, + count: 0 + } : + await this.crmService.queryFromObject( + "dcp_projects", + queryObject, + itemsPerPage + ); const valueMappedRecords = overwriteCodesWithLabels( projects,