diff --git a/build-functions.sh b/build-functions.sh index 360e655f0e..f8622f638a 100644 --- a/build-functions.sh +++ b/build-functions.sh @@ -10,7 +10,7 @@ isIgnoredDirectory() { #logTrace "${FUNCNAME[0]}: Checking for ${1}" 1 name=$(basename ${1}) - if [[ -f "${1}" || "${name}" == "src" || "${name}" == "test" || "${name}" == "integrationtest" || "${name}" == "reports" || "${name}" == "coverage" || "${name}" == "assets" || "${name}" == "node_modules" ]]; then + if [[ -f "${1}" || "${name}" == "src" || "${name}" == "test" || "${name}" == "integrationtest" || "${name}" == "reports" || "${name}" == "coverage" || "${name}" == "assets" || "${name}" == "typings" || "${name}" == "node_modules" ]]; then #logTrace "No" 1 return 0 else diff --git a/build.sh b/build.sh index 9aab868251..daa4b19689 100644 --- a/build.sh +++ b/build.sh @@ -258,6 +258,11 @@ do syncOptions=(-a --include="**/assets/" --exclude="*.js" --exclude="*.js.map" --exclude="*.ts" --include="*.json" --exclude="node_modules/" --exclude="coverage/" --exclude="reports/") syncFiles ${SRC_DIR} ${OUT_DIR} "${syncOptions[@]}" unset syncOptions + + logInfo "******************************* Copy typings folders for package $PACKAGE" + syncOptions=(-a --include="/typings/***" --exclude="*") + syncFiles $SRC_DIR $OUT_DIR "${syncOptions[@]}" + unset syncOptions fi if [[ ${BUNDLE} == true ]]; then diff --git a/packages/rollup.config.common-data.js b/packages/rollup.config.common-data.js index c08f1e3122..d21d1ed0b3 100644 --- a/packages/rollup.config.common-data.js +++ b/packages/rollup.config.common-data.js @@ -67,6 +67,7 @@ const globals = { "prismjs/components/prism-json.min.js": "Prism.languages.json", "prismjs/components/prism-css-extras.min.js": "Prism.languages.css.selector", "prismjs/components/prism-scss.min.js": "Prism.languages.scss", + "text-mask-core": "textMaskCore", uuid: "uuid", rxjs: "rxjs", diff --git a/packages/stark-ui/package.json b/packages/stark-ui/package.json index 633559c340..1be602c9e0 100644 --- a/packages/stark-ui/package.json +++ b/packages/stark-ui/package.json @@ -30,11 +30,14 @@ "@mdi/angular-material": "^3.3.92", "@types/nouislider": "^9.0.4", "@types/prismjs": "^1.9.0", + "angular2-text-mask": "^9.0.0", "normalize.css": "^8.0.1", "nouislider": "^12.1.0", "prettier": "^1.16.1", "pretty-data": "^0.40.0", - "prismjs": "^1.15.0" + "prismjs": "^1.15.0", + "text-mask-addons": "^3.8.0", + "text-mask-core": "^5.1.2" }, "devDependencies": { "@nationalbankbelgium/stark-testing": "../stark-testing" diff --git a/packages/stark-ui/src/modules.ts b/packages/stark-ui/src/modules.ts index 758e639e86..bc7351a35c 100644 --- a/packages/stark-ui/src/modules.ts +++ b/packages/stark-ui/src/modules.ts @@ -13,6 +13,7 @@ export * from "./modules/dropdown"; export * from "./modules/generic-search"; export * from "./modules/keyboard-directives"; export * from "./modules/language-selector"; +export * from "./modules/mask-directives"; export * from "./modules/message-pane"; export * from "./modules/minimap"; export * from "./modules/pagination"; diff --git a/packages/stark-ui/src/modules/mask-directives.ts b/packages/stark-ui/src/modules/mask-directives.ts new file mode 100644 index 0000000000..0b55a2eaa2 --- /dev/null +++ b/packages/stark-ui/src/modules/mask-directives.ts @@ -0,0 +1,2 @@ +export * from "./mask-directives/directives"; +export * from "./mask-directives/mask-directives.module"; diff --git a/packages/stark-ui/src/modules/mask-directives/directives.ts b/packages/stark-ui/src/modules/mask-directives/directives.ts new file mode 100644 index 0000000000..a2cd6472e9 --- /dev/null +++ b/packages/stark-ui/src/modules/mask-directives/directives.ts @@ -0,0 +1,9 @@ +export * from "./directives/email-mask.directive"; +export * from "./directives/number-mask.directive"; +export * from "./directives/number-mask-config.intf"; +export * from "./directives/text-mask.constants"; +export * from "./directives/text-mask.directive"; +export * from "./directives/text-mask-config.intf"; +export * from "./directives/timestamp-mask.directive"; +export * from "./directives/timestamp-mask-config.intf"; +export * from "./directives/timestamp-pipe.fn"; diff --git a/packages/stark-ui/src/modules/mask-directives/directives/email-mask.directive.ts b/packages/stark-ui/src/modules/mask-directives/directives/email-mask.directive.ts new file mode 100644 index 0000000000..3e9ef5b54a --- /dev/null +++ b/packages/stark-ui/src/modules/mask-directives/directives/email-mask.directive.ts @@ -0,0 +1,55 @@ +import { Directive, ElementRef, forwardRef, Inject, Input, OnChanges, Optional, Provider, Renderer2, SimpleChanges } from "@angular/core"; +import { COMPOSITION_BUFFER_MODE, NG_VALUE_ACCESSOR } from "@angular/forms"; +import { CombinedPipeMask } from "text-mask-core"; +import * as textMaskAddons from "text-mask-addons"; +import { MaskedInputDirective, TextMaskConfig as Ng2TextMaskConfig } from "angular2-text-mask"; + +/** + * Name of the directive + */ +const directiveName: string = "[starkEmailMask]"; + +export const STARK_EMAIL_MASK_VALUE_ACCESSOR: Provider = { + provide: NG_VALUE_ACCESSOR, + // tslint:disable-next-line:no-forward-ref + useExisting: forwardRef(() => StarkEmailMaskDirective), + multi: true +}; + +@Directive({ + host: { + "(input)": "_handleInput($event.target.value)", + "(blur)": "onTouched()", + "(compositionstart)": "_compositionStart()", + "(compositionend)": "_compositionEnd($event.target.value)" + }, + selector: directiveName, + exportAs: "starkEmailMask", + providers: [STARK_EMAIL_MASK_VALUE_ACCESSOR] +}) +export class StarkEmailMaskDirective extends MaskedInputDirective implements OnChanges { + /* tslint:disable:no-input-rename */ + @Input("starkEmailMask") + public maskConfig: boolean; + + public constructor( + _renderer: Renderer2, + _elementRef: ElementRef, + @Optional() @Inject(COMPOSITION_BUFFER_MODE) _compositionMode: boolean + ) { + super(_renderer, _elementRef, _compositionMode); + } + + public ngOnChanges(changes: SimpleChanges): void { + // TODO: Ng2TextMaskConfig is not the same as Core TextMaskConfig + // even though emailMask is passed as a mask, it is actually made of both a mask and a pipe bundled together for convenience + // https://github.com/text-mask/text-mask/tree/master/addons + const { mask, pipe }: CombinedPipeMask = textMaskAddons.emailMask; + const textMaskConfig: Ng2TextMaskConfig = { mask: mask, pipe: pipe }; + + console.log("CCR==========> textMaskConfig", textMaskConfig); + this.textMaskConfig = textMaskConfig; + + super.ngOnChanges(changes); + } +} diff --git a/packages/stark-ui/src/modules/mask-directives/directives/number-mask-config.intf.ts b/packages/stark-ui/src/modules/mask-directives/directives/number-mask-config.intf.ts new file mode 100644 index 0000000000..7d7f5a181d --- /dev/null +++ b/packages/stark-ui/src/modules/mask-directives/directives/number-mask-config.intf.ts @@ -0,0 +1,60 @@ +/** + * Interface based on the API of the createNumberMask() function from the text-mask-addons library + * See https://github.com/text-mask/text-mask/tree/master/addons#createnumbermask + */ +export interface StarkNumberMaskConfig { + /** + * String to be displayed before the amount. Default: empty string ("") + */ + prefix?: string; + + /** + * String to be displayed after the amount. Default: empty string ("") + */ + suffix?: string; + + /** + * Whether or not to separate thousands. Default: true + */ + includeThousandsSeparator?: boolean; + + /** + * Character to be used as thousands separator. Default: "," + */ + thousandsSeparatorSymbol?: string; + + /** + * Whether or not to allow the user to enter a fraction with the amount. Default: false + */ + allowDecimal?: boolean; + + /** + * Character to be used as decimal point. Default: "." + */ + decimalSymbol?: string; + + /** + * Number of digits to allow in the decimal part of the number. Default: 2 + */ + decimalLimit?: number; + + /** + * Limit the length of the integer number. Default: undefined (unlimited) + */ + integerLimit?: number; + + /** + * Whether or not to always include a decimal point and placeholder for decimal digits after the integer. Default: false + */ + requireDecimal?: boolean; + + /** + * Whether or not to allow negative numbers. Default: true + */ + allowNegative?: boolean; + + /** + * Whether or not to allow leading zeroes. Default: false + */ + allowLeadingZeroes?: boolean; +} diff --git a/packages/stark-ui/src/modules/mask-directives/directives/number-mask.directive.ts b/packages/stark-ui/src/modules/mask-directives/directives/number-mask.directive.ts new file mode 100644 index 0000000000..f09bcc530f --- /dev/null +++ b/packages/stark-ui/src/modules/mask-directives/directives/number-mask.directive.ts @@ -0,0 +1,66 @@ +import { Directive, ElementRef, forwardRef, Inject, Input, OnChanges, Optional, Provider, Renderer2, SimpleChanges } from "@angular/core"; +import { COMPOSITION_BUFFER_MODE, NG_VALUE_ACCESSOR } from "@angular/forms"; +import { MaskedInputDirective, TextMaskConfig as Ng2TextMaskConfig } from "angular2-text-mask"; +import { StarkNumberMaskConfig } from "./number-mask-config.intf"; +import * as textMaskAddons from "text-mask-addons"; + +/** + * Name of the directive + */ +const directiveName: string = "[starkNumberMask]"; + +export const STARK_NUMBER_MASK_VALUE_ACCESSOR: Provider = { + provide: NG_VALUE_ACCESSOR, + // tslint:disable-next-line:no-forward-ref + useExisting: forwardRef(() => StarkNumberMaskDirective), + multi: true +}; + +const defaultNumberMaskConfig: StarkNumberMaskConfig = { + prefix: "", + suffix: "", + includeThousandsSeparator: true, + thousandsSeparatorSymbol: ",", + allowDecimal: false, + decimalSymbol: ".", + decimalLimit: 2, + requireDecimal: false, + allowNegative: true, + allowLeadingZeroes: false +}; + +@Directive({ + host: { + "(input)": "_handleInput($event.target.value)", + "(blur)": "onTouched()", + "(compositionstart)": "_compositionStart()", + "(compositionend)": "_compositionEnd($event.target.value)" + }, + selector: directiveName, + exportAs: "starkNumberMask", + providers: [STARK_NUMBER_MASK_VALUE_ACCESSOR] +}) +export class StarkNumberMaskDirective extends MaskedInputDirective implements OnChanges { + /* tslint:disable:no-input-rename */ + @Input("starkNumberMask") + public maskConfig: StarkNumberMaskConfig; + + public constructor( + _renderer: Renderer2, + _elementRef: ElementRef, + @Optional() @Inject(COMPOSITION_BUFFER_MODE) _compositionMode: boolean + ) { + super(_renderer, _elementRef, _compositionMode); + } + + public ngOnChanges(changes: SimpleChanges): void { + // TODO: Ng2TextMaskConfig is not the same as Core TextMaskConfig + const numberMaskConfig: StarkNumberMaskConfig = { ...defaultNumberMaskConfig, ...this.maskConfig }; + const textMaskConfig: Ng2TextMaskConfig = { mask: textMaskAddons.createNumberMask(numberMaskConfig) }; + + console.log("CCR==========> textMaskConfig", textMaskConfig); + this.textMaskConfig = textMaskConfig; + + super.ngOnChanges(changes); + } +} diff --git a/packages/stark-ui/src/modules/mask-directives/directives/text-mask-config.intf.ts b/packages/stark-ui/src/modules/mask-directives/directives/text-mask-config.intf.ts new file mode 100644 index 0000000000..ccbe4ccd2b --- /dev/null +++ b/packages/stark-ui/src/modules/mask-directives/directives/text-mask-config.intf.ts @@ -0,0 +1,35 @@ +import * as textMaskCore from "text-mask-core"; + +export interface StarkTextMaskBaseConfig { + /** + * Whether to show the mask while the user is typing in the input field in order to guide him. Default: true. + * See https://github.com/text-mask/text-mask/blob/master/componentDocumentation.md#guide + */ + guide?: boolean; + + /** + * Placeholder character represents the fillable spot in the mask. Default: "_". + * See https://github.com/text-mask/text-mask/blob/master/componentDocumentation.md#placeholderchar + */ + placeholderChar?: string; + + /** + * Whether to keep the spaces used by character after they are added/deleted. Default: true. + * https://github.com/text-mask/text-mask/blob/master/componentDocumentation.md#keepcharpositions + */ + keepCharPositions?: boolean; +} + +export interface StarkTextMaskConfig extends StarkTextMaskBaseConfig { + /** + * Array or a function that defines how the user input is going to be masked. If is set to false, the mask will be removed. + * See https://github.com/text-mask/text-mask/blob/master/componentDocumentation.md#mask + */ + mask: textMaskCore.Mask | false; + + /** + * Function that can modify the conformed value before it is displayed on the screen. + * See https://github.com/text-mask/text-mask/blob/master/componentDocumentation.md#pipe + */ + pipe?: textMaskCore.PipeFunction; +} diff --git a/packages/stark-ui/src/modules/mask-directives/directives/text-mask-ng1.directive.ts b/packages/stark-ui/src/modules/mask-directives/directives/text-mask-ng1.directive.ts new file mode 100644 index 0000000000..2b5dbaf507 --- /dev/null +++ b/packages/stark-ui/src/modules/mask-directives/directives/text-mask-ng1.directive.ts @@ -0,0 +1,117 @@ +import { Directive, ElementRef, Inject, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from "@angular/core"; +import { STARK_LOGGING_SERVICE, StarkLoggingService } from "@nationalbankbelgium/stark-core"; +import { createTextMaskInputElement, TextMaskInputElement } from "text-mask-core"; +import { StarkTextMaskBaseConfig, StarkTextMaskConfig } from "./text-mask-config.intf"; + +/** + * Name of the directive + */ +const directiveName: string = "[starkTextMaskNg1]"; + +const defaultTextMaskConfig: StarkTextMaskBaseConfig = { + guide: true, + placeholderChar: "_", + keepCharPositions: true +}; + +/** + * Directive to display a mask in an input field. + * + * Currently the Text Mask supports only input of type text, tel, url, password, and search. + * Due to a limitation in browser API, other input types, such as email or number, cannot be supported. + */ +@Directive({ + selector: directiveName +}) +export class StarkTextMaskNg1Directive implements OnInit, OnChanges, OnDestroy { + /** + * {@link StarkTextMaskConfig} object for the mask to be added to the input + */ + /* tslint:disable:no-input-rename */ + @Input("starkTextMaskNg1") + public maskConfig: StarkTextMaskConfig; + + public eventHandler: EventListener; + public textMaskInputElement?: TextMaskInputElement; + public inputElement: HTMLInputElement; + + public constructor(@Inject(STARK_LOGGING_SERVICE) private logger: StarkLoggingService, private element: ElementRef) { + this.inputElement = this.element.nativeElement; + } + + public ngOnInit(): void { + if (this.element.nativeElement.tagName === "INPUT") { + // `textMask` directive is used directly on an input element + this.inputElement = this.element.nativeElement; + } else { + // `textMask` directive is used on an abstracted input element + this.inputElement = this.element.nativeElement.getElementsByTagName("INPUT")[0]; + } + + const eventHandler: EventListener = (_event: Event) => { + // avoid unnecessary updates to the ngModel when the input value hasn't changed otherwise it could be messed up (i.e. date-pickers) + if ((this.textMaskInputElement).state.previousConformedValue !== this.inputElement.value) { + (this.textMaskInputElement).update(this.inputElement.value); + // TODO this.ngModel.$setViewValue(this.inputElement.value); + } + }; + this.eventHandler = eventHandler.bind(this); // bind the directive's context to the handler + + console.log("CCR==========> ngOnInit this.maskConfig", this.maskConfig); + // there is no isolate scope so it is not possible to have bindings + // so we use the $eval to get the evaluated attribute to mimic what Angular does with the bindings + this.initTextMask(this.maskConfig); + // TODO this.ngModel.$formatters.unshift((fromModelValue: string) => this.formatter(fromModelValue)); + + this.logger.debug(directiveName + ": directive initialized"); + } + + public ngOnChanges(changes: SimpleChanges): void { + if (changes["starkTextMask"]) { + console.log("CCR==========> ngOnChanges this.maskConfig", this.maskConfig); + this.initTextMask(this.maskConfig); + if (typeof this.textMaskInputElement !== "undefined") { + this.textMaskInputElement.update(); + // TODO this.ngModel.$setViewValue(this.inputElement.value); + } + } + } + + public ngOnDestroy(): void { + this.enableEventListeners(false); + } + + public initTextMask(config: StarkTextMaskConfig): void { + if (typeof config !== "undefined") { + const textMaskConfig: StarkTextMaskConfig = { ...defaultTextMaskConfig, ...config }; + + this.textMaskInputElement = createTextMaskInputElement({ inputElement: this.inputElement, ...textMaskConfig }); + + this.enableEventListeners(true); + } else { + this.textMaskInputElement = undefined; + this.enableEventListeners(false); + } + } + + public formatter(fromModelValue: string): string { + // set the `inputElement.value` for cases where the `mask` is disabled + const normalizedValue: string = typeof fromModelValue === "undefined" ? "" : fromModelValue; + this.inputElement.value = normalizedValue; + + if (typeof this.textMaskInputElement !== "undefined") { + this.textMaskInputElement.update(normalizedValue); + } + return this.inputElement.value; + } + + public enableEventListeners(enabled: boolean): void { + for (const eventType of ["blur", "keyup", "change", "input"]) { + if (enabled) { + this.element.nativeElement.addEventListener(eventType, this.eventHandler); + } else { + this.element.nativeElement.removeEventListener(eventType, this.eventHandler); + } + } + } +} diff --git a/packages/stark-ui/src/modules/mask-directives/directives/text-mask.constants.ts b/packages/stark-ui/src/modules/mask-directives/directives/text-mask.constants.ts new file mode 100644 index 0000000000..1a43268b68 --- /dev/null +++ b/packages/stark-ui/src/modules/mask-directives/directives/text-mask.constants.ts @@ -0,0 +1,54 @@ +export class StarkTextMasks { + public static STRUCTURED_NUMBER: (RegExp | string)[] = [ + "+", + "+", + "+", + /\d/, + /\d/, + /\d/, + "/", + /\d/, + /\d/, + /\d/, + /\d/, + "/", + /\d/, + /\d/, + /\d/, + /\d/, + /\d/, + "+", + "+", + "+" + ]; + + public static CREDITCARD_NUMBER: (RegExp | string)[] = [ + /\d/, + /\d/, + /\d/, + /\d/, + "-", + /\d/, + /\d/, + /\d/, + /\d/, + "-", + /\d/, + /\d/, + /\d/, + /\d/, + "-", + /\d/, + /\d/, + /\d/, + /\d/ + ]; + + public static DATE_MM_YY: (RegExp | string)[] = [/[0-1]/, /\d/, "/", /\d/, /\d/]; + + public static DATE_DD_MM_YYYY: (RegExp | string)[] = [/[0-3]/, /\d/, "/", /[0-1]/, /\d/, "/", /[1-2]/, /\d/, /\d/, /\d/]; + + public static TIME_HH_MM: (RegExp | string)[] = [/[0-2]/, /\d/, ":", /[0-5]/, /\d/]; + + public static TIME_HH_MM_SS: (RegExp | string)[] = [/[0-2]/, /\d/, ":", /[0-5]/, /\d/, ":", /[0-5]/, /\d/]; +} diff --git a/packages/stark-ui/src/modules/mask-directives/directives/text-mask.directive.ts b/packages/stark-ui/src/modules/mask-directives/directives/text-mask.directive.ts new file mode 100644 index 0000000000..5fede47918 --- /dev/null +++ b/packages/stark-ui/src/modules/mask-directives/directives/text-mask.directive.ts @@ -0,0 +1,56 @@ +import { Directive, ElementRef, forwardRef, Inject, Input, OnChanges, Optional, Provider, Renderer2, SimpleChanges } from "@angular/core"; +import { COMPOSITION_BUFFER_MODE, NG_VALUE_ACCESSOR } from "@angular/forms"; +import { MaskedInputDirective, TextMaskConfig as Ng2TextMaskConfig } from "angular2-text-mask"; +import { StarkTextMaskBaseConfig, StarkTextMaskConfig } from "./text-mask-config.intf"; + +/** + * Name of the directive + */ +const directiveName: string = "[starkTextMask]"; + +export const STARK_TEXT_MASK_VALUE_ACCESSOR: Provider = { + provide: NG_VALUE_ACCESSOR, + // tslint:disable-next-line:no-forward-ref + useExisting: forwardRef(() => StarkTextMaskDirective), + multi: true +}; + +const defaultTextMaskConfig: StarkTextMaskBaseConfig = { + guide: true, + placeholderChar: "_", + keepCharPositions: true +}; + +@Directive({ + host: { + "(input)": "_handleInput($event.target.value)", + "(blur)": "onTouched()", + "(compositionstart)": "_compositionStart()", + "(compositionend)": "_compositionEnd($event.target.value)" + }, + selector: directiveName, + exportAs: "starkTextMask", + providers: [STARK_TEXT_MASK_VALUE_ACCESSOR] +}) +export class StarkTextMaskDirective extends MaskedInputDirective implements OnChanges { + /* tslint:disable:no-input-rename */ + @Input("starkTextMask") + public maskConfig: StarkTextMaskConfig; + + public constructor( + _renderer: Renderer2, + _elementRef: ElementRef, + @Optional() @Inject(COMPOSITION_BUFFER_MODE) _compositionMode: boolean + ) { + super(_renderer, _elementRef, _compositionMode); + } + + public ngOnChanges(changes: SimpleChanges): void { + // TODO: Ng2TextMaskConfig is not the same as Core TextMaskConfig + const textMaskConfig: Ng2TextMaskConfig = { ...defaultTextMaskConfig, ...(this.maskConfig) }; + console.log("CCR==========> textMaskConfig", textMaskConfig); + this.textMaskConfig = textMaskConfig; + + super.ngOnChanges(changes); + } +} diff --git a/packages/stark-ui/src/modules/mask-directives/directives/timestamp-mask-config.intf.ts b/packages/stark-ui/src/modules/mask-directives/directives/timestamp-mask-config.intf.ts new file mode 100644 index 0000000000..a82b6e84f2 --- /dev/null +++ b/packages/stark-ui/src/modules/mask-directives/directives/timestamp-mask-config.intf.ts @@ -0,0 +1,3 @@ +export interface StarkTimestampMaskConfig { + format: string; +} diff --git a/packages/stark-ui/src/modules/mask-directives/directives/timestamp-mask.directive.ts b/packages/stark-ui/src/modules/mask-directives/directives/timestamp-mask.directive.ts new file mode 100644 index 0000000000..54dea61c9d --- /dev/null +++ b/packages/stark-ui/src/modules/mask-directives/directives/timestamp-mask.directive.ts @@ -0,0 +1,82 @@ +import { Directive, ElementRef, forwardRef, Inject, Input, OnChanges, Optional, Provider, Renderer2, SimpleChanges } from "@angular/core"; +import { COMPOSITION_BUFFER_MODE, NG_VALUE_ACCESSOR } from "@angular/forms"; +import { MaskedInputDirective, TextMaskConfig as Ng2TextMaskConfig } from "angular2-text-mask"; +import { StarkTimestampMaskConfig } from "./timestamp-mask-config.intf"; +import { createTimestampPipe } from "./timestamp-pipe.fn"; +import { MaskArray } from "text-mask-core"; + +/** + * Name of the directive + */ +const directiveName: string = "[starkTimestampMask]"; + +export const STARK_TIMESTAMP_MASK_VALUE_ACCESSOR: Provider = { + provide: NG_VALUE_ACCESSOR, + // tslint:disable-next-line:no-forward-ref + useExisting: forwardRef(() => StarkTimestampMaskDirective), + multi: true +}; + +const defaultTimestampMaskConfig: StarkTimestampMaskConfig = { + format: "DD-MM-YYYY HH:mm:ss" +}; + +@Directive({ + host: { + "(input)": "_handleInput($event.target.value)", + "(blur)": "onTouched()", + "(compositionstart)": "_compositionStart()", + "(compositionend)": "_compositionEnd($event.target.value)" + }, + selector: directiveName, + exportAs: "starkTimestampMask", + providers: [STARK_TIMESTAMP_MASK_VALUE_ACCESSOR] +}) +export class StarkTimestampMaskDirective extends MaskedInputDirective implements OnChanges { + /* tslint:disable:no-input-rename */ + @Input("starkTimestampMask") + public maskConfig: StarkTimestampMaskConfig; + + public constructor( + _renderer: Renderer2, + _elementRef: ElementRef, + @Optional() @Inject(COMPOSITION_BUFFER_MODE) _compositionMode: boolean + ) { + super(_renderer, _elementRef, _compositionMode); + } + + public ngOnChanges(changes: SimpleChanges): void { + // TODO: Ng2TextMaskConfig is not the same as Core TextMaskConfig + const timestampMaskConfig: StarkTimestampMaskConfig = { ...defaultTimestampMaskConfig, ...this.maskConfig }; + + const textMaskConfig: Ng2TextMaskConfig = { + pipe: createTimestampPipe(timestampMaskConfig.format), + mask: this.getMaskFromFormat(timestampMaskConfig.format), + placeholderChar: "_" + }; + + console.log("CCR==========> textMaskConfig", textMaskConfig); + this.textMaskConfig = textMaskConfig; + + super.ngOnChanges(changes); + } + + public getMaskFromFormat(format: string): MaskArray { + const mask: MaskArray = []; + for (let i: number = 0; i < format.length; i++) { + if ( + format.charAt(i) === "D" || + format.charAt(i) === "M" || + format.charAt(i) === "Y" || + format.charAt(i) === "H" || + format.charAt(i) === "m" || + format.charAt(i) === "s" + ) { + mask[i] = /\d/; + } else { + mask[i] = format.charAt(i); + } + } + return mask; + } +} diff --git a/packages/stark-ui/src/modules/mask-directives/directives/timestamp-pipe.fn.ts b/packages/stark-ui/src/modules/mask-directives/directives/timestamp-pipe.fn.ts new file mode 100644 index 0000000000..cc75841aef --- /dev/null +++ b/packages/stark-ui/src/modules/mask-directives/directives/timestamp-pipe.fn.ts @@ -0,0 +1,81 @@ +import { starkIsDateTime } from "@nationalbankbelgium/stark-core"; + +// TODO: refactor this function to reduce its cognitive complexity +// tslint:disable-next-line:cognitive-complexity +export function createTimestampPipe(timestampFormat: string = "DD-MM-YYYY HH:mm:ss"): any { + return (conformedValue: string) => { + const dateFormatArray: string[] = timestampFormat.split(/[^DMYHms]+/); + const maxValue: object = {DD: 31, MM: 12, YYYY: 2999, HH: 24, mm: 60, ss: 60}; + const minValue: object = {DD: 0, MM: 0, YYYY: 1, HH: 0, mm: 0, ss: 0}; + + function isLeapDay(value: string, format: string, fullFormat: string): boolean { + const textValue: string = value.replace(/\D/, ""); // removing all non digits + const dayMonthFormat: string = format.replace(/[^DM]/, ""); // keeping only day and month parts + const leapDays: { format: string; date: string }[] = [ + {format: "DDMM", date: "2902"}, + {format: "MMDD", date: "0229"} + ]; + + // is leap day as long as there is no year entered yet and the full format does have a year part + for (const leapDay of leapDays) { + const indexOfDayMonth: number = dayMonthFormat.indexOf(leapDay.format); + if (textValue.substr(indexOfDayMonth, 4) === leapDay.date && dayMonthFormat.substr(indexOfDayMonth, 4) === leapDay.format + && ((fullFormat.indexOf("YYYY") > 0 && inputValue.length < fullFormat.indexOf("YYYY") + 4) + || (fullFormat.indexOf("YY") > 0 && inputValue.length < fullFormat.indexOf("YY") + 2))) { + return true; + } + } + return false; + } + + let skipValidation: boolean = false; + + // Check for invalid date + const isInvalid: boolean = dateFormatArray.some((format: string) => { + const position: number = timestampFormat.indexOf(format); + const length: number = format.length; + const textValue: string = conformedValue.substr(position, length).replace(/\D/g, ""); + const value: number = parseInt(textValue, 10); + + // skip the validation if the day starts with 0, but is not 00 + // because if we would validate it would give not valid, because day 0 doesn't exist + // but maybe we want to type for example 02 + // it should not give invalid if we only already have typed the 0 + if (format === "DD" && (value === 0 && textValue !== "00")) { + skipValidation = true; + // same for month + } else if (format === "MM" && (value === 0 && textValue !== "00")) { + skipValidation = true; + } + return value > maxValue[format] || (textValue.length === length && value < minValue[format]); + }); + + // remove all non digits at the end of the conformed value + const inputValue: string = conformedValue.replace(/\D*$/, ""); + const partialFormat: string = timestampFormat.substring(0, inputValue.length); + + // MomentJs gives always false for input 31, but it depends on the month + // so we say it is always true + // if 31 is a month or year or hour than we couldn't even type the 3 + if (inputValue === "31") { + skipValidation = true; + + // 29 february must be checked after we have typed the year if there is a year in the format + } else if (isLeapDay(inputValue, partialFormat, timestampFormat)) { + skipValidation = true; + } + + if (!skipValidation && !isInvalid && inputValue.length > 0 && !starkIsDateTime(inputValue, partialFormat)) { + return false; + } + + skipValidation = false; + + if (isInvalid) { + return false; + } + + return conformedValue; + + }; +} diff --git a/packages/stark-ui/src/modules/mask-directives/mask-directives.module.ts b/packages/stark-ui/src/modules/mask-directives/mask-directives.module.ts new file mode 100644 index 0000000000..0654989040 --- /dev/null +++ b/packages/stark-ui/src/modules/mask-directives/mask-directives.module.ts @@ -0,0 +1,8 @@ +import { NgModule } from "@angular/core"; +import { StarkEmailMaskDirective, StarkNumberMaskDirective, StarkTextMaskDirective, StarkTimestampMaskDirective } from "./directives"; + +@NgModule({ + declarations: [StarkEmailMaskDirective, StarkNumberMaskDirective, StarkTextMaskDirective, StarkTimestampMaskDirective], + exports: [StarkEmailMaskDirective, StarkNumberMaskDirective, StarkTextMaskDirective, StarkTimestampMaskDirective] +}) +export class StarkMaskDirectivesModule {} diff --git a/packages/stark-ui/testing/tsconfig-build.json b/packages/stark-ui/testing/tsconfig-build.json index 691c98ae1f..e2f71ee06c 100644 --- a/packages/stark-ui/testing/tsconfig-build.json +++ b/packages/stark-ui/testing/tsconfig-build.json @@ -6,7 +6,8 @@ "typeRoots": [ "../node_modules/@types", "../node_modules/@nationalbankbelgium/stark-testing/node_modules/@types", - "../../stark-build/typings" + "../../stark-build/typings", + "../typings" ], "paths": { "cerialize": ["../../stark-core/node_modules/cerialize"], diff --git a/packages/stark-ui/tsconfig-build.json b/packages/stark-ui/tsconfig-build.json index 489a5280ab..56d220be3f 100644 --- a/packages/stark-ui/tsconfig-build.json +++ b/packages/stark-ui/tsconfig-build.json @@ -6,7 +6,8 @@ "typeRoots": [ "./node_modules/@types", "./node_modules/@nationalbankbelgium/stark-testing/node_modules/@types", - "../stark-build/typings" + "../stark-build/typings", + "./typings" ], "lib": ["dom", "dom.iterable", "es2017"], "paths": { diff --git a/packages/stark-ui/typings/index.d.ts b/packages/stark-ui/typings/index.d.ts new file mode 100644 index 0000000000..b82a78d501 --- /dev/null +++ b/packages/stark-ui/typings/index.d.ts @@ -0,0 +1,4 @@ +/* tslint:disable:no-import-side-effect */ +import "./text-mask-addons/index"; +import "./text-mask-core/index"; +/* tslint:enable */ diff --git a/packages/stark-ui/typings/text-mask-addons/index.d.ts b/packages/stark-ui/typings/text-mask-addons/index.d.ts new file mode 100644 index 0000000000..b817d9eb4a --- /dev/null +++ b/packages/stark-ui/typings/text-mask-addons/index.d.ts @@ -0,0 +1,31 @@ +// typings not yet provided in the text-mask library +// TODO: remove these and use the ones from DefinitelyTyped once they are implemented +declare module "text-mask-addons" { + + import * as textMaskCore from "text-mask-core"; + + export = textMaskAddons; + + namespace textMaskAddons { + + interface NumberMaskConfig { + prefix?: string; + suffix?: string; + includeThousandsSeparator?: boolean; + thousandsSeparatorSymbol?: string; + allowDecimal?: boolean; + decimalSymbol?: string; + decimalLimit?: number; + integerLimit?: number; + requireDecimal?: boolean; + allowNegative?: boolean; + allowLeadingZeroes?: boolean; + } + + function createNumberMask(config: NumberMaskConfig): textMaskCore.MaskFunction; + + function createAutoCorrectedDatePipe(dateFormat: string): textMaskCore.PipeFunction; + + const emailMask: textMaskCore.CombinedPipeMask; + } +} diff --git a/packages/stark-ui/typings/text-mask-core/index.d.ts b/packages/stark-ui/typings/text-mask-core/index.d.ts new file mode 100644 index 0000000000..0038bb940b --- /dev/null +++ b/packages/stark-ui/typings/text-mask-core/index.d.ts @@ -0,0 +1,88 @@ +// typings not yet provided in the text-mask library +// TODO: remove these and use the ones from DefinitelyTyped once they are implemented +declare module "text-mask-core" { + export = textMaskCore; + + namespace textMaskCore { + type MaskArray = (string | RegExp)[]; + + type MaskFunction = (rawValue: string, config: MaskFunctionConfig) => MaskArray; + + type Mask = MaskArray | MaskFunction | CombinedPipeMask; + + type PipeFunction = (conformedValue: string, config: TextMaskConfig) => false | string | PipeResultObject; + + interface MaskFunctionConfig { + currentCaretPosition?: number; + previousConformedValue?: string; + placeholderChar?: string; + } + + interface CombinedPipeMask { + mask: Mask; + pipe: PipeFunction; + } + + interface PipeResultObject { + value: string; + indexesOfPipedChars: number[]; + } + + interface TextMaskConfig { + mask: Mask | false; + guide?: boolean; + placeholderChar?: string; + keepCharPositions?: boolean; + pipe?: PipeFunction; + showMask?: boolean; + } + + interface ConformToMaskResult { + conformedValue: string; + meta: { + someCharsRejected: boolean; + }; + } + + interface ConformToMaskConfig { + guide?: boolean; + pipe?: PipeFunction; + previousConformedValue?: string; + placeholderChar?: string; + placeholder?: string; + currentCaretPosition: number; + keepCharPositions: boolean; + } + + function conformToMask(rawValue: string, mask: MaskArray | MaskFunction, config: ConformToMaskConfig): ConformToMaskResult; + + interface AdjustCaretPositionConfig { + previousConformedValue: string; + conformedValue: string; + currentCaretPosition: number; + rawValue: string; + placeholderChar: string; + placeholder: string; + indexesOfPipedChars: string[]; + currentTrapIndexes: number[]; + } + + function adjustCaretPosition(config: AdjustCaretPositionConfig): number; + + interface CreateTextMaskInputElementConfig extends TextMaskConfig { + inputElement: HTMLInputElement; + } + + interface TextMaskInputElement { + update(rawValue?: string): void; + + state: { + // anything that needs to be kept between "update" calls, it is stored in this "state" object. + previousConformedValue: string; + previousPlaceholder: string; + }; + } + + function createTextMaskInputElement(config: CreateTextMaskInputElementConfig): TextMaskInputElement; + } +} diff --git a/showcase/src/app/app-menu.config.ts b/showcase/src/app/app-menu.config.ts index 87cca18875..775f9992aa 100644 --- a/showcase/src/app/app-menu.config.ts +++ b/showcase/src/app/app-menu.config.ts @@ -200,6 +200,13 @@ export const APP_MENU_CONFIG: StarkMenuConfig = { isEnabled: true, targetState: "demo-ui.keyboard-directives" }, + { + id: "menu-stark-ui-directives-mask", + label: "Text Mask", + isVisible: true, + isEnabled: true, + targetState: "demo-ui.mask-directives" + }, { id: "menu-stark-ui-directives-progress-indicator", label: "Progress indicator", diff --git a/showcase/src/app/demo-ui/demo-ui.module.ts b/showcase/src/app/demo-ui/demo-ui.module.ts index 87a9d812e2..e8b7bad5ae 100644 --- a/showcase/src/app/demo-ui/demo-ui.module.ts +++ b/showcase/src/app/demo-ui/demo-ui.module.ts @@ -8,6 +8,7 @@ import { MatButtonModule } from "@angular/material/button"; import { MatButtonToggleModule } from "@angular/material/button-toggle"; import { MatCardModule } from "@angular/material/card"; import { MatDividerModule } from "@angular/material/divider"; +import { MatExpansionModule } from "@angular/material/expansion"; import { MatIconModule } from "@angular/material/icon"; import { MatTabsModule } from "@angular/material/tabs"; import { MatTooltipModule } from "@angular/material/tooltip"; @@ -32,6 +33,7 @@ import { StarkGenericSearchModule, StarkKeyboardDirectivesModule, StarkLanguageSelectorModule, + StarkMaskDirectivesModule, StarkMinimapModule, StarkPaginationModule, StarkPrettyPrintModule, @@ -50,11 +52,14 @@ import { DemoDateRangePickerPageComponent, DemoDropdownPageComponent, DemoFooterPageComponent, + DemoGenericSearchFormComponent, DemoGenericSearchPageComponent, + demoGenericSearchReducers, DemoGenericService, DemoKeyboardDirectivesPageComponent, DemoLanguageSelectorPageComponent, DemoLogoutPageComponent, + DemoMaskDirectivesComponent, DemoMenuPageComponent, DemoMessagePanePageComponent, DemoMinimapPageComponent, @@ -77,8 +82,6 @@ import { TableWithSelectionComponent, TableWithTranscludedActionBarComponent } from "./components"; -import { DemoGenericSearchFormComponent, demoGenericSearchReducers } from "./pages/generic-search"; -import { MatExpansionModule } from "@angular/material/expansion"; @NgModule({ imports: [ @@ -117,6 +120,7 @@ import { MatExpansionModule } from "@angular/material/expansion"; StarkGenericSearchModule, StarkKeyboardDirectivesModule, StarkLanguageSelectorModule, + StarkMaskDirectivesModule, StarkMinimapModule, StarkPaginationModule, StarkProgressIndicatorModule, @@ -140,6 +144,7 @@ import { MatExpansionModule } from "@angular/material/expansion"; DemoKeyboardDirectivesPageComponent, DemoLanguageSelectorPageComponent, DemoLogoutPageComponent, + DemoMaskDirectivesComponent, DemoMenuPageComponent, DemoMessagePanePageComponent, DemoMinimapPageComponent, @@ -172,6 +177,7 @@ import { MatExpansionModule } from "@angular/material/expansion"; DemoKeyboardDirectivesPageComponent, DemoLanguageSelectorPageComponent, DemoLogoutPageComponent, + DemoMaskDirectivesComponent, DemoMenuPageComponent, DemoMessagePanePageComponent, DemoMinimapPageComponent, diff --git a/showcase/src/app/demo-ui/pages/index.ts b/showcase/src/app/demo-ui/pages/index.ts index ea113b74f9..0beeb1950e 100644 --- a/showcase/src/app/demo-ui/pages/index.ts +++ b/showcase/src/app/demo-ui/pages/index.ts @@ -4,12 +4,14 @@ export * from "./breadcrumb"; export * from "./collapsible"; export * from "./date-picker"; export * from "./date-range-picker"; +export * from "./sidebar"; export * from "./dropdown"; export * from "./footer"; export * from "./generic-search"; export * from "./keyboard-directives"; export * from "./language-selector"; export * from "./logout"; +export * from "./mask-directives"; export * from "./menu"; export * from "./message-pane"; export * from "./minimap"; diff --git a/showcase/src/app/demo-ui/pages/mask-directives/demo-mask-directives.component.html b/showcase/src/app/demo-ui/pages/mask-directives/demo-mask-directives.component.html new file mode 100644 index 0000000000..ae1b1086bf --- /dev/null +++ b/showcase/src/app/demo-ui/pages/mask-directives/demo-mask-directives.component.html @@ -0,0 +1,81 @@ + +
+ + Credit Card Mask + + + + + Structured Message + + + + + Date DD_MM_YYYY + + + + + Short date MM_YY + + + + Time HH_MM + + + + + Full time HH_MM_SS + + + + + Custom + + + + + Number mask + + + + + Email mask + + + + + Timestamp mask + + + + + Timestamp without year (DD-MM) + + + + + Timestamp without year (MM-DD) + + + + + Timestamp Mask 2 (YYYY-MM-DD) + + + + + Timestamp Mask 5 (MM-DD-YYYY) + + + + + Time Mask + + +
+
diff --git a/showcase/src/app/demo-ui/pages/mask-directives/demo-mask-directives.component.scss b/showcase/src/app/demo-ui/pages/mask-directives/demo-mask-directives.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/showcase/src/app/demo-ui/pages/mask-directives/demo-mask-directives.component.ts b/showcase/src/app/demo-ui/pages/mask-directives/demo-mask-directives.component.ts new file mode 100644 index 0000000000..d93386eb08 --- /dev/null +++ b/showcase/src/app/demo-ui/pages/mask-directives/demo-mask-directives.component.ts @@ -0,0 +1,97 @@ +import { Component, Inject, OnInit } from "@angular/core"; +import { STARK_LOGGING_SERVICE, StarkLoggingService } from "@nationalbankbelgium/stark-core"; +import { StarkTextMasks, StarkTextMaskConfig, StarkTimestampMaskConfig, StarkNumberMaskConfig } from "@nationalbankbelgium/stark-ui"; + +@Component({ + selector: "showcase-demo-mask-directives", + styleUrls: ["./demo-mask-directives.component.scss"], + templateUrl: "./demo-mask-directives.component.html" +}) +export class DemoMaskDirectivesComponent implements OnInit { + public creditCardMaskConfig: StarkTextMaskConfig; + public structuredMessageMaskConfig: StarkTextMaskConfig; + public dateMaskConfig: StarkTextMaskConfig; + public shortDateMaskConfig: StarkTextMaskConfig; + public timeMaskConfig: StarkTextMaskConfig; + public fullTimeMaskConfig: StarkTextMaskConfig; + public customMaskConfig: StarkTextMaskConfig; + public numberMaskConfig: StarkNumberMaskConfig; + + public timeTimestampMaskConfig: StarkTimestampMaskConfig; + public timestampMaskConfig: StarkTimestampMaskConfig; + public timestampMaskConfig2: StarkTimestampMaskConfig; + public timestampMaskConfig3: StarkTimestampMaskConfig; + public timestampMaskConfig4: StarkTimestampMaskConfig; + public timestampMaskConfig5: StarkTimestampMaskConfig; + + public constructor(@Inject(STARK_LOGGING_SERVICE) private logger: StarkLoggingService) {} + + public ngOnInit(): void { + this.creditCardMaskConfig = { + mask: StarkTextMasks.CREDITCARD_NUMBER + }; + + this.structuredMessageMaskConfig = { + mask: StarkTextMasks.STRUCTURED_NUMBER + }; + + this.dateMaskConfig = { + mask: StarkTextMasks.DATE_DD_MM_YYYY + }; + + this.shortDateMaskConfig = { + mask: StarkTextMasks.DATE_MM_YY + }; + + this.timeMaskConfig = { + mask: StarkTextMasks.TIME_HH_MM + }; + + this.fullTimeMaskConfig = { + mask: StarkTextMasks.TIME_HH_MM_SS + }; + + this.timestampMaskConfig = { + format: "DD-MM-YYYY HH:mm:ss" + }; + + this.timestampMaskConfig2 = { + format: "YYYY-MM-DD HH:mm:ss" + }; + this.timestampMaskConfig5 = { + format: "MM-DD-YYYY HH:mm:ss" + }; + + this.timestampMaskConfig3 = { + format: "DD-MM" + }; + + this.timestampMaskConfig4 = { + format: "MM-DD" + }; + + this.timeTimestampMaskConfig = { + format: "HH:mm:ss" + }; + + this.numberMaskConfig = { + prefix: "$ ", + suffix: "", + includeThousandsSeparator: true, + thousandsSeparatorSymbol: ",", + allowDecimal: true, + decimalSymbol: ".", + decimalLimit: 5, + integerLimit: 1450, + allowNegative: true, + allowLeadingZeroes: true + }; + + this.customMaskConfig = { + mask: ["(", "+", "3", "2", ")", " ", /\d/, /\d/, /\d/, " ", /\d/, /\d/, " ", /\d/, /\d/, " ", /\d/, /\d/], + placeholderChar: "#" + }; + + this.logger.debug("DemoMaskDirectivesComponent - initialized"); + } +} diff --git a/showcase/src/app/demo-ui/pages/mask-directives/index.ts b/showcase/src/app/demo-ui/pages/mask-directives/index.ts new file mode 100644 index 0000000000..b09616c936 --- /dev/null +++ b/showcase/src/app/demo-ui/pages/mask-directives/index.ts @@ -0,0 +1 @@ +export * from "./demo-mask-directives.component"; diff --git a/showcase/src/app/demo-ui/routes.ts b/showcase/src/app/demo-ui/routes.ts index 97fc7aca80..1a17077a91 100644 --- a/showcase/src/app/demo-ui/routes.ts +++ b/showcase/src/app/demo-ui/routes.ts @@ -12,6 +12,7 @@ import { DemoKeyboardDirectivesPageComponent, DemoLanguageSelectorPageComponent, DemoLogoutPageComponent, + DemoMaskDirectivesComponent, DemoMenuPageComponent, DemoMessagePanePageComponent, DemoMinimapPageComponent, @@ -120,6 +121,14 @@ export const DEMO_STATES: Ng2StateDeclaration[] = [ }, views: { "@": { component: DemoLogoutPageComponent } } }, + { + name: "demo-ui.mask-directives", + url: "/mask-directives", + data: { + translationKey: "FIX ME" + }, + views: { "@": { component: DemoMaskDirectivesComponent } } + }, { name: "demo-ui.menu", url: "/menu", diff --git a/showcase/src/assets/examples/mask-directives/text-mask-directive.html b/showcase/src/assets/examples/mask-directives/text-mask-directive.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/showcase/src/assets/examples/mask-directives/text-mask-directive.ts b/showcase/src/assets/examples/mask-directives/text-mask-directive.ts new file mode 100644 index 0000000000..0ae040b692 --- /dev/null +++ b/showcase/src/assets/examples/mask-directives/text-mask-directive.ts @@ -0,0 +1 @@ +//test diff --git a/showcase/tsconfig.json b/showcase/tsconfig.json index 4af2ea9a0b..8743281ade 100644 --- a/showcase/tsconfig.json +++ b/showcase/tsconfig.json @@ -5,7 +5,11 @@ "outDir": "./dist", "declaration": false, "lib": ["dom", "dom.iterable", "es2017"], - "typeRoots": ["./node_modules/@types", "./node_modules/@nationalbankbelgium/stark-build/typings"], + "typeRoots": [ + "./node_modules/@types", + "./node_modules/@nationalbankbelgium/stark-build/typings", + "./node_modules/@nationalbankbelgium/stark-ui/typings" + ], "paths": { "@angular/*": ["../node_modules/@angular/*"], "@nationalbankbelgium/*": ["../node_modules/@nationalbankbelgium/*"] diff --git a/starter/tsconfig.json b/starter/tsconfig.json index 4af2ea9a0b..8743281ade 100644 --- a/starter/tsconfig.json +++ b/starter/tsconfig.json @@ -5,7 +5,11 @@ "outDir": "./dist", "declaration": false, "lib": ["dom", "dom.iterable", "es2017"], - "typeRoots": ["./node_modules/@types", "./node_modules/@nationalbankbelgium/stark-build/typings"], + "typeRoots": [ + "./node_modules/@types", + "./node_modules/@nationalbankbelgium/stark-build/typings", + "./node_modules/@nationalbankbelgium/stark-ui/typings" + ], "paths": { "@angular/*": ["../node_modules/@angular/*"], "@nationalbankbelgium/*": ["../node_modules/@nationalbankbelgium/*"]