Merge pull request #686 from Alfresco/dev-denys-633

Support for Typeahead widget within Activiti form #633
This commit is contained in:
Mario Romano
2016-09-06 12:25:03 +01:00
committed by GitHub
11 changed files with 496 additions and 9 deletions

View File

@@ -1,6 +1,7 @@
.activiti-form-container { .activiti-form-container {
width: 100%; width: 100%;
min-height: 100px; min-height: 100px;
overflow: visible;
} }
.activiti-form-container > .mdl-card__media { .activiti-form-container > .mdl-card__media {

View File

@@ -41,11 +41,13 @@ will be removed during future revisions
--> -->
<div class="activiti-form-debug-container"> <div class="activiti-form-debug-container">
<div style="float: right">
<label class="mdl-switch mdl-js-switch mdl-js-ripple-effect" for="switch-1" [class.is-checked]="debugMode"> <label class="mdl-switch mdl-js-switch mdl-js-ripple-effect" for="switch-1" [class.is-checked]="debugMode">
<input type="checkbox" id="switch-1" class="mdl-switch__input" [(ngModel)]="debugMode"> <input type="checkbox" id="switch-1" class="mdl-switch__input" [(ngModel)]="debugMode">
<span class="mdl-switch__label"></span> <span class="mdl-switch__label"></span>
<span class="debug-toggle-text">Debug mode</span> <span class="debug-toggle-text">Debug mode</span>
</label> </label>
</div>
<div *ngIf="debugMode && hasForm()"> <div *ngIf="debugMode && hasForm()">
<h4>Values</h4> <h4>Values</h4>

View File

@@ -46,6 +46,9 @@
<div *ngSwitchCase="'upload'"> <div *ngSwitchCase="'upload'">
<upload-widget [field]="field" (fieldChanged)="fieldChanged($event);"></upload-widget> <upload-widget [field]="field" (fieldChanged)="fieldChanged($event);"></upload-widget>
</div> </div>
<div *ngSwitchCase="'typeahead'">
<typeahead-widget [field]="field" (fieldChanged)="fieldChanged($event);"></typeahead-widget>
</div>
<div *ngSwitchDefault> <div *ngSwitchDefault>
<span>UNKNOWN WIDGET TYPE: {{field.type}}</span> <span>UNKNOWN WIDGET TYPE: {{field.type}}</span>
</div> </div>

View File

@@ -24,6 +24,7 @@ export class FormFieldTypes {
static DISPLAY_VALUE: string = 'readonly'; static DISPLAY_VALUE: string = 'readonly';
static READONLY_TEXT: string = 'readonly-text'; static READONLY_TEXT: string = 'readonly-text';
static UPLOAD: string = 'upload'; static UPLOAD: string = 'upload';
static TYPEAHEAD: string = 'typeahead';
static READONLY_TYPES: string[] = [ static READONLY_TYPES: string[] = [
FormFieldTypes.HYPERLINK, FormFieldTypes.HYPERLINK,

View File

@@ -150,9 +150,9 @@ export class FormFieldModel extends FormWidgetModel {
This is needed due to Activiti issue related to reading radio button values as value string This is needed due to Activiti issue related to reading radio button values as value string
but saving back as object: { id: <id>, name: <name> } but saving back as object: { id: <id>, name: <name> }
*/ */
let entry: FormFieldOption[] = this.options.filter(opt => opt.id === this.value); let rbEntry: FormFieldOption[] = this.options.filter(opt => opt.id === this.value);
if (entry.length > 0) { if (rbEntry.length > 0) {
this.form.values[this.id] = entry[0]; this.form.values[this.id] = rbEntry[0];
} else if (this.options.length > 0) { } else if (this.options.length > 0) {
this.form.values[this.id] = this.options[0]; this.form.values[this.id] = this.options[0];
} }
@@ -164,6 +164,14 @@ export class FormFieldModel extends FormWidgetModel {
this.form.values[this.id] = null; this.form.values[this.id] = null;
} }
break; break;
case FormFieldTypes.TYPEAHEAD:
let taEntry: FormFieldOption[] = this.options.filter(opt => opt.id === this.value);
if (taEntry.length > 0) {
this.form.values[this.id] = taEntry[0];
} else if (this.options.length > 0) {
this.form.values[this.id] = null;
}
break;
default: default:
if (!FormFieldTypes.isReadOnlyType(this.type)) { if (!FormFieldTypes.isReadOnlyType(this.type)) {
this.form.values[this.id] = this.value; this.form.values[this.id] = this.value;

View File

@@ -28,6 +28,7 @@ import { RadioButtonsWidget } from './radio-buttons/radio-buttons.widget';
import { DisplayValueWidget } from './display-value/display-value.widget'; import { DisplayValueWidget } from './display-value/display-value.widget';
import { DisplayTextWidget } from './display-text/display-text.widget'; import { DisplayTextWidget } from './display-text/display-text.widget';
import { UploadWidget } from './upload/upload.widget'; import { UploadWidget } from './upload/upload.widget';
import { TypeaheadWidget } from './typeahead/typeahead.widget';
// core // core
export * from './widget.component'; export * from './widget.component';
@@ -48,6 +49,7 @@ export * from './radio-buttons/radio-buttons.widget';
export * from './display-value/display-value.widget'; export * from './display-value/display-value.widget';
export * from './display-text/display-text.widget'; export * from './display-text/display-text.widget';
export * from './upload/upload.widget'; export * from './upload/upload.widget';
export * from './typeahead/typeahead.widget';
export const CONTAINER_WIDGET_DIRECTIVES: [any] = [ export const CONTAINER_WIDGET_DIRECTIVES: [any] = [
TabsWidget, TabsWidget,
@@ -64,7 +66,8 @@ export const PRIMITIVE_WIDGET_DIRECTIVES: [any] = [
RadioButtonsWidget, RadioButtonsWidget,
DisplayValueWidget, DisplayValueWidget,
DisplayTextWidget, DisplayTextWidget,
UploadWidget UploadWidget,
TypeaheadWidget
]; ];

View File

@@ -0,0 +1,29 @@
.typeahead-widget {
width: 100%;
}
.typeahead-autocomplete {
background-color: #fff;
position: absolute;
z-index: 5;
color: #555;
margin: -15px 0 0 0;
}
.typeahead-autocomplete > ul {
list-style-type: none;
position: static;
height: auto;
width: auto;
min-width: 124px;
padding: 8px 0;
margin: 0;
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12);
border-radius: 2px;
}
.typeahead-autocomplete > ul > li {
opacity: 1;
}

View File

@@ -0,0 +1,22 @@
<div class="mdl-textfield mdl-js-textfield mdl-textfield--floating-label typeahead-widget"
[class.is-dirty]="value">
<input class="mdl-textfield__input"
type="text"
[attr.id]="field.id"
[(ngModel)]="value"
(ngModelChange)="checkVisibility(field)"
(keyup)="onKeyUp($event)"
(blur)="onBlur()"
[disabled]="field.readOnly">
<label class="mdl-textfield__label" [attr.for]="field.id">{{field.name}}</label>
</div>
<div class="typeahead-autocomplete mdl-shadow--2dp" *ngIf="popupVisible">
<ul>
<li *ngFor="let item of getOptions()"
class="mdl-menu__item"
(click)="onItemClick(item, $event)">
{{item.name}}
</li>
</ul>
</div>

View File

@@ -0,0 +1,298 @@
/*!
* @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 { it, describe, expect, beforeEach } from '@angular/core/testing';
import { Observable } from 'rxjs/Rx';
import { TypeaheadWidget } from './typeahead.widget';
import { FormService } from '../../../services/form.service';
import { FormModel } from '../core/form.model';
import { FormFieldModel } from '../core/form-field.model';
import { FormFieldOption } from '../core/form-field-option';
describe('TypeaheadWidget', () => {
let formService: FormService;
let widget: TypeaheadWidget;
beforeEach(() => {
formService = new FormService(null, null);
widget = new TypeaheadWidget(formService);
widget.field = new FormFieldModel(new FormModel());
});
it('should request field values from service', () => {
const taskId = '<form-id>';
const fieldId = '<field-id>';
let form = new FormModel({
taskId: taskId
});
widget.field = new FormFieldModel(form, {
id: fieldId
});
spyOn(formService, 'getRestFieldValues').and.returnValue(Observable.create(observer => {
observer.next(null);
observer.complete();
}));
widget.ngOnInit();
expect(formService.getRestFieldValues).toHaveBeenCalledWith(taskId, fieldId);
});
it('should handle error when requesting fields', () => {
const err = 'Error';
spyOn(formService, 'getRestFieldValues').and.returnValue(Observable.throw(err));
spyOn(widget, 'handleError').and.callThrough();
widget.ngOnInit();
expect(formService.getRestFieldValues).toHaveBeenCalled();
expect(widget.handleError).toHaveBeenCalledWith(err);
});
it('should log error to console by default', () => {
spyOn(console, 'error').and.stub();
widget.handleError('Err');
expect(console.error).toHaveBeenCalledWith('Err');
});
it('should show popup on key up', () => {
widget.minTermLength = 1;
widget.value = 'some value';
widget.popupVisible = false;
widget.onKeyUp(null);
expect(widget.popupVisible).toBeTruthy();
});
it('should require value to show popup', () => {
widget.minTermLength = 1;
widget.value = '';
widget.popupVisible = false;
widget.onKeyUp(null);
expect(widget.popupVisible).toBeFalsy();
});
it('should require value to be of min length to show popup', () => {
widget.minTermLength = 3;
widget.value = 'v';
// value less than constraint
widget.popupVisible = false;
widget.onKeyUp(null);
expect(widget.popupVisible).toBeFalsy();
// value satisfies constraint
widget.value = 'value';
widget.onKeyUp(null);
expect(widget.popupVisible).toBeTruthy();
// value gets less than allowed again
widget.value = 'va';
widget.onKeyUp(null);
expect(widget.popupVisible).toBeFalsy();
});
it('should flush value on blur', (done) => {
spyOn(widget, 'flushValue').and.stub();
widget.onBlur();
setTimeout(() => {
expect(widget.flushValue).toHaveBeenCalled();
done();
}, 200);
});
it('should prevent default behaviour on option item click', () => {
let event = jasmine.createSpyObj('event', ['preventDefault']);
widget.onItemClick(null, event);
expect(event.preventDefault).toHaveBeenCalled();
});
it('should update values on option item click', () => {
let option: FormFieldOption = <FormFieldOption> {
id: '1',
name: 'name'
};
widget.onItemClick(option, null);
expect(widget.field.value).toBe(option.id);
expect(widget.value).toBe(option.name);
});
it('should setup initial value', () => {
spyOn(formService, 'getRestFieldValues').and.returnValue(Observable.create(observer => {
observer.next([
{ id: '1', name: 'One' },
{ id: '2', name: 'Two' }
]);
observer.complete();
}));
widget.field.value = '2';
widget.ngOnInit();
expect(formService.getRestFieldValues).toHaveBeenCalled();
expect(widget.value).toBe('Two');
});
it('should not setup initial value due to missing option', () => {
spyOn(formService, 'getRestFieldValues').and.returnValue(Observable.create(observer => {
observer.next([
{ id: '1', name: 'One' },
{ id: '2', name: 'Two' }
]);
observer.complete();
}));
widget.field.value = '3';
widget.ngOnInit();
expect(formService.getRestFieldValues).toHaveBeenCalled();
expect(widget.value).toBeUndefined();
});
it('should setup field options on load', () => {
let options: FormFieldOption[] = [
{ id: '1', name: 'One' },
{ id: '2', name: 'Two' }
];
spyOn(formService, 'getRestFieldValues').and.returnValue(Observable.create(observer => {
observer.next(options);
observer.complete();
}));
widget.ngOnInit();
expect(widget.field.options).toEqual(options);
});
it('should update form upon options setup', () => {
spyOn(formService, 'getRestFieldValues').and.returnValue(Observable.create(observer => {
observer.next([]);
observer.complete();
}));
spyOn(widget.field, 'updateForm').and.callThrough();
widget.ngOnInit();
expect(widget.field.updateForm).toHaveBeenCalled();
});
it('should get filtered options', () => {
let options: FormFieldOption[] = [
{ id: '1', name: 'Item one' },
{ id: '2', name: 'Item two'}
];
widget.field.options = options;
widget.value = 'tw';
let filtered = widget.getOptions();
expect(filtered.length).toBe(1);
expect(filtered[0]).toEqual(options[1]);
});
it('should be case insensitive when filtering options', () => {
let options: FormFieldOption[] = [
{ id: '1', name: 'Item one' },
{ id: '2', name: 'iTEM TWo' }
];
widget.field.options = options;
widget.value = 'tW';
let filtered = widget.getOptions();
expect(filtered.length).toBe(1);
expect(filtered[0]).toEqual(options[1]);
});
it('should hide popup on flush', () => {
widget.popupVisible = true;
widget.flushValue();
expect(widget.popupVisible).toBeFalsy();
});
it('should update form on value flush', () => {
spyOn(widget.field, 'updateForm').and.callThrough();
widget.flushValue();
expect(widget.field.updateForm).toHaveBeenCalled();
});
it('should flush selected value', () => {
let options: FormFieldOption[] = [
{ id: '1', name: 'Item one' },
{ id: '2', name: 'Item Two' }
];
widget.field.options = options;
widget.value = 'Item Two';
widget.flushValue();
expect(widget.value).toBe(options[1].name);
expect(widget.field.value).toBe(options[1].id);
});
it('should be case insensitive when flushing value', () => {
let options: FormFieldOption[] = [
{ id: '1', name: 'Item one' },
{ id: '2', name: 'iTEM TWo' }
];
widget.field.options = options;
widget.value = 'ITEM TWO';
widget.flushValue();
expect(widget.value).toBe(options[1].name);
expect(widget.field.value).toBe(options[1].id);
});
it('should reset fields when flushing missing option value', () => {
widget.field.options = [
{id: '1', name: 'Item one'},
{id: '2', name: 'Item two'}
];
widget.value = 'Missing item';
widget.flushValue();
expect(widget.value).toBeNull();
expect(widget.field.value).toBeNull();
});
it('should reset fields when flushing incorrect value', () => {
widget.field.options = [
{id: '1', name: 'Item one'},
{id: '2', name: 'Item two'}
];
widget.field.value = 'Item two';
widget.value = 'Item two!';
widget.flushValue();
expect(widget.value).toBeNull();
expect(widget.field.value).toBeNull();
});
it('should reset fields when flushing value having no options', () => {
widget.field.options = null;
widget.field.value = 'item 1';
widget.value = 'new item';
widget.flushValue();
expect(widget.value).toBeNull();
expect(widget.field.value).toBeNull();
});
});

View File

@@ -0,0 +1,114 @@
/*!
* @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 { Component, OnInit } from '@angular/core';
import { FormService } from './../../../services/form.service';
import { WidgetComponent } from './../widget.component';
import { FormFieldOption } from './../core/form-field-option';
declare let __moduleName: string;
declare var componentHandler;
@Component({
moduleId: __moduleName,
selector: 'typeahead-widget',
templateUrl: './typeahead.widget.html',
styleUrls: ['./typeahead.widget.css']
})
export class TypeaheadWidget extends WidgetComponent implements OnInit {
popupVisible: boolean = false;
minTermLength: number = 1;
value: string;
constructor(private formService: FormService) {
super();
}
ngOnInit() {
this.formService
.getRestFieldValues(
this.field.form.taskId,
this.field.id
)
.subscribe(
(result: FormFieldOption[]) => {
let options = result || [];
this.field.options = options;
this.field.updateForm();
let fieldValue = this.field.value;
if (fieldValue) {
let toSelect = options.find(item => item.id === fieldValue);
if (toSelect) {
this.value = toSelect.name;
}
}
},
this.handleError
);
}
getOptions(): FormFieldOption[] {
let val = this.value.toLocaleLowerCase();
return this.field.options.filter(item => {
let name = item.name.toLocaleLowerCase();
return name.indexOf(val) > -1;
});
}
onKeyUp(event: KeyboardEvent) {
this.popupVisible = !!(this.value && this.value.length >= this.minTermLength);
}
onBlur() {
setTimeout(() => {
this.flushValue();
}, 200);
}
flushValue() {
this.popupVisible = false;
let options = this.field.options || [];
let field = options.find(item => item.name.toLocaleLowerCase() === this.value.toLocaleLowerCase());
if (field) {
this.field.value = field.id;
this.value = field.name;
} else {
this.field.value = null;
this.value = null;
}
this.field.updateForm();
}
onItemClick(item: FormFieldOption, event: Event) {
if (item) {
this.field.value = item.id;
this.value = item.name;
}
if (event) {
event.preventDefault();
}
}
handleError(error: any) {
console.error(error);
}
}

View File

@@ -212,6 +212,12 @@ export class FormService {
.catch(this.handleError); .catch(this.handleError);
} }
getRestFieldValues(taskId: string, field: string): Observable<any> {
let alfrescoApi = this.authService.getAlfrescoApi();
return Observable.fromPromise(alfrescoApi.activiti.taskFormsApi.getRestFieldValues(taskId, field));
}
getFormId(res: any) { getFormId(res: any) {
let result = null; let result = null;