From beb6171e1e90842548f033fbfde7dce9d9988c8a Mon Sep 17 00:00:00 2001 From: cipchk Date: Sun, 20 May 2018 19:08:08 +0800 Subject: [PATCH] test(form): add test unit --- packages/form/docs/getting-started.md | 12 +- packages/form/spec/base.spec.ts | 100 +++++++++++++- packages/form/spec/form.spec.ts | 123 ++++++++++++------ packages/form/spec/schema.spec.ts | 2 +- packages/form/src/model/array.property.ts | 2 +- packages/form/src/model/object.property.ts | 25 ++-- packages/form/src/schema/ui.ts | 1 + packages/form/src/sf.component.ts | 3 +- packages/form/src/utils.ts | 28 +--- .../src/widgets/array/array.widget.spec.ts | 113 ++++++++++++++++ .../form/src/widgets/array/array.widget.ts | 8 +- .../autocomplete/autocomplete.widget.spec.ts | 118 +++++++++++++++++ .../autocomplete/autocomplete.widget.ts | 38 +++--- .../form/src/widgets/autocomplete/index.md | 6 +- .../widgets/boolean/boolean.widget.spec.ts | 52 ++++++++ .../src/widgets/cascader/cascader.widget.ts | 14 +- packages/form/src/widgets/cascader/index.md | 5 +- .../src/widgets/custom/custom.widget.spec.ts | 54 ++++++++ packages/form/src/widgets/number/index.md | 4 +- .../src/widgets/number/number.widget.spec.ts | 117 +++++++++++++++++ packages/form/src/widgets/select/index.md | 6 +- tslint.json | 2 +- 22 files changed, 702 insertions(+), 131 deletions(-) create mode 100644 packages/form/src/widgets/array/array.widget.spec.ts create mode 100644 packages/form/src/widgets/autocomplete/autocomplete.widget.spec.ts create mode 100644 packages/form/src/widgets/boolean/boolean.widget.spec.ts create mode 100644 packages/form/src/widgets/custom/custom.widget.spec.ts create mode 100644 packages/form/src/widgets/number/number.widget.spec.ts diff --git a/packages/form/docs/getting-started.md b/packages/form/docs/getting-started.md index 5c5008afd4..78ebb807f4 100644 --- a/packages/form/docs/getting-started.md +++ b/packages/form/docs/getting-started.md @@ -15,7 +15,17 @@ type: Documents - 可自定义小部件满足业务需求 - 无任何第三方依赖,可适用所有 antd 项目 -## 如何使用? +## 如何阅读 + +在开始之前需要知道文档的一些简单编写规则: + +- 代码以 `schema.` 开头的表示 JSON Schema 对象属性 +- 代码以 `ui.` 开头的表示 UI 对象属性 +- 部分小部件数据源分为 **静态** 和 **实时** 两类 + - **静态** 理解为 `schema.enum` 值,是符合 JSON Schema 标准,且限数组格式 `any[]` + - **实时** 理解为 `ui.asyncData` 值,非 JSON Schema 标准,格式 `(input?: any) => Observable` + +## 如何使用 安装 `@delon/form` 依赖包: diff --git a/packages/form/spec/base.spec.ts b/packages/form/spec/base.spec.ts index d839c73706..edc138892e 100644 --- a/packages/form/spec/base.spec.ts +++ b/packages/form/spec/base.spec.ts @@ -1,8 +1,13 @@ import { Component, ViewChild, DebugElement } from '@angular/core'; -import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { + TestBed, + ComponentFixture, + tick, + discardPeriodicTasks, +} from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { By } from '@angular/platform-browser'; -import { deepGet } from '@delon/util'; +import { deepGet, deepCopy } from '@delon/util'; import { SFSchema } from '../src/schema'; import { SFUISchema } from '../src/schema/ui'; @@ -10,6 +15,7 @@ import { SFButton } from '../src/interface'; import { ErrorData } from '../src/errors'; import { DelonFormModule } from '../src/module'; import { SFComponent } from '../src/sf.component'; +import { dispatchFakeEvent, typeInElement } from '../../testing'; export const SCHEMA = { user: { @@ -28,11 +34,19 @@ export const SCHEMA = { let fixture: ComponentFixture; let dl: DebugElement; let context: TestFormComponent; -export function builder() { +export function builder(options?: { + detectChanges?: boolean; + template?: string; + ingoreAntd?: boolean; +}) { + options = Object.assign({ detectChanges: true }, options); TestBed.configureTestingModule({ imports: [NoopAnimationsModule, DelonFormModule.forRoot()], declarations: [TestFormComponent], }); + if (options.template) { + TestBed.overrideTemplate(TestFormComponent, options.template); + } fixture = TestBed.createComponent(TestFormComponent); dl = fixture.debugElement; context = fixture.componentInstance; @@ -40,7 +54,9 @@ export function builder() { spyOn(context, 'formSubmit'); spyOn(context, 'formReset'); spyOn(context, 'formError'); - fixture.detectChanges(); + if (options.detectChanges !== false) { + fixture.detectChanges(); + } const page = new SFPage(context.comp); return { fixture, @@ -63,8 +79,19 @@ export class SFPage { return el.nativeElement as HTMLElement; } + getWidget(cls: string): T { + return this.getDl(cls).componentInstance as T; + } + + private fixPath(path: string) { + return path.startsWith('/') ? path : '/' + path; + } + setValue(path: string, value: any): this { - this.comp.rootProperty.searchProperty(path).widget.setValue(value); + path = this.fixPath(path); + const property = this.comp.rootProperty.searchProperty(path); + expect(property).not.toBeNull(`can't found ${path}`); + property.widget.setValue(value); return this; } @@ -92,15 +119,33 @@ export class SFPage { this.getEl('.add button').click(); return this; } + /** 下标从 `1` 开始 */ + remove(index = 1): this { + this.getEl( + `.sf-array-container [data-index="${index - 1}"] .remove`, + ).click(); + return this; + } - newSchema(schema: SFSchema, ui?: SFUISchema): this { + newSchema(schema: SFSchema, ui?: SFUISchema, formData?: any): this { context.schema = schema; if (typeof ui !== 'undefined') context.ui = ui; + if (typeof formData !== 'undefined') context.formData = formData; + fixture.detectChanges(); + return this; + } + + /** 强制指定 `a` 节点 */ + chainSchema(schema: SFSchema, overObject: SFSchema): this { + context.schema = Object.assign({}, deepCopy(schema), { + properties: { a: overObject }, + }); fixture.detectChanges(); return this; } checkSchema(path: string, propertyName: string, value: any): this { + path = this.fixPath(path); const property = this.comp.rootProperty.searchProperty(path); expect(property != null).toBe(true); const item = property.schema; @@ -110,6 +155,7 @@ export class SFPage { } checkUI(path: string, propertyName: string, value: any): this { + path = this.fixPath(path); const property = this.comp.rootProperty.searchProperty(path); expect(property != null).toBe(true); const item = property.ui; @@ -119,6 +165,7 @@ export class SFPage { } checkValue(path: string, value: any, propertyName?: string): this { + path = this.fixPath(path); const property = this.comp.rootProperty.searchProperty(path); expect(property != null).toBe(true); if (typeof propertyName !== 'undefined') { @@ -130,6 +177,16 @@ export class SFPage { return this; } + checkElText(cls: string, value: any): this { + const node = this.getEl(cls); + if (value == null) { + expect(node).toBeNull(); + } else { + expect(node.textContent.trim()).toBe(value); + } + return this; + } + checkCls(cls: string, value: string): this { const el = this.getEl(cls); expect(el).not.toBe(null); @@ -157,6 +214,36 @@ export class SFPage { expect(dl.queryAll(By.css(cls)).length).toBe(count); return this; } + + click(cls: string): this { + const el = this.getEl(cls); + expect(el).not.toBeNull(); + el.click(); + fixture.detectChanges(); + return this; + } + + typeChar(value: any, cls = 'input'): this { + const node = this.getEl(cls) as HTMLInputElement; + typeInElement(value, node); + tick(); + fixture.detectChanges(); + return this; + } + + typeEvent(eventName: string, cls = 'input'): this { + const node = this.getEl(cls) as HTMLInputElement; + dispatchFakeEvent(node, eventName); + tick(); + fixture.detectChanges(); + return this; + } + + asyncEnd(time = 0) { + tick(time); + discardPeriodicTasks(); + return this; + } } @Component({ @@ -165,7 +252,6 @@ export class SFPage { [schema]="schema" [ui]="ui" [formData]="formData" - [mode]="mode" [button]="button" [liveValidate]="liveValidate" [autocomplete]="autocomplete" diff --git a/packages/form/spec/form.spec.ts b/packages/form/spec/form.spec.ts index 30bb4a2747..c5a9877f57 100644 --- a/packages/form/spec/form.spec.ts +++ b/packages/form/spec/form.spec.ts @@ -11,9 +11,9 @@ describe('form: component', () => { let context: TestFormComponent; let page: SFPage; - beforeEach(() => ({ fixture, dl, context, page } = builder())); - describe('[default]', () => { + beforeEach(() => ({ fixture, dl, context, page } = builder())); + it('should be create a form', () => { expect(context).not.toBeUndefined(); }); @@ -69,27 +69,10 @@ describe('form: component', () => { }); describe('[button]', () => { + beforeEach(() => ({ fixture, dl, context, page } = builder({}))); it('should be has a primary button when default value', () => { page.checkCount('.sf-btns', 1).checkCount('.ant-btn-primary', 1); }); - it('should be has a fix 100px width', () => { - page - .newSchema({ - properties: { - name: { - type: 'string', - ui: { - spanLabelFixed: 100, - }, - }, - }, - }) - .checkStyle( - '.sf-btns .ant-form-item-control-wrapper', - 'margin-left', - '100px', - ); - }); it('should be null', () => { context.button = null; fixture.detectChanges(); @@ -105,9 +88,57 @@ describe('form: component', () => { fixture.detectChanges(); page.checkCount('.sf-btns', 0); }); + describe('when layout is horizontal', () => { + it('should be has a fix 100px width', () => { + page + .newSchema({ + properties: { + name: { + type: 'string', + ui: { + spanLabelFixed: 100, + }, + }, + }, + }) + .checkStyle( + '.sf-btns .ant-form-item-control-wrapper', + 'margin-left', + '100px', + ); + }); + it('should be specified grid', () => { + const span = 11; + context.button = { + render: { + grid: { span }, + }, + }; + fixture.detectChanges(); + page.checkCls( + '.sf-btns .ant-form-item-control-wrapper', + `ant-col-${span}`, + ); + }); + it('should be fixed label', () => { + const spanLabelFixed = 56; + context.button = { + render: { + spanLabelFixed, + }, + }; + fixture.detectChanges(); + page.checkStyle( + '.sf-btns .ant-form-item-control-wrapper', + 'margin-left', + `${spanLabelFixed}px`, + ); + }); + }); }); describe('properites', () => { + beforeEach(() => ({ fixture, dl, context, page } = builder({}))); describe('#validate', () => { it('should be validate when submitted and not liveValidate', () => { page.submit(false); @@ -170,17 +201,20 @@ describe('form: component', () => { }) .checkStyle('.ant-form-item-label', 'width', '100px'); }); - it('shoule be fixed label width if parent node had setting', () => { + it('should inherit parent node', () => { page .newSchema({ properties: { - name: { type: 'string' }, - }, - ui: { - spanLabelFixed: 99, + address: { + type: 'object', + ui: { spanLabelFixed: 98 }, + properties: { + city: { type: 'string' }, + }, + }, }, }) - .checkStyle('.ant-form-item-label', 'width', '99px'); + .checkStyle('.ant-form-item-label', 'width', '98px'); }); }); }); @@ -208,23 +242,6 @@ describe('form: component', () => { }); }); - describe('#mode', () => { - it('with search', () => { - context.mode = 'search'; - fixture.detectChanges(); - expect(context.comp.layout).toBe('inline'); - expect(context.comp.firstVisual).toBe(false); - expect(context.comp.liveValidate).toBe(false); - }); - it('with edit', () => { - context.mode = 'edit'; - fixture.detectChanges(); - expect(context.comp.layout).toBe('horizontal'); - expect(context.comp.firstVisual).toBe(false); - expect(context.comp.liveValidate).toBe(true); - }); - }); - it('#formChange', () => { page.setValue('/name', 'cipchk'); expect(context.formChange).toHaveBeenCalled(); @@ -253,6 +270,7 @@ describe('form: component', () => { }); describe('[widgets]', () => { + beforeEach(() => ({ fixture, dl, context, page } = builder({}))); it('#size', () => { page .newSchema({ @@ -273,4 +291,23 @@ describe('form: component', () => { .checkCls('sf-string', 'test-cls'); }); }); + + describe('#mode', () => { + beforeEach(() => { + ({ fixture, dl, context, page } = builder({ + detectChanges: false, + template: ``, + })); + }); + it('should be auto 搜索 in submit', () => { + context.mode = 'search'; + fixture.detectChanges(); + expect(page.getEl('.ant-btn-primary').textContent).toBe('搜索'); + }); + it('should be auto 保存 in submit', () => { + context.mode = 'edit'; + fixture.detectChanges(); + expect(page.getEl('.ant-btn-primary').textContent).toBe('保存'); + }); + }); }); diff --git a/packages/form/spec/schema.spec.ts b/packages/form/spec/schema.spec.ts index 34a8e8503e..79aead66a1 100644 --- a/packages/form/spec/schema.spec.ts +++ b/packages/form/spec/schema.spec.ts @@ -429,7 +429,7 @@ describe('form: schema', () => { b: { type: 'string' } }, ui: { - order: ['a', '*', '*'], + order: ['a', '*', '*', '*', '*'], }, }); }).toThrow(); diff --git a/packages/form/src/model/array.property.ts b/packages/form/src/model/array.property.ts index 5ac10a15e2..d1ec2d872a 100644 --- a/packages/form/src/model/array.property.ts +++ b/packages/form/src/model/array.property.ts @@ -86,7 +86,7 @@ export class ArrayProperty extends PropertyGroup { // region: actions - add(value: any = null): FormProperty { + add(value: any): FormProperty { const newProperty = this.addProperty(value); newProperty.resetValue(value, false); return newProperty; diff --git a/packages/form/src/model/object.property.ts b/packages/form/src/model/object.property.ts index fddac41f80..35396285f2 100644 --- a/packages/form/src/model/object.property.ts +++ b/packages/form/src/model/object.property.ts @@ -43,17 +43,15 @@ export class ObjectProperty extends PropertyGroup { ); } orderedProperties.forEach(propertyId => { - if (this.schema.properties.hasOwnProperty(propertyId)) { - const propertySchema = this.schema.properties[propertyId]; - this.properties[propertyId] = this.formPropertyFactory.createProperty( - this.schema.properties[propertyId], - this.ui['$' + propertyId], - (this.formData || {})[propertyId], - this, - propertyId, - ); - this._propertiesId.push(propertyId); - } + const propertySchema = this.schema.properties[propertyId]; + this.properties[propertyId] = this.formPropertyFactory.createProperty( + this.schema.properties[propertyId], + this.ui['$' + propertyId], + (this.formData || {})[propertyId], + this, + propertyId, + ); + this._propertiesId.push(propertyId); }); } @@ -67,10 +65,9 @@ export class ObjectProperty extends PropertyGroup { } resetValue(value: any, onlySelf: boolean) { value = value || this.schema.default || {}; + // tslint:disable-next-line:forin for (const propertyId in this.schema.properties) { - if (this.schema.properties.hasOwnProperty(propertyId)) { - this.properties[propertyId].resetValue(value[propertyId], true); - } + this.properties[propertyId].resetValue(value[propertyId], true); } this.updateValueAndValidity(onlySelf, true); } diff --git a/packages/form/src/schema/ui.ts b/packages/form/src/schema/ui.ts index 775e622fde..3f66967fe5 100644 --- a/packages/form/src/schema/ui.ts +++ b/packages/form/src/schema/ui.ts @@ -125,6 +125,7 @@ export interface SFDataSchema { /** * 异步静态数据源 * - `input` 可能根据不同部件的情况存在值,例如:`autocomplete` 表示当前键入的值 + * - 参数、返回值:可能根据不同部件需求而定,具体参阅相应小部件独立说明 */ asyncData?: (input?: any) => Observable; } diff --git a/packages/form/src/sf.component.ts b/packages/form/src/sf.component.ts index a3d5514942..f90503ed66 100644 --- a/packages/form/src/sf.component.ts +++ b/packages/form/src/sf.component.ts @@ -238,8 +238,9 @@ export class SFComponent implements OnInit, OnChanges, OnDestroy { // 继承父节点布局属性 if (isHorizontal) { if (parentUiSchema.spanLabelFixed) { - if (!ui.spanLabelFixed) + if (!ui.spanLabelFixed) { ui.spanLabelFixed = parentUiSchema.spanLabelFixed; + } } else { if (!ui.spanLabel) ui.spanLabel = diff --git a/packages/form/src/utils.ts b/packages/form/src/utils.ts index 0a7ea77163..c92eb80818 100644 --- a/packages/form/src/utils.ts +++ b/packages/form/src/utils.ts @@ -5,7 +5,11 @@ import { SFUISchema, SFUISchemaItem, SFUISchemaItemRun } from './schema/ui'; import { SFSchema, SFSchemaDefinition, SFSchemaEnum } from './schema'; export const FORMATMAPS = { - 'date-time': { widget: 'date', showTime: true, format: 'YYYY-MM-DDTHH:mm:ssZ' }, + 'date-time': { + widget: 'date', + showTime: true, + format: 'YYYY-MM-DDTHH:mm:ssZ', + }, date: { widget: 'date', format: 'YYYY-MM-DD' }, 'full-date': { widget: 'date', format: 'YYYY-MM-DD' }, time: { widget: 'time' }, @@ -27,10 +31,7 @@ export function di(...args) { } /** 根据 `$ref` 查找 `definitions` */ -function findSchemaDefinition( - $ref: string, - definitions: SFSchemaDefinition = {}, -) { +function findSchemaDefinition($ref: string, definitions: SFSchemaDefinition) { const match = /^#\/definitions\/(.*)$/.exec($ref); if (match && match[1]) { // parser JSON Pointer @@ -115,10 +116,7 @@ export function orderProperties(properties: string[], order: string[]) { prev[curr] = true; return prev; }, {}); - const errorPropList = arr => - arr.length > 1 - ? `properties '${arr.join(`', '`)}'` - : `property '${arr[0]}'`; + const errorPropList = arr => `property [${arr.join(`', '`)}]`; const propertyHash = arrayToHash(properties); const orderHash = arrayToHash(order); @@ -148,18 +146,6 @@ export function orderProperties(properties: string[], order: string[]) { return complete; } -export function getUiOptions(uiSchema: SFUISchema) { - if (!uiSchema) return {}; - return Object.keys(uiSchema) - .filter(key => !key.startsWith('$')) - .reduce( - (options, key) => { - return { ...options, [key]: uiSchema[key] }; - }, - {}, - ); -} - export function getEnum(list: any[], formData: any): SFSchemaEnum[] { if (isBlank(list) || !Array.isArray(list) || list.length === 0) return []; if (typeof list[0] !== 'object') { diff --git a/packages/form/src/widgets/array/array.widget.spec.ts b/packages/form/src/widgets/array/array.widget.spec.ts new file mode 100644 index 0000000000..e96f4b335d --- /dev/null +++ b/packages/form/src/widgets/array/array.widget.spec.ts @@ -0,0 +1,113 @@ +import { DebugElement } from '@angular/core'; +import { ComponentFixture } from '@angular/core/testing'; +import { deepCopy } from '@delon/util'; +import { + builder, + TestFormComponent, + SFPage, + SCHEMA, +} from '../../../spec/base.spec'; +import { SFSchema } from '../../../src/schema/index'; +import { SFUISchemaItem, SFUISchema } from '../../../src/schema/ui'; + +describe('form: widget: array', () => { + let fixture: ComponentFixture; + let dl: DebugElement; + let context: TestFormComponent; + let page: SFPage; + const maxItems = 3; + const schema: SFSchema = { + properties: { + arr: { + type: 'array', + maxItems, + items: { + type: 'object', + properties: { + a: { type: 'string' }, + }, + }, + }, + }, + }; + + beforeEach(() => + ({ fixture, dl, context, page } = builder({ detectChanges: false }))); + + it('should be add item', () => { + page + .newSchema(schema) + .checkCount('.sf-array-item', 0) + .add() + .checkCount('.sf-array-item', 1); + }); + it(`should be maximum ${maxItems}`, () => { + page + .newSchema(schema) + .add() + .add() + .add() + .checkCount('.sf-array-item', maxItems) + .add() + .checkCount('.sf-array-item', maxItems); + }); + it('should be set values', () => { + page + .newSchema(schema) + .checkCount('.sf-array-item', 0) + .add() + .checkCount('.sf-array-item', 1) + .setValue('/arr', []) + .checkCount('.sf-array-item', 0); + }); + describe('#removable', () => { + it('with true', () => { + const s = deepCopy(schema) as SFSchema; + s.properties.arr.ui = { removable: true }; + page + .newSchema(s) + .checkCount('.sf-array-item', 0) + .add() + .checkCount('.sf-array-item', 1) + .remove() + .checkCount('.sf-array-item', 0); + }); + it('with false', () => { + const s = deepCopy(schema) as SFSchema; + s.properties.arr.ui = { removable: false }; + page + .newSchema(s) + .checkCount('.sf-array-item', 0) + .add() + .checkCount('.sf-array-item', 1) + .checkCount(`.sf-array-container [data-index="0"] .remove`, 0); + }); + }); + describe('#default data', () => { + it('via formData in sf component', () => { + const data = { + arr: [{ a: 'a1' }, { a: 'a2' }], + }; + context.formData = data; + page.newSchema(schema).checkCount('.sf-array-item', data.arr.length); + }); + it('via default in schema', () => { + const data = [{ a: 'a1' }, { a: 'a2' }]; + const s = deepCopy(schema) as SFSchema; + s.properties.arr.default = data; + page.newSchema(s).checkCount('.sf-array-item', data.length); + }); + it('should be keeping default value in reset action', () => { + const data = [{ a: 'a1' }, { a: 'a2' }]; + const s = deepCopy(schema) as SFSchema; + s.properties.arr.default = data; + page.newSchema(s) + .checkCount('.sf-array-item', data.length) + .add() + .checkCount('.sf-array-item', data.length + 1) + .reset() + .checkCount('.sf-array-item', data.length) + ; + }); + }); +}); diff --git a/packages/form/src/widgets/array/array.widget.ts b/packages/form/src/widgets/array/array.widget.ts index 85ea135ea0..3016f8cfe9 100644 --- a/packages/form/src/widgets/array/array.widget.ts +++ b/packages/form/src/widgets/array/array.widget.ts @@ -24,7 +24,7 @@ import { ArrayLayoutWidget } from '../../widget'; - + @@ -67,14 +67,10 @@ export class ArrayWidget extends ArrayLayoutWidget implements OnInit { } addItem() { - this.formProperty.add(); + this.formProperty.add(null); } removeItem(index: number) { this.formProperty.remove(index); } - - trackByIndex(index: number, item: any) { - return index; - } } diff --git a/packages/form/src/widgets/autocomplete/autocomplete.widget.spec.ts b/packages/form/src/widgets/autocomplete/autocomplete.widget.spec.ts new file mode 100644 index 0000000000..8290918d2c --- /dev/null +++ b/packages/form/src/widgets/autocomplete/autocomplete.widget.spec.ts @@ -0,0 +1,118 @@ +import { DebugElement } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { ComponentFixture, fakeAsync, tick } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { NzAutocompleteComponent } from 'ng-zorro-antd'; +import { deepCopy } from '@delon/util'; + +import { + builder, + TestFormComponent, + SFPage, + SCHEMA, +} from '../../../spec/base.spec'; +import { SFSchema, SFSchemaEnum } from '../../../src/schema/index'; +import { SFUISchemaItem, SFUISchema } from '../../../src/schema/ui'; +import { AutoCompleteWidget, EMAILSUFFIX } from './autocomplete.widget'; + +describe('form: widget: autocomplete', () => { + let fixture: ComponentFixture; + let dl: DebugElement; + let context: TestFormComponent; + let page: SFPage; + const widget = 'autocomplete'; + + beforeEach(() => + ({ fixture, dl, context, page } = builder({ detectChanges: false }))); + + describe('[data source]', () => { + it( + 'with enum', + fakeAsync(() => { + const data = ['aaa', 'bbb', 'ccc']; + const s: SFSchema = { + properties: { + a: { type: 'string', ui: { widget }, enum: data }, + }, + }; + const typeValue = 'a'; + page + .newSchema(s) + .typeEvent('focusin') + .checkCount('nz-auto-option', data.length) + .click('nz-auto-option') + .checkValue('a', `aaa`) + .asyncEnd(50); + }), + ); + it('with async data', () => { + const s: SFSchema = { + properties: { + a: { + type: 'string', + ui: { widget, asyncData: () => of(['asdf']), debounceTime: 1 }, + }, + }, + }; + page.newSchema(s); + const comp = page.getWidget('sf-autocomplete'); + comp.list.subscribe(res => expect(res[0].label).toBe('asdf')); + }); + it( + 'with email of format', + fakeAsync(() => { + const s: SFSchema = { + properties: { + a: { + type: 'string', + format: 'email', + }, + }, + }; + const typeValue = 'a'; + page + .newSchema(s) + .typeChar(typeValue) + .checkCount('nz-auto-option', EMAILSUFFIX.length) + .click('nz-auto-option') + .checkValue('a', `${typeValue}@${EMAILSUFFIX[0]}`) + .asyncEnd(50); + // TIP: 一个非常不要脸的校验数据正确性的代码 + // 当测试用例需要依赖第三方组件时,如何更好的校验这是一个问题? + // const autoComp = page.getWidget('nz-autocomplete'); + // expect(autoComp.options.length).toBeGreaterThan(0); + // expect(autoComp.options.first.nzValue).toBe('asdf@qq.com'); + }), + ); + }); + + describe('[ui]', () => { + it( + 'should be custom filterOption', + fakeAsync(() => { + const data = ['a1', 'a11', 'a111']; + const s: SFSchema = { + properties: { + a: { + type: 'string', + ui: { + widget, + filterOption: (input: string, option: SFSchemaEnum) => + option.label === 'a11', + }, + enum: data, + }, + }, + }; + const typeValue = 'a'; + page + .newSchema(s) + .typeChar('a1') + .checkCount('nz-auto-option', 1) + .typeChar('a11') + .checkCount('nz-auto-option', 1) + .asyncEnd(50); + }), + ); + }); +}); diff --git a/packages/form/src/widgets/autocomplete/autocomplete.widget.ts b/packages/form/src/widgets/autocomplete/autocomplete.widget.ts index ac4115376c..2f276bbde3 100644 --- a/packages/form/src/widgets/autocomplete/autocomplete.widget.ts +++ b/packages/form/src/widgets/autocomplete/autocomplete.widget.ts @@ -5,7 +5,13 @@ import { ControlWidget } from '../../widget'; import { SFSchemaEnum } from '../../schema'; import { getCopyEnum, getEnum } from '../../utils'; -const EMAILSUFFIX = ['qq.com', '163.com', 'gmail.com', '126.com', 'aliyun.com']; +export const EMAILSUFFIX = [ + 'qq.com', + '163.com', + 'gmail.com', + '126.com', + 'aliyun.com', +]; @Component({ selector: 'sf-autocomplete', @@ -47,11 +53,8 @@ export class AutoCompleteWidget extends ControlWidget implements OnInit { this.filterOption = this.ui.filterOption || true; if (typeof this.filterOption === 'boolean') { - this.filterOption = - this.filterOption === true - ? (input: string, option: SFSchemaEnum) => - option.label.toLowerCase().indexOf(input.toLowerCase()) > -1 - : () => true; + this.filterOption = (input: string, option: SFSchemaEnum) => + option.label.toLowerCase().indexOf(input.toLowerCase()) > -1; } this.isAsync = !!this.ui.asyncData; @@ -69,18 +72,17 @@ export class AutoCompleteWidget extends ControlWidget implements OnInit { } reset(value: any) { - if (!this.isAsync) { - switch (this.ui.type) { - case 'email': - this.fixData = getCopyEnum(EMAILSUFFIX, null); - break; - default: - this.fixData = getCopyEnum( - this.schema.enum, - this.formProperty.formData, - ); - break; - } + if (this.isAsync) return; + switch (this.ui.type) { + case 'email': + this.fixData = getCopyEnum(EMAILSUFFIX, null); + break; + default: + this.fixData = getCopyEnum( + this.schema.enum, + this.formProperty.formData, + ); + break; } } diff --git a/packages/form/src/widgets/autocomplete/index.md b/packages/form/src/widgets/autocomplete/index.md index c452ef2ed4..c8729bb894 100644 --- a/packages/form/src/widgets/autocomplete/index.md +++ b/packages/form/src/widgets/autocomplete/index.md @@ -10,11 +10,13 @@ type: Widgets **静态** -指获取后每一次筛选是通过 `filterOption` 过滤,数据来源于 `asyncData`。 +指获取后每一次筛选是通过 `filterOption` 过滤,数据来源于 `asyncData`、`enum`。 + +若 `schema.format: 'email'` 时自动渲染为自动补全邮箱后缀,默认 `['qq.com', '163.com', 'gmail.com', '126.com', 'aliyun.com']` 可通过 `enum` 来重新调整该值。 **实时** -指每一次筛选都会触发一个HTTP请求,数据来源于 `asyncData`。 +指获取后每一次筛选是通过 `filterOption` 过滤,数据来源于 `asyncData`。 ## API diff --git a/packages/form/src/widgets/boolean/boolean.widget.spec.ts b/packages/form/src/widgets/boolean/boolean.widget.spec.ts new file mode 100644 index 0000000000..253b1d2089 --- /dev/null +++ b/packages/form/src/widgets/boolean/boolean.widget.spec.ts @@ -0,0 +1,52 @@ +import { DebugElement } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { ComponentFixture, fakeAsync, tick } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { deepCopy } from '@delon/util'; + +import { + builder, + TestFormComponent, + SFPage, + SCHEMA, +} from '../../../spec/base.spec'; +import { SFSchema, SFSchemaEnum } from '../../../src/schema/index'; +import { SFUISchemaItem, SFUISchema } from '../../../src/schema/ui'; + +describe('form: widget: boolean', () => { + let fixture: ComponentFixture; + let dl: DebugElement; + let context: TestFormComponent; + let page: SFPage; + const widget = 'boolean'; + const clickCls = '.ant-switch'; + + beforeEach(() => + ({ fixture, dl, context, page } = builder({ detectChanges: false }))); + + it('should be default true via schema.default', () => { + const s: SFSchema = { + properties: { a: { type: 'string', ui: { widget }, default: true } }, + }; + page.newSchema(s).checkValue('a', true); + }); + + describe('[ui]', () => { + it('should be custom (un)checked children', () => { + const s: SFSchema = { + properties: { + a: { + type: 'string', + ui: { widget, checkedChildren: 'Y', unCheckedChildren: 'N' }, + }, + }, + }; + page + .newSchema(s) + .click(clickCls) + .checkElText('.ant-switch-inner', 'Y') + .click(clickCls) + .checkElText('.ant-switch-inner', 'N'); + }); + }); +}); diff --git a/packages/form/src/widgets/cascader/cascader.widget.ts b/packages/form/src/widgets/cascader/cascader.widget.ts index 7ecff542b1..50683de81a 100644 --- a/packages/form/src/widgets/cascader/cascader.widget.ts +++ b/packages/form/src/widgets/cascader/cascader.widget.ts @@ -51,9 +51,9 @@ export class CascaderWidget extends ControlWidget implements OnInit { this.showArrow = this.ui.showArrow || true; this.showInput = this.ui.showInput || true; this.triggerAction = this.ui.triggerAction || ['click']; - if (!!this.ui.loadData) { + if (!!this.ui.asyncData) { this.loadData = (node: any, index: number) => - this.ui.loadData(node, index, this); + (this.ui.asyncData as any)(node, index, this); } } @@ -67,23 +67,23 @@ export class CascaderWidget extends ControlWidget implements OnInit { } _visibleChange(status: boolean) { - if (this.ui.visibleChange) this.ui.visibleChange(status); + this.ui.visibleChange && this.ui.visibleChange(status); } _change(value: string) { this.setValue(value); - if (this.ui.change) this.ui.change(value); + this.ui.change && this.ui.change(value); } _selectionChange(options: any) { - if (this.ui.selectionChange) this.ui.selectionChange(options); + this.ui.selectionChange && this.ui.selectionChange(options); } _select(options: any) { - if (this.ui.select) this.ui.select(options); + this.ui.select && this.ui.select(options); } _clear(options: any) { - if (this.ui.clear) this.ui.clear(options); + this.ui.clear && this.ui.clear(options); } } diff --git a/packages/form/src/widgets/cascader/index.md b/packages/form/src/widgets/cascader/index.md index bfa88291f2..758056b887 100644 --- a/packages/form/src/widgets/cascader/index.md +++ b/packages/form/src/widgets/cascader/index.md @@ -18,7 +18,7 @@ type: Widgets **实时** -指每一次选择会触发HTTP请求,数据来源于 `loadData`;包含三个参数 `(node: CascaderOption, index: number, me: CascaderWidget) => PromiseLike`,其中 `me` 表示当前小部件实例,由于所有小部件的变更检测都是手控,因此数据请求返回后,**务必调用** `me.detectChanges()` 触发小部件变更检测。 +指每一次每一次选择会触发HTTP请求,数据来源于 `asyncData`;包含三个参数 `(node: CascaderOption, index: number, me: CascaderWidget) => PromiseLike`,其中 `me` 表示当前小部件实例,由于所有小部件的变更检测都是手控,因此数据请求返回后,**务必调用** `me.detectChanges()` 触发小部件变更检测。 ## API @@ -33,7 +33,7 @@ readOnly | 禁用状态 | `boolean` | - 参数 | 说明 | 类型 | 默认值 ----|------|-----|------ -asyncData | 异步静态数据源 | `(input: string) => Observable` | - +asyncData | 异步静态数据源 | `(node: CascaderOption, index: number, me: CascaderWidget) => PromiseLike` | - size | 大小,等同 `nzSize` | `string` | - placeholder | 在文字框中显示提示讯息 | `string` | - showSearch | 是否支持搜索 | `bool` | `false` @@ -55,5 +55,4 @@ visibleChange | 异步加载事件 | `(value: boolean) => void` | - change | 选项值变更事件 | `(values: any[]) => void` | - selectionChange | 选项变更事件 | `(values: CascaderOption[]) => void` | - select | 选项被选中事件 | `(values: { option: CascaderOption, index: number }) => void` | - -loadData | 实时数据源 | `(node: CascaderOption, index: number, me: CascaderWidget) => PromiseLike` | - clear | 内容被清空事件 | `() => void` | - diff --git a/packages/form/src/widgets/custom/custom.widget.spec.ts b/packages/form/src/widgets/custom/custom.widget.spec.ts new file mode 100644 index 0000000000..d7426774eb --- /dev/null +++ b/packages/form/src/widgets/custom/custom.widget.spec.ts @@ -0,0 +1,54 @@ +import { DebugElement } from '@angular/core'; +import { ComponentFixture } from '@angular/core/testing'; +import { deepCopy } from '@delon/util'; +import { + builder, + TestFormComponent, + SFPage, + SCHEMA, +} from '../../../spec/base.spec'; +import { SFSchema } from '../../../src/schema/index'; +import { SFUISchemaItem, SFUISchema } from '../../../src/schema/ui'; + +describe('form: widget: custom', () => { + let fixture: ComponentFixture; + let dl: DebugElement; + let context: TestFormComponent; + let page: SFPage; + const schema: SFSchema = { + properties: { a: { type: 'string', ui: { widget: 'custom' } } }, + }; + + function detectChanges(path = '/a') { + context.comp.rootProperty.searchProperty(path).widget.detectChanges(); + return page; + } + + it('should be custom widget', () => { + ({ fixture, dl, context, page } = builder({ + detectChanges: false, + template: `custom:
{{ id }}
` + })); + page.newSchema(schema); + detectChanges().checkCount('.custom-el', 1); + }); + + it('should be auto fix path when not start with /', () => { + ({ fixture, dl, context, page } = builder({ + detectChanges: false, + template: `custom:
{{ id }}
` + })); + page.newSchema(schema); + detectChanges().checkCount('.custom-el', 1); + }); + + it('should be throw error when not found path', () => { + spyOn(console, 'warn'); + ({ fixture, dl, context, page } = builder({ + detectChanges: false, + template: `custom:
{{ id }}
` + })); + page.newSchema(schema); + expect(console.warn).toHaveBeenCalled(); + }); +}); diff --git a/packages/form/src/widgets/number/index.md b/packages/form/src/widgets/number/index.md index 5afe880aca..db1cca8f62 100644 --- a/packages/form/src/widgets/number/index.md +++ b/packages/form/src/widgets/number/index.md @@ -17,9 +17,9 @@ type: Widgets 参数 | 说明 | 类型 | 默认值 ----|------|-----|------ minimum | 最小值 | `number` | - -exclusiveMinimum | 约束是否包括 `minimum` 值 | `boolean` | - +exclusiveMinimum | 约束是否包括 `minimum` 值,`true` 表示排除 `minimum` 值 | `boolean` | - maximum | 最小值 | `number` | - -exclusiveMaximum | 约束是否包括 `maximum` 值 | `boolean` | - +exclusiveMaximum | 约束是否包括 `maximum` 值,`true` 表示排除 `maximum` 值 | `boolean` | - multipleOf | 倍数 | `number` | `1` ### ui 属性 diff --git a/packages/form/src/widgets/number/number.widget.spec.ts b/packages/form/src/widgets/number/number.widget.spec.ts new file mode 100644 index 0000000000..e0982e4b24 --- /dev/null +++ b/packages/form/src/widgets/number/number.widget.spec.ts @@ -0,0 +1,117 @@ +import { DebugElement } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { ComponentFixture, fakeAsync, tick } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { deepCopy } from '@delon/util'; + +import { + builder, + TestFormComponent, + SFPage, + SCHEMA, +} from '../../../spec/base.spec'; +import { SFSchema, SFSchemaEnum } from '../../../src/schema/index'; +import { SFUISchemaItem, SFUISchema } from '../../../src/schema/ui'; + +describe('form: widget: number', () => { + let fixture: ComponentFixture; + let dl: DebugElement; + let context: TestFormComponent; + let page: SFPage; + const widget = 'number'; + + beforeEach(() => + ({ fixture, dl, context, page } = builder({ detectChanges: false }))); + + it('should be default true via schema.default', () => { + const s: SFSchema = { + properties: { a: { type: 'number', default: 1 } }, + }; + page.newSchema(s).checkValue('a', 1); + }); + describe('#limit', () => { + it( + 'should be limit via schema.minimum & maximum', + fakeAsync(() => { + const minimum = 10, + maximum = 100; + const s: SFSchema = { + properties: { a: { type: 'number', minimum, maximum, default: 1 } }, + }; + page + .newSchema(s) + .typeChar(minimum - 1) + .typeEvent('blur') + .checkValue('a', minimum) + .typeChar(maximum + 1) + .typeEvent('blur') + .checkValue('a', maximum); + }), + ); + it( + 'should be exclusive min(max)imum via exclusive', + fakeAsync(() => { + const minimum = 10, + maximum = 100; + const s: SFSchema = { + properties: { + a: { + type: 'number', + minimum, + exclusiveMinimum: true, + maximum, + exclusiveMaximum: true, + default: 1, + }, + }, + }; + page + .newSchema(s) + .typeChar(minimum - 1) + .typeEvent('blur') + .checkValue('a', minimum + 1) + .typeChar(maximum + 1) + .typeEvent('blur') + .checkValue('a', maximum - 1); + }), + ); + it( + 'should be trunc value when schema type is integer', + fakeAsync(() => { + const minimum = 10.8, + maximum = 100.8; + const s: SFSchema = { + properties: { a: { type: 'integer', minimum, maximum, default: 1 } }, + }; + page + .newSchema(s) + .typeChar(minimum - 1) + .typeEvent('blur') + .checkValue('a', 10) + .typeChar(maximum + 1) + .typeEvent('blur') + .checkValue('a', 100); + }), + ); + }); + describe('[ui]', () => { + it( + '#formatter & #parser', + fakeAsync(() => { + const s: SFSchema = { + properties: { a: { type: 'number', default: 1 } }, + }; + const ui = (s.properties.a.ui = { + formatter: jasmine.createSpy('formatter'), + parser: jasmine.createSpy('parser'), + }); + page + .newSchema(s) + .typeChar(10) + .typeEvent('blur'); + expect(ui.formatter).toHaveBeenCalled(); + expect(ui.parser).toHaveBeenCalled(); + }), + ); + }); +}); diff --git a/packages/form/src/widgets/select/index.md b/packages/form/src/widgets/select/index.md index 00db12aff0..d38ebf3526 100644 --- a/packages/form/src/widgets/select/index.md +++ b/packages/form/src/widgets/select/index.md @@ -32,6 +32,6 @@ maxMultipleCount | 最多选中多少个标签| `number` | `Infinity` mode | 设置 nz-select 的模式,`tags` 建议增加 `default: null`,否则可能会遇到初始化有一个空的标签。 | `multiple,tags,default` | `default` notFoundContent | 当下拉列表为空时显示的内容 | `string` | - showSearch | 使单选模式可搜索 | `boolean` | `false` -searchChange | 搜索内容变化回调函数,参数为搜索内容,必须返回 `Promise` 对象 | `Function` | - -openChange | 下拉菜单打开关闭回调函数 | `Function` | - -scrollToBottom | 下拉菜单滚动到底部回调,可用于作为动态加载的触发条件 | `Function` | - +onSearch | 搜索内容变化回调函数,参数为搜索内容,必须返回 `Promise` 对象 | `(text: string) => Promise` | - +openChange | 下拉菜单打开关闭回调函数 | `(nzOpen:boolean)=>{}` | - +scrollToBottom | 下拉菜单滚动到底部回调,可用于作为动态加载的触发条件 | `()=>{}` | - diff --git a/tslint.json b/tslint.json index 133ba45e31..19c8ecf9ed 100644 --- a/tslint.json +++ b/tslint.json @@ -48,7 +48,7 @@ "no-switch-case-fall-through": true, "no-trailing-whitespace": false, "no-unnecessary-initializer": true, - "no-unused-expression": true, + "no-unused-expression": false, "no-use-before-declare": true, "no-var-keyword": true, "object-literal-sort-keys": false,