skynet2 / ngx-google-places-autocomplete

Google Places autocomplete for angular web project
MIT License
92 stars 76 forks source link

There is no support for Angular 16! #118

Open psiegers opened 1 year ago

psiegers commented 1 year ago

Our web application uses google recaptcha which was developed using Angular 14. When we were close to the go-live our client tested it on several real devices, one of them is an iPhone 11 Pro Max with iOS 16.3.1 installed. He reports having problems filling in some form fields. We could replicate these in a Browserstack Live session on a real device of the same model. Turns out that we needed to update Angular and Material from version 14 to 16 to resolve those issues. So we first decided to upgrade to Angular 15 first including Material. We did this without too many problems and used Browserstack Live again and the problems were still there for iOS versions 16. So then we tried to upgrade to Angular / Material 16 but that didn't work out well, as ng-recaptcha reported an issue, this we could fix to upgrade to version 12, but then there was another issue: ngx-google-places-autocomplete did not support Angular 16. Looks like this package is not updated since a couple of years - is it still active? So, the big Q is here, will there be support for Angular 16? Or should we completely steer away from using Google recaptcha?

lana-white commented 1 year ago

Please update google places to be compatible with Angular 16!

lana-white commented 1 year ago

I have literally written my own directive for this in about 30 mins and it works fine! Couldn't find a suitable solution as don't use Angular Material for anything.

mfsi-samu commented 1 year ago

@lana-white can you share your solution of ngx-google-places-autocomplete for angular16.

lana-white commented 1 year ago

@mfsi-samu, I've just made my own. I couldn't find anything/any plugin suitable. I last tried Angular Magic's (ngx-gp-autocomplete), but kept getting errors with autocomplete being undefined when calling reset(). So i based my code off of this, but changed it drastically. This utilises the @types/google.maps library.

This is the Directive:

/// <reference types="@types/google.maps" />
import { Directive, ElementRef, OnInit, Output, EventEmitter, NgZone, Input, Injectable } from '@angular/core';

@Injectable({providedIn: 'root'})
@Directive({
    selector: '[google-places]',
    exportAs: 'ngx-google-places'
})
export class NgxGooglePlacesDirective implements OnInit {
    public element: HTMLInputElement;
    autocomplete?: google.maps.places.Autocomplete;
    eventListener?: google.maps.MapsEventListener;
    ngZone:NgZone;
    place?: google.maps.places.PlaceResult;
    @Output() onAddressChange: EventEmitter<any> = new EventEmitter();
    @Input() options?: google.maps.places.AutocompleteOptions;

    constructor(elRef: ElementRef, ngZone: NgZone) {
        //elRef will get a reference to the element where
        //the directive is placed
        this.element = elRef.nativeElement;
        this.ngZone = ngZone;
    }

    ngOnInit() {
        this.autocomplete = new google.maps.places.Autocomplete(this.element);
        //Event listener to monitor place changes in the input
        if (this.autocomplete.addListener !== null)
            this.eventListener = google.maps.event.addListener(this.autocomplete, 'place_changed', () => {
                this.handleChangeEvent();
        });
    }

    reset() {
        this.autocomplete?.setComponentRestrictions(this.options?.componentRestrictions || null);
        this.autocomplete?.setTypes(this.options?.types || []);
    }

    handleChangeEvent() {
        this.ngZone.run(() => {
            this.place = this.autocomplete?.getPlace();
            if (this.place) {
                this.onAddressChange.emit(this.place);
            }
        });
    }
}

This is my component (which handles the formatting etc):

import { AfterViewInit, Component, ElementRef, forwardRef, Input, isDevMode, OnInit, ViewChild } from '@angular/core';
import { AbstractControl, ControlContainer, ControlValueAccessor, FormControl, FormControlDirective, FormGroup, FormGroupDirective, NG_VALUE_ACCESSOR } from '@angular/forms';
import { map } from 'rxjs';
import { IAddressFields } from '../../models/data/form/address-fields.model';
import { IFormField } from '../../models/data/form/form-field.model';
import { filterByFieldId, getAddressFieldsByStreetId, getCountryFieldIdByStreet } from '../../utils/form-utils';
import { htmlDecode } from '../../utils/utils';

import { NgxGooglePlacesDirective } from '../../directives/ngx-google-places.directive';

@Component({
    selector: 'nova-form-field-address-block',
    template: `    
        <div class="form-floating">
              <input 
                google-places
                #placesRef="ngx-google-places"
                [options]="options"
                (change)="callbackFunction ? callbackFunction!($event) : null"
                (onAddressChange)="handleAddressChange($event)"
                type="text" 
                [id]="field.id" 
                [name]="field.id" 
                [placeholder]="lookupPlaceholderText" 
                [formControlName]="controlName" 
                class="form-control has-subtext" 
                [attr.aria-describedby]="field.id + 'Feedback'"/>                        
            <label [for]="field.id" [innerHtml]="((field.id == -20 ? 'Billing ' : field.id === -27 ? 'Shipping ' : '') + 'Address' + (field.isRequired ? ' *' : '')  | safeHtml)"></label>
             <div class="subtext">
                <p class='p-small'>Can&apos;t find your address&quest; 
                    <a (click)="toggleAddrFields($event)">Enter in manually<i [ngClass]="{'fa-chevron-down': !addressFieldsVisible, 'fa-chevron-up': addressFieldsVisible}" class="fa-solid fw-bold ms-1 text-decoration-none text-link fa-2x "></i>
                    </a>
            </p>
             </div>
            <nova-form-validation [cName]="controlName" [fGroup]="formGroup" [field]="field"></nova-form-validation> 
        </div>
    `,
    viewProviders: [{ provide: ControlContainer, useExisting: FormGroupDirective }]
})

export class FormFieldAddressBlockComponent implements OnInit, AfterViewInit {
    @Input() field!: IFormField;
    @Input() formGroup!: FormGroup;
    @Input() controlName!: string;
    @Input() data?: any;
    @Input() callbackFunction?: (args: any) => void;
    @Input() callbackFunctionFocus?: (args: any) => void;

    addressObj: IAddressFields = {} as IAddressFields;
    lookupControl!: FormControl;
    street1Control!: FormControl;
    street2Control?: FormControl;
    suburbControl!: FormControl;
    stateControl!: FormControl;
    postcodeControl!: FormControl;
    countryControl!: FormControl;

    lookupField!: IFormField;
    street1Field!: IFormField;
    street2Field?: IFormField;
    suburbField!: IFormField;
    stateField!: IFormField;
    postcodeField!: IFormField;
    countryField!: IFormField;

    backupPlaceholderText: string = 'Start typing your address...';
    lookupPlaceholderText: string = 'Start typing your address...';
    addressFieldsVisible:boolean = false;

    //Set to false if not wanting to have addr subfields update lookup field on blur
    updateLookupField: boolean = true;

    @ViewChild("placesRef") placesRef?: NgxGooglePlacesDirective;
    options!: google.maps.places.AutocompleteOptions;
    constructor() {         
    }

    ngOnInit(): void {

        this.options = {
            componentRestrictions: {
                country: this.formGroup.get(getCountryFieldIdByStreet(this.field.id).toString())!.value,
            },

            types: [],
            //Restrict to basic data fields            
            fields: ['address_component', 'adr_address', 'business_status', 'formatted_address', 'geometry', 'icon', 'icon_mask_base_uri', 'icon_background_color', 'name', 'photo', 'place_id', 'plus_code', 'type', 'url', 'utc_offset_minutes', 'vicinity', 'wheelchair_accessible_entrance']
        };

    }

    ngAfterViewInit(): void {
        // this.ngxGpAutocompleteService.setOptions(this.options);
        this.addressObj = getAddressFieldsByStreetId(this.field.id);

        if (this.addressObj){
            if (this.addressObj.address) {
                this.lookupControl = this.formGroup.get(this.addressObj.address.toString()) as FormControl;
                this.lookupField = filterByFieldId(this.data.form, this.addressObj.address, false) as IFormField;
            }
            if (this.addressObj.street1) {                
                this.street1Control = this.formGroup.get(this.addressObj.street1.toString()) as FormControl;
                this.street1Field = filterByFieldId(this.data.form, this.addressObj.street1, false) as IFormField;              
            }
            if (this.addressObj.street2) {
                this.street2Control = this.formGroup.get(this.addressObj.street2.toString()) as FormControl;
                this.street2Field = filterByFieldId(this.data.form, this.addressObj.street2, false) as IFormField;
            }
            if (this.addressObj.suburb) {
                this.suburbControl = this.formGroup.get(this.addressObj.suburb.toString()) as FormControl;
                this.suburbField = filterByFieldId(this.data.form, this.addressObj.suburb, false) as IFormField;
            }
            if (this.addressObj.state) {
                this.stateControl = this.formGroup.get(this.addressObj.state.toString()) as FormControl;
                this.stateField = filterByFieldId(this.data.form, this.addressObj.state, false) as IFormField;
            }
            if (this.addressObj.postcode) {
                this.postcodeControl = this.formGroup.get(this.addressObj.postcode.toString()) as FormControl;
                this.postcodeField = filterByFieldId(this.data.form, this.addressObj.postcode, false) as IFormField;
            }          
            if (this.addressObj.country) {
                this.countryControl = this.formGroup.get(this.addressObj.country.toString()) as FormControl;
                this.countryField = filterByFieldId(this.data.form, this.addressObj.country, false) as IFormField;
            }          
        }                

        if (this.placesRef) {
            this.placesRef.options = this.options;
        }               

        this.formGroup.get(getCountryFieldIdByStreet(this.field.id).toString())?.valueChanges
            .pipe(
                map(c => c)
            )
            .subscribe({
                next: (value) => {
                    if (this.placesRef && this.placesRef.options) {
                        if (isDevMode()) {
                            console.log('new country!', value, 'current country:', this.placesRef?.options.componentRestrictions?.country);
                        }
                        if (this.options?.componentRestrictions && value && this.placesRef) {

                            if (this.placesRef.options.componentRestrictions?.country !== value) {
                                this.options.componentRestrictions.country = value;
                                this.placesRef.options.componentRestrictions!.country = value;

                                this.placesRef.reset();
                                this.resetAddrFields();

                            }
                        }
                    }

                }
            })

    }

    handleAddressChange(address: google.maps.places.PlaceResult | any) {
        if(isDevMode()) console.log('handle addr change', address);
        let addr = address.adr_address;
        if (address.adr_address && addr && address) {
            //Get Subpremise, street_number and route
            let street1Formatted = address.adr_address?.split('locality')[0];
            //Replace span tags from address
            street1Formatted = htmlDecode(street1Formatted);
            // street1Formatted = street1Formatted.replace(/<[^>]*>?/gm, '');
            //Remove last comma in street1 address
            street1Formatted = street1Formatted.replace(/(^\s*,)|(,\s*$)/g, '');
            //Remove all HTML tags
            let result = htmlDecode(addr);
            // let result = addr.replace(/<[^>]*>?/gm, '');
            this.formGroup.get(this.controlName)?.setValue(result, { emitEvent: false });
            this.fillInAddress(address, street1Formatted);
        }

    }
    addrFieldsValid() {
        return this.street1Control.valid && this.street2Control?.valid && this.suburbControl.valid && this.stateControl.valid && this.postcodeControl.valid;
    }

    public toggleAddrFields(e?: any) {
        if (this.street1Field.hide) {
            if (this.updateLookupField) {
                this.detectManualAddressChange();
            }
            else {
                this.lookupControl.setValue('', { emitEvent: false });
                this.lookupPlaceholderText = this.backupPlaceholderText;
                this.lookupControl.disable({ emitEvent: false });
            }

            this.addressFieldsVisible = true;
            this.street1Field.hide = false;
            if (this.street2Field) this.street2Field.hide = false;
            this.suburbField.hide = false;
            this.postcodeField.hide = false;
            this.stateField.hide = false;
            this.countryField.hide = false;

        }
        else {
            if (this.updateLookupField) {

            }
            else {
                this.lookupPlaceholderText = '';
                this.resetAddrFields();
                this.lookupControl.enable({ emitEvent: false });
                this.lookupControl.updateValueAndValidity();
            }

            if ((this.addrFieldsValid() && this.updateLookupField) || !this.updateLookupField) {
                this.addressFieldsVisible = false;
                this.street1Field.hide = true;
                if (this.street2Field) this.street2Field.hide = true;
                this.suburbField.hide = true;
                this.postcodeField.hide = true;
                this.stateField.hide = true;
                this.countryField.hide = true;
            }
        }       
    }

    resetAddrFields() {               
        Object.entries(this.addressObj).forEach(([key, value]) => {
            let fc;
            fc = this.formGroup.get(value.toString());
            if (value !== this.addressObj.country) {
                fc?.setValue('', { emitEvent: false });

                fc?.updateValueAndValidity();      
            }                  
        });        
    }

    fillInAddress(address: google.maps.places.PlaceResult, street1Formatted: any) {
        // Get the place details from the autocomplete object.
        if (address && address.address_components) {
            const place = address;

            let street1 = "";
            let street2 = "";
            let suburb = "";
            let state = "";
            let postcode = "";
            //Not requiring Long/Lat at this stage. add to AddrObj etc if needed
            let locationLong = "";
            let locationLat = "";

            // Get each component of the address from the place details,
            // and then fill-in the corresponding field on the form.
            // place.address_components are google.maps.GeocoderAddressComponent objects
            // which are documented at http://goo.gle/3l5i5Mr
            for (const component of place.address_components!) {
                const componentType = component.types[0];

                switch (componentType) {
                    //Using formatted subpremise/street1address from result.addr_address for street1 field instead
                    // case "subpremise":
                    //     {
                    //         street1 = `${component.long_name}/`;
                    //         break;
                    //     }
                    // case "street_number":
                    // case "route": {
                    //     console.log('street_number:', component.long_name);
                    //     console.log('-----');
                    //     if (street1.length > 0) { street1 += " "; }
                    //     street1 += `${component.long_name}`;                    
                    //     break;
                    // }
                    case "route": {
                        street1 += `${(street1 ? ' ' : '')}${component.short_name}`;
                        break;
                    }
                    case "administrative_area_level_1":
                        state += component.short_name;
                        break;
                    case "postal_code": {
                        postcode = `${component.long_name}${postcode}`;
                        break;
                    }

                    case "postal_code_suffix": {
                        //Only really applies to US postcodes
                        postcode = `${postcode}-${component.long_name}`;
                        break;
                    }
                    case "sublocality_level_1":
                    case "locality":
                        //Allow for some suburbs not listed as locality (e.g. 876 McDonald Ave, Brooklyn in USA)
                        suburb = component.long_name;
                        break;
                }
            }

            this.street1Control.setValue(street1Formatted, { emitEvent: false });
            this.suburbControl.setValue(suburb, { emitEvent: false });
            this.stateControl.setValue(state, { emitEvent: false });
            this.postcodeControl.setValue(postcode, { emitEvent: false });
            //If required fields aren't filled in from lookup, then show addr fields and don't alow fields to hide.
            if (!this.addrFieldsValid() && this.updateLookupField) {
                this.toggleAddrFields();
            }     
        }

    }  
    concatAddr(st1:any, st2:any, suburb:any, state:any, postcode:any) {
        var fullAddr = '';
        fullAddr = st1 ? st1 + ', ' : '';
        fullAddr += st2 ? st2 + ', ' : '';
        fullAddr += suburb ? suburb + ', ' : '';
        fullAddr += state ? state + ' ' : '';
        fullAddr += postcode ? postcode + ', ' : '';
        fullAddr += this.countryControl.value;
        return fullAddr;
    }

    detectManualAddressChange() {
        let fullAddr = '';     
        var fieldElems:HTMLInputElement[] = [];
        Object.entries(this.addressObj).forEach(([key, value]) => {
            if (value !== this.addressObj.country) {
                let field: HTMLInputElement = document.getElementById(value.toString()) as HTMLInputElement;
                fieldElems.push(field);               
            }

        }); 
        fieldElems.forEach(field => {            
            field.addEventListener('blur', (e: any) => {               
                switch (Number(field.id)) {
                    case this.addressObj.street1: {
                        fullAddr = this.concatAddr(e?.target?.value, this.street2Control?.value, this.suburbControl.value, this.stateControl.value, this.postcodeControl.value);
                        break;
                    }
                    case this.addressObj.street2: {
                        fullAddr = this.concatAddr(this.street1Control?.value, e?.target?.value, this.suburbControl.value, this.stateControl.value, this.postcodeControl.value);
                        break;
                    }
                    case this.addressObj.suburb: {
                        fullAddr = this.concatAddr(this.street1Control?.value, this.street2Control?.value, e?.target?.value, this.stateControl.value, this.postcodeControl.value);
                        break;
                    }
                    case this.addressObj.state: {
                        fullAddr = this.concatAddr(this.street1Control?.value, this.street2Control?.value, this.suburbControl.value, e?.target?.value, this.postcodeControl.value);
                        break;
                    }
                    case this.addressObj.postcode: {
                        fullAddr = this.concatAddr(this.street1Control?.value, this.street2Control?.value, this.suburbControl.value, this.stateControl.value, e?.target?.value);
                        break;
                    }
                }
                this.lookupControl.setValue(fullAddr, {emitEvent: false})
            });
        });        
    }
}
dancornilov commented 1 year ago

Because of inactivity of this package, I created my own package which is fully compatible with this one, code base was used from this package and adjusted to new coding standards, is Angular 16 compatible.

You can try: https://www.npmjs.com/package/@angular-magic/ngx-gp-autocomplete

psiegers commented 1 year ago

I was on holiday, but anyways, thanks for the posts! I didn't actually have the time (then) to wait for any useful answer so we decided to implement an autosuggest input control based on some Material components, and the use of the Bing Maps API (specifically the Locations API at https://learn.microsoft.com/en-us/bingmaps/rest-services/locations/). With this approach I was able to create a similar UX with a back-end API call that did the same and did not depend on any other package. It only does require you to register to get a key. I'm unable to share the code as I rolled off the project before leaving - sorry for that - but it can be done without too many effort.

tithidutta commented 5 months ago

Because of inactivity of this package, I created my own package which is fully compatible with this one, code base was used from this package and adjusted to new coding standards, is Angular 16 compatible.

You can try: https://www.npmjs.com/package/@angular-magic/ngx-gp-autocomplete

No directive found with exportAs 'ngx-places'.I got this error please help. One more thing do u need ngx-google-places-autocomplete package too??

In HTML: <input nz-input placeholder="Search Location " type="text" formControlName="line2"

placesRef="ngx-places"

                            ngx-gp-autocomplete
                            (onAddressChange)="handleAddressChange($event,i)"
                          />

In ts:
@ViewChild('ngxPlaces') placesRef!: NgxGpAutocompleteDirective;