Skip to content

Commit

Permalink
feat(Criteria Builder): Add location radius option to the criteria co…
Browse files Browse the repository at this point in the history
…ndition builder (#1573)

* feat(Criteria Builder): Add location radius option to the address criteria

* Explicit dirtying of form

* Updated demo

* Enable form updates

* Updated Demo

* Fix CSS for dropdown

* Listen to updates to the radius

* Fix an error and unit tests

* Update example

---------

Co-authored-by: Nathan Dickerson <[email protected]>
  • Loading branch information
ndickerson and Nathan Dickerson authored Aug 2, 2024
1 parent 3f915b5 commit f84172d
Show file tree
Hide file tree
Showing 15 changed files with 414 additions and 86 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -260,4 +260,5 @@ export enum AdaptiveOperator {
Like = 'like',
StartsWith = 'startsWith',
EndsWith = 'endsWith',
Radius = 'radius',
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { BasePickerResults } from 'novo-elements/elements/picker';
import { GlobalRef } from 'novo-elements/services';
import { Key } from 'novo-elements/utils';
import { Observable } from 'rxjs';
import { GooglePlacesService } from './places.service';

export interface Settings {
Expand Down Expand Up @@ -424,4 +425,9 @@ export class PlacesListComponent extends BasePickerResults implements OnInit, On
}
}
}

search(term, mode?): Observable<any> {
// Disable the base search term functionality here since it is handled by the places picker separately
return new Observable();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
<ng-content></ng-content>
</form>

<novo-condition-templates *ngIf="isConditionHost"></novo-condition-templates>
<novo-condition-templates *ngIf="isConditionHost" [addressConfig]="addressConfig"/>

<!-- EMPTY STATE TEMPLATE -->
<!-- <ng-template #empty>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@
}
novo-field {
width: fit-content;

&.address-radius {
width: 100px;
min-width: 100px;
max-width: 100px;
margin-right: 1rem;
novo-select {
min-width: 70px;
}
}
}
.novo-field-infix {
white-space: nowrap;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { BaseConditionFieldDef } from '../query-builder.directives';
import { QueryBuilderConfig, QueryBuilderService } from '../query-builder.service';
import { NOVO_CONDITION_BUILDER } from '../query-builder.tokens';
import { BaseFieldDef, FieldConfig, QueryFilterOutlet } from '../query-builder.types';
import { AddressCriteriaConfig, BaseFieldDef, FieldConfig, QueryFilterOutlet } from '../query-builder.types';

/**
* Provides a handle for the table to grab the view container's ng-container to insert data rows.
Expand Down Expand Up @@ -72,6 +72,7 @@ export class ConditionBuilderComponent implements OnInit, OnChanges, AfterConten
isFirst = input(false);
@Input() andIndex: number;
@Input() groupIndex: number;
@Input() addressConfig: AddressCriteriaConfig;

// This component can either be directly hosted as a host to a condition, or it can be part of a condition group within a criteria builder.
// In the former case, config will come from inputs, and we will instantiate our own QueryBuilderService. In the latter, it comes from
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
import { ChangeDetectionStrategy, Component, ElementRef, QueryList, ViewChild, ViewChildren, ViewEncapsulation } from '@angular/core';
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
input,
InputSignal,
OnDestroy,
QueryList,
Signal,
signal,
ViewChild,
ViewChildren,
ViewEncapsulation,
WritableSignal
} from '@angular/core';
import { AbstractControl } from '@angular/forms';
import { NovoPickerToggleElement } from 'novo-elements/elements/field';
import { PlacesListComponent } from 'novo-elements/elements/places';
import { NovoLabelService } from 'novo-elements/services';
import { Key } from 'novo-elements/utils';
import { Helpers, Key } from 'novo-elements/utils';
import { Subscription } from 'rxjs';
import { AddressCriteriaConfig, AddressData, AddressRadius, AddressRadiusUnitsName, Operator, RadiusUnits } from '../query-builder.types';
import { AbstractConditionFieldDef } from './abstract-condition.definition';
import { Operator } from '../query-builder.types';

/**
* Handle selection of field values when a list of options is provided.
Expand All @@ -18,47 +35,106 @@ import { Operator } from '../query-builder.types';
<novo-select [placeholder]="labels.operator" formControlName="operator" (onSelect)="onOperatorSelect(formGroup)">
<novo-option value="includeAny">{{ labels.includeAny }}</novo-option>
<novo-option value="excludeAny">{{ labels.exclude }}</novo-option>
<novo-option value="radius" *ngIf="radiusEnabled()">{{ labels.radius }}</novo-option>
</novo-select>
</novo-field>
<ng-container *novoConditionInputDef="let formGroup; viewIndex as viewIndex; fieldMeta as meta" [ngSwitch]="formGroup.value.operator" [formGroup]="formGroup">
<novo-field *novoSwitchCases="['includeAny', 'excludeAny']" #novoField>
<novo-chip-list [(ngModel)]="chipListModel" [ngModelOptions]="{ standalone: true }" (click)="openPlacesList(viewIndex)">
<novo-chip *ngFor="let item of formGroup.get('value').value" (removed)="remove(item, formGroup, viewIndex)">
<novo-text ellipsis>{{ item.formatted_address }}</novo-text>
<novo-icon novoChipRemove>close</novo-icon>
</novo-chip>
<input
novoChipInput
[id]="viewIndex"
[placeholder]="labels.location"
(keyup)="onKeyup($event, viewIndex)"
(keydown)="onKeydown($event, viewIndex)"
[picker]="placesPicker"
#addressInput />
</novo-chip-list>
<novo-picker-toggle [overlayId]="viewIndex" icon="location" novoSuffix>
<google-places-list [term]="term" (select)="selectPlace($event, formGroup, viewIndex)" formControlName="value" #placesPicker></google-places-list>
</novo-picker-toggle>
</novo-field>
<ng-container *novoConditionInputDef="let formGroup; viewIndex as viewIndex; fieldMeta as meta" [formGroup]="formGroup">
<novo-flex justify="space-between" align="end">
<novo-field #novoRadiusField *ngIf="formGroup.value.operator === 'radius'" class="address-radius">
<novo-select
#radiusSelect [placeholder]="labels.radius"
(onSelect)="onRadiusSelect(formGroup, $event.selected)"
[value]="radius()"
[options]="radiusOptions()">
</novo-select>
</novo-field>
<novo-field #novoField class="address-location">
<novo-chip-list [(ngModel)]="chipListModel" [ngModelOptions]="{ standalone: true }" (click)="openPlacesList(viewIndex)">
<novo-chip *ngFor="let item of formGroup.get('value').value" (removed)="remove(item, formGroup, viewIndex)">
<novo-text ellipsis>{{ item.formatted_address }}</novo-text>
<novo-icon novoChipRemove>close</novo-icon>
</novo-chip>
<input
novoChipInput
[id]="viewIndex"
[placeholder]="labels.location"
(keyup)="onKeyup($event, viewIndex)"
(keydown)="onKeydown($event, viewIndex)"
[picker]="placesPicker"
#addressInput/>
</novo-chip-list>
<novo-picker-toggle [overlayId]="viewIndex" icon="location" novoSuffix>
<google-places-list
[term]="term"
(select)="selectPlace($event, formGroup, viewIndex)"
formControlName="value"
#placesPicker/>
</novo-picker-toggle>
</novo-field>
</novo-flex>
</ng-container>
</ng-container>
`,
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.Default,
})
export class NovoDefaultAddressConditionDef extends AbstractConditionFieldDef {
export class NovoDefaultAddressConditionDef extends AbstractConditionFieldDef implements AfterViewInit, OnDestroy {
@ViewChildren(NovoPickerToggleElement) overlayChildren: QueryList<NovoPickerToggleElement>;
@ViewChildren('addressInput') inputChildren: QueryList<ElementRef>;
@ViewChild('placesPicker') placesPicker: PlacesListComponent;

// Static defaults
radiusValues: number[] = [5, 10, 20, 30, 40, 50, 100];
defaultRadius: number = 30;

// Overridable defaults
defaults: AddressCriteriaConfig = {
radiusEnabled: false,
radiusUnits: 'miles',
};
config: InputSignal<AddressCriteriaConfig> = input();
radiusUnits: Signal<AddressRadiusUnitsName> = computed(() =>
this.config()?.radiusUnits || this.defaults.radiusUnits
);
radiusEnabled: Signal<boolean> = computed(() =>
this.config()?.radiusEnabled || this.defaults.radiusEnabled
);

radius: WritableSignal<number> = signal(this.defaultRadius);
radiusOptions: Signal<{ label: string; value: number; }[]> = computed(() => {
const unitsLabel = this.radiusUnits() === RadiusUnits.miles ? this.labels.miles : this.labels.km;
return this.radiusValues.map(value => ({
label: `${value.toString()} ${unitsLabel}`,
value,
}));
});

defaultOperator = Operator.includeAny;
chipListModel: any = '';
term: string = '';

private _addressChangesSubscription: Subscription = Subscription.EMPTY;

constructor(public element: ElementRef, public labels: NovoLabelService) {
super(labels);
}

ngAfterViewInit() {
setTimeout(() => {
// Initialize the radius value from existing data
this.assignRadiusFromValue();

// Update the radius on address value changes
this._addressChangesSubscription = this.inputChildren.changes.subscribe(() => {
this.assignRadiusFromValue();
})
});
}

ngOnDestroy() {
this._addressChangesSubscription.unsubscribe();
}

onKeyup(event, viewIndex) {
if (![Key.Escape, Key.Enter].includes(event.key)) {
this.openPlacesList(viewIndex);
Expand All @@ -78,7 +154,7 @@ export class NovoDefaultAddressConditionDef extends AbstractConditionFieldDef {
}
}

getValue(formGroup: AbstractControl): any[] {
getValue(formGroup: AbstractControl): AddressData[] {
return formGroup.value.value || [];
}

Expand All @@ -99,26 +175,24 @@ export class NovoDefaultAddressConditionDef extends AbstractConditionFieldDef {
}

selectPlace(event: any, formGroup: AbstractControl, viewIndex: string): void {
const valueToAdd = {
const valueToAdd: AddressData = {
address_components: event.address_components,
formatted_address: event.formatted_address,
geometry: event.geometry,
place_id: event.place_id,
};
const current = this.getValue(formGroup);
if (!Array.isArray(current)) {
formGroup.get('value').setValue([valueToAdd]);
} else {
formGroup.get('value').setValue([...current, valueToAdd]);
}
const current: AddressData | AddressData[] = this.getValue(formGroup);
const updated: AddressData[] = Array.isArray(current) ? [...current, valueToAdd] : [valueToAdd];
formGroup.get('value').setValue(this.updateRadiusInValues(formGroup, updated));

this.inputChildren.forEach(input => {
input.nativeElement.value = '';
})
this.getCurrentInput(viewIndex)?.nativeElement.focus();
this.closePlacesList(viewIndex);
}

remove(valueToRemove: any, formGroup: AbstractControl, viewIndex: string): void {
remove(valueToRemove: AddressData, formGroup: AbstractControl, viewIndex: string): void {
const current = this.getValue(formGroup);
const index = current.indexOf(valueToRemove);
if (index >= 0) {
Expand All @@ -128,4 +202,43 @@ export class NovoDefaultAddressConditionDef extends AbstractConditionFieldDef {
}
this.closePlacesList(viewIndex);
}

onRadiusSelect(formGroup: AbstractControl, radius: number): void {
this.radius.set(radius);
// We must dirty the form explicitly to show up as a user modification when it was done programmatically
formGroup.get('value').setValue(this.updateRadiusInValues(formGroup, this.getValue(formGroup)));
formGroup.markAsDirty();
}

private assignRadiusFromValue() {
if (this.placesPicker?.model?.length) {
const addressData: AddressData = this.placesPicker.model[0];
const initialRadius = addressData.radius?.value;
if (initialRadius && Helpers.isNumber(initialRadius)) {
this.radius.set(initialRadius);
}
}
}

private updateRadiusInValues(formGroup: AbstractControl, values: AddressData[]): AddressData[] {
return values.map(val => ({
...val,
radius: this.isRadiusOperatorSelected(formGroup) ? this.getRadiusData(formGroup) : undefined,
}));
}

private getRadiusData(formGroup: AbstractControl): AddressRadius {
return {
value: this.getRadius(formGroup),
units: this.radiusUnits(),
};
}

private getRadius(formGroup: AbstractControl): number | undefined {
return this.isRadiusOperatorSelected(formGroup) ? this.radius() : undefined;
}

private isRadiusOperatorSelected(formGroup: AbstractControl): boolean {
return formGroup.get('operator').value === 'radius';
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
<novo-id-condition-def name="ID"></novo-id-condition-def>
<novo-date-condition-def name="DATE"></novo-date-condition-def>
<novo-date-time-condition-def name="TIMESTAMP"></novo-date-time-condition-def>
<novo-string-condition-def name="STRING"></novo-string-condition-def>
<novo-number-condition-def name="FLOAT"></novo-number-condition-def>
<novo-number-condition-def name="INTEGER"></novo-number-condition-def>
<novo-number-condition-def name="BIGDECIMAL"></novo-number-condition-def>
<novo-number-condition-def name="DOUBLE"></novo-number-condition-def>
<novo-address-condition-def name="ADDRESS"></novo-address-condition-def>
<novo-boolean-condition-def name="BOOLEAN"></novo-boolean-condition-def>
<novo-picker-condition-def name="SELECT"></novo-picker-condition-def>
<novo-string-condition-def name="DEFAULT"></novo-string-condition-def>
<novo-id-condition-def name="ID"/>
<novo-date-condition-def name="DATE"/>
<novo-date-time-condition-def name="TIMESTAMP"/>
<novo-string-condition-def name="STRING"/>
<novo-number-condition-def name="FLOAT"/>
<novo-number-condition-def name="INTEGER"/>
<novo-number-condition-def name="BIGDECIMAL"/>
<novo-number-condition-def name="DOUBLE"/>
<novo-address-condition-def name="ADDRESS" [config]="addressConfig"/>
<novo-boolean-condition-def name="BOOLEAN"/>
<novo-picker-condition-def name="SELECT"/>
<novo-string-condition-def name="DEFAULT"/>
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { Component } from '@angular/core';
import { Component, Input } from '@angular/core';
import { AddressCriteriaConfig } from '../query-builder.types';

@Component({
selector: 'novo-condition-templates',
templateUrl: './condition-templates.component.html'
})
export class NovoConditionTemplatesComponent {
}
@Input() addressConfig: AddressCriteriaConfig;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@
<novo-stack [formArrayName]="controlName" class="criteria-builder-inner">
<ng-container
*ngFor="let andGroup of root.controls; let andIndex = index; let isFirst = first;let isLastAnd = last;">
<novo-label *ngIf="!isFirst" color="ash" size="xs" uppercase padding="sm">{{qbs.getConjunctionLabel('and')}}
<novo-label *ngIf="!isFirst" color="ash" size="xs" uppercase padding="sm">{{ qbs.getConjunctionLabel('and') }}
</novo-label>
<novo-condition-group [groupIndex]="andIndex" [formGroupName]="andIndex"></novo-condition-group>
</ng-container>
</novo-stack>
</form>
<novo-condition-templates></novo-condition-templates>
<novo-condition-templates [addressConfig]="addressConfig"/>

<!--
<!--
{
$and: [{
$or: [{
Expand All @@ -21,4 +21,4 @@
}]
}]
}
-->
-->
Loading

0 comments on commit f84172d

Please sign in to comment.