Browse Source

UI: Phone input with country flag select

pull/6650/head
fe-dev 4 years ago
parent
commit
baa681634b
  1. 1
      ui-ngx/package.json
  2. 51
      ui-ngx/src/app/shared/components/phone-input.component.html
  3. 60
      ui-ngx/src/app/shared/components/phone-input.component.scss
  4. 215
      ui-ngx/src/app/shared/components/phone-input.component.ts
  5. 1587
      ui-ngx/src/app/shared/models/country.models.ts
  6. 7
      ui-ngx/src/app/shared/shared.module.ts
  7. 6
      ui-ngx/src/assets/locale/locale.constant-en_US.json
  8. 5
      ui-ngx/yarn.lock

1
ui-ngx/package.json

@ -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

@ -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

@ -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

@ -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

File diff suppressed because it is too large

7
ui-ngx/src/app/shared/shared.module.ts

@ -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 { }

6
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -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",

5
ui-ngx/yarn.lock

@ -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…
Cancel
Save