[ADF-147] Add input mask to Text Widget on Form (#1885)

* ADF-147 create directive to apply input mask
* ADF-147 - created directive for calculate input mask
* ADF-147 added input mask for text element
This commit is contained in:
Vito 2017-05-19 09:47:15 -07:00 committed by Eugenio Romano
parent b79ee13d11
commit e8889a5adb
11 changed files with 417 additions and 10 deletions

View File

@ -30,7 +30,7 @@ import { WidgetVisibilityService } from './src/services/widget-visibility.servic
import { ActivitiAlfrescoContentService } from './src/services/activiti-alfresco.service';
import { FormRenderingService } from './src/services/form-rendering.service';
import { HttpModule } from '@angular/http';
import { WIDGET_DIRECTIVES } from './src/components/widgets/index';
import { WIDGET_DIRECTIVES, MASK_DIRECTIVE } from './src/components/widgets/index';
export * from './src/components/activiti-form.component';
export * from './src/components/activiti-content.component';
@ -67,7 +67,8 @@ export const ACTIVITI_FORM_PROVIDERS: any[] = [
HttpModule
],
declarations: [
...ACTIVITI_FORM_DIRECTIVES
...ACTIVITI_FORM_DIRECTIVES,
...MASK_DIRECTIVE
],
entryComponents: [
...WIDGET_DIRECTIVES

View File

@ -42,7 +42,6 @@
"@angular/platform-browser": "~4.0.0",
"@angular/platform-browser-dynamic": "~4.0.0",
"@angular/router": "~4.0.0",
"@angular/material": "2.0.0-beta.1",
"alfresco-js-api": "~1.4.0",
"core-js": "2.4.1",

View File

@ -23,6 +23,7 @@ import { ActivitiStartForm } from './activiti-start-form.component';
import { FormFieldComponent } from './form-field/form-field.component';
import { ActivitiContent } from './activiti-content.component';
import { WIDGET_DIRECTIVES } from './widgets/index';
import { MASK_DIRECTIVE } from './widgets/index';
import { FormService } from './../services/form.service';
import { EcmModelService } from './../services/ecm-model.service';
import { WidgetVisibilityService } from './../services/widget-visibility.service';
@ -47,7 +48,8 @@ describe('ActivitiStartForm', () => {
ActivitiStartForm,
FormFieldComponent,
ActivitiContent,
...WIDGET_DIRECTIVES
...WIDGET_DIRECTIVES,
...MASK_DIRECTIVE
],
providers: [
{ provide: AlfrescoTranslationService, useClass: TranslationMock },

View File

@ -21,6 +21,7 @@ import { FormFieldComponent } from './form-field.component';
import { FormRenderingService } from './../../services/form-rendering.service';
import { FormModel, FormFieldModel, FormFieldTypes } from './../widgets/core/index';
import { TextWidget } from './../widgets/text/text.widget';
import { InputMaskDirective } from './../widgets/text/text-mask.component';
import { CheckboxWidget } from './../widgets/checkbox/checkbox.widget';
import { WidgetVisibilityService } from './../../services/widget-visibility.service';
@ -36,7 +37,7 @@ describe('FormFieldComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [CoreModule],
declarations: [FormFieldComponent, TextWidget, CheckboxWidget],
declarations: [FormFieldComponent, TextWidget, CheckboxWidget, InputMaskDirective],
providers: [
FormRenderingService,
WidgetVisibilityService

View File

@ -23,6 +23,7 @@ import { FormFieldModel } from './../core/form-field.model';
import { ComponentFixture, TestBed, async } from '@angular/core/testing';
import { CoreModule } from 'ng2-alfresco-core';
import { WIDGET_DIRECTIVES } from '../index';
import { MASK_DIRECTIVE } from '../index';
import { FormFieldComponent } from './../../form-field/form-field.component';
import { ActivitiContent } from './../../activiti-content.component';
import { fakeFormJson } from '../../../services/assets/widget-visibility.service.mock';
@ -133,7 +134,7 @@ describe('ContainerWidget', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [CoreModule],
declarations: [FormFieldComponent, ActivitiContent, WIDGET_DIRECTIVES]
declarations: [FormFieldComponent, ActivitiContent, WIDGET_DIRECTIVES, MASK_DIRECTIVE]
}).compileComponents().then(() => {
fixture = TestBed.createComponent(ContainerWidget);
containerWidgetComponent = fixture.componentInstance;

View File

@ -41,6 +41,7 @@ import { DropdownEditorComponent } from './dynamic-table/editors/dropdown/dropdo
import { BooleanEditorComponent } from './dynamic-table/editors/boolean/boolean.editor';
import { TextEditorComponent } from './dynamic-table/editors/text/text.editor';
import { RowEditorComponent } from './dynamic-table/editors/row.editor';
import { InputMaskDirective } from './text/text-mask.component';
// core
export * from './widget.component';
@ -76,6 +77,7 @@ export * from './dynamic-table/editors/date/date.editor';
export * from './dynamic-table/editors/dropdown/dropdown.editor';
export * from './dynamic-table/editors/boolean/boolean.editor';
export * from './dynamic-table/editors/text/text.editor';
export * from './text/text-mask.component';
export const WIDGET_DIRECTIVES: any[] = [
UnknownWidget,
@ -105,3 +107,7 @@ export const WIDGET_DIRECTIVES: any[] = [
TextEditorComponent,
RowEditorComponent
];
export const MASK_DIRECTIVE: any[] = [
InputMaskDirective
];

View File

@ -22,6 +22,7 @@ import { fakeFormJson } from '../../../services/assets/widget-visibility.service
import { TabsWidget } from './tabs.widget';
import { TabModel } from '../core/tab.model';
import { WIDGET_DIRECTIVES } from '../index';
import { MASK_DIRECTIVE } from '../index';
import { FormFieldComponent } from './../../form-field/form-field.component';
import { ActivitiContent } from './../../activiti-content.component';
import { CoreModule } from 'ng2-alfresco-core';
@ -104,7 +105,7 @@ describe('TabsWidget', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [CoreModule],
declarations: [FormFieldComponent, ActivitiContent, WIDGET_DIRECTIVES]
declarations: [FormFieldComponent, ActivitiContent, WIDGET_DIRECTIVES, MASK_DIRECTIVE]
}).compileComponents().then(() => {
fixture = TestBed.createComponent(TabsWidget);
tabWidgetComponent = fixture.componentInstance;

View File

@ -0,0 +1,223 @@
/*!
* @license
* Copyright 2016 Alfresco Software, Ltd.
*
* 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 {
Directive,
ElementRef,
Renderer,
HostListener,
Input,
OnChanges,
SimpleChanges,
forwardRef
} from '@angular/core';
import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms';
export const CUSTOM_INPUT_CONTROL_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => InputMaskDirective),
multi: true
};
@Directive({
selector: '[textMask]',
providers: [CUSTOM_INPUT_CONTROL_VALUE_ACCESSOR]
})
export class InputMaskDirective implements OnChanges, ControlValueAccessor {
@Input('textMask') inputMask: {
mask: '',
isReversed: false
};
private translationMask = {
'0': { pattern: /\d/ },
'9': { pattern: /\d/, optional: true },
'#': { pattern: /\d/, recursive: true },
'A': { pattern: /[a-zA-Z0-9]/ },
'S': { pattern: /[a-zA-Z]/ }
};
private byPassKeys = [9, 16, 17, 18, 36, 37, 38, 39, 40, 91];
private value;
private invalidCharacters = [];
constructor(private el: ElementRef, private render: Renderer) {
}
_onChange = (_: any) => {
}
_onTouched = () => {
}
@HostListener('input', ['$event'])
@HostListener('keyup', ['$event']) onTextInput(event: KeyboardEvent) {
if (this.inputMask && this.inputMask.mask) {
this.maskValue(this.el.nativeElement.value, this.el.nativeElement.selectionStart,
this.inputMask.mask, this.inputMask.isReversed, event.keyCode);
}
}
ngOnChanges(changes: SimpleChanges) {
if (changes['inputMask'] && changes['inputMask'].currentValue['mask']) {
this.inputMask = changes['inputMask'].currentValue;
}
}
writeValue(value: any) {
this.el.nativeElement.value = value;
}
registerOnChange(fn: any) {
this._onChange = fn;
}
registerOnTouched(fn: () => any): void {
this._onTouched = fn;
}
private maskValue(actualValue, startCaret, maskToApply, isMaskReversed, keyCode) {
if (this.byPassKeys.indexOf(keyCode) === -1) {
let value = this.getMasked(false, actualValue, maskToApply, isMaskReversed);
let calculatedCaret = this.calculateCaretPosition(startCaret, actualValue, keyCode);
this.render.setElementAttribute(this.el.nativeElement, 'value', value);
this.el.nativeElement.value = value;
this.setValue(value);
this._onChange(value);
this.setCaretPosition(calculatedCaret);
}
}
private setCaretPosition(caretPosition) {
this.el.nativeElement.moveStart = caretPosition;
this.el.nativeElement.moveEnd = caretPosition;
}
calculateCaretPosition(caretPosition, newValue, keyCode) {
let newValueLength = newValue.length;
let oldValue = this.getValue() || '';
let oldValueLength = oldValue.length;
if (keyCode === 8 && oldValue !== newValue) {
caretPosition = caretPosition - (newValue.slice(0, caretPosition).length - oldValue.slice(0, caretPosition).length);
} else if (oldValue !== newValue) {
if (caretPosition >= oldValueLength) {
caretPosition = newValueLength;
} else {
caretPosition = caretPosition + (newValue.slice(0, caretPosition).length - oldValue.slice(0, caretPosition).length);
}
}
return caretPosition;
}
getMasked(skipMaskChars, val, mask, isReversed = false) {
let buf = [],
value = val,
maskIndex = 0,
maskLen = mask.length,
valueIndex = 0,
valueLength = value.length,
offset = 1,
addMethod = 'push',
resetPos = -1,
lastMaskChar,
lastUntranslatedMaskChar,
check;
if (isReversed) {
addMethod = 'unshift';
offset = -1;
lastMaskChar = 0;
maskIndex = maskLen - 1;
valueIndex = valueLength - 1;
} else {
lastMaskChar = maskLen - 1;
}
check = this.isToCheck(isReversed, maskIndex, maskLen, valueIndex, valueLength);
while (check) {
let maskDigit = mask.charAt(maskIndex),
valDigit = value.charAt(valueIndex),
translation = this.translationMask[maskDigit];
if (translation) {
if (valDigit.match(translation.pattern)) {
buf[addMethod](valDigit);
if (translation.recursive) {
if (resetPos === -1) {
resetPos = maskIndex;
} else if (maskIndex === lastMaskChar) {
maskIndex = resetPos - offset;
}
if (lastMaskChar === resetPos) {
maskIndex -= offset;
}
}
maskIndex += offset;
} else if (valDigit === lastUntranslatedMaskChar) {
lastUntranslatedMaskChar = undefined;
} else if (translation.optional) {
maskIndex += offset;
valueIndex -= offset;
} else {
this.invalidCharacters.push({
index: valueIndex,
digit: valDigit,
translated: translation.pattern
});
}
valueIndex += offset;
} else {
if (!skipMaskChars) {
buf[addMethod](maskDigit);
}
if (valDigit === maskDigit) {
valueIndex += offset;
} else {
lastUntranslatedMaskChar = maskDigit;
}
maskIndex += offset;
}
check = this.isToCheck(isReversed, maskIndex, maskLen, valueIndex, valueLength);
}
let lastMaskCharDigit = mask.charAt(lastMaskChar);
if (maskLen === valueLength + 1 && !this.translationMask[lastMaskCharDigit]) {
buf.push(lastMaskCharDigit);
}
return buf.join('');
}
private isToCheck(isReversed, maskIndex, maskLen, valueIndex, valueLength) {
let check = false;
if (isReversed) {
check = (maskIndex > -1) && (valueIndex > -1);
} else {
check = (maskIndex < maskLen) && (valueIndex < valueLength);
}
return check;
}
private setValue(value) {
this.value = value;
}
private getValue() {
return this.value;
}
}

View File

@ -9,6 +9,7 @@
[(ngModel)]="field.value"
(ngModelChange)="onFieldChanged(field)"
[disabled]="field.readOnly"
[textMask]="{mask: mask, isReversed: isMaskReversed}"
placeholder="{{field.placeholder}}">
<span *ngIf="field.validationSummary" class="mdl-textfield__error">{{field.validationSummary}}</span>
</div>

View File

@ -16,6 +16,12 @@
*/
import { TextWidget } from './text.widget';
import { ComponentFixture, TestBed, async } from '@angular/core/testing';
import { CoreModule } from 'ng2-alfresco-core';
import { InputMaskDirective } from './text-mask.component';
import { FormFieldModel } from '../core/form-field.model';
import { FormModel } from '../core/form.model';
import { FormFieldTypes } from '../core/form-field-types';
describe('TextWidget', () => {
@ -25,11 +31,167 @@ describe('TextWidget', () => {
beforeEach(() => {
widget = new TextWidget();
componentHandler = jasmine.createSpyObj('componentHandler', [
componentHandler = jasmine.createSpyObj('componentHandler', [
'upgradeAllRegistered'
]);
window['componentHandler'] = componentHandler;
});
describe('when template is ready', () => {
let textWidget: TextWidget;
let fixture: ComponentFixture<TextWidget>;
let element: HTMLInputElement;
let componentHandler;
beforeEach(async(() => {
componentHandler = jasmine.createSpyObj('componentHandler', ['upgradeAllRegistered', 'upgradeElement']);
window['componentHandler'] = componentHandler;
}));
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [CoreModule],
declarations: [TextWidget, InputMaskDirective]
}).compileComponents().then(() => {
fixture = TestBed.createComponent(TextWidget);
textWidget = fixture.componentInstance;
element = fixture.nativeElement;
});
}));
afterEach(() => {
fixture.destroy();
TestBed.resetTestingModule();
});
describe('and mask is configured on text element', () => {
let inputElement: HTMLInputElement;
beforeEach(() => {
textWidget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id' }), {
id: 'text-id',
name: 'text-name',
value: '',
params: { inputMask: '##-##0,00%' },
type: FormFieldTypes.TEXT,
readOnly: false
});
fixture.detectChanges();
inputElement = <HTMLInputElement>element.querySelector('#text-id');
});
it('should show text widget', () => {
expect(element.querySelector('#text-id')).toBeDefined();
expect(element.querySelector('#text-id')).not.toBeNull();
});
it('should prevent text to be written if is not allowed by the mask on keyUp event', async(() => {
expect(element.querySelector('#text-id')).not.toBeNull();
inputElement.value = 'F';
textWidget.field.value = 'F';
let event: any = new Event('keyup');
event.keyCode = '70';
inputElement.dispatchEvent(event);
fixture.detectChanges();
fixture.whenStable().then(() => {
fixture.detectChanges();
inputElement = <HTMLInputElement>element.querySelector('#text-id');
expect(inputElement.value).toBe('');
});
}));
it('should prevent text to be written if is not allowed by the mask on input event', async(() => {
expect(element.querySelector('#text-id')).not.toBeNull();
inputElement.value = 'F';
textWidget.field.value = 'F';
inputElement.dispatchEvent(new Event('input'));
fixture.detectChanges();
fixture.whenStable().then(() => {
fixture.detectChanges();
inputElement = <HTMLInputElement>element.querySelector('#text-id');
expect(inputElement.value).toBe('');
});
}));
it('should allow masked configured value on keyUp event', async(() => {
expect(element.querySelector('#text-id')).not.toBeNull();
inputElement.value = '1';
textWidget.field.value = '1';
let event: any = new Event('keyup');
event.keyCode = '49';
inputElement.dispatchEvent(event);
fixture.whenStable().then(() => {
fixture.detectChanges();
let inputElement: HTMLInputElement = <HTMLInputElement>element.querySelector('#text-id');
expect(inputElement.value).toBe('1');
});
}));
it('should autofill masked configured value on keyUp event', async(() => {
expect(element.querySelector('#text-id')).not.toBeNull();
inputElement.value = '12345678';
textWidget.field.value = '12345678';
let event: any = new Event('keyup');
event.keyCode = '49';
inputElement.dispatchEvent(event);
fixture.whenStable().then(() => {
fixture.detectChanges();
let inputElement: HTMLInputElement = <HTMLInputElement>element.querySelector('#text-id');
expect(inputElement.value).toBe('12-345,67%');
});
}));
});
describe('when the mask is reversed ', () => {
let inputElement: HTMLInputElement;
beforeEach(() => {
textWidget.field = new FormFieldModel(new FormModel({ taskId: 'fake-task-id' }), {
id: 'text-id',
name: 'text-name',
value: '',
params: { existingColspan: 1, maxColspan: 2, inputMask: "#.##0,00%", inputMaskReversed: true },
type: FormFieldTypes.TEXT,
readOnly: false
});
fixture.detectChanges();
inputElement = <HTMLInputElement>element.querySelector('#text-id');
});
afterEach(() => {
fixture.destroy();
TestBed.resetTestingModule();
});
it('should be able to apply the mask reversed', async(() => {
expect(element.querySelector('#text-id')).not.toBeNull();
inputElement.value = '1234';
textWidget.field.value = '1234';
let event: any = new Event('keyup');
event.keyCode = '49';
inputElement.dispatchEvent(event);
fixture.whenStable().then(() => {
fixture.detectChanges();
let inputElement: HTMLInputElement = <HTMLInputElement>element.querySelector('#text-id');
expect(inputElement.value).toBe('12,34%');
});
}));
});
});
});

View File

@ -15,7 +15,7 @@
* limitations under the License.
*/
import { Component } from '@angular/core';
import { Component, OnInit } from '@angular/core';
import { WidgetComponent } from './../widget.component';
@Component({
@ -23,5 +23,15 @@ import { WidgetComponent } from './../widget.component';
templateUrl: './text.widget.html',
styleUrls: ['./text.widget.css']
})
export class TextWidget extends WidgetComponent {
export class TextWidget extends WidgetComponent implements OnInit {
private mask;
private isMaskReversed;
ngOnInit() {
if (this.field.params && this.field.params['inputMask']) {
this.mask = this.field.params['inputMask'];
this.isMaskReversed = this.field.params['inputMaskReversed'] ? this.field.params['inputMaskReversed'] : false;
}
}
}