From 30e9e6a73d8b24867151f201a173ef7405762f61 Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Mon, 22 Jun 2020 19:38:08 +0300 Subject: [PATCH] feat: add extensibility system as @abp/ng.theme.shared/extensions --- .../theme-shared/extensions/ng-package.json | 8 + .../src/lib/adapters/date-time.adapter.ts | 56 ++++ .../src/lib/adapters/date.adapter.ts | 29 ++ .../src/lib/adapters/time.adapter.ts | 35 ++ .../abstract-actions.component.ts | 32 ++ .../date-time-picker.component.ts | 74 +++++ .../extensible-form-prop.component.html | 92 ++++++ .../extensible-form-prop.component.ts | 80 +++++ .../extensible-form.component.html | 22 ++ .../extensible-form.component.ts | 82 +++++ .../extensible-table.component.html | 39 +++ .../extensible-table.component.ts | 88 +++++ .../grid-actions/grid-actions.component.html | 24 ++ .../grid-actions/grid-actions.component.ts | 36 +++ .../page-toolbar/page-toolbar.component.html | 18 ++ .../page-toolbar/page-toolbar.component.ts | 49 +++ .../src/lib/constants/extra-properties.ts | 1 + .../src/lib/directives/disabled.directive.ts | 19 ++ .../src/lib/directives/prop-data.directive.ts | 54 ++++ .../extensions/src/lib/enums/props.enum.ts | 12 + .../extensions/src/lib/models/actions.ts | 79 +++++ .../src/lib/models/entity-actions.ts | 53 +++ .../extensions/src/lib/models/entity-props.ts | 67 ++++ .../extensions/src/lib/models/form-props.ts | 109 +++++++ .../lib/models/internal/object-extensions.ts | 96 ++++++ .../src/lib/models/object-extensions.ts | 3 + .../extensions/src/lib/models/props.ts | 85 +++++ .../src/lib/models/toolbar-actions.ts | 78 +++++ .../src/lib/services/extensions.service.ts | 16 + .../src/lib/tokens/extensions.token.ts | 13 + .../src/lib/ui-extensions.module.ts | 48 +++ .../extensions/src/lib/utils/actions.util.ts | 49 +++ .../extensions/src/lib/utils/enum.util.ts | 73 +++++ .../extensions/src/lib/utils/factory.util.ts | 3 + .../src/lib/utils/form-props.util.ts | 61 ++++ .../src/lib/utils/localization.util.ts | 27 ++ .../extensions/src/lib/utils/props.util.ts | 62 ++++ .../extensions/src/lib/utils/state.util.ts | 174 ++++++++++ .../src/lib/utils/validation.util.ts | 14 + .../theme-shared/extensions/src/public-api.ts | 70 ++++ .../extensions/src/tests/actions.spec.ts | 22 ++ .../extensions/src/tests/actions.util.spec.ts | 53 +++ .../src/tests/date-time.adapter.spec.ts | 35 ++ .../extensions/src/tests/date.adapter.spec.ts | 31 ++ .../src/tests/entity-actions.spec.ts | 151 +++++++++ .../extensions/src/tests/entity-props.spec.ts | 166 ++++++++++ .../extensions/src/tests/enum.util.spec.ts | 133 ++++++++ .../src/tests/extensions.service.spec.ts | 50 +++ .../extensions/src/tests/factory.util.spec.ts | 28 ++ .../extensions/src/tests/form-props.spec.ts | 193 +++++++++++ .../src/tests/form-props.util.spec.ts | 142 ++++++++ .../src/tests/localization.util.spec.ts | 31 ++ .../extensions/src/tests/props.spec.ts | 22 ++ .../extensions/src/tests/props.util.spec.ts | 72 +++++ .../extensions/src/tests/state.util.spec.ts | 302 ++++++++++++++++++ .../extensions/src/tests/time.adapter.spec.ts | 36 +++ .../src/tests/toolbar-actions.spec.ts | 216 +++++++++++++ .../src/tests/validation.util.spec.ts | 20 ++ 58 files changed, 3733 insertions(+) create mode 100644 npm/ng-packs/packages/theme-shared/extensions/ng-package.json create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/adapters/date-time.adapter.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/adapters/date.adapter.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/adapters/time.adapter.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/components/abstract-actions/abstract-actions.component.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/components/date-time-picker/date-time-picker.component.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/components/extensible-form/extensible-form-prop.component.html create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/components/extensible-form/extensible-form-prop.component.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/components/extensible-form/extensible-form.component.html create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/components/extensible-form/extensible-form.component.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/components/extensible-table/extensible-table.component.html create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/components/extensible-table/extensible-table.component.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/components/grid-actions/grid-actions.component.html create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/components/grid-actions/grid-actions.component.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/components/page-toolbar/page-toolbar.component.html create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/components/page-toolbar/page-toolbar.component.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/constants/extra-properties.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/directives/disabled.directive.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/directives/prop-data.directive.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/enums/props.enum.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/models/actions.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/models/entity-actions.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/models/entity-props.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/models/form-props.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/models/internal/object-extensions.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/models/object-extensions.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/models/props.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/models/toolbar-actions.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/services/extensions.service.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/tokens/extensions.token.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/ui-extensions.module.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/actions.util.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/enum.util.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/factory.util.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/form-props.util.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/localization.util.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/props.util.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/state.util.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/validation.util.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/public-api.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/tests/actions.spec.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/tests/actions.util.spec.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/tests/date-time.adapter.spec.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/tests/date.adapter.spec.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/tests/entity-actions.spec.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/tests/entity-props.spec.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/tests/enum.util.spec.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/tests/extensions.service.spec.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/tests/factory.util.spec.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/tests/form-props.spec.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/tests/form-props.util.spec.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/tests/localization.util.spec.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/tests/props.spec.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/tests/props.util.spec.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/tests/state.util.spec.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/tests/time.adapter.spec.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/tests/toolbar-actions.spec.ts create mode 100644 npm/ng-packs/packages/theme-shared/extensions/src/tests/validation.util.spec.ts diff --git a/npm/ng-packs/packages/theme-shared/extensions/ng-package.json b/npm/ng-packs/packages/theme-shared/extensions/ng-package.json new file mode 100644 index 0000000000..37346b8d80 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/ng-package.json @@ -0,0 +1,8 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/theme-shared/extensions", + "lib": { + "entryFile": "src/public-api.ts" + }, + "whitelistedNonPeerDependencies": ["@abp/ng.theme.shared"] +} diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/adapters/date-time.adapter.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/adapters/date-time.adapter.ts new file mode 100644 index 0000000000..11da774d1b --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/adapters/date-time.adapter.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@angular/core'; +import { NgbDateStruct, NgbTimeStruct } from '@ng-bootstrap/ng-bootstrap'; + +@Injectable() +export class DateTimeAdapter { + value: NgbDateTimeStruct; + + fromModel(value: string | Date): NgbDateTimeStruct | null { + if (!value) return null; + + const date = new Date(value); + + if (isNaN((date as unknown) as number)) return null; + + this.value = { + year: date.getFullYear(), + month: date.getMonth() + 1, + day: date.getDate(), + hour: date.getHours(), + minute: date.getMinutes(), + second: date.getSeconds(), + }; + + return this.value; + } + + toModel(value: NgbDateTimeStruct | null): string { + if (!value) return ''; + + const now = new Date(); + + value = { + year: now.getUTCFullYear(), + month: now.getMonth() + 1, + day: now.getDate(), + hour: 0, + minute: 0, + second: 0, + ...this.value, + ...value, + }; + + const date = new Date( + value.year, + value.month - 1, + value.day, + value.hour, + value.minute, + value.second, + ); + + return new Date(date).toISOString(); + } +} + +type NgbDateTimeStruct = NgbDateStruct & NgbTimeStruct; diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/adapters/date.adapter.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/adapters/date.adapter.ts new file mode 100644 index 0000000000..4449bae024 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/adapters/date.adapter.ts @@ -0,0 +1,29 @@ +import { formatDate } from '@angular/common'; +import { Injectable } from '@angular/core'; +import { NgbDateAdapter, NgbDateStruct } from '@ng-bootstrap/ng-bootstrap'; + +@Injectable() +export class DateAdapter extends NgbDateAdapter { + fromModel(value: string | Date): NgbDateStruct | null { + if (!value) return null; + + const date = new Date(value); + + if (isNaN((date as unknown) as number)) return null; + + return { + day: date.getDate(), + month: date.getMonth() + 1, + year: date.getFullYear(), + }; + } + + toModel(value: NgbDateStruct | null): string { + if (!value) return ''; + + const date = new Date(value.year, value.month - 1, value.day); + const formattedDate = formatDate(date, 'yyyy-MM-dd', 'en'); + + return formattedDate; + } +} diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/adapters/time.adapter.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/adapters/time.adapter.ts new file mode 100644 index 0000000000..33c25ddd8c --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/adapters/time.adapter.ts @@ -0,0 +1,35 @@ +import { formatDate } from '@angular/common'; +import { Injectable } from '@angular/core'; +import { NgbTimeAdapter, NgbTimeStruct } from '@ng-bootstrap/ng-bootstrap'; + +@Injectable() +export class TimeAdapter extends NgbTimeAdapter { + fromModel(value: string | Date): NgbTimeStruct | null { + if (!value) return null; + + const date = isTimeStr(value) + ? new Date(0, 0, 1, ...value.split(':').map(Number)) + : new Date(value); + + if (isNaN((date as unknown) as number)) return null; + + return { + hour: date.getHours(), + minute: date.getMinutes(), + second: date.getSeconds(), + }; + } + + toModel(value: NgbTimeStruct | null): string { + if (!value) return ''; + + const date = new Date(0, 0, 1, value.hour, value.minute, value.second); + const formattedDate = formatDate(date, 'HH:mm', 'en'); + + return formattedDate; + } +} + +function isTimeStr(value: string | Date): value is string { + return /^((2[123])|[01][0-9])(\:[0-5][0-9]){1,2}$/.test(String(value)); +} diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/abstract-actions/abstract-actions.component.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/abstract-actions/abstract-actions.component.ts new file mode 100644 index 0000000000..14d680059a --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/abstract-actions/abstract-actions.component.ts @@ -0,0 +1,32 @@ +import { Directive, Injector, Input } from '@angular/core'; +import { ActionData, ActionList } from '../../models/actions'; +import { ExtensionsService } from '../../services/extensions.service'; +import { EXTENSIONS_ACTION_TYPE, EXTENSIONS_IDENTIFIER } from '../../tokens/extensions.token'; + +// tslint:disable: directive-class-suffix +// Fix for https://github.com/angular/angular/issues/23904 +// @dynamic +@Directive() +export abstract class AbstractActionsComponent> extends ActionData< + InferredRecord +> { + readonly actionList: L; + + readonly getInjected: InferredData['getInjected']; + + @Input() readonly record: InferredData['record']; + + constructor(injector: Injector) { + super(); + + // tslint:disable-next-line + this.getInjected = injector.get.bind(injector); + const extensions = injector.get(ExtensionsService); + const name = injector.get(EXTENSIONS_IDENTIFIER); + const type = injector.get(EXTENSIONS_ACTION_TYPE); + this.actionList = (extensions[type].get(name).actions as unknown) as L; + } +} + +type InferredData = ActionData>; +type InferredRecord = L extends ActionList ? R : never; diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/date-time-picker/date-time-picker.component.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/date-time-picker/date-time-picker.component.ts new file mode 100644 index 0000000000..157f9f49e2 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/date-time-picker/date-time-picker.component.ts @@ -0,0 +1,74 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Input, + Optional, + SkipSelf, + ViewChild, +} from '@angular/core'; +import { ControlContainer } from '@angular/forms'; +import { + NgbDateAdapter, + NgbInputDatepicker, + NgbTimeAdapter, + NgbTimepicker, +} from '@ng-bootstrap/ng-bootstrap'; +import { DateTimeAdapter } from '../../adapters/date-time.adapter'; +import { FormProp } from '../../models/form-props'; +import { selfFactory } from '../../utils/factory.util'; + +@Component({ + exportAs: 'abpDateTimePicker', + selector: 'abp-date-time-picker', + template: ` + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + viewProviders: [ + { + provide: ControlContainer, + useFactory: selfFactory, + deps: [[new Optional(), new SkipSelf(), ControlContainer]], + }, + { + provide: NgbDateAdapter, + useClass: DateTimeAdapter, + }, + { + provide: NgbTimeAdapter, + useClass: DateTimeAdapter, + }, + ], +}) +export class DateTimePickerComponent { + @Input() prop: FormProp; + + @ViewChild(NgbInputDatepicker) date: NgbInputDatepicker; + @ViewChild(NgbTimepicker) time: NgbTimepicker; + + constructor(public readonly cdRef: ChangeDetectorRef) {} + + setDate(datestr: string) { + this.date.writeValue(datestr); + } + + setTime(datestr: string) { + this.time.writeValue(datestr); + } +} diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/extensible-form/extensible-form-prop.component.html b/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/extensible-form/extensible-form-prop.component.html new file mode 100644 index 0000000000..ef1647b540 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/extensible-form/extensible-form-prop.component.html @@ -0,0 +1,92 @@ +
+ + + + + + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/extensible-form/extensible-form-prop.component.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/extensible-form/extensible-form-prop.component.ts new file mode 100644 index 0000000000..f796c2d5fe --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/extensible-form/extensible-form-prop.component.ts @@ -0,0 +1,80 @@ +import { TrackByService } from '@abp/ng.core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Input, + Optional, + SkipSelf, +} from '@angular/core'; +import { ControlContainer, Validators } from '@angular/forms'; +import { NgbDateAdapter, NgbTimeAdapter } from '@ng-bootstrap/ng-bootstrap'; +import { DateAdapter } from '../../adapters/date.adapter'; +import { TimeAdapter } from '../../adapters/time.adapter'; +import { ePropType } from '../../enums/props.enum'; +import { FormProp } from '../../models/form-props'; +import { PropData } from '../../models/props'; +import { selfFactory } from '../../utils/factory.util'; + +@Component({ + selector: 'abp-extensible-form-prop', + templateUrl: './extensible-form-prop.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + viewProviders: [ + { + provide: ControlContainer, + useFactory: selfFactory, + deps: [[new Optional(), new SkipSelf(), ControlContainer]], + }, + { provide: NgbDateAdapter, useClass: DateAdapter }, + { provide: NgbTimeAdapter, useClass: TimeAdapter }, + ], +}) +export class ExtensibleFormPropComponent { + @Input() data: PropData; + + @Input() prop: FormProp; + + constructor(public readonly cdRef: ChangeDetectorRef, public readonly track: TrackByService) {} + + getAsterisk(prop: FormProp, data: PropData): string { + return prop.validators(data).some(validator => validator === Validators.required) ? '*' : ''; + } + + getComponent(prop: FormProp): string { + if (prop.options) return 'select'; + + switch (prop.type) { + case ePropType.Boolean: + return 'checkbox'; + case ePropType.Date: + return 'date'; + case ePropType.DateTime: + return 'dateTime'; + case ePropType.Text: + return 'textarea'; + case ePropType.Time: + return 'time'; + default: + return 'input'; + } + } + + getType(prop: FormProp): string { + switch (prop.type) { + case ePropType.Date: + case ePropType.String: + return 'text'; + case ePropType.Boolean: + return 'checkbox'; + case ePropType.Number: + return 'number'; + case ePropType.Email: + return 'email'; + case ePropType.Password: + return 'password'; + default: + return 'hidden'; + } + } +} diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/extensible-form/extensible-form.component.html b/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/extensible-form/extensible-form.component.html new file mode 100644 index 0000000000..f8b5fba383 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/extensible-form/extensible-form.component.html @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/extensible-form/extensible-form.component.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/extensible-form/extensible-form.component.ts new file mode 100644 index 0000000000..995b29b8d1 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/extensible-form/extensible-form.component.ts @@ -0,0 +1,82 @@ +import { TrackByService } from '@abp/ng.core'; +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Inject, + Input, + OnDestroy, + Optional, + QueryList, + SkipSelf, + ViewChildren, +} from '@angular/core'; +import { ControlContainer, FormGroup } from '@angular/forms'; +import { Subscription } from 'rxjs'; +import { debounceTime } from 'rxjs/operators'; +import { EXTRA_PROPERTIES_KEY } from '../../constants/extra-properties'; +import { FormPropList } from '../../models/form-props'; +import { ExtensionsService } from '../../services/extensions.service'; +import { EXTENSIONS_IDENTIFIER } from '../../tokens/extensions.token'; +import { selfFactory } from '../../utils/factory.util'; +import { ExtensibleFormPropComponent } from './extensible-form-prop.component'; + +@Component({ + exportAs: 'abpExtensibleForm', + selector: 'abp-extensible-form', + templateUrl: './extensible-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + viewProviders: [ + { + provide: ControlContainer, + useFactory: selfFactory, + deps: [[new Optional(), new SkipSelf(), ControlContainer]], + }, + ], +}) +export class ExtensibleFormComponent implements AfterViewInit, OnDestroy { + @ViewChildren(ExtensibleFormPropComponent) + formProps: QueryList; + + @Input() + set selectedRecord(record: R) { + const type = !record || JSON.stringify(record) === '{}' ? 'create' : 'edit'; + this.propList = this.extensions[`${type}FormProps`].get(this.identifier).props; + this.record = record; + } + + private subscription = new Subscription(); + extraPropertiesKey = EXTRA_PROPERTIES_KEY; + propList: FormPropList; + record: R; + + get form(): FormGroup { + return (this.container ? this.container.control : { controls: {} }) as FormGroup; + } + + get extraProperties(): FormGroup { + return (this.form.controls.extraProperties || { controls: {} }) as FormGroup; + } + + constructor( + public readonly cdRef: ChangeDetectorRef, + public readonly track: TrackByService, + private container: ControlContainer, + private extensions: ExtensionsService, + @Inject(EXTENSIONS_IDENTIFIER) private identifier: string, + ) {} + + ngAfterViewInit() { + this.subscription.add( + this.form.statusChanges.pipe(debounceTime(0)).subscribe(() => { + this.formProps.forEach(prop => prop.cdRef.markForCheck()); + this.cdRef.detectChanges(); + }), + ); + } + + ngOnDestroy() { + this.subscription.unsubscribe(); + } +} diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/extensible-table/extensible-table.component.html b/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/extensible-table/extensible-table.component.html new file mode 100644 index 0000000000..d30b005a98 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/extensible-table/extensible-table.component.html @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + +
+
+
+
+
+
diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/extensible-table/extensible-table.component.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/extensible-table/extensible-table.component.ts new file mode 100644 index 0000000000..7768d70c2c --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/extensible-table/extensible-table.component.ts @@ -0,0 +1,88 @@ +import { ListService } from '@abp/ng.core'; +import { formatDate } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + Inject, + Injector, + Input, + LOCALE_ID, + TemplateRef, + TrackByFunction, +} from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { ePropType } from '../../enums/props.enum'; +import { EntityProp, EntityPropList } from '../../models/entity-props'; +import { PropData } from '../../models/props'; +import { ExtensionsService } from '../../services/extensions.service'; +import { EXTENSIONS_IDENTIFIER } from '../../tokens/extensions.token'; +const DEFAULT_ACTIONS_COLUMN_WIDTH = 150; + +@Component({ + exportAs: 'abpExtensibleTable', + selector: 'abp-extensible-table', + templateUrl: './extensible-table.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ExtensibleTableComponent { + @Input() actionsText: string; + @Input() data: R[]; + @Input() list: ListService; + @Input() recordsTotal: number; + @Input() set actionsColumnWidth(width: number) { + this.setColumnWidths(width ? Number(width) : undefined); + } + @Input() actionsTemplate: TemplateRef; + + readonly columnWidths: number[]; + + readonly propList: EntityPropList; + + readonly trackByFn: TrackByFunction> = (_, item) => item.name; + + constructor(@Inject(LOCALE_ID) private locale: string, injector: Injector) { + const extensions = injector.get(ExtensionsService); + const name = injector.get(EXTENSIONS_IDENTIFIER); + this.propList = extensions.entityProps.get(name).props; + this.setColumnWidths(DEFAULT_ACTIONS_COLUMN_WIDTH); + } + + private setColumnWidths(actionsColumn: number) { + const widths = [actionsColumn]; + this.propList.forEach(({ value: prop }) => { + widths.push(prop.columnWidth); + }); + (this.columnWidths as any) = widths; + } + + private getDate(value: Date, format: string) { + return value ? formatDate(value, format, this.locale) : ''; + } + + private getIcon(value: boolean) { + return value + ? '
' + : '
'; + } + + getContent(prop: EntityProp, data: PropData): Observable { + return prop.valueResolver(data).pipe( + map(value => { + switch (prop.type) { + case ePropType.Boolean: + return this.getIcon(value); + case ePropType.Date: + return this.getDate(value, 'yyyy-MM-dd'); + case ePropType.Time: + return this.getDate(value, 'HH:mm'); + case ePropType.DateTime: + return this.getDate(value, 'yyyy-MM-dd HH:mm:ss Z'); + default: + return value; + // More types can be handled in the future + } + }), + ); + } +} diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/grid-actions/grid-actions.component.html b/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/grid-actions/grid-actions.component.html new file mode 100644 index 0000000000..04ac1017a4 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/grid-actions/grid-actions.component.html @@ -0,0 +1,24 @@ +
+ +
+ + + +
+
diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/grid-actions/grid-actions.component.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/grid-actions/grid-actions.component.ts new file mode 100644 index 0000000000..30d36699b6 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/grid-actions/grid-actions.component.ts @@ -0,0 +1,36 @@ +import { + ChangeDetectionStrategy, + Component, + Injector, + Input, + TrackByFunction, +} from '@angular/core'; +import { EntityAction, EntityActionList } from '../../models/entity-actions'; +import { EXTENSIONS_ACTION_TYPE } from '../../tokens/extensions.token'; +import { AbstractActionsComponent } from '../abstract-actions/abstract-actions.component'; + +@Component({ + exportAs: 'abpGridActions', + selector: 'abp-grid-actions', + templateUrl: './grid-actions.component.html', + providers: [ + { + provide: EXTENSIONS_ACTION_TYPE, + useValue: 'entityActions', + }, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class GridActionsComponent extends AbstractActionsComponent> { + @Input() icon = 'fa fa-cog'; + + @Input() readonly index: number; + + @Input() text = ''; + + readonly trackByFn: TrackByFunction> = (_, item) => item.text; + + constructor(injector: Injector) { + super(injector); + } +} diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/page-toolbar/page-toolbar.component.html b/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/page-toolbar/page-toolbar.component.html new file mode 100644 index 0000000000..8f97f6aa64 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/page-toolbar/page-toolbar.component.html @@ -0,0 +1,18 @@ +
+
+ + + + + + + + + +
+
diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/page-toolbar/page-toolbar.component.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/page-toolbar/page-toolbar.component.ts new file mode 100644 index 0000000000..dfd7c0105d --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/components/page-toolbar/page-toolbar.component.ts @@ -0,0 +1,49 @@ +import { + ChangeDetectionStrategy, + Component, + InjectFlags, + InjectionToken, + Injector, + TrackByFunction, + Type, +} from '@angular/core'; +import { ToolbarActionList, ToolbarComponent } from '../../models/toolbar-actions'; +import { + EXTENSIONS_ACTION_CALLBACK, + EXTENSIONS_ACTION_DATA, + EXTENSIONS_ACTION_TYPE, +} from '../../tokens/extensions.token'; +import { AbstractActionsComponent } from '../abstract-actions/abstract-actions.component'; + +@Component({ + exportAs: 'abpPageToolbar', + selector: 'abp-page-toolbar', + templateUrl: './page-toolbar.component.html', + providers: [ + { + provide: EXTENSIONS_ACTION_TYPE, + useValue: 'toolbarActions', + }, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PageToolbarComponent extends AbstractActionsComponent> { + readonly trackByFn: TrackByFunction> = (_, item) => + item.action || item.component; + + constructor(private readonly injector: Injector) { + super(injector); + } + + createInjector(action: ToolbarComponent): Injector { + const get = (token: Type | InjectionToken, notFoundValue?: T, flags?: InjectFlags) => { + return token === EXTENSIONS_ACTION_DATA + ? this.data + : token === EXTENSIONS_ACTION_CALLBACK + ? (data = this.data) => action.action(data) + : this.getInjected.call(this.injector, token, notFoundValue, flags); + }; + + return { get }; + } +} diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/constants/extra-properties.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/constants/extra-properties.ts new file mode 100644 index 0000000000..e46e633e07 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/constants/extra-properties.ts @@ -0,0 +1 @@ +export const EXTRA_PROPERTIES_KEY = 'extraProperties'; diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/directives/disabled.directive.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/directives/disabled.directive.ts new file mode 100644 index 0000000000..da3b985666 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/directives/disabled.directive.ts @@ -0,0 +1,19 @@ +import { Directive, Host, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { NgControl } from '@angular/forms'; + +@Directive({ + selector: '[abpDisabled]', +}) +export class DisabledDirective implements OnChanges { + @Input() + abpDisabled: boolean; + + constructor(@Host() private ngControl: NgControl) {} + + // Related issue: https://github.com/angular/angular/issues/35330 + ngOnChanges({ abpDisabled }: SimpleChanges) { + if (this.ngControl.control && abpDisabled) { + this.ngControl.control[abpDisabled.currentValue ? 'disable' : 'enable'](); + } + } +} diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/directives/prop-data.directive.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/directives/prop-data.directive.ts new file mode 100644 index 0000000000..8de502f14b --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/directives/prop-data.directive.ts @@ -0,0 +1,54 @@ +import { + Directive, + Injector, + Input, + OnChanges, + OnDestroy, + TemplateRef, + ViewContainerRef, +} from '@angular/core'; +import { PropData, PropList } from '../models/props'; + +@Directive({ + exportAs: 'abpPropData', + selector: '[abpPropData]', +}) +export class PropDataDirective> extends PropData> + implements OnChanges, OnDestroy { + /* tslint:disable:no-input-rename */ + @Input('abpPropDataFromList') readonly propList: L; + + @Input('abpPropDataWithRecord') readonly record: InferredData['record']; + + @Input('abpPropDataAtIndex') readonly index: number; + /* tslint:enable:no-input-rename */ + + readonly getInjected: InferredData['getInjected']; + + constructor( + private tempRef: TemplateRef, + private vcRef: ViewContainerRef, + injector: Injector, + ) { + super(); + + // tslint:disable-next-line + this.getInjected = injector.get.bind(injector); + } + + ngOnChanges() { + this.vcRef.clear(); + + this.vcRef.createEmbeddedView(this.tempRef, { + $implicit: this.data, + index: 0, + }); + } + + ngOnDestroy() { + this.vcRef.clear(); + } +} + +type InferredData = PropData>; +type InferredRecord = L extends PropList ? R : never; diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/enums/props.enum.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/enums/props.enum.ts new file mode 100644 index 0000000000..1acb15f8d4 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/enums/props.enum.ts @@ -0,0 +1,12 @@ +export const enum ePropType { + Boolean = 'boolean', + Date = 'date', + DateTime = 'dateTime', + Email = 'email', + Enum = 'enum', + Number = 'number', + Password = 'password', + String = 'string', + Text = 'text', + Time = 'time', +} diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/models/actions.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/models/actions.ts new file mode 100644 index 0000000000..4b1d1bd740 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/models/actions.ts @@ -0,0 +1,79 @@ +/* tslint:disable:variable-name */ +import { LinkedList } from '@abp/utils'; +import { InjectFlags, InjectionToken, Type } from '@angular/core'; +import { O } from 'ts-toolbelt'; + +export abstract class ActionList> extends LinkedList {} + +export abstract class ActionData { + abstract getInjected: ( + token: Type | InjectionToken, + notFoundValue?: T, + flags?: InjectFlags, + ) => T; + index?: number; + abstract record: R; + + get data(): ReadonlyActionData { + return { + getInjected: this.getInjected, + index: this.index, + record: this.record, + }; + } +} + +export type ReadonlyActionData = O.Readonly, 'data'>>; + +export abstract class Action { + constructor( + public readonly permission: string, + public readonly visible: ActionPredicate = _ => true, + public readonly action: ActionCallback = _ => {}, + ) {} +} + +export type ActionCallback = (data?: Omit, 'data'>) => R; +export type ActionPredicate = (data?: Omit, 'data'>) => boolean; + +export abstract class ActionsFactory> { + protected abstract _ctor: Type; + private contributorCallbacks: ActionContributorCallbacks> = {}; + + get(name: string): C { + this.contributorCallbacks[name] = this.contributorCallbacks[name] || []; + + return new this._ctor(this.contributorCallbacks[name]); + } +} + +export abstract class Actions { + protected abstract _ctor: Type; + + get actions(): L { + const actionList = new this._ctor(); + + this.callbackList.forEach(callback => callback(actionList)); + + return actionList; + } + + constructor(private readonly callbackList: ActionContributorCallback[]) {} + + addContributor(contributeCallback: ActionContributorCallback) { + this.callbackList.push(contributeCallback); + } + + clearContributors() { + while (this.callbackList.length) this.callbackList.pop(); + } +} + +export type ActionContributorCallbacks> = Record< + string, + ActionContributorCallback[] +>; + +export type ActionContributorCallback> = (actionList: L) => any; + +type InferredActionList = C extends Actions ? L : never; diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/models/entity-actions.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/models/entity-actions.ts new file mode 100644 index 0000000000..4938e10337 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/models/entity-actions.ts @@ -0,0 +1,53 @@ +/* tslint:disable:variable-name */ +import { Type } from '@angular/core'; +import { O } from 'ts-toolbelt'; +import { + Action, + ActionContributorCallback, + ActionContributorCallbacks, + ActionList, + Actions, + ActionsFactory, +} from './actions'; + +export class EntityActionList extends ActionList> {} + +export class EntityActions extends Actions> { + protected _ctor: Type> = EntityActionList; +} + +export class EntityActionsFactory extends ActionsFactory> { + protected _ctor: Type> = EntityActions; +} + +export class EntityAction extends Action { + readonly text: string; + readonly icon: string; + + constructor(options: EntityActionOptions) { + super(options.permission, options.visible, options.action); + this.text = options.text; + this.icon = options.icon || ''; + } + + static create(options: EntityActionOptions) { + return new EntityAction(options); + } + + static createMany(arrayOfOptions: EntityActionOptions[]) { + return arrayOfOptions.map(EntityAction.create); + } +} + +export type EntityActionOptions = O.Optional< + O.Writable>, + 'permission' | 'visible' | 'icon' +>; + +export type EntityActionDefaults = Record[]>; +export type EntityActionContributorCallback = ActionContributorCallback< + EntityActionList +>; +export type EntityActionContributorCallbacks = ActionContributorCallbacks< + EntityActionList +>; diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/models/entity-props.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/models/entity-props.ts new file mode 100644 index 0000000000..a49a8e8d41 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/models/entity-props.ts @@ -0,0 +1,67 @@ +/* tslint:disable:variable-name */ +import { Type } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { O } from 'ts-toolbelt'; +import { + Prop, + PropCallback, + PropContributorCallback, + PropContributorCallbacks, + PropList, + Props, + PropsFactory, +} from './props'; + +export class EntityPropList extends PropList> {} + +export class EntityProps extends Props> { + protected _ctor: Type> = EntityPropList; +} + +export class EntityPropsFactory extends PropsFactory> { + protected _ctor: Type> = EntityProps; +} + +export class EntityProp extends Prop { + readonly columnWidth: number | undefined; + readonly sortable: boolean; + readonly valueResolver: PropCallback>; + + constructor(options: EntityPropOptions) { + super( + options.type, + options.name, + options.displayName, + options.permission, + options.visible, + options.isExtra, + ); + + this.columnWidth = options.columnWidth; + this.sortable = options.sortable || false; + this.valueResolver = options.valueResolver || (data => of(data.record[this.name])); + } + + static create(options: EntityPropOptions) { + return new EntityProp(options); + } + + static createMany(arrayOfOptions: EntityPropOptions[]) { + return arrayOfOptions.map(EntityProp.create); + } +} + +export type EntityPropOptions = O.Optional< + O.Writable>, + | 'permission' + | 'visible' + | 'displayName' + | 'isExtra' + | 'columnWidth' + | 'sortable' + | 'valueResolver' +>; + +export type EntityPropDefaults = Record[]>; +export type EntityPropContributorCallback = PropContributorCallback>; +export type EntityPropContributorCallbacks = PropContributorCallbacks>; diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/models/form-props.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/models/form-props.ts new file mode 100644 index 0000000000..f9e6dca9ff --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/models/form-props.ts @@ -0,0 +1,109 @@ +/* tslint:disable:variable-name */ +import { ABP } from '@abp/ng.core'; +import { Injector, Type } from '@angular/core'; +import { AsyncValidatorFn, ValidatorFn } from '@angular/forms'; +import { Observable } from 'rxjs'; +import { O } from 'ts-toolbelt'; +import { + Prop, + PropCallback, + PropContributorCallback, + PropContributorCallbacks, + PropData, + PropList, + PropPredicate, + Props, + PropsFactory, +} from './props'; + +export class FormPropList extends PropList> {} + +export class FormProps extends Props> { + protected _ctor: Type> = FormPropList; +} + +export class CreateFormPropsFactory extends PropsFactory> { + protected _ctor: Type> = FormProps; +} + +export class EditFormPropsFactory extends PropsFactory> { + protected _ctor: Type> = FormProps; +} + +export class FormProp extends Prop { + readonly validators: PropCallback; + readonly asyncValidators: PropCallback; + readonly disabled: PropPredicate; + readonly readonly: PropPredicate; + readonly autocomplete: string; + readonly defaultValue: boolean | number | string | Date; + readonly options: PropCallback[]>> | undefined; + readonly id: string | undefined; + + constructor(options: FormPropOptions) { + super( + options.type, + options.name, + options.displayName, + options.permission, + options.visible, + options.isExtra, + ); + + this.asyncValidators = options.asyncValidators || (_ => []); + this.validators = options.validators || (_ => []); + this.disabled = options.disabled || (_ => false); + this.readonly = options.readonly || (_ => false); + this.autocomplete = options.autocomplete || 'off'; + this.options = options.options; + this.id = options.id || options.name; + const defaultValue = options.defaultValue; + this.defaultValue = isFalsyValue(defaultValue) ? defaultValue : defaultValue || null; + } + + static create(options: FormPropOptions) { + return new FormProp(options); + } + + static createMany(arrayOfOptions: FormPropOptions[]) { + return arrayOfOptions.map(FormProp.create); + } +} + +export class FormPropData extends PropData { + getInjected: PropData['getInjected']; + + constructor(injector: Injector, public readonly record: R) { + super(); + + // tslint:disable-next-line + this.getInjected = injector.get.bind(injector); + } +} + +export type FormPropOptions = O.Optional< + O.Writable>, + | 'permission' + | 'visible' + | 'displayName' + | 'isExtra' + | 'validators' + | 'asyncValidators' + | 'disabled' + | 'readonly' + | 'autocomplete' + | 'defaultValue' + | 'options' + | 'id' +>; + +export type CreateFormPropDefaults = Record[]>; +export type CreateFormPropContributorCallback = PropContributorCallback>; +export type CreateFormPropContributorCallbacks = PropContributorCallbacks>; +export type EditFormPropDefaults = Record[]>; +export type EditFormPropContributorCallback = PropContributorCallback>; +export type EditFormPropContributorCallbacks = PropContributorCallbacks>; + +function isFalsyValue(defaultValue: FormProp['defaultValue']): boolean { + return [0, '', false].indexOf(defaultValue as any) > -1; +} diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/models/internal/object-extensions.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/models/internal/object-extensions.ts new file mode 100644 index 0000000000..6a44e4e231 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/models/internal/object-extensions.ts @@ -0,0 +1,96 @@ +import { ePropType } from '../../enums/props.enum'; +import { EntityPropList } from '../entity-props'; +import { FormPropList } from '../form-props'; +import { PropContributorCallbacks } from '../props'; + +export interface PropContributors { + prop: PropContributorCallbacks>; + createForm: PropContributorCallbacks>; + editForm: PropContributorCallbacks>; +} + +export interface Config { + objectExtensions: Item; +} + +export interface Item { + modules: Modules; + enums: Enums; +} + +export type Modules = Configuration; + +export interface Module { + configuration: Configuration; + entities: Entities; +} + +export type Entities = Configuration; + +export interface Entity { + configuration: Configuration; + properties: Properties; +} + +export type Properties = Configuration; + +export interface Property { + type: string; + typeSimple: ePropType; + displayName: DisplayName | null; + api: Api; + ui: Ui; + attributes: Attribute[]; + configuration: Configuration; + defaultValue?: any; +} + +export interface DisplayName { + name: string; + resource: string; +} + +export interface Api { + onGet: ApiConfig; + onCreate: ApiConfig; + onUpdate: ApiConfig; +} + +export interface ApiConfig { + isAvailable: boolean; +} + +export interface Ui { + onTable: UiPropConfig; + onCreateForm: UiFormConfig; + onEditForm: UiFormConfig; +} + +export interface UiPropConfig { + isSortable: boolean; + isVisible: boolean; +} + +export interface UiFormConfig { + isVisible: boolean; +} + +export interface Attribute { + typeSimple: string; + config: Configuration; +} + +export type Configuration = Record; + +export type Enums = Record; + +export interface Enum { + fields: EnumMember[]; + localizationResource?: string; + transformed?: any; +} + +export interface EnumMember { + name: string; + value: any; +} diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/models/object-extensions.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/models/object-extensions.ts new file mode 100644 index 0000000000..65bc2a7fdf --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/models/object-extensions.ts @@ -0,0 +1,3 @@ +import * as ObjectExtensions from './internal/object-extensions'; + +export { ObjectExtensions }; diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/models/props.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/models/props.ts new file mode 100644 index 0000000000..1e2fa9a0a0 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/models/props.ts @@ -0,0 +1,85 @@ +/* tslint:disable:variable-name */ +import { LinkedList } from '@abp/utils'; +import { InjectFlags, InjectionToken, Type } from '@angular/core'; +import { O } from 'ts-toolbelt'; +import { ePropType } from '../enums/props.enum'; + +export abstract class PropList> extends LinkedList {} + +export abstract class PropData { + abstract getInjected: ( + token: Type | InjectionToken, + notFoundValue?: T, + flags?: InjectFlags, + ) => T; + index?: number; + abstract record: R; + + get data(): ReadonlyPropData { + return { + getInjected: this.getInjected, + index: this.index, + record: this.record, + }; + } +} + +export type ReadonlyPropData = O.Readonly, 'data'>>; + +export abstract class Prop { + constructor( + public readonly type: ePropType, + public readonly name: string, + public readonly displayName: string, + public readonly permission: string, + public readonly visible: PropPredicate = _ => true, + public readonly isExtra = false, + ) { + this.displayName = this.displayName || this.name; + } +} + +export type PropCallback = (data?: Omit, 'data'>) => R; +export type PropPredicate = (data?: Omit, 'data'>) => boolean; + +export abstract class PropsFactory> { + protected abstract _ctor: Type; + private contributorCallbacks: PropContributorCallbacks> = {}; + + get(name: string): C { + this.contributorCallbacks[name] = this.contributorCallbacks[name] || []; + + return new this._ctor(this.contributorCallbacks[name]); + } +} + +export abstract class Props { + protected abstract _ctor: Type; + + get props(): L { + const propList = new this._ctor(); + + this.callbackList.forEach(callback => callback(propList)); + + return propList; + } + + constructor(private readonly callbackList: PropContributorCallback[]) {} + + addContributor(contributeCallback: PropContributorCallback) { + this.callbackList.push(contributeCallback); + } + + clearContributors() { + while (this.callbackList.length) this.callbackList.pop(); + } +} + +export type PropContributorCallbacks> = Record< + string, + PropContributorCallback[] +>; + +export type PropContributorCallback> = (propList: L) => any; + +type InferredPropList = C extends Props ? L : never; diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/models/toolbar-actions.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/models/toolbar-actions.ts new file mode 100644 index 0000000000..1a166b083d --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/models/toolbar-actions.ts @@ -0,0 +1,78 @@ +/* tslint:disable:variable-name */ +import { Type } from '@angular/core'; +import { O } from 'ts-toolbelt'; +import { + Action, + ActionContributorCallback, + ActionContributorCallbacks, + ActionList, + Actions, + ActionsFactory, +} from './actions'; + +export class ToolbarActionList extends ActionList< + R, + ToolbarAction | ToolbarComponent +> {} + +export class ToolbarActions extends Actions> { + protected _ctor: Type> = ToolbarActionList; +} + +export class ToolbarActionsFactory extends ActionsFactory> { + protected _ctor: Type> = ToolbarActions; +} + +export class ToolbarAction extends Action { + readonly text: string; + readonly icon: string; + + constructor(options: ToolbarActionOptions) { + super(options.permission, options.visible, options.action); + this.text = options.text; + this.icon = options.icon || ''; + } + + static create(options: ToolbarActionOptions) { + return new ToolbarAction(options); + } + + static createMany(arrayOfOptions: ToolbarActionOptions[]) { + return arrayOfOptions.map(ToolbarAction.create); + } +} + +export class ToolbarComponent extends Action { + readonly component: Type; + + constructor(options: ToolbarComponentOptions) { + super(options.permission, options.visible, options.action); + this.component = options.component; + } + + static create(options: ToolbarComponentOptions) { + return new ToolbarComponent(options); + } + + static createMany(arrayOfOptions: ToolbarComponentOptions[]) { + return arrayOfOptions.map(ToolbarComponent.create); + } +} + +export type ToolbarActionOptions = O.Optional< + O.Writable>, + 'permission' | 'visible' | 'icon' +>; + +export type ToolbarComponentOptions = O.Optional< + O.Writable>, + 'permission' | 'visible' | 'action' +>; + +export type ToolbarActionDefaults = Record[]>; +export type ToolbarActionContributorCallback = ActionContributorCallback< + ToolbarActionList +>; +export type ToolbarActionContributorCallbacks = ActionContributorCallbacks< + ToolbarActionList +>; diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/services/extensions.service.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/services/extensions.service.ts new file mode 100644 index 0000000000..e57a874ef8 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/services/extensions.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@angular/core'; +import { EntityActionsFactory } from '../models/entity-actions'; +import { EntityPropsFactory } from '../models/entity-props'; +import { CreateFormPropsFactory, EditFormPropsFactory } from '../models/form-props'; +import { ToolbarActionsFactory } from '../models/toolbar-actions'; + +@Injectable({ + providedIn: 'root', +}) +export class ExtensionsService { + readonly entityActions = new EntityActionsFactory(); + readonly toolbarActions = new ToolbarActionsFactory(); + readonly entityProps = new EntityPropsFactory(); + readonly createFormProps = new CreateFormPropsFactory(); + readonly editFormProps = new EditFormPropsFactory(); +} diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/tokens/extensions.token.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/tokens/extensions.token.ts new file mode 100644 index 0000000000..ba504756aa --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/tokens/extensions.token.ts @@ -0,0 +1,13 @@ +import { InjectionToken } from '@angular/core'; +import { ActionCallback, ReadonlyActionData as ActionData } from '../models/actions'; +import { ExtensionsService } from '../services/extensions.service'; + +export const EXTENSIONS_IDENTIFIER = new InjectionToken('EXTENSIONS_IDENTIFIER'); +export type ActionKeys = Extract<'entityActions' | 'toolbarActions', keyof ExtensionsService>; + +export const EXTENSIONS_ACTION_TYPE = new InjectionToken('EXTENSIONS_ACTION_TYPE'); + +export const EXTENSIONS_ACTION_DATA = new InjectionToken('EXTENSIONS_ACTION_DATA'); +export const EXTENSIONS_ACTION_CALLBACK = new InjectionToken>( + 'EXTENSIONS_ACTION_DATA', +); diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/ui-extensions.module.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/ui-extensions.module.ts new file mode 100644 index 0000000000..c0c93ce151 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/ui-extensions.module.ts @@ -0,0 +1,48 @@ +import { CoreModule } from '@abp/ng.core'; +import { ThemeSharedModule } from '@abp/ng.theme.shared'; +import { NgModule } from '@angular/core'; +import { + NgbDatepickerModule, + NgbDropdownModule, + NgbTimepickerModule, +} from '@ng-bootstrap/ng-bootstrap'; +import { NgxValidateCoreModule } from '@ngx-validate/core'; +import { DateTimePickerComponent } from './components/date-time-picker/date-time-picker.component'; +import { ExtensibleFormPropComponent } from './components/extensible-form/extensible-form-prop.component'; +import { ExtensibleFormComponent } from './components/extensible-form/extensible-form.component'; +import { ExtensibleTableComponent } from './components/extensible-table/extensible-table.component'; +import { GridActionsComponent } from './components/grid-actions/grid-actions.component'; +import { PageToolbarComponent } from './components/page-toolbar/page-toolbar.component'; +import { DisabledDirective } from './directives/disabled.directive'; +import { PropDataDirective } from './directives/prop-data.directive'; + +@NgModule({ + exports: [ + DateTimePickerComponent, + PageToolbarComponent, + GridActionsComponent, + ExtensibleFormComponent, + ExtensibleTableComponent, + PropDataDirective, + DisabledDirective, + ], + declarations: [ + DateTimePickerComponent, + PageToolbarComponent, + GridActionsComponent, + ExtensibleFormPropComponent, + ExtensibleFormComponent, + ExtensibleTableComponent, + PropDataDirective, + DisabledDirective, + ], + imports: [ + CoreModule, + ThemeSharedModule, + NgxValidateCoreModule, + NgbDatepickerModule, + NgbDropdownModule, + NgbTimepickerModule, + ], +}) +export class UiExtensionsModule {} diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/actions.util.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/actions.util.ts new file mode 100644 index 0000000000..f4501317df --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/actions.util.ts @@ -0,0 +1,49 @@ +import { ActionContributorCallback, ActionList, ActionsFactory } from '../models/actions'; +import { + EntityActionContributorCallbacks, + EntityActionDefaults, + EntityActions, + EntityActionsFactory, +} from '../models/entity-actions'; +import { + ToolbarActionContributorCallbacks, + ToolbarActionDefaults, + ToolbarActions, + ToolbarActionsFactory, +} from '../models/toolbar-actions'; + +export function mergeWithDefaultActions>( + extension: F, + defaultActions: InferredActionDefaults, + ...contributors: InferredActionContributorCallbacks[] +) { + Object.keys(defaultActions).forEach((name: string) => { + const actions: InferredActions = extension.get(name); + actions.clearContributors(); + actions.addContributor((actionList: ActionList) => + actionList.addManyTail(defaultActions[name]), + ); + contributors.forEach(contributor => + (contributor[name] || []).forEach((callback: ActionContributorCallback) => + actions.addContributor(callback), + ), + ); + }); +} +type InferredActionDefaults = F extends EntityActionsFactory + ? EntityActionDefaults + : F extends ToolbarActionsFactory + ? ToolbarActionDefaults + : never; + +type InferredActionContributorCallbacks = F extends EntityActionsFactory + ? EntityActionContributorCallbacks + : F extends ToolbarActionsFactory + ? ToolbarActionContributorCallbacks + : never; + +type InferredActions = F extends EntityActionsFactory + ? EntityActions + : F extends ToolbarActionsFactory + ? ToolbarActions + : never; diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/enum.util.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/enum.util.ts new file mode 100644 index 0000000000..869b9a8c80 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/enum.util.ts @@ -0,0 +1,73 @@ +import { ABP, LocalizationService } from '@abp/ng.core'; +import { merge, Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { EXTRA_PROPERTIES_KEY } from '../constants/extra-properties'; +import { ObjectExtensions } from '../models/object-extensions'; +import { PropCallback } from '../models/props'; + +export function createEnum(members: ObjectExtensions.EnumMember[]) { + const enumObject: any = {}; + + members.forEach(({ name, value }) => { + enumObject[(enumObject[name] = value)] = name; + }); + + return enumObject; +} + +export function createEnumValueResolver( + enumType: string, + lookupEnum: ObjectExtensions.Enum, + propName: string, +): PropCallback> { + return data => { + const value = data.record[EXTRA_PROPERTIES_KEY][propName]; + const key = lookupEnum.transformed[value]; + const l10n = data.getInjected(LocalizationService); + const localizeEnum = createEnumLocalizer(l10n, enumType, lookupEnum); + + return createLocalizationStream(l10n, localizeEnum(key)); + }; +} + +export function createEnumOptions( + enumType: string, + lookupEnum: ObjectExtensions.Enum, +): PropCallback[]>> { + return data => { + const l10n = data.getInjected(LocalizationService); + const localizeEnum = createEnumLocalizer(l10n, enumType, lookupEnum); + + return createLocalizationStream( + l10n, + lookupEnum.fields.map(({ name, value }) => ({ + key: localizeEnum(name), + value, + })), + ); + }; +} + +function createLocalizationStream(l10n: LocalizationService, mapTarget: any) { + return merge(of(null), l10n.languageChange).pipe(map(() => mapTarget)); +} + +function createEnumLocalizer( + l10n: LocalizationService, + enumType: string, + lookupEnum: ObjectExtensions.Enum, +): (key: string) => string { + const resource = lookupEnum.localizationResource; + const shortType = getShortEnumType(enumType); + + return key => + l10n.localizeWithFallbackSync( + [resource], + ['Enum:' + shortType + '.' + key, shortType + '.' + key, key], + key, + ); +} + +function getShortEnumType(enumType: string): string { + return enumType.split('.').pop(); +} diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/factory.util.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/factory.util.ts new file mode 100644 index 0000000000..5bb6265075 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/factory.util.ts @@ -0,0 +1,3 @@ +export function selfFactory(dependency?: any) { + return dependency; +} diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/form-props.util.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/form-props.util.ts new file mode 100644 index 0000000000..828ac383d0 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/form-props.util.ts @@ -0,0 +1,61 @@ +import { FormControl, FormGroup } from '@angular/forms'; +import { DateTimeAdapter } from '../adapters/date-time.adapter'; +import { DateAdapter } from '../adapters/date.adapter'; +import { TimeAdapter } from '../adapters/time.adapter'; +import { EXTRA_PROPERTIES_KEY } from '../constants/extra-properties'; +import { ePropType } from '../enums/props.enum'; +import { FormPropList } from '../models/form-props'; +import { PropData } from '../models/props'; +import { ExtensionsService } from '../services/extensions.service'; +import { EXTENSIONS_IDENTIFIER } from '../tokens/extensions.token'; + +export function generateFormFromProps(data: PropData) { + const extensions = data.getInjected(ExtensionsService); + const identifier = data.getInjected(EXTENSIONS_IDENTIFIER); + + const form = new FormGroup({}); + const extraForm = new FormGroup({}); + form.addControl(EXTRA_PROPERTIES_KEY, extraForm); + + const record = data.record || {}; + const type = JSON.stringify(record) === '{}' ? 'create' : 'edit'; + const props: FormPropList = extensions[`${type}FormProps`].get(identifier).props; + const extraProperties = record[EXTRA_PROPERTIES_KEY] || {}; + + props.forEach(({ value: prop }) => { + const name = prop.name; + const isExtraProperty = prop.isExtra || name in extraProperties; + let value = isExtraProperty ? extraProperties[name] : name in record ? record[name] : undefined; + + if (typeof value === 'undefined') value = prop.defaultValue; + + if (value) { + let adapter: DateAdapter | TimeAdapter | DateTimeAdapter; + switch (prop.type) { + case ePropType.Date: + adapter = new DateAdapter(); + value = adapter.toModel(adapter.fromModel(value)); + break; + case ePropType.Time: + adapter = new TimeAdapter(); + value = adapter.toModel(adapter.fromModel(value)); + break; + case ePropType.DateTime: + adapter = new DateTimeAdapter(); + value = adapter.toModel(adapter.fromModel(value) as any); + break; + default: + break; + } + } + + const formControl = new FormControl(value, { + asyncValidators: prop.asyncValidators(data), + validators: prop.validators(data), + }); + + (isExtraProperty ? extraForm : form).addControl(name, formControl); + }); + + return form; +} diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/localization.util.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/localization.util.ts new file mode 100644 index 0000000000..72fed93dea --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/localization.util.ts @@ -0,0 +1,27 @@ +import { ApplicationConfiguration, createLocalizationPipeKeyGenerator } from '@abp/ng.core'; +import { ObjectExtensions } from '../models/object-extensions'; + +export function createDisplayNameLocalizationPipeKeyGenerator( + localization: ApplicationConfiguration.Localization, +) { + const generateLocalizationPipeKey = createLocalizationPipeKeyGenerator(localization); + + return (displayName: ObjectExtensions.DisplayName, fallback: ObjectExtensions.DisplayName) => { + if (displayName && displayName.name) + return generateLocalizationPipeKey( + [displayName.resource], + [displayName.name], + displayName.name, + ); + + const key = generateLocalizationPipeKey( + [fallback.resource], + ['DisplayName:' + fallback.name], + undefined, + ); + + if (key) return key; + + return generateLocalizationPipeKey([fallback.resource], [fallback.name], fallback.name); + }; +} diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/props.util.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/props.util.ts new file mode 100644 index 0000000000..b750131065 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/props.util.ts @@ -0,0 +1,62 @@ +import { of } from 'rxjs'; +import { EXTRA_PROPERTIES_KEY } from '../constants/extra-properties'; +import { + EntityPropContributorCallbacks, + EntityPropDefaults, + EntityProps, + EntityPropsFactory, +} from '../models/entity-props'; +import { + CreateFormPropContributorCallbacks, + CreateFormPropDefaults, + CreateFormPropsFactory, + EditFormPropContributorCallbacks, + EditFormPropDefaults, + EditFormPropsFactory, + FormProps, +} from '../models/form-props'; +import { PropContributorCallback, PropData, PropList, PropsFactory } from '../models/props'; + +export function createExtraPropertyValueResolver(name: string) { + return (data?: PropData) => of(data.record[EXTRA_PROPERTIES_KEY][name]); +} + +export function mergeWithDefaultProps>( + extension: F, + defaultProps: InferredPropDefaults, + ...contributors: InferredPropContributorCallbacks[] +) { + Object.keys(defaultProps).forEach((name: string) => { + const props: InferredProps = extension.get(name); + props.clearContributors(); + props.addContributor((propList: PropList) => propList.addManyTail(defaultProps[name])); + contributors.forEach(contributor => + (contributor[name] || []).forEach((callback: PropContributorCallback) => + props.addContributor(callback), + ), + ); + }); +} +type InferredPropDefaults = F extends EntityPropsFactory + ? EntityPropDefaults + : F extends CreateFormPropsFactory + ? CreateFormPropDefaults + : F extends EditFormPropsFactory + ? EditFormPropDefaults + : never; + +type InferredPropContributorCallbacks = F extends EntityPropsFactory + ? EntityPropContributorCallbacks + : F extends CreateFormPropsFactory + ? CreateFormPropContributorCallbacks + : F extends EditFormPropsFactory + ? EditFormPropContributorCallbacks + : never; + +type InferredProps = F extends EntityPropsFactory + ? EntityProps + : F extends CreateFormPropsFactory + ? FormProps + : F extends EditFormPropsFactory + ? FormProps + : never; diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/state.util.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/state.util.ts new file mode 100644 index 0000000000..9ae9784e26 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/state.util.ts @@ -0,0 +1,174 @@ +import { ABP, ApplicationConfiguration } from '@abp/ng.core'; +import { createSelector, Store } from '@ngxs/store'; +import { Observable, pipe, zip } from 'rxjs'; +import { filter, map, switchMap, take } from 'rxjs/operators'; +import { ePropType } from '../enums/props.enum'; +import { EntityProp, EntityPropList } from '../models/entity-props'; +import { FormProp, FormPropList } from '../models/form-props'; +import { ObjectExtensions } from '../models/object-extensions'; +import { PropCallback } from '../models/props'; +import { createEnum, createEnumOptions, createEnumValueResolver } from './enum.util'; +import { createDisplayNameLocalizationPipeKeyGenerator } from './localization.util'; +import { createExtraPropertyValueResolver } from './props.util'; +import { getValidatorsFromProperty } from './validation.util'; + +const selectConfig = (state: any) => state.ConfigState; + +const selectObjectExtensions = createSelector( + [selectConfig], + (config: ObjectExtensions.Config) => config.objectExtensions, +); + +const selectLocalization = createSelector( + [selectConfig], + (config: ApplicationConfiguration.Response) => config.localization, +); + +const selectEnums = createSelector( + [selectObjectExtensions, selectLocalization], + (extensions: ObjectExtensions.Item) => + Object.keys(extensions.enums).reduce((acc, key) => { + const { fields, localizationResource } = extensions.enums[key]; + acc[key] = { + fields, + localizationResource, + transformed: createEnum(fields), + }; + return acc; + }, {} as ObjectExtensions.Enums), +); + +const createObjectExtensionEntitiesSelector = (moduleKey: ModuleKey) => + createSelector([selectObjectExtensions], (extensions: ObjectExtensions.Item) => { + if (!extensions) return null; + + return (extensions.modules[moduleKey] || ({} as ObjectExtensions.Module)).entities; + }); + +export function getObjectExtensionEntitiesFromStore(store: Store, moduleKey: ModuleKey) { + return store.select(createObjectExtensionEntitiesSelector(moduleKey)).pipe( + map(entities => (isUndefined(entities) ? {} : entities)), + filter(Boolean), + take(1), + ); +} + +export function mapEntitiesToContributors(store: Store, resource: string) { + return pipe( + switchMap(entities => + zip(store.select(selectLocalization), store.select(selectEnums)).pipe( + map(([localization, enums]) => { + const generateDisplayName = createDisplayNameLocalizationPipeKeyGenerator(localization); + + return Object.keys(entities).reduce( + (acc, key: keyof ObjectExtensions.Entities) => { + acc.prop[key] = []; + acc.createForm[key] = []; + acc.editForm[key] = []; + + const entity: ObjectExtensions.Entity = entities[key]; + if (!entity) return acc; + + const properties = entity.properties; + if (!properties) return acc; + + const mapPropertiesToContributors = createPropertiesToContributorsMapper( + generateDisplayName, + resource, + enums, + ); + + return mapPropertiesToContributors(properties, acc, key); + }, + { + prop: {}, + createForm: {}, + editForm: {}, + } as ObjectExtensions.PropContributors, + ); + }), + ), + ), + take(1), + ); +} + +function createPropertiesToContributorsMapper( + generateDisplayName: DisplayNameGeneratorFn, + resource: string, + enums: Record, +) { + return ( + properties: Record, + contributors: ObjectExtensions.PropContributors, + key: string, + ) => { + const isExtra = true; + + Object.keys(properties).forEach((name: string) => { + const property = properties[name]; + const type = getTypeFromProperty(property); + const displayName = generateDisplayName(property.displayName, { name, resource }); + + if (property.ui.onTable.isVisible) { + const sortable = Boolean(property.ui.onTable.isSortable); + const columnWidth = type === ePropType.Boolean ? 150 : 250; + const valueResolver = + type === ePropType.Enum + ? createEnumValueResolver(property.type, enums[property.type], name) + : createExtraPropertyValueResolver(name); + + const entityProp = new EntityProp({ + type, + name, + displayName, + sortable, + columnWidth, + valueResolver, + isExtra, + }); + + const contributor = (propList: EntityPropList) => propList.addTail(entityProp); + contributors.prop[key].push(contributor); + } + + const isOnCreateForm = property.ui.onCreateForm.isVisible; + const isOnEditForm = property.ui.onEditForm.isVisible; + + if (isOnCreateForm || isOnEditForm) { + const defaultValue = property.defaultValue; + const validators = () => getValidatorsFromProperty(property); + let options: PropCallback[]>>; + if (type === ePropType.Enum) options = createEnumOptions(name, enums[property.type]); + + const formProp = new FormProp({ + type, + name, + displayName, + options, + defaultValue, + validators, + isExtra, + }); + + const formContributor = (propList: FormPropList) => propList.addTail(formProp); + + if (isOnCreateForm) contributors.createForm[key].push(formContributor); + if (isOnEditForm) contributors.editForm[key].push(formContributor); + } + }); + + return contributors; + }; +} + +function getTypeFromProperty(property: ObjectExtensions.Property): ePropType { + return (property.typeSimple.replace(/\?$/, '') as string) as ePropType; +} + +function isUndefined(obj: any): obj is undefined { + return typeof obj === 'undefined'; +} + +type DisplayNameGeneratorFn = ReturnType; +type ModuleKey = keyof ObjectExtensions.Modules; diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/validation.util.ts b/npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/validation.util.ts new file mode 100644 index 0000000000..26d546fbd3 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/lib/utils/validation.util.ts @@ -0,0 +1,14 @@ +import { AbpValidators } from '@abp/ng.core'; +import { ValidatorFn } from '@angular/forms'; +import { ObjectExtensions } from '../models/object-extensions'; + +export function getValidatorsFromProperty(property: ObjectExtensions.Property): ValidatorFn[] { + const validators: ValidatorFn[] = []; + + property.attributes.forEach(attr => { + if (attr.typeSimple in AbpValidators) + validators.push(AbpValidators[attr.typeSimple](attr.config)); + }); + + return validators; +} diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/public-api.ts b/npm/ng-packs/packages/theme-shared/extensions/src/public-api.ts new file mode 100644 index 0000000000..a3afbe7efd --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/public-api.ts @@ -0,0 +1,70 @@ +export * from './lib/adapters/date-time.adapter'; +export * from './lib/adapters/date.adapter'; +export * from './lib/adapters/time.adapter'; +export * from './lib/components/date-time-picker/date-time-picker.component'; +export * from './lib/components/extensible-form/extensible-form-prop.component'; +export * from './lib/components/extensible-form/extensible-form.component'; +export * from './lib/components/extensible-table/extensible-table.component'; +export * from './lib/components/grid-actions/grid-actions.component'; +export * from './lib/components/page-toolbar/page-toolbar.component'; +export * from './lib/constants/extra-properties'; +export * from './lib/directives/disabled.directive'; +export * from './lib/directives/prop-data.directive'; +export * from './lib/enums/props.enum'; +export { + ActionCallback, + ActionList, + ActionPredicate, + ReadonlyActionData as ActionData, +} from './lib/models/actions'; +export { + EntityAction, + EntityActionContributorCallback, + EntityActionList, + EntityActionOptions, + EntityActions, + EntityActionsFactory, +} from './lib/models/entity-actions'; +export { + EntityProp, + EntityPropContributorCallback, + EntityPropList, + EntityPropOptions, + EntityProps, + EntityPropsFactory, +} from './lib/models/entity-props'; +export { + CreateFormPropContributorCallback, + CreateFormPropsFactory, + EditFormPropContributorCallback, + EditFormPropsFactory, + FormProp, + FormPropData, + FormPropList, + FormPropOptions, + FormProps, +} from './lib/models/form-props'; +export * from './lib/models/object-extensions'; +export { + PropCallback, + PropList, + PropPredicate, + ReadonlyPropData as PropData, +} from './lib/models/props'; +export { + ToolbarAction, + ToolbarActionContributorCallback, + ToolbarActionList, + ToolbarActionOptions, + ToolbarActions, + ToolbarActionsFactory, + ToolbarComponent, + ToolbarComponentOptions, +} from './lib/models/toolbar-actions'; +export * from './lib/services/extensions.service'; +export * from './lib/tokens/extensions.token'; +export * from './lib/ui-extensions.module'; +export * from './lib/utils/actions.util'; +export * from './lib/utils/form-props.util'; +export * from './lib/utils/props.util'; +export * from './lib/utils/state.util'; diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/tests/actions.spec.ts b/npm/ng-packs/packages/theme-shared/extensions/src/tests/actions.spec.ts new file mode 100644 index 0000000000..ea8d111bec --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/tests/actions.spec.ts @@ -0,0 +1,22 @@ +import { ActionData } from '../lib/models/actions'; + +describe('ActionData', () => { + describe('#data', () => { + it('should return record and getInjected', () => { + const spy = jest.fn(); + class Data extends ActionData { + index = 0; + record = 'X'; + getInjected = spy; + } + + const data = new Data(); + data.data.getInjected(null); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(null); + expect(data.data.index).toBe(0); + expect(data.data.record).toBe('X'); + }); + }); +}); diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/tests/actions.util.spec.ts b/npm/ng-packs/packages/theme-shared/extensions/src/tests/actions.util.spec.ts new file mode 100644 index 0000000000..fd3b27c564 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/tests/actions.util.spec.ts @@ -0,0 +1,53 @@ +import { + EntityAction, + EntityActionContributorCallbacks, + EntityActionDefaults, + EntityActionsFactory, +} from '../lib/models/entity-actions'; +import { mergeWithDefaultActions } from '../lib/utils/actions.util'; + +describe('Entity Action Utils', () => { + describe('#mergeEntityActions', () => { + let entityActions: EntityActionsFactory; + + beforeEach(() => { + entityActions = new EntityActionsFactory(); + }); + + it('should merge default actions with action contributors', () => { + const defaults: EntityActionDefaults = { + x: [(1 as any) as EntityAction, (2 as any) as EntityAction, (3 as any) as EntityAction], + y: [(1 as any) as EntityAction, (2 as any) as EntityAction, (3 as any) as EntityAction], + }; + + const contributors1: EntityActionContributorCallbacks = { + x: [ + actionList => { + const x2 = actionList.dropByIndex(1); // 1 <-> 3 + actionList.addHead(x2.value); // 2 <-> 1 <-> 3 + }, + actionList => { + actionList.dropTail(); // 2 <-> 1 + }, + ], + }; + + const contributors2: EntityActionContributorCallbacks = { + y: [ + actionList => { + const y2 = actionList.dropByIndex(1); // 1 <-> 3 + actionList.addTail(y2.value); // 1 <-> 3 <-> 2 + }, + actionList => { + actionList.dropHead(); // 3 <-> 2 + }, + ], + }; + + mergeWithDefaultActions(entityActions, defaults, contributors1, contributors2); + + expect(entityActions.get('x').actions.toString()).toBe('2 <-> 1'); + expect(entityActions.get('y').actions.toString()).toBe('3 <-> 2'); + }); + }); +}); diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/tests/date-time.adapter.spec.ts b/npm/ng-packs/packages/theme-shared/extensions/src/tests/date-time.adapter.spec.ts new file mode 100644 index 0000000000..621246b5e0 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/tests/date-time.adapter.spec.ts @@ -0,0 +1,35 @@ +import { DateTimeAdapter } from '../lib/adapters/date-time.adapter'; + +describe('DateTime Adapter', () => { + const adapter = new DateTimeAdapter(); + const date = new Date(2002, 2, 30, 13, 30, 45, 0); + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + const hour = date.getHours(); + const minute = date.getMinutes(); + const second = date.getSeconds(); + + describe('#fromModel', () => { + test.each` + param | expected + ${undefined} | ${null} + ${null} | ${null} + ${'x'} | ${null} + ${date} | ${{ year, month, day, hour, minute, second }} + `('should return $expected when $param is given', ({ param, expected }) => { + expect(adapter.fromModel(param)).toEqual(expected); + }); + }); + + describe('#toModel', () => { + test.each` + param | expected + ${undefined} | ${''} + ${null} | ${''} + ${{ year, month, day, hour, minute, second }} | ${date.toISOString()} + `('should return $expected when $param is given', ({ param, expected }) => { + expect(adapter.toModel(param)).toEqual(expected); + }); + }); +}); diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/tests/date.adapter.spec.ts b/npm/ng-packs/packages/theme-shared/extensions/src/tests/date.adapter.spec.ts new file mode 100644 index 0000000000..e1101f6a29 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/tests/date.adapter.spec.ts @@ -0,0 +1,31 @@ +import { DateAdapter } from '../lib/adapters/date.adapter'; + +describe('Date Adapter', () => { + const adapter = new DateAdapter(); + + describe('#fromModel', () => { + test.each` + param | expected + ${undefined} | ${null} + ${null} | ${null} + ${'x'} | ${null} + ${'2002-03-30'} | ${{ day: 30, month: 3, year: 2002 }} + ${'03/30/2002'} | ${{ day: 30, month: 3, year: 2002 }} + ${new Date(0)} | ${{ day: 1, month: 1, year: 1970 }} + `('should return $expected when $param is given', ({ param, expected }) => { + expect(adapter.fromModel(param)).toEqual(expected); + }); + }); + + describe('#toModel', () => { + test.each` + param | expected + ${undefined} | ${''} + ${null} | ${''} + ${{ day: 30, month: 3, year: 2002 }} | ${'2002-03-30'} + ${{ day: 1, month: 1, year: 1970 }} | ${'1970-01-01'} + `('should return $expected when $param is given', ({ param, expected }) => { + expect(adapter.toModel(param)).toEqual(expected); + }); + }); +}); diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/tests/entity-actions.spec.ts b/npm/ng-packs/packages/theme-shared/extensions/src/tests/entity-actions.spec.ts new file mode 100644 index 0000000000..35833a1950 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/tests/entity-actions.spec.ts @@ -0,0 +1,151 @@ +import { LinkedList } from '@abp/utils'; +import { + EntityAction, + EntityActionContributorCallback, + EntityActionList, + EntityActions, + EntityActionsFactory, +} from '../lib/models/entity-actions'; + +describe('EntityActionList', () => { + it('should inherit from LinkedList', () => { + expect(new EntityActionList() instanceof LinkedList).toBe(true); + }); +}); + +describe('EntityActions', () => { + const add1toTail: EntityActionContributorCallback = actionList => actionList.addTail(1 as any); + const add2toTail: EntityActionContributorCallback = actionList => actionList.addTail(2 as any); + const add3toTail: EntityActionContributorCallback = actionList => actionList.addTail(3 as any); + const dropIndex1: EntityActionContributorCallback = actionList => actionList.dropByIndex(1); + + describe('#actions', () => { + test.each` + callbackList | expected + ${[]} | ${''} + ${[add1toTail, add2toTail, add3toTail]} | ${'1 <-> 2 <-> 3'} + ${[add3toTail, add2toTail, add1toTail]} | ${'3 <-> 2 <-> 1'} + ${[add1toTail, add2toTail, add3toTail, dropIndex1]} | ${'1 <-> 3'} + `( + 'should return $expected when given callbackList is $callbackList', + ({ callbackList, expected }) => { + const creator = new EntityActions(callbackList); + expect(creator.actions.toString()).toBe(expected); + }, + ); + }); + + describe('#addContributor', () => { + const creator = new EntityActions([]); + + test.each` + callbackList | callback | expected + ${[]} | ${add1toTail} | ${'1'} + ${[add1toTail]} | ${add2toTail} | ${'1 <-> 2'} + ${[add1toTail, add2toTail]} | ${add3toTail} | ${'1 <-> 2 <-> 3'} + ${[add1toTail, add2toTail, add3toTail]} | ${dropIndex1} | ${'1 <-> 3'} + `( + 'should set actions to $expected when callbackList is $callbackList and given callback is $callback', + ({ callback, expected }) => { + creator.addContributor(callback); + expect(creator.actions.toString()).toBe(expected); + }, + ); + }); +}); + +describe('EntityActionsFactory', () => { + describe('#get', () => { + it('should create and return an EntityActions instance', () => { + const entityActions = new EntityActionsFactory(); + const creator = entityActions.get(''); + + expect(creator).toBeInstanceOf(EntityActions); + }); + + it('should store and pass contributorCallbacks to EntityActionsCreator instance it returns', () => { + const entityActions = new EntityActionsFactory(); + const creatorX1 = entityActions.get('X'); + + expect(creatorX1).toBeInstanceOf(EntityActions); + expect(creatorX1.actions.toArray()).toHaveLength(0); + + creatorX1.addContributor(actionList => actionList.addTail(1 as any)); + + const creatorX2 = entityActions.get('X'); + expect(creatorX2.actions.toArray()).toHaveLength(1); + }); + }); +}); + +describe('EntityAction', () => { + it('should be created when options object is passed as argument', () => { + const options = { + text: 'TEXT', + action: () => 'ACTION', + permission: 'PERMISSION', + visible: () => false, + icon: 'ICON', + }; + + const action = new EntityAction(options); + + expect(action.text).toBe(options.text); + expect(action.action(null)).toBe(options.action()); + expect(action.permission).toBe(options.permission); + expect(action.visible(null)).toBe(options.visible()); + expect(action.icon).toBe(options.icon); + }); + + it('should be created when only required options are passed', () => { + const options = { + text: 'TEXT', + action: () => 'ACTION', + }; + + const action = new EntityAction(options); + + expect(action.text).toBe(options.text); + expect(action.action).toBe(options.action); + expect(action.permission).toBeUndefined(); + expect(action.visible(null)).toBe(true); + expect(action.icon).toBe(''); + }); + + describe('#create', () => { + it('should return a new instance from given options', () => { + const options = { + text: 'TEXT', + action: () => 'ACTION', + }; + + const action = EntityAction.create(options); + + expect(action).toBeInstanceOf(EntityAction); + expect(action.text).toBe(options.text); + expect(action.action).toBe(options.action); + }); + }); + + describe('#createMany', () => { + it('should return multiple instances from given options array', () => { + const options1 = { + text: 'TEXT 1', + action: () => 'ACTION 1', + }; + const options2 = { + text: 'TEXT 2', + action: () => 'ACTION 2', + }; + + const [action1, action2] = EntityAction.createMany([options1, options2]); + + expect(action1).toBeInstanceOf(EntityAction); + expect(action1.text).toBe(options1.text); + expect(action1.action).toBe(options1.action); + expect(action2).toBeInstanceOf(EntityAction); + expect(action2.text).toBe(options2.text); + expect(action2.action).toBe(options2.action); + }); + }); +}); diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/tests/entity-props.spec.ts b/npm/ng-packs/packages/theme-shared/extensions/src/tests/entity-props.spec.ts new file mode 100644 index 0000000000..faccbf53b2 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/tests/entity-props.spec.ts @@ -0,0 +1,166 @@ +import { LinkedList } from '@abp/utils'; +import { NEVER } from 'rxjs'; +import { ePropType } from '../lib/enums/props.enum'; +import { + EntityProp, + EntityPropContributorCallback, + EntityPropList, + EntityProps, + EntityPropsFactory, +} from '../lib/models/entity-props'; +import { PropData } from '../lib/models/props'; + +describe('EntityPropList', () => { + it('should inherit from LinkedList', () => { + expect(new EntityPropList() instanceof LinkedList).toBe(true); + }); +}); + +describe('EntityProps', () => { + const add1toTail: EntityPropContributorCallback = propList => propList.addTail(1 as any); + const add2toTail: EntityPropContributorCallback = propList => propList.addTail(2 as any); + const add3toTail: EntityPropContributorCallback = propList => propList.addTail(3 as any); + const dropIndex1: EntityPropContributorCallback = propList => propList.dropByIndex(1); + + describe('#props', () => { + test.each` + callbackList | expected + ${[]} | ${''} + ${[add1toTail, add2toTail, add3toTail]} | ${'1 <-> 2 <-> 3'} + ${[add3toTail, add2toTail, add1toTail]} | ${'3 <-> 2 <-> 1'} + ${[add1toTail, add2toTail, add3toTail, dropIndex1]} | ${'1 <-> 3'} + `( + 'should return $expected when given callbackList is $callbackList', + ({ callbackList, expected }) => { + const creator = new EntityProps(callbackList); + expect(creator.props.toString()).toBe(expected); + }, + ); + }); + + describe('#addContributor', () => { + const creator = new EntityProps([]); + + test.each` + callbackList | callback | expected + ${[]} | ${add1toTail} | ${'1'} + ${[add1toTail]} | ${add2toTail} | ${'1 <-> 2'} + ${[add1toTail, add2toTail]} | ${add3toTail} | ${'1 <-> 2 <-> 3'} + ${[add1toTail, add2toTail, add3toTail]} | ${dropIndex1} | ${'1 <-> 3'} + `( + 'should set props to $expected when callbackList is $callbackList and given callback is $callback', + ({ callback, expected }) => { + creator.addContributor(callback); + expect(creator.props.toString()).toBe(expected); + }, + ); + }); +}); + +describe('EntityPropsFactory', () => { + describe('#get', () => { + it('should create and return an EntityProps instance', () => { + const entityProps = new EntityPropsFactory(); + const creator = entityProps.get(''); + + expect(creator).toBeInstanceOf(EntityProps); + }); + + it('should store and pass contributorCallbacks to EntityPropsCreator instance it returns', () => { + const entityProps = new EntityPropsFactory(); + const creatorX1 = entityProps.get('X'); + + expect(creatorX1).toBeInstanceOf(EntityProps); + expect(creatorX1.props.toArray()).toHaveLength(0); + + creatorX1.addContributor(propList => propList.addTail(1 as any)); + + const creatorX2 = entityProps.get('X'); + expect(creatorX2.props.toArray()).toHaveLength(1); + }); + }); +}); + +describe('EntityProp', () => { + it('should be created when options object is passed as argument', () => { + const options = { + type: ePropType.String, + name: 'NAME', + displayName: 'DISPLAY NAME', + permission: 'PERMISSION', + visible: () => false, + valueResolver: () => NEVER, + sortable: true, + columnWidth: 999, + }; + + const prop = new EntityProp(options); + + expect(prop.type).toBe(options.type); + expect(prop.name).toBe(options.name); + expect(prop.displayName).toBe(options.displayName); + expect(prop.permission).toBe(options.permission); + expect(prop.visible()).toBe(options.visible()); + expect(prop.valueResolver()).toBe(options.valueResolver()); + expect(prop.sortable).toBe(options.sortable); + expect(prop.columnWidth).toBe(options.columnWidth); + }); + + it('should be created when only required options are passed', done => { + const options = { + type: ePropType.String, + name: 'NAME', + }; + + const prop = new EntityProp(options); + + expect(prop.type).toBe(options.type); + expect(prop.name).toBe(options.name); + expect(prop.displayName).toBe(options.name); + expect(prop.permission).toBeUndefined(); + expect(prop.visible()).toBe(true); + expect(prop.sortable).toBe(false); + expect(prop.columnWidth).toBeUndefined(); + prop.valueResolver({ record: { NAME: 'X' } } as PropData).subscribe(value => { + expect(value).toBe('X'); + done(); + }); + }); + + describe('#create', () => { + it('should return a new instance from given options', () => { + const options = { + type: ePropType.String, + name: 'NAME', + }; + + const prop = EntityProp.create(options); + + expect(prop).toBeInstanceOf(EntityProp); + expect(prop.type).toBe(options.type); + expect(prop.name).toBe(options.name); + }); + }); + + describe('#createMany', () => { + it('should return multiple instances from given options array', () => { + const options1 = { + type: ePropType.String, + name: 'NAME 1', + }; + const options2 = { + type: ePropType.Boolean, + name: 'NAME 2', + }; + + const [prop1, prop2] = EntityProp.createMany([options1, options2]); + + expect(prop1).toBeInstanceOf(EntityProp); + expect(prop1.type).toBe(options1.type); + expect(prop1.name).toBe(options1.name); + expect(prop2).toBeInstanceOf(EntityProp); + expect(prop2.type).toBe(options2.type); + expect(prop2.name).toBe(options2.name); + }); + }); +}); diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/tests/enum.util.spec.ts b/npm/ng-packs/packages/theme-shared/extensions/src/tests/enum.util.spec.ts new file mode 100644 index 0000000000..68f7df2fb5 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/tests/enum.util.spec.ts @@ -0,0 +1,133 @@ +import { LocalizationService } from '@abp/ng.core'; +import { Store } from '@ngxs/store'; +import { Subject } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { PropData } from '../lib/models/props'; +import { createEnum, createEnumOptions, createEnumValueResolver } from '../lib/utils/enum.util'; + +const fields = [ + { name: 'foo', value: 1 }, + { name: 'bar', value: 2 }, + { name: 'baz', value: 3 }, +]; + +class MockPropData extends PropData { + getInjected: PropData['getInjected']; + + constructor(public readonly record: R) { + super(); + } +} + +describe('Enum Utils', () => { + describe('#createEnum', () => { + const enumFromFields = createEnum(fields); + + test.each` + key | expected + ${'foo'} | ${1} + ${'bar'} | ${2} + ${'baz'} | ${3} + ${1} | ${'foo'} + ${2} | ${'bar'} + ${3} | ${'baz'} + `('should create an enum that returns $expected when $key is accessed', ({ key, expected }) => { + expect(enumFromFields[key]).toBe(expected); + }); + }); + + describe('#createEnumValueResolver', () => { + const service = new LocalizationService( + new Subject().asObservable(), + ({ + selectSnapshot: () => ({ + values: { + Default: { + 'Enum:MyEnum.foo': 'Foo', + 'MyEnum.bar': 'Bar', + baz: 'Baz', + }, + }, + defaultResourceName: 'Default', + currentCulture: null, + languages: [], + }), + } as unknown) as Store, + null, + null, + null, + ); + + test.each` + value | expected + ${1} | ${'Foo'} + ${2} | ${'Bar'} + ${3} | ${'Baz'} + `( + 'should create a resolver that returns observable $expected when enum value is $value', + async ({ value, expected }) => { + const valueResolver = createEnumValueResolver( + 'MyCompanyName.MyProjectName.MyEnum', + { + fields, + localizationResource: null, + transformed: createEnum(fields), + }, + 'EnumProp', + ); + const propData = new MockPropData({ extraProperties: { EnumProp: value } }); + propData.getInjected = () => service as any; + + const resolved = await valueResolver(propData) + .pipe(take(1)) + .toPromise(); + + expect(resolved).toBe(expected); + }, + ); + }); + + describe('#createEnumOptions', () => { + const service = new LocalizationService( + new Subject().asObservable(), + ({ + selectSnapshot: () => ({ + values: { + Default: { + 'Enum:MyEnum.foo': 'Foo', + 'MyEnum.bar': 'Bar', + baz: 'Baz', + }, + }, + defaultResourceName: 'Default', + currentCulture: null, + languages: [], + }), + } as unknown) as Store, + null, + null, + null, + ); + + it('should create a generator that returns observable options from enums', async () => { + const options = createEnumOptions('MyCompanyName.MyProjectName.MyEnum', { + fields, + localizationResource: null, + transformed: createEnum(fields), + }); + + const propData = new MockPropData({}); + propData.getInjected = () => service as any; + + const resolved = await options(propData) + .pipe(take(1)) + .toPromise(); + + expect(resolved).toEqual([ + { key: 'Foo', value: 1 }, + { key: 'Bar', value: 2 }, + { key: 'Baz', value: 3 }, + ]); + }); + }); +}); diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/tests/extensions.service.spec.ts b/npm/ng-packs/packages/theme-shared/extensions/src/tests/extensions.service.spec.ts new file mode 100644 index 0000000000..73aa7efe12 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/tests/extensions.service.spec.ts @@ -0,0 +1,50 @@ +import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest'; +import { EntityActionsFactory } from '../lib/models/entity-actions'; +import { EntityPropsFactory } from '../lib/models/entity-props'; +import { CreateFormPropsFactory, EditFormPropsFactory } from '../lib/models/form-props'; +import { ToolbarActionsFactory } from '../lib/models/toolbar-actions'; +import { ExtensionsService } from '../lib/services/extensions.service'; + +describe('ExtensionsService', () => { + let service: ExtensionsService; + let spectator: SpectatorService; + + const createService = createServiceFactory({ + service: ExtensionsService, + }); + + beforeEach(() => { + spectator = createService(); + service = spectator.service; + }); + + describe('#entityActions', () => { + it('should be an instance of EntityActionsFactory class', () => { + expect(service.entityActions).toBeInstanceOf(EntityActionsFactory); + }); + }); + + describe('#toolbarActions', () => { + it('should be an instance of ToolbarActionsFactory class', () => { + expect(service.toolbarActions).toBeInstanceOf(ToolbarActionsFactory); + }); + }); + + describe('#entityProps', () => { + it('should be an instance of EntityPropsFactory class', () => { + expect(service.entityProps).toBeInstanceOf(EntityPropsFactory); + }); + }); + + describe('#createFormProps', () => { + it('should be an instance of CreateFormPropsFactory class', () => { + expect(service.createFormProps).toBeInstanceOf(CreateFormPropsFactory); + }); + }); + + describe('#editFormProps', () => { + it('should be an instance of EditFormPropsFactory class', () => { + expect(service.editFormProps).toBeInstanceOf(EditFormPropsFactory); + }); + }); +}); diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/tests/factory.util.spec.ts b/npm/ng-packs/packages/theme-shared/extensions/src/tests/factory.util.spec.ts new file mode 100644 index 0000000000..61bd744f23 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/tests/factory.util.spec.ts @@ -0,0 +1,28 @@ +import { selfFactory } from '../lib/utils/factory.util'; + +describe('Factory Utils', () => { + describe('#selfFactory', () => { + const arr = []; + const obj = {}; + const date = new Date(); + const promise = Promise.resolve(null); + + test.each` + parameter + ${'x'} + ${''} + ${1} + ${0} + ${true} + ${false} + ${arr} + ${obj} + ${date} + ${promise} + ${null} + ${undefined} + `('should return given $parameter back', ({ parameter }) => { + expect(selfFactory(parameter)).toBe(parameter); + }); + }); +}); diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/tests/form-props.spec.ts b/npm/ng-packs/packages/theme-shared/extensions/src/tests/form-props.spec.ts new file mode 100644 index 0000000000..a7ad56aca9 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/tests/form-props.spec.ts @@ -0,0 +1,193 @@ +import { LinkedList } from '@abp/utils'; +import { NEVER } from 'rxjs'; +import { ePropType } from '../lib/enums/props.enum'; +import { + CreateFormPropContributorCallback, + CreateFormPropsFactory, + EditFormPropsFactory, + FormProp, + FormPropList, + FormProps, +} from '../lib/models/form-props'; + +describe('FormPropList', () => { + it('should inherit from LinkedList', () => { + expect(new FormPropList() instanceof LinkedList).toBe(true); + }); +}); + +describe('FormProps', () => { + const add1toTail: CreateFormPropContributorCallback = propList => propList.addTail(1 as any); + const add2toTail: CreateFormPropContributorCallback = propList => propList.addTail(2 as any); + const add3toTail: CreateFormPropContributorCallback = propList => propList.addTail(3 as any); + const dropIndex1: CreateFormPropContributorCallback = propList => propList.dropByIndex(1); + + describe('#props', () => { + test.each` + callbackList | expected + ${[]} | ${''} + ${[add1toTail, add2toTail, add3toTail]} | ${'1 <-> 2 <-> 3'} + ${[add3toTail, add2toTail, add1toTail]} | ${'3 <-> 2 <-> 1'} + ${[add1toTail, add2toTail, add3toTail, dropIndex1]} | ${'1 <-> 3'} + `( + 'should return $expected when given callbackList is $callbackList', + ({ callbackList, expected }) => { + const creator = new FormProps(callbackList); + expect(creator.props.toString()).toBe(expected); + }, + ); + }); + + describe('#addContributor', () => { + const creator = new FormProps([]); + + test.each` + callbackList | callback | expected + ${[]} | ${add1toTail} | ${'1'} + ${[add1toTail]} | ${add2toTail} | ${'1 <-> 2'} + ${[add1toTail, add2toTail]} | ${add3toTail} | ${'1 <-> 2 <-> 3'} + ${[add1toTail, add2toTail, add3toTail]} | ${dropIndex1} | ${'1 <-> 3'} + `( + 'should set props to $expected when callbackList is $callbackList and given callback is $callback', + ({ callback, expected }) => { + creator.addContributor(callback); + expect(creator.props.toString()).toBe(expected); + }, + ); + }); +}); + +describe('FormPropsFactory', () => { + describe('#get', () => { + it('should create and return an FormProps instance', () => { + const formProps = new CreateFormPropsFactory(); + const creator = formProps.get(''); + + expect(creator).toBeInstanceOf(FormProps); + }); + + it('should store and pass contributorCallbacks to FormPropsCreator instance it returns', () => { + const formProps = new EditFormPropsFactory(); + const creatorX1 = formProps.get('X'); + + expect(creatorX1).toBeInstanceOf(FormProps); + expect(creatorX1.props.toArray()).toHaveLength(0); + + creatorX1.addContributor(propList => propList.addTail(1 as any)); + + const creatorX2 = formProps.get('X'); + expect(creatorX2.props.toArray()).toHaveLength(1); + }); + }); +}); + +describe('FormProp', () => { + it('should be created when options object is passed as argument', () => { + const options = { + type: ePropType.String, + name: 'NAME', + displayName: 'DISPLAY NAME', + permission: 'PERMISSION', + visible: () => false, + asyncValidators: () => [null], + validators: () => [null], + disabled: () => true, + readonly: () => true, + autocomplete: 'AUTOCOMPLETE', + defaultValue: 'DEFAULT VALUE', + options: () => NEVER, + id: 'ID', + }; + + const prop = new FormProp(options); + + expect(prop.type).toBe(options.type); + expect(prop.name).toBe(options.name); + expect(prop.displayName).toBe(options.displayName); + expect(prop.permission).toBe(options.permission); + expect(prop.visible()).toBe(options.visible()); + expect(prop.asyncValidators()).toEqual(options.asyncValidators()); + expect(prop.validators()).toEqual(options.validators()); + expect(prop.disabled()).toBe(options.disabled()); + expect(prop.readonly()).toBe(options.readonly()); + expect(prop.autocomplete).toBe(options.autocomplete); + expect(prop.defaultValue).toBe(options.defaultValue); + expect(prop.options()).toBe(options.options()); + expect(prop.id).toBe(options.id); + }); + + it('should be created when only required options are passed', () => { + const options = { + type: ePropType.String, + name: 'NAME', + }; + + const prop = new FormProp(options); + + expect(prop.type).toBe(options.type); + expect(prop.name).toBe(options.name); + expect(prop.displayName).toBe(options.name); + expect(prop.permission).toBeUndefined(); + expect(prop.visible()).toBe(true); + expect(prop.asyncValidators()).toEqual([]); + expect(prop.validators()).toEqual([]); + expect(prop.disabled()).toBe(false); + expect(prop.readonly()).toBe(false); + expect(prop.autocomplete).toBe('off'); + expect(prop.defaultValue).toBeNull(); + expect(prop.options).toBeUndefined(); + expect(prop.id).toBe(options.name); + }); + + test.each` + defaultValue | expected + ${0} | ${0} + ${''} | ${''} + ${false} | ${false} + ${undefined} | ${null} + `( + 'should set defaultValue as $expected when $defaultValue is given', + ({ defaultValue, expected }) => { + const options = { type: null, name: null, defaultValue }; + const prop = new FormProp(options); + expect(prop.defaultValue).toBe(expected); + }, + ); + + describe('#create', () => { + it('should return a new instance from given options', () => { + const options = { + type: ePropType.String, + name: 'NAME', + }; + + const prop = FormProp.create(options); + + expect(prop).toBeInstanceOf(FormProp); + expect(prop.type).toBe(options.type); + expect(prop.name).toBe(options.name); + }); + }); + + describe('#createMany', () => { + it('should return multiple instances from given options array', () => { + const options1 = { + type: ePropType.String, + name: 'NAME 1', + }; + const options2 = { + type: ePropType.Boolean, + name: 'NAME 2', + }; + + const [prop1, prop2] = FormProp.createMany([options1, options2]); + + expect(prop1).toBeInstanceOf(FormProp); + expect(prop1.type).toBe(options1.type); + expect(prop1.name).toBe(options1.name); + expect(prop2).toBeInstanceOf(FormProp); + expect(prop2.type).toBe(options2.type); + expect(prop2.name).toBe(options2.name); + }); + }); +}); diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/tests/form-props.util.spec.ts b/npm/ng-packs/packages/theme-shared/extensions/src/tests/form-props.util.spec.ts new file mode 100644 index 0000000000..af1edbb9fb --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/tests/form-props.util.spec.ts @@ -0,0 +1,142 @@ +import { LocalizationService } from '@abp/ng.core'; +import { Injector } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest'; +import { ePropType } from '../lib/enums/props.enum'; +import { FormProp, FormPropData } from '../lib/models/form-props'; +import { ExtensionsService } from '../lib/services/extensions.service'; +import { EXTENSIONS_IDENTIFIER } from '../lib/tokens/extensions.token'; +import { generateFormFromProps } from '../lib/utils/form-props.util'; + +describe('Form Prop Utils', () => { + describe('#generateFormFromProps', () => { + let spectator: SpectatorService>; + let injector: Injector; + const identifier = 'X'; + + const createService = createServiceFactory({ + service: ExtensionsService, + providers: [ + { + provide: EXTENSIONS_IDENTIFIER, + useValue: identifier, + }, + { + provide: LocalizationService, + useValue: { currentLang: 'en' }, + }, + ], + }); + + beforeEach(() => { + spectator = createService(); + const props = FormProp.createMany([ + { + type: ePropType.String, + name: 'foo', + validators: () => [Validators.required], + defaultValue: 'bar', + }, + { + type: ePropType.Boolean, + name: 'bool', + }, + { + type: ePropType.Date, + name: 'date', + }, + { + type: ePropType.DateTime, + name: 'dateTime', + }, + { + type: ePropType.Time, + name: 'time', + }, + ]); + + spectator.service.createFormProps + .get(identifier) + .addContributor(propList => propList.addManyTail(props)); + spectator.service.editFormProps + .get(identifier) + .addContributor(propList => propList.addManyTail(props)); + + const generator = getInjected(spectator); + injector = { + get: () => generator.next().value as any, + }; + }); + + it('should return a blank FormGroup instance', () => { + const data = new FormPropData(injector, null); + + const formGroup = generateFormFromProps(data); + expect(formGroup).toBeInstanceOf(FormGroup); + expect(formGroup.value.foo).toBe('bar'); + + const formControl = formGroup.get('foo'); + expect(formControl).toBeInstanceOf(FormControl); + expect(formControl.valid).toBe(true); + }); + + it('should return a prefilled FormGroup instance', () => { + const data = new FormPropData(injector, { id: 1, foo: null }); + + const formGroup = generateFormFromProps(data); + expect(formGroup).toBeInstanceOf(FormGroup); + expect(formGroup.value.foo).toBe(null); + + const formControl = formGroup.get('foo'); + expect(formControl).toBeInstanceOf(FormControl); + expect(formControl.invalid).toBe(true); + }); + + it('should add a FormGroup named extraProperties', () => { + const data = new FormPropData(injector, null); + + const formGroup = generateFormFromProps(data); + const extraPropertiesGroup = formGroup.get('extraProperties'); + expect(extraPropertiesGroup).toBeInstanceOf(FormGroup); + }); + + it('should add extraProperties to extraProperties FormGroup', () => { + const data = new FormPropData(injector, { + id: 1, + foo: undefined, + extraProperties: { + bool: true, + date: '03/30/2002', + dateTime: '2002-03-30 13:30:59Z', + time: '13:30:59', + }, + }); + + const formGroup = generateFormFromProps(data); + const extraPropertiesGroup = formGroup.get('extraProperties'); + expect(extraPropertiesGroup.value).toEqual({ + bool: true, + date: '2002-03-30', + dateTime: '2002-03-30T13:30:59.000Z', + time: '13:30', + }); + }); + }); +}); + +function* getInjected(spectator: SpectatorService) { + yield spectator.service; + yield spectator.get(EXTENSIONS_IDENTIFIER); + yield spectator.get(LocalizationService); +} + +interface Foo { + id: number; + foo: string; + extraProperties?: { + bool: boolean; + date: string; + dateTime: string; + time: string; + }; +} diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/tests/localization.util.spec.ts b/npm/ng-packs/packages/theme-shared/extensions/src/tests/localization.util.spec.ts new file mode 100644 index 0000000000..f0abe48f76 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/tests/localization.util.spec.ts @@ -0,0 +1,31 @@ +import { createDisplayNameLocalizationPipeKeyGenerator } from '../lib/utils/localization.util'; + +describe('Localization Utils', () => { + describe('#createDisplayNameLocalizationPipeKeyGenerator', () => { + const generateDisplayName = createDisplayNameLocalizationPipeKeyGenerator({ + values: { + Foo: { Bar: 'Bar', 'DisplayName:Bar': 'Bar' }, + Default: { Bar: 'Bar', 'DisplayName:Bar': 'Bar' }, + }, + defaultResourceName: 'Default', + currentCulture: null, + languages: [], + }); + + test.each` + displayName | fallback | expected + ${{ name: 'Bar', resource: 'Foo' }} | ${null} | ${'Foo::Bar'} + ${{ name: 'Baz', resource: 'Foo' }} | ${null} | ${'Baz'} + ${null} | ${{ name: 'Bar', resource: 'Foo' }} | ${'Foo::DisplayName:Bar'} + ${null} | ${{ name: 'Bar', resource: 'Default' }} | ${'Default::DisplayName:Bar'} + ${null} | ${{ name: 'Baz', resource: 'Default' }} | ${'Baz'} + `( + 'should return $expected when diplay name is $displayName and fallback is $fallback', + ({ displayName, fallback, expected }) => { + const result = generateDisplayName(displayName, fallback); + + expect(result).toBe(expected); + }, + ); + }); +}); diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/tests/props.spec.ts b/npm/ng-packs/packages/theme-shared/extensions/src/tests/props.spec.ts new file mode 100644 index 0000000000..adaaea677d --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/tests/props.spec.ts @@ -0,0 +1,22 @@ +import { PropData } from '../lib/models/props'; + +describe('PropData', () => { + describe('#data', () => { + it('should return record and getInjected', () => { + const spy = jest.fn(); + class Data extends PropData { + index = 0; + record = 'X'; + getInjected = spy; + } + + const data = new Data(); + data.data.getInjected(null); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(null); + expect(data.data.index).toBe(0); + expect(data.data.record).toBe('X'); + }); + }); +}); diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/tests/props.util.spec.ts b/npm/ng-packs/packages/theme-shared/extensions/src/tests/props.util.spec.ts new file mode 100644 index 0000000000..0c18d71eb5 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/tests/props.util.spec.ts @@ -0,0 +1,72 @@ +import { + EntityProp, + EntityPropContributorCallbacks, + EntityPropDefaults, + EntityPropsFactory, +} from '../lib/models/entity-props'; +import { PropData } from '../lib/models/props'; +import { createExtraPropertyValueResolver, mergeWithDefaultProps } from '../lib/utils/props.util'; + +class MockPropData extends PropData { + getInjected: PropData['getInjected']; + + constructor(public readonly record: R) { + super(); + } +} + +describe('Entity Prop Utils', () => { + describe('#createExtraPropertyValueResolver', () => { + it('should return a resolver that resolves an observable value from extraProperties', async () => { + const valueResolver = createExtraPropertyValueResolver('foo'); + const propData = new MockPropData({ extraProperties: { foo: 'bar' } }); + + const bar = await valueResolver(propData).toPromise(); + expect(bar).toBe('bar'); + }); + }); + + describe('#mergeEntityProps', () => { + let entityProps: EntityPropsFactory; + + beforeEach(() => { + entityProps = new EntityPropsFactory(); + }); + + it('should merge default props with prop contributors', () => { + const defaults: EntityPropDefaults = { + x: [(1 as any) as EntityProp, (2 as any) as EntityProp, (3 as any) as EntityProp], + y: [(1 as any) as EntityProp, (2 as any) as EntityProp, (3 as any) as EntityProp], + }; + + const contributors1: EntityPropContributorCallbacks = { + x: [ + propList => { + const x2 = propList.dropByIndex(1); // 1 <-> 3 + propList.addHead(x2.value); // 2 <-> 1 <-> 3 + }, + propList => { + propList.dropTail(); // 2 <-> 1 + }, + ], + }; + + const contributors2: EntityPropContributorCallbacks = { + y: [ + propList => { + const y2 = propList.dropByIndex(1); // 1 <-> 3 + propList.addTail(y2.value); // 1 <-> 3 <-> 2 + }, + propList => { + propList.dropHead(); // 3 <-> 2 + }, + ], + }; + + mergeWithDefaultProps(entityProps, defaults, contributors1, contributors2); + + expect(entityProps.get('x').props.toString()).toBe('2 <-> 1'); + expect(entityProps.get('y').props.toString()).toBe('3 <-> 2'); + }); + }); +}); diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/tests/state.util.spec.ts b/npm/ng-packs/packages/theme-shared/extensions/src/tests/state.util.spec.ts new file mode 100644 index 0000000000..9592d4c9c1 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/tests/state.util.spec.ts @@ -0,0 +1,302 @@ +import { ApplicationConfiguration, ConfigState } from '@abp/ng.core'; +import { HttpClient } from '@angular/common/http'; +import { SpectatorService } from '@ngneat/spectator'; +import { createServiceFactory } from '@ngneat/spectator/jest'; +import { NgxsModule, Store } from '@ngxs/store'; +import { of } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { ePropType } from '../lib/enums/props.enum'; +import { EntityPropList } from '../lib/models/entity-props'; +import { FormPropList } from '../lib/models/form-props'; +import { ObjectExtensions } from '../lib/models/object-extensions'; +import { + getObjectExtensionEntitiesFromStore, + mapEntitiesToContributors, +} from '../lib/utils/state.util'; + +describe('State Utils', () => { + let spectator: SpectatorService; + let store: Store; + + const createService = createServiceFactory({ + service: Store, + imports: [NgxsModule.forRoot([ConfigState])], + mocks: [HttpClient], + }); + + beforeEach(() => { + spectator = createService(); + store = spectator.service; + store.reset(createMockState()); + }); + + describe('#getObjectExtensionEntitiesFromStore', () => { + it('should return observable entities of an existing module', async () => { + const entities = await getObjectExtensionEntitiesFromStore(store, 'Identity').toPromise(); + expect('Role' in entities).toBe(true); + }); + + it('should return observable empty object if module does not exist', async () => { + const entities = await getObjectExtensionEntitiesFromStore(store, 'Saas').toPromise(); + expect(entities).toEqual({}); + }); + + it('should not emit when object extensions do not exist', done => { + store.reset({ ConfigState: {} }); + const emit = jest.fn(); + + getObjectExtensionEntitiesFromStore(store, 'Identity').subscribe(emit); + + setTimeout(() => { + expect(emit).not.toHaveBeenCalled(); + done(); + }, 1000); + }); + }); + + describe('#mapEntitiesToContributors', () => { + it('should return contributors from given entities', async () => { + const contributors = await of(createMockEntities()) + .pipe(mapEntitiesToContributors(store, 'AbpIdentity'), take(1)) + .toPromise(); + + const propList = new EntityPropList(); + contributors.prop.Role.forEach(callback => callback(propList)); + + expect(propList.length).toBe(3); + expect(propList.head.value.name).toBe('Title'); + expect(propList.head.next.value.name).toBe('IsHero'); + expect(propList.head.next.next.value.name).toBe('MyEnum'); + + const createFormList = new FormPropList(); + contributors.createForm.Role.forEach(callback => callback(createFormList)); + + expect(createFormList.length).toBe(2); + expect(createFormList.head.value.name).toBe('Title'); + expect(createFormList.head.next.value.name).toBe('MyEnum'); + + const editFormList = new FormPropList(); + contributors.editForm.Role.forEach(callback => callback(editFormList)); + + expect(editFormList.length).toBe(2); + expect(editFormList.head.value.name).toBe('Title'); + expect(editFormList.head.next.value.name).toBe('IsHero'); + }); + }); +}); + +function createMockState(): MockState { + return { + ConfigState: { + objectExtensions: { + modules: { + Identity: { + entities: createMockEntities(), + configuration: null, + }, + }, + enums: { + 'MyCompanyName.MyProjectName.MyEnum': { + fields: [ + { + name: 'MyEnumValue0', + value: 0, + }, + { + name: 'MyEnumValue1', + value: 1, + }, + { + name: 'MyEnumValue2', + value: 2, + }, + ], + localizationResource: null, + }, + }, + }, + localization: { + values: { + Default: {}, + AbpIdentity: {}, + }, + defaultResourceName: 'Default', + currentCulture: null, + languages: [], + }, + }, + }; +} + +function createMockEntities(): ObjectExtensions.Entities { + return { + Role: { + properties: { + Title: { + type: 'System.String', + typeSimple: ePropType.String, + displayName: null, + api: { + onGet: { + isAvailable: true, + }, + onCreate: { + isAvailable: true, + }, + onUpdate: { + isAvailable: true, + }, + }, + ui: { + onTable: { + isSortable: true, + isVisible: true, + }, + onCreateForm: { + isVisible: true, + }, + onEditForm: { + isVisible: true, + }, + }, + attributes: [ + { + typeSimple: 'required', + config: {}, + }, + { + typeSimple: 'stringLength', + config: { + maximumLength: 20, + minimumLength: 2, + }, + }, + ], + configuration: {}, + }, + IsHero: { + type: 'System.Boolean', + typeSimple: ePropType.Boolean, + displayName: null, + api: { + onGet: { + isAvailable: true, + }, + onCreate: { + isAvailable: true, + }, + onUpdate: { + isAvailable: true, + }, + }, + ui: { + onTable: { + isSortable: false, + isVisible: true, + }, + onCreateForm: { + isVisible: false, + }, + onEditForm: { + isVisible: true, + }, + }, + attributes: [], + configuration: {}, + }, + AsOf: { + type: 'System.Date', + typeSimple: ePropType.Date, + displayName: { + name: 'Active as of', + resource: 'AbpIdentity', + }, + api: { + onGet: { + isAvailable: true, + }, + onCreate: { + isAvailable: true, + }, + onUpdate: { + isAvailable: true, + }, + }, + ui: { + onTable: { + isSortable: false, + isVisible: false, + }, + onCreateForm: { + isVisible: false, + }, + onEditForm: { + isVisible: false, + }, + }, + attributes: [], + configuration: {}, + }, + MyEnum: { + type: 'MyCompanyName.MyProjectName.MyEnum', + typeSimple: ePropType.Enum, + displayName: null, + api: { + onGet: { + isAvailable: true, + }, + onCreate: { + isAvailable: true, + }, + onUpdate: { + isAvailable: true, + }, + }, + ui: { + onTable: { + isSortable: false, + isVisible: true, + }, + onCreateForm: { + isVisible: true, + }, + onEditForm: { + isVisible: false, + }, + }, + attributes: [ + { + typeSimple: 'required', + config: { + allowEmptyStrings: false, + }, + }, + { + typeSimple: 'enumDataType', + config: { + enumType: 'MyCompanyName.MyProjectName.MyEnum', + dataType: 'Custom', + customDataType: 'Enumeration', + }, + }, + ], + configuration: {}, + defaultValue: 2, + }, + }, + configuration: {}, + }, + User: { + properties: null, + configuration: {}, + }, + ClaimType: null, + }; +} + +interface MockState { + ConfigState: { + localization: ApplicationConfiguration.Localization; + objectExtensions: ObjectExtensions.Item; + }; +} diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/tests/time.adapter.spec.ts b/npm/ng-packs/packages/theme-shared/extensions/src/tests/time.adapter.spec.ts new file mode 100644 index 0000000000..40d7fab58b --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/tests/time.adapter.spec.ts @@ -0,0 +1,36 @@ +import { TimeAdapter } from '../lib/adapters/time.adapter'; + +describe('Time Adapter', () => { + const adapter = new TimeAdapter(); + + describe('#fromModel', () => { + const date = new Date(); + const hour = date.getHours(); + const minute = date.getMinutes(); + const second = date.getSeconds(); + + test.each` + param | expected + ${undefined} | ${null} + ${null} | ${null} + ${'x'} | ${null} + ${'13:30:45'} | ${{ hour: 13, minute: 30, second: 45 }} + ${'13:30'} | ${{ hour: 13, minute: 30, second: 0 }} + ${date} | ${{ hour, minute, second }} + `('should return $expected when $param is given', ({ param, expected }) => { + expect(adapter.fromModel(param)).toEqual(expected); + }); + }); + + describe('#toModel', () => { + test.each` + param | expected + ${undefined} | ${''} + ${null} | ${''} + ${{ hour: 13, minute: 30, second: 0 }} | ${'13:30'} + ${{ hour: 13, minute: 30, second: 45 }} | ${'13:30'} + `('should return $expected when $param is given', ({ param, expected }) => { + expect(adapter.toModel(param)).toEqual(expected); + }); + }); +}); diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/tests/toolbar-actions.spec.ts b/npm/ng-packs/packages/theme-shared/extensions/src/tests/toolbar-actions.spec.ts new file mode 100644 index 0000000000..a966628a56 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/tests/toolbar-actions.spec.ts @@ -0,0 +1,216 @@ +import { LinkedList } from '@abp/utils'; +import { + ToolbarAction, + ToolbarActionContributorCallback, + ToolbarActionList, + ToolbarActions, + ToolbarActionsFactory, + ToolbarComponent, +} from '../lib/models/toolbar-actions'; + +describe('ToolbarActionList', () => { + it('should inherit from LinkedList', () => { + expect(new ToolbarActionList() instanceof LinkedList).toBe(true); + }); +}); + +describe('ToolbarActions', () => { + const add1toTail: ToolbarActionContributorCallback = actionList => actionList.addTail(1 as any); + const add2toTail: ToolbarActionContributorCallback = actionList => actionList.addTail(2 as any); + const add3toTail: ToolbarActionContributorCallback = actionList => actionList.addTail(3 as any); + const dropIndex1: ToolbarActionContributorCallback = actionList => actionList.dropByIndex(1); + + describe('#actions', () => { + test.each` + callbackList | expected + ${[]} | ${''} + ${[add1toTail, add2toTail, add3toTail]} | ${'1 <-> 2 <-> 3'} + ${[add3toTail, add2toTail, add1toTail]} | ${'3 <-> 2 <-> 1'} + ${[add1toTail, add2toTail, add3toTail, dropIndex1]} | ${'1 <-> 3'} + `( + 'should return $expected when given callbackList is $callbackList', + ({ callbackList, expected }) => { + const creator = new ToolbarActions(callbackList); + expect(creator.actions.toString()).toBe(expected); + }, + ); + }); + + describe('#addContributor', () => { + const creator = new ToolbarActions([]); + + test.each` + callbackList | callback | expected + ${[]} | ${add1toTail} | ${'1'} + ${[add1toTail]} | ${add2toTail} | ${'1 <-> 2'} + ${[add1toTail, add2toTail]} | ${add3toTail} | ${'1 <-> 2 <-> 3'} + ${[add1toTail, add2toTail, add3toTail]} | ${dropIndex1} | ${'1 <-> 3'} + `( + 'should set actions to $expected when callbackList is $callbackList and given callback is $callback', + ({ callback, expected }) => { + creator.addContributor(callback); + expect(creator.actions.toString()).toBe(expected); + }, + ); + }); +}); + +describe('ToolbarActionsFactory', () => { + describe('#get', () => { + it('should create and return an ToolbarActions instance', () => { + const entityActions = new ToolbarActionsFactory(); + const creator = entityActions.get(''); + + expect(creator).toBeInstanceOf(ToolbarActions); + }); + + it('should store and pass contributorCallbacks to ToolbarActionsCreator instance it returns', () => { + const entityActions = new ToolbarActionsFactory(); + const creatorX1 = entityActions.get('X'); + + expect(creatorX1).toBeInstanceOf(ToolbarActions); + expect(creatorX1.actions.toArray()).toHaveLength(0); + + creatorX1.addContributor(actionList => actionList.addTail(1 as any)); + + const creatorX2 = entityActions.get('X'); + expect(creatorX2.actions.toArray()).toHaveLength(1); + }); + }); +}); + +describe('ToolbarAction', () => { + it('should be created when options object is passed as argument', () => { + const options = { + text: 'TEXT', + action: () => 'ACTION', + permission: 'PERMISSION', + visible: () => false, + icon: 'ICON', + }; + + const action = new ToolbarAction(options); + + expect(action.text).toBe(options.text); + expect(action.action(null)).toBe(options.action()); + expect(action.permission).toBe(options.permission); + expect(action.visible(null)).toBe(options.visible()); + expect(action.icon).toBe(options.icon); + }); + + it('should be created when only required options are passed', () => { + const options = { + text: 'TEXT', + action: () => 'ACTION', + }; + + const action = new ToolbarAction(options); + + expect(action.text).toBe(options.text); + expect(action.action).toBe(options.action); + expect(action.permission).toBeUndefined(); + expect(action.visible(null)).toBe(true); + expect(action.icon).toBe(''); + }); + + describe('#create', () => { + it('should return a new instance from given options', () => { + const options = { + text: 'TEXT', + action: () => 'ACTION', + }; + + const action = ToolbarAction.create(options); + + expect(action).toBeInstanceOf(ToolbarAction); + expect(action.text).toBe(options.text); + expect(action.action).toBe(options.action); + }); + }); + + describe('#createMany', () => { + it('should return multiple instances from given options array', () => { + const options1 = { + text: 'TEXT 1', + action: () => 'ACTION 1', + }; + const options2 = { + text: 'TEXT 2', + action: () => 'ACTION 2', + }; + + const [action1, action2] = ToolbarAction.createMany([options1, options2]); + + expect(action1).toBeInstanceOf(ToolbarAction); + expect(action1.text).toBe(options1.text); + expect(action1.action).toBe(options1.action); + expect(action2).toBeInstanceOf(ToolbarAction); + expect(action2.text).toBe(options2.text); + expect(action2.action).toBe(options2.action); + }); + }); +}); +class MockComponent1 {} +class MockComponent2 {} + +describe('ToolbarComponent', () => { + it('should be created when options object is passed as argument', () => { + const options = { + component: MockComponent1, + action: () => 'ACTION', + permission: 'PERMISSION', + visible: () => false, + }; + + const action = new ToolbarComponent(options); + + expect(action.component).toBe(options.component); + expect(action.action).toBe(options.action); + expect(action.permission).toBe(options.permission); + expect(action.visible).toBe(options.visible); + }); + + it('should be created when only required options are passed', () => { + const options = { + component: MockComponent1, + }; + + const action = new ToolbarComponent(options); + + expect(action.component).toBe(options.component); + expect(action.action()).toBeUndefined(); + expect(action.permission).toBeUndefined(); + expect(action.visible()).toBe(true); + }); + + describe('#create', () => { + it('should return a new instance from given options', () => { + const options = { + component: MockComponent1, + }; + + const action = ToolbarComponent.create(options); + + expect(action).toBeInstanceOf(ToolbarComponent); + expect(action.component).toBe(options.component); + }); + }); + + describe('#createMany', () => { + it('should return multiple instances from given options array', () => { + const options1 = { + component: MockComponent1, + }; + const options2 = { + component: MockComponent2, + }; + + const [action1, action2] = ToolbarComponent.createMany([options1, options2]); + + expect(action1).toBeInstanceOf(ToolbarComponent); + expect(action1.component).toBe(options1.component); + expect(action2).toBeInstanceOf(ToolbarComponent); + expect(action2.component).toBe(options2.component); + }); + }); +}); diff --git a/npm/ng-packs/packages/theme-shared/extensions/src/tests/validation.util.spec.ts b/npm/ng-packs/packages/theme-shared/extensions/src/tests/validation.util.spec.ts new file mode 100644 index 0000000000..b26bd9e1b0 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/extensions/src/tests/validation.util.spec.ts @@ -0,0 +1,20 @@ +import { Validators } from '@angular/forms'; +import { ObjectExtensions } from '../lib/models/object-extensions'; +import { getValidatorsFromProperty } from '../lib/utils/validation.util'; + +describe('Validation Utils', () => { + describe('#getValidatorsFromProperty', () => { + it('should return a list of validators derived from property attributes', () => { + const property = { + attributes: [ + { + typeSimple: 'emailAddress', + config: {}, + }, + ], + } as ObjectExtensions.Property; + + expect(getValidatorsFromProperty(property)[0]).toBe(Validators.email); + }); + }); +});