UI: Phone input with country flag select
This commit is contained in:
		
							parent
							
								
									19a953428e
								
							
						
					
					
						commit
						baa681634b
					
				@ -62,6 +62,7 @@
 | 
			
		||||
    "leaflet-providers": "^1.13.0",
 | 
			
		||||
    "leaflet.gridlayer.googlemutant": "^0.13.4",
 | 
			
		||||
    "leaflet.markercluster": "^1.5.3",
 | 
			
		||||
    "libphonenumber-js": "^1.10.4",
 | 
			
		||||
    "messageformat": "^2.3.0",
 | 
			
		||||
    "moment": "^2.29.1",
 | 
			
		||||
    "moment-timezone": "^0.5.34",
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										51
									
								
								ui-ngx/src/app/shared/components/phone-input.component.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								ui-ngx/src/app/shared/components/phone-input.component.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,51 @@
 | 
			
		||||
<!--
 | 
			
		||||
 | 
			
		||||
    Copyright © 2016-2022 The Thingsboard Authors
 | 
			
		||||
 | 
			
		||||
    Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
    you may not use this file except in compliance with the License.
 | 
			
		||||
    You may obtain a copy of the License at
 | 
			
		||||
 | 
			
		||||
        http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
 | 
			
		||||
    Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
    distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
    See the License for the specific language governing permissions and
 | 
			
		||||
    limitations under the License.
 | 
			
		||||
 | 
			
		||||
-->
 | 
			
		||||
 | 
			
		||||
<form [formGroup]="phoneFormGroup">
 | 
			
		||||
  <div class="phone-input-container">
 | 
			
		||||
    <div class="flags-select-container" *ngIf="enableFlagsSelect">
 | 
			
		||||
      <span class="flag-container">{{ flagIcon }}</span>
 | 
			
		||||
      <mat-select class="country-select" formControlName="country">
 | 
			
		||||
        <mat-option *ngFor="let country of allCountries" [value]="country.iso2">
 | 
			
		||||
          <span class="country-flag">{{country.flag}}</span>
 | 
			
		||||
          <span>{{country.name + ' +' + country.dialCode }}</span>
 | 
			
		||||
        </mat-option>
 | 
			
		||||
      </mat-select>
 | 
			
		||||
    </div>
 | 
			
		||||
    <mat-form-field class="phone-input">
 | 
			
		||||
      <mat-label translate>phone-input.phone-input-label</mat-label>
 | 
			
		||||
      <input
 | 
			
		||||
        formControlName="phoneNumber"
 | 
			
		||||
        type="tel"
 | 
			
		||||
        matInput
 | 
			
		||||
        [placeholder]="phonePlaceholder"
 | 
			
		||||
        [pattern]="phoneNumberPattern"
 | 
			
		||||
        autocomplete="off"
 | 
			
		||||
        [required]="required">
 | 
			
		||||
      <mat-error *ngIf="phoneFormGroup.get('phoneNumber').hasError('required')">
 | 
			
		||||
        {{ 'phone-input.phone-input-required' | translate }}
 | 
			
		||||
      </mat-error>
 | 
			
		||||
      <mat-error *ngIf="phoneFormGroup.get('phoneNumber').hasError('invalidPhoneNumber')">
 | 
			
		||||
        {{ 'phone-input.phone-input-validation' | translate }}
 | 
			
		||||
      </mat-error>
 | 
			
		||||
      <mat-error *ngIf="phoneFormGroup.get('phoneNumber').hasError('pattern')">
 | 
			
		||||
        {{ 'phone-input.phone-input-pattern' | translate }}
 | 
			
		||||
      </mat-error>
 | 
			
		||||
    </mat-form-field>
 | 
			
		||||
  </div>
 | 
			
		||||
</form>
 | 
			
		||||
							
								
								
									
										60
									
								
								ui-ngx/src/app/shared/components/phone-input.component.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								ui-ngx/src/app/shared/components/phone-input.component.scss
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,60 @@
 | 
			
		||||
/**
 | 
			
		||||
 * Copyright © 2016-2022 The Thingsboard Authors
 | 
			
		||||
 *
 | 
			
		||||
 * Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
 * you may not use this file except in compliance with the License.
 | 
			
		||||
 * You may obtain a copy of the License at
 | 
			
		||||
 *
 | 
			
		||||
 *     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
 *
 | 
			
		||||
 * Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
 * distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
 * See the License for the specific language governing permissions and
 | 
			
		||||
 * limitations under the License.
 | 
			
		||||
 */
 | 
			
		||||
:host ::ng-deep {
 | 
			
		||||
 | 
			
		||||
  .phone-input-container {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
 | 
			
		||||
    .phone-input {
 | 
			
		||||
      width: 100%;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .flags-select-container {
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    position: relative;
 | 
			
		||||
    width: 50px;
 | 
			
		||||
    height: 100%;
 | 
			
		||||
    margin-right: 5px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .flag-container {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    font-size: 20px;
 | 
			
		||||
    top: 50%;
 | 
			
		||||
    left: 0;
 | 
			
		||||
    transform: translate(0, -50%);
 | 
			
		||||
  }
 | 
			
		||||
  .country-select {
 | 
			
		||||
    width: 45px;
 | 
			
		||||
    height: 30px;
 | 
			
		||||
 | 
			
		||||
    .country-flag {
 | 
			
		||||
      font-size: 22px;
 | 
			
		||||
      padding-right: 10px;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .mat-select-trigger {
 | 
			
		||||
      height: 100%;
 | 
			
		||||
      width: 100%;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .mat-select-value {
 | 
			
		||||
      visibility: hidden;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										215
									
								
								ui-ngx/src/app/shared/components/phone-input.component.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										215
									
								
								ui-ngx/src/app/shared/components/phone-input.component.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,215 @@
 | 
			
		||||
///
 | 
			
		||||
/// Copyright © 2016-2022 The Thingsboard Authors
 | 
			
		||||
///
 | 
			
		||||
/// Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
/// you may not use this file except in compliance with the License.
 | 
			
		||||
/// You may obtain a copy of the License at
 | 
			
		||||
///
 | 
			
		||||
///     http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
///
 | 
			
		||||
/// Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
/// distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
/// See the License for the specific language governing permissions and
 | 
			
		||||
/// limitations under the License.
 | 
			
		||||
///
 | 
			
		||||
 | 
			
		||||
import { Component, forwardRef, Input, OnInit } from '@angular/core';
 | 
			
		||||
import {
 | 
			
		||||
  ControlValueAccessor,
 | 
			
		||||
  FormBuilder,
 | 
			
		||||
  FormControl,
 | 
			
		||||
  FormGroup,
 | 
			
		||||
  NG_VALIDATORS,
 | 
			
		||||
  NG_VALUE_ACCESSOR,
 | 
			
		||||
  ValidationErrors,
 | 
			
		||||
  Validator,
 | 
			
		||||
  ValidatorFn,
 | 
			
		||||
  Validators
 | 
			
		||||
} from '@angular/forms';
 | 
			
		||||
import { TranslateService } from '@ngx-translate/core';
 | 
			
		||||
import { Country, CountryData } from '@shared/models/country.models';
 | 
			
		||||
import examples from 'libphonenumber-js/examples.mobile.json';
 | 
			
		||||
import { CountryCode, getExampleNumber, parsePhoneNumberFromString } from 'libphonenumber-js';
 | 
			
		||||
import { phoneNumberPattern } from '@shared/models/settings.models';
 | 
			
		||||
import { Subscription } from 'rxjs';
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'tb-phone-input',
 | 
			
		||||
  templateUrl: './phone-input.component.html',
 | 
			
		||||
  styleUrls: ['./phone-input.component.scss'],
 | 
			
		||||
  providers: [
 | 
			
		||||
    CountryData,
 | 
			
		||||
    {
 | 
			
		||||
      provide: NG_VALUE_ACCESSOR,
 | 
			
		||||
      useExisting: forwardRef(() => PhoneInputComponent),
 | 
			
		||||
      multi: true
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      provide: NG_VALIDATORS,
 | 
			
		||||
      useExisting: forwardRef(() => PhoneInputComponent),
 | 
			
		||||
      multi: true
 | 
			
		||||
    }
 | 
			
		||||
  ]
 | 
			
		||||
})
 | 
			
		||||
export class PhoneInputComponent implements OnInit, ControlValueAccessor, Validator {
 | 
			
		||||
 | 
			
		||||
  @Input() disabled: boolean;
 | 
			
		||||
  @Input() defaultCountry: CountryCode = 'US';
 | 
			
		||||
  @Input() enableFlagsSelect = true;
 | 
			
		||||
  @Input() required = true;
 | 
			
		||||
 | 
			
		||||
  allCountries: Array<Country> = [];
 | 
			
		||||
  phonePlaceholder: string;
 | 
			
		||||
  countryCallingCode: string;
 | 
			
		||||
  flagIcon: string;
 | 
			
		||||
 | 
			
		||||
  phoneNumberPattern = phoneNumberPattern;
 | 
			
		||||
 | 
			
		||||
  private modelValue: string;
 | 
			
		||||
  public phoneFormGroup: FormGroup;
 | 
			
		||||
  private valueChange$: Subscription = null;
 | 
			
		||||
  private propagateChange = (v: any) => { };
 | 
			
		||||
 | 
			
		||||
  constructor(private translate: TranslateService,
 | 
			
		||||
              private fb: FormBuilder,
 | 
			
		||||
              private countryCodeData: CountryData) {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ngOnInit(): void {
 | 
			
		||||
    if (this.enableFlagsSelect) {
 | 
			
		||||
      this.fetchCountryData();
 | 
			
		||||
    }
 | 
			
		||||
    this.phoneFormGroup = this.fb.group({
 | 
			
		||||
      country: [this.defaultCountry, []],
 | 
			
		||||
      phoneNumber: [
 | 
			
		||||
        this.countryCallingCode,
 | 
			
		||||
        this.required ? [Validators.required, Validators.pattern(phoneNumberPattern), this.validatePhoneNumber()] : []
 | 
			
		||||
      ]
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    this.valueChange$ = this.phoneFormGroup.get('phoneNumber').valueChanges.subscribe(() => {
 | 
			
		||||
      this.updateModel();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    this.phoneFormGroup.get('country').valueChanges.subscribe(value => {
 | 
			
		||||
      if (value) {
 | 
			
		||||
        const code = this.countryCallingCode;
 | 
			
		||||
        this.getFlagAndPhoneNumberData(value);
 | 
			
		||||
        let phoneNumber = this.phoneFormGroup.get('phoneNumber').value;
 | 
			
		||||
        if (code !== this.countryCallingCode && phoneNumber) {
 | 
			
		||||
          phoneNumber = phoneNumber.replace(code, this.countryCallingCode);
 | 
			
		||||
        } else {
 | 
			
		||||
          phoneNumber = this.countryCallingCode;
 | 
			
		||||
        }
 | 
			
		||||
        this.phoneFormGroup.get('phoneNumber').patchValue(phoneNumber);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    this.phoneFormGroup.get('phoneNumber').valueChanges.subscribe(value => {
 | 
			
		||||
      if (value) {
 | 
			
		||||
        const parsedPhoneNumber = parsePhoneNumberFromString(value);
 | 
			
		||||
        const country = this.phoneFormGroup.get('country').value;
 | 
			
		||||
        if (parsedPhoneNumber?.country && parsedPhoneNumber?.country !== country) {
 | 
			
		||||
          this.phoneFormGroup.get('country').patchValue(parsedPhoneNumber.country, {emitEvent: true});
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ngOnDestroy() {
 | 
			
		||||
    if (this.valueChange$) {
 | 
			
		||||
      this.valueChange$.unsubscribe();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getFlagAndPhoneNumberData(country) {
 | 
			
		||||
    if (this.enableFlagsSelect) {
 | 
			
		||||
      this.flagIcon = this.getFlagIcon(country);
 | 
			
		||||
    }
 | 
			
		||||
    this.getPhoneNumberData(country);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getPhoneNumberData(country): void {
 | 
			
		||||
    const phoneData = getExampleNumber(country, examples);
 | 
			
		||||
    this.phonePlaceholder = phoneData.number;
 | 
			
		||||
    this.countryCallingCode = '+' + phoneData.countryCallingCode;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getFlagIcon(countryCode) {
 | 
			
		||||
    const base = 127462 - 65;
 | 
			
		||||
    return  String.fromCodePoint(...countryCode.split('').map(country => base + country.charCodeAt(0)));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  validatePhoneNumber(): ValidatorFn {
 | 
			
		||||
    return (c: FormControl) => {
 | 
			
		||||
      const phoneNumber = c.value;
 | 
			
		||||
      if (phoneNumber) {
 | 
			
		||||
        const parsedPhoneNumber = parsePhoneNumberFromString(phoneNumber);
 | 
			
		||||
        if (!parsedPhoneNumber?.isValid() || !parsedPhoneNumber?.isPossible()) {
 | 
			
		||||
          return {
 | 
			
		||||
            invalidPhoneNumber: {
 | 
			
		||||
              valid: false
 | 
			
		||||
            }
 | 
			
		||||
          };
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      return null;
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  validate(): ValidationErrors | null {
 | 
			
		||||
    return this.phoneFormGroup.get('phoneNumber').valid ? null : {
 | 
			
		||||
      phoneFormGroup: false
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  registerOnChange(fn: any): void {
 | 
			
		||||
    this.propagateChange = fn;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  registerOnTouched(fn: any): void {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setDisabledState(isDisabled: boolean): void {
 | 
			
		||||
    this.disabled = isDisabled;
 | 
			
		||||
    if (isDisabled) {
 | 
			
		||||
      this.phoneFormGroup.disable({emitEvent: false});
 | 
			
		||||
    } else {
 | 
			
		||||
      this.phoneFormGroup.enable({emitEvent: false});
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  protected fetchCountryData(): void {
 | 
			
		||||
    this.allCountries = [];
 | 
			
		||||
    this.countryCodeData.allCountries.forEach((c) => {
 | 
			
		||||
      const cc = c[1];
 | 
			
		||||
      const country: Country = {
 | 
			
		||||
        name: c[0].toString(),
 | 
			
		||||
        iso2: c[1].toString(),
 | 
			
		||||
        dialCode: c[2].toString(),
 | 
			
		||||
        flag: this.getFlagIcon(cc),
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      this.allCountries.push(country);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  writeValue(phoneNumber): void {
 | 
			
		||||
    this.modelValue = phoneNumber;
 | 
			
		||||
    const country = phoneNumber ? parsePhoneNumberFromString(phoneNumber)?.country : this.defaultCountry;
 | 
			
		||||
    this.getFlagAndPhoneNumberData(country);
 | 
			
		||||
    this.phoneFormGroup.patchValue({phoneNumber, country}, {emitEvent: !phoneNumber});
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private updateModel() {
 | 
			
		||||
    const phoneNumber = this.phoneFormGroup.get('phoneNumber');
 | 
			
		||||
    if (phoneNumber.valid && this.modelValue !== phoneNumber.value) {
 | 
			
		||||
      this.modelValue = phoneNumber.value;
 | 
			
		||||
      this.propagateChange(this.modelValue);
 | 
			
		||||
    } else {
 | 
			
		||||
      this.propagateChange(null);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1587
									
								
								ui-ngx/src/app/shared/models/country.models.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1587
									
								
								ui-ngx/src/app/shared/models/country.models.ts
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@ -159,6 +159,7 @@ import { TbMarkdownComponent } from '@shared/components/markdown.component';
 | 
			
		||||
import { ProtobufContentComponent } from '@shared/components/protobuf-content.component';
 | 
			
		||||
import { CssComponent } from '@shared/components/css.component';
 | 
			
		||||
import { SafePipe } from '@shared/pipe/safe.pipe';
 | 
			
		||||
import { PhoneInputComponent } from '@shared/components/phone-input.component';
 | 
			
		||||
 | 
			
		||||
export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) {
 | 
			
		||||
  return markedOptionsService;
 | 
			
		||||
@ -277,7 +278,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
 | 
			
		||||
    WidgetsBundleSearchComponent,
 | 
			
		||||
    CopyButtonComponent,
 | 
			
		||||
    TogglePasswordComponent,
 | 
			
		||||
    ProtobufContentComponent
 | 
			
		||||
    ProtobufContentComponent,
 | 
			
		||||
    PhoneInputComponent
 | 
			
		||||
  ],
 | 
			
		||||
  imports: [
 | 
			
		||||
    CommonModule,
 | 
			
		||||
@ -472,7 +474,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
 | 
			
		||||
    WidgetsBundleSearchComponent,
 | 
			
		||||
    CopyButtonComponent,
 | 
			
		||||
    TogglePasswordComponent,
 | 
			
		||||
    ProtobufContentComponent
 | 
			
		||||
    ProtobufContentComponent,
 | 
			
		||||
    PhoneInputComponent
 | 
			
		||||
  ]
 | 
			
		||||
})
 | 
			
		||||
export class SharedModule { }
 | 
			
		||||
 | 
			
		||||
@ -3351,6 +3351,12 @@
 | 
			
		||||
        "material-icons": "Material icons",
 | 
			
		||||
        "show-all": "Show all icons"
 | 
			
		||||
    },
 | 
			
		||||
    "phone-input": {
 | 
			
		||||
        "phone-input-label": "Phone number",
 | 
			
		||||
        "phone-input-required": "Phone number is required",
 | 
			
		||||
        "phone-input-validation": "Phone number is invalid or not possible",
 | 
			
		||||
        "phone-input-pattern": "Invalid phone number. Should be in E.164 format, ex. +19995550123."
 | 
			
		||||
    },
 | 
			
		||||
    "custom": {
 | 
			
		||||
        "widget-action": {
 | 
			
		||||
            "action-cell-button": "Action cell button",
 | 
			
		||||
 | 
			
		||||
@ -6027,6 +6027,11 @@ less@4.1.1:
 | 
			
		||||
    needle "^2.5.2"
 | 
			
		||||
    source-map "~0.6.0"
 | 
			
		||||
 | 
			
		||||
libphonenumber-js@^1.10.4:
 | 
			
		||||
  version "1.10.4"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.10.4.tgz#90397f0ed620262570a32244c9fbc389cc417ce4"
 | 
			
		||||
  integrity sha512-9QWxEk4GW5RDnFzt8UtyRENfFpAN8u7Sbf9wf32tcXY9tdtnz1dKHIBwW2Wnfx8ypXJb9zUnTpK9aQJ/B8AlnA==
 | 
			
		||||
 | 
			
		||||
license-webpack-plugin@2.3.20:
 | 
			
		||||
  version "2.3.20"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/license-webpack-plugin/-/license-webpack-plugin-2.3.20.tgz#f51fb674ca31519dbedbe1c7aabc036e5a7f2858"
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user