diff --git a/ng2-components/ng2-activiti-form/index.ts b/ng2-components/ng2-activiti-form/index.ts index b20bf4efb1..8ee90c9dd8 100644 --- a/ng2-components/ng2-activiti-form/index.ts +++ b/ng2-components/ng2-activiti-form/index.ts @@ -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 diff --git a/ng2-components/ng2-activiti-form/package.json b/ng2-components/ng2-activiti-form/package.json index 2886e31b79..3dc28649b7 100644 --- a/ng2-components/ng2-activiti-form/package.json +++ b/ng2-components/ng2-activiti-form/package.json @@ -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", diff --git a/ng2-components/ng2-activiti-form/src/components/activiti-start-form.component.spec.ts b/ng2-components/ng2-activiti-form/src/components/activiti-start-form.component.spec.ts index 7036dcd298..9cf614e1eb 100644 --- a/ng2-components/ng2-activiti-form/src/components/activiti-start-form.component.spec.ts +++ b/ng2-components/ng2-activiti-form/src/components/activiti-start-form.component.spec.ts @@ -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 }, diff --git a/ng2-components/ng2-activiti-form/src/components/form-field/form-field.component.spec.ts b/ng2-components/ng2-activiti-form/src/components/form-field/form-field.component.spec.ts index 675f875fd3..2a3df2f17f 100644 --- a/ng2-components/ng2-activiti-form/src/components/form-field/form-field.component.spec.ts +++ b/ng2-components/ng2-activiti-form/src/components/form-field/form-field.component.spec.ts @@ -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 diff --git a/ng2-components/ng2-activiti-form/src/components/widgets/container/container.widget.spec.ts b/ng2-components/ng2-activiti-form/src/components/widgets/container/container.widget.spec.ts index 130d6d5417..f50f11a51a 100644 --- a/ng2-components/ng2-activiti-form/src/components/widgets/container/container.widget.spec.ts +++ b/ng2-components/ng2-activiti-form/src/components/widgets/container/container.widget.spec.ts @@ -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; diff --git a/ng2-components/ng2-activiti-form/src/components/widgets/index.ts b/ng2-components/ng2-activiti-form/src/components/widgets/index.ts index fdc59cff0f..d537524388 100644 --- a/ng2-components/ng2-activiti-form/src/components/widgets/index.ts +++ b/ng2-components/ng2-activiti-form/src/components/widgets/index.ts @@ -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 +]; diff --git a/ng2-components/ng2-activiti-form/src/components/widgets/tabs/tabs.widget.spec.ts b/ng2-components/ng2-activiti-form/src/components/widgets/tabs/tabs.widget.spec.ts index 1ffd67bebf..40d5f9b819 100644 --- a/ng2-components/ng2-activiti-form/src/components/widgets/tabs/tabs.widget.spec.ts +++ b/ng2-components/ng2-activiti-form/src/components/widgets/tabs/tabs.widget.spec.ts @@ -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; diff --git a/ng2-components/ng2-activiti-form/src/components/widgets/text/text-mask.component.ts b/ng2-components/ng2-activiti-form/src/components/widgets/text/text-mask.component.ts new file mode 100644 index 0000000000..faf6128b3a --- /dev/null +++ b/ng2-components/ng2-activiti-form/src/components/widgets/text/text-mask.component.ts @@ -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; + } +} diff --git a/ng2-components/ng2-activiti-form/src/components/widgets/text/text.widget.html b/ng2-components/ng2-activiti-form/src/components/widgets/text/text.widget.html index 7f78f0af84..5efc1d0c0b 100644 --- a/ng2-components/ng2-activiti-form/src/components/widgets/text/text.widget.html +++ b/ng2-components/ng2-activiti-form/src/components/widgets/text/text.widget.html @@ -9,6 +9,7 @@ [(ngModel)]="field.value" (ngModelChange)="onFieldChanged(field)" [disabled]="field.readOnly" + [textMask]="{mask: mask, isReversed: isMaskReversed}" placeholder="{{field.placeholder}}"> {{field.validationSummary}} diff --git a/ng2-components/ng2-activiti-form/src/components/widgets/text/text.widget.spec.ts b/ng2-components/ng2-activiti-form/src/components/widgets/text/text.widget.spec.ts index 5edea9002c..209f3bffca 100644 --- a/ng2-components/ng2-activiti-form/src/components/widgets/text/text.widget.spec.ts +++ b/ng2-components/ng2-activiti-form/src/components/widgets/text/text.widget.spec.ts @@ -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; + 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 = 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 = 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 = 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 = 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 = 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 = 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 = element.querySelector('#text-id'); + expect(inputElement.value).toBe('12,34%'); + }); + })); + }); + }); + }); diff --git a/ng2-components/ng2-activiti-form/src/components/widgets/text/text.widget.ts b/ng2-components/ng2-activiti-form/src/components/widgets/text/text.widget.ts index 309ed71135..e093a143b5 100644 --- a/ng2-components/ng2-activiti-form/src/components/widgets/text/text.widget.ts +++ b/ng2-components/ng2-activiti-form/src/components/widgets/text/text.widget.ts @@ -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; + } + } }