From 8261a4ea8091c27b61ac581a852e2e18283b3cdd Mon Sep 17 00:00:00 2001 From: YuanHu Date: Tue, 27 Mar 2018 17:33:22 +0800 Subject: Include paletx components Include paletx components to WF Designer UI. Issue-ID: SDC-1130,SDC-1131 Change-Id: Iad06b2dde8fc98d03a0e3633e829b686d75cafd0 Signed-off-by: YuanHu --- .../paletx/plx-datepicker/numberedFixLen.pipe.ts | 27 + .../paletx/plx-datepicker/picker.component.html | 134 ++ .../paletx/plx-datepicker/picker.component.less | 434 +++++ .../app/paletx/plx-datepicker/picker.component.ts | 1712 ++++++++++++++++++++ .../src/app/paletx/plx-datepicker/picker.module.ts | 27 + .../plx-datepicker/pickerrange.component.html | 14 + .../paletx/plx-datepicker/pickerrange.component.ts | 162 ++ .../app/paletx/plx-datepicker/popover-config.ts | 13 + .../src/app/paletx/plx-datepicker/popover.ts | 175 ++ .../src/app/paletx/plx-datepicker/time.ts | 51 + .../app/paletx/plx-datepicker/timepicker-config.ts | 19 + .../src/app/paletx/plx-datepicker/timepicker.less | 163 ++ .../src/app/paletx/plx-datepicker/timepicker.ts | 558 +++++++ .../src/app/paletx/plx-datepicker/util/popup.ts | 58 + .../app/paletx/plx-datepicker/util/positioning.ts | 153 ++ .../src/app/paletx/plx-datepicker/util/triggers.ts | 62 + .../src/app/paletx/plx-datepicker/util/util.ts | 39 + .../app/paletx/plx-modal/modal-backdrop.spec.ts | 16 + .../src/app/paletx/plx-modal/modal-backdrop.ts | 9 + .../app/paletx/plx-modal/modal-dismiss-reasons.ts | 4 + .../src/app/paletx/plx-modal/modal-ref.ts | 109 ++ .../src/app/paletx/plx-modal/modal-stack.ts | 103 ++ .../src/app/paletx/plx-modal/modal-window.spec.ts | 114 ++ .../src/app/paletx/plx-modal/modal-window.ts | 82 + .../src/app/paletx/plx-modal/modal.less | 125 ++ .../src/app/paletx/plx-modal/modal.module.ts | 21 + .../src/app/paletx/plx-modal/modal.spec.ts | 597 +++++++ .../src/app/paletx/plx-modal/modal.ts | 54 + .../src/app/paletx/plx-text-input/index.ts | 8 + .../plx-text-input/ipv4-validator.directive.ts | 24 + .../plx-text-input/ipv6-validator.directive.ts | 24 + .../plx-text-input/max-validator.directive.ts | 49 + .../plx-text-input/min-validator.directive.ts | 49 + .../text-input-ip-address.component.ts | 170 ++ .../plx-text-input/text-input-ip.component.ts | 189 +++ .../paletx/plx-text-input/text-input.component.ts | 765 +++++++++ .../src/app/paletx/plx-text-input/text-input.html | 69 + .../src/app/paletx/plx-text-input/text-input.less | 423 +++++ .../app/paletx/plx-text-input/text-input.module.ts | 31 + .../plx-text-input/validate-on-blur.directive.ts | 18 + .../paletx/plx-tooltip/plx-tooltip-config.spec.ts | 11 + .../app/paletx/plx-tooltip/plx-tooltip-config.ts | 13 + .../src/app/paletx/plx-tooltip/plx-tooltip.less | 241 +++ .../app/paletx/plx-tooltip/plx-tooltip.module.ts | 12 + .../src/app/paletx/plx-tooltip/plx-tooltip.spec.ts | 485 ++++++ .../src/app/paletx/plx-tooltip/plx-tooltip.ts | 176 ++ 46 files changed, 7792 insertions(+) create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/numberedFixLen.pipe.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/picker.component.html create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/picker.component.less create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/picker.component.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/picker.module.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/pickerrange.component.html create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/pickerrange.component.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/popover-config.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/popover.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/time.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/timepicker-config.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/timepicker.less create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/timepicker.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/util/popup.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/util/positioning.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/util/triggers.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/util/util.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-backdrop.spec.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-backdrop.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-dismiss-reasons.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-ref.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-stack.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-window.spec.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-window.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal.less create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal.module.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal.spec.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-text-input/index.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-text-input/ipv4-validator.directive.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-text-input/ipv6-validator.directive.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-text-input/max-validator.directive.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-text-input/min-validator.directive.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-text-input/text-input-ip-address.component.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-text-input/text-input-ip.component.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-text-input/text-input.component.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-text-input/text-input.html create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-text-input/text-input.less create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-text-input/text-input.module.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-text-input/validate-on-blur.directive.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-tooltip/plx-tooltip-config.spec.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-tooltip/plx-tooltip-config.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-tooltip/plx-tooltip.less create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-tooltip/plx-tooltip.module.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-tooltip/plx-tooltip.spec.ts create mode 100644 sdc-workflow-designer-ui/src/app/paletx/plx-tooltip/plx-tooltip.ts diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/numberedFixLen.pipe.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/numberedFixLen.pipe.ts new file mode 100644 index 00000000..9d26b16f --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/numberedFixLen.pipe.ts @@ -0,0 +1,27 @@ +/** + * numberFixedLen.pipe + */ + +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'numberFixedLen' +}) +export class NumberFixedLenPipe implements PipeTransform { + transform(num: number, len: number): any { + let numberInt = Math.floor(num); + let length = Math.floor(len); + + if (num === null || isNaN(numberInt) || isNaN(length)) { + return num; + } + + let numString = numberInt.toString(); + + while (numString.length < length) { + numString = '0' + numString; + } + + return numString; + } +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/picker.component.html b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/picker.component.html new file mode 100644 index 00000000..8e4102c2 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/picker.component.html @@ -0,0 +1,134 @@ +
+
+ + + + +
+ +
+ +
+
+
+ {{formattedValue}} + {{placeHolder}} +
+
+
+
+ +
+
+ {{pickerMonth}} + {{pickerYear}} +
+
+ +
+
+
+ + + + + + + + + + + +
+ {{weekDay}} +
+
+ {{d.num}} +
+
+
+
+ + + + + + +
+
+ {{month}} +
+
+
+
+ + + + + + +
+
+ {{year}} +
+
+ +
+
+
+
+ +
+ +
+
+
+
+
\ No newline at end of file diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/picker.component.less b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/picker.component.less new file mode 100644 index 00000000..8e50660b --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/picker.component.less @@ -0,0 +1,434 @@ +@import "../../assets/components/themes/default/theme.less"; +@import "../../assets/components/themes/common/plx-input.less"; +@import "../../assets/components/themes/common/plx-button.less"; + +.owl-dateTime { + display: inline-block; + position: relative; + width: 100%; + font-family: @font-family; + font-size: @font-size; + background: @component-bg; + color: @text-color; +} + +.owl-dateTime input { + .plx-input; +} + +.owl-dateTime input:-ms-input-placeholder { + color: @unselected-text-color !important; +} +.owl-dateTime input::-webkit-input-placeholder { + color: @unselected-text-color !important; +} + +.owl-dateTime-input { + width: 100%; + padding-right: 1.5em; } + +.owl-dateTime-cancel { + position: absolute; + top: 50%; + right: .1em; + border-radius: 50%; + transform: translateY(-50%); + cursor: pointer; + color: inherit; } + +.owl-dateTime-inputWrapper { + position: relative; } + +.owl-dateTime-customTemp { + display: inline-block; + position: relative; } + +.owl-dateTime-dialog { + padding: 0px; + position: absolute; } + +.owl-dateTime-dialogHeader { + display: flex; + justify-content: center; + align-items: center; + width: 100%; } + +.owl-calendar-wrapper, +.owl-timer-wrapper { + position: relative; + width: 100%; + padding: .2em .5em; } + +.owl-calendar-control { + display: flex; + justify-content: space-around; + width: 100%; + height: 2em; } + .owl-calendar-control .owl-calendar-controlNav { + position: relative; + cursor: pointer; + width: 12.5%; } + .owl-calendar-control .owl-calendar-controlContent { + display: flex; + justify-content: center; + align-items: center; + width: 75%; + height: 100%; } + +.owl-calendar { + position: relative; + min-height: 13.7em; } + .owl-calendar table { + width: 100%; + border-collapse: collapse; } + .owl-calendar tbody td { + position: relative; + text-align: center; } + .owl-calendar tbody td a { + display: block; + width: 100%; + height: 100%; + text-decoration: none; + color: inherit; + font-size:12px; + } + .owl-calendar .owl-calendar-yearArrow { + position: absolute; + top: 50%; + width: 1.5em; + height: 1.5em; + transform: translateY(-50%); + cursor: pointer; } + .owl-calendar .owl-calendar-yearArrow.left { + left: -.5em; } + .owl-calendar .owl-calendar-yearArrow.right { + right: -.5em; } + +.owl-timer-wrapper { + position: relative; + display: flex; + justify-content: center; } + .owl-timer-wrapper .owl-timer { + position: relative; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 25%; + height: 100%; } + .owl-timer-wrapper .owl-timer-control { + display: flex; + justify-content: center; + align-items: center; + height: 30%; + width: 100%; + cursor: pointer; } + .owl-timer-wrapper .owl-timer-control .icon:before { + margin: 0; } + .owl-timer-wrapper .owl-timer-input { + width: 60%; + height: 100%; } + +/*# sourceMappingURL=picker.component.css.map */ +.font-face { + font-weight: normal; + font-style: normal; } + +[class^="paletx-datepicker-icon-"]:before, [class*="paletx-datepicker-icon-"]:before { + font-family: "fontello"; + font-style: normal; + font-weight: normal; + speak: none; + display: inline-block; + text-decoration: inherit; + width: 1em; + margin-right: .2em; + text-align: center; + /* opacity: .8; */ + /* For safety - reset parent styles, that can break glyph codes*/ + font-variant: normal; + text-transform: none; + /* fix buttons height, for twitter bootstrap */ + line-height: 1em; + /* Animation center compensation - margins should be symmetric */ + /* remove if not needed */ + margin-left: .2em; + /* you can be more comfortable with increased icons size */ + /* font-size: 120%; */ + /* Font smoothing. That was taken from TWBS */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + /* Uncomment for 3D effect */ + /* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */ } + +.paletx-datepicker-icon-cancel:before { + content: '\e802'; } + +/* '' */ +.paletx-datepicker-icon-up-open:before { + content: '\e805'; } + +/* '' */ +.paletx-datepicker-icon-down-open:before { + content: '\e80b'; } + +/* '' */ +.paletx-datepicker-icon-left-open:before { + content: '\e817'; } + +/* '' */ +.paletx-datepicker-icon-right-open:before { + content: '\e818'; } + +/* '' */ +.owl-widget, +.owl-widget * { + box-sizing: border-box; } + +.owl-widget { + font-size: 1em; } +.owl-padding{ + padding: 0px; +} +.owl-corner-all { + border-radius: 3px; } + +.owl-corner-top { + border-top-left-radius: 3px; + border-top-right-radius: 3px; } + +.owl-state-default { + border: 1px solid @border-color-base; + background: @component-bg; + color: @text-color; } + +.owl-inputtext { + margin: 0; + outline: medium none; + transition: .2s; } + + + .owl-dateTime.owl-dateTime-inline { + width: auto; } + .owl-dateTime.owl-dateTime-inline .owl-dateTime-dialog { + position: relative; + z-index: auto; } + +.owl-dateTime-dialog { + width: 300px; + user-select: none; + z-index: 99999; } + +.owl-dateTime-dialogHeader { + height: 2.5em; + padding: .25em; + background-color: @component-bg; + overflow-y: auto; } + +.owl-calendar-control .owl-calendar-controlNav .nav-prev, +.owl-calendar-control .owl-calendar-controlNav .nav-next { + position: absolute; + top: 50%; + right: auto; + bottom: auto; + left: 50%; + transform: translate(-50%, -50%); +} + +.owl-cal-header{ + background: @selected-bg-color; + //color: @form-label; + height: 35px; + //width: 105%; + //margin-left: -7px; +} + .owl-calendar-control .owl-calendar-controlNav .nav-prev:before, + .owl-calendar-control .owl-calendar-controlNav .nav-next:before { + //content: ""; + border-top: .5em solid transparent; + border-bottom: .5em solid transparent; + border-right: 0.75em solid #000000; + width: 0; + height: 0; + display: block; + margin: 0 auto; } +.owl-calendar-control .owl-calendar-controlNav .nav-next:before { + border-right: 0; + border-left: 0.75em solid #000000; } +.owl-calendar-control .owl-calendar-controlContent .month-control, +.owl-calendar-control .owl-calendar-controlContent .year-control { + color: @unselected-text-color; + display: inline-block; + cursor: pointer; + transition: transform 200ms ease; } + .owl-calendar-control .owl-calendar-controlContent .month-control:hover, + .owl-calendar-control .owl-calendar-controlContent .year-control:hover { + // transform: scale(1.2); } + color: @guide-color; } +.owl-calendar-control .owl-calendar-controlContent .month-control { + font-size: @font-size-title-group; + margin-right: .8rem; +} +.owl-calendar-control .owl-calendar-controlContent .year-control { + font-size: @font-size-title-group; +} + +.owl-calendar tbody td.owl-calendar-selected { + background-color: @guide-color; + color: @component-bg } +.owl-calendar tbody td.owl-calendar-invalid { + color: @disabled-text-color } +.owl-calendar tbody td.owl-calendar-outFocus { + color: @unselected-text-color; } +.owl-calendar tbody td.owl-calendar-hidden { + visibility: hidden; } + /** +.owl-calendar tbody td:not(.owl-calendar-selected):not(.owl-calendar-invalid):hover { + background-color: @hover-bg-color; + color: @shadow-color } +**/ +.owl-years td.owl-year, +.owl-years td.owl-month, +.owl-months td.owl-year, +.owl-months td.owl-month { + font-size: 1.2em; + height: 2.5em; + width: 33.33%; + line-height: 2.5em; + border-radius: 60px; + } + +.owl-weekdays th.owl-weekday { + height: 1em; + line-height: 2em; + text-align: center; + font-weight: normal; + font-size: @font-size; + /**color: @unselected-text-color; **/ + } + +.owl-days td.owl-day { + border-radius: 30px; + height: 2em; + width: calc(100% / 7); + line-height: 2em; } + .owl-days td.owl-day.owl-day-today:before { + content: ''; + display: block; + position: absolute; + right: 2px; + top: 2px; + color: @primary-color; + border-top: 0.5em solid @primary-color-hover; + border-left: .5em solid transparent; + } + +.owl-timer-wrapper { + height: 5.4em; + background-color: @shadow-color; } + .owl-timer-wrapper .owl-timer-text { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 40%; + font-size: 1.5em; } + .owl-timer-wrapper .owl-meridian-btn { + font-size: .8em; + color: @guide-color; + background-image: none; + background-color: transparent; + border-color: @guide-color; } + .owl-timer-wrapper .owl-meridian-btn:hover { + color: @scene-textcolor; + background-color: @guide-color; + border-color: @guide-color; } + +.owl-timer-divider { + display: inline-block; + align-self: flex-end; + position: absolute; + width: .6em; + height: 100%; + left: -.3em; } + .owl-timer-divider .owl-timer-dot { + display: block; + width: .3em; + height: .3em; + position: absolute; + left: 50%; + border-radius: 50%; + transform: translateX(-50%); } + .owl-timer-divider .owl-timer-dot.dot-top { + top: 38%; } + .owl-timer-divider .owl-timer-dot.dot-bottom { + bottom: 38%; } +.owl-icon{ + position: absolute; + top: 50%; + right: .1em; + border-radius: 50%; + -webkit-transform: translateY(-50%); + transform: translateY(-50%); + cursor: pointer; + color: @fonticon-color; +} + +.oes-time-control{ + color: @text-color !important; +} +.owl-calendar-selected { + background-color: @guide-color; + color: #fff; + border-radius: 50%; +} +.owl-calendar tbody td div.day:not(.owl-calendar-selected):not(.owl-calendar-invalid):hover { + background-color: @hover-bg-color; + color:#000; + border-radius: 50%; } +.oes-time-control{ + font-size: @font-size; +} +.owl-calendar-year-part{ + width: 42px; + margin-left: 30px; + text-align: center; +} +.owl-calendar-year-part:hover{ + background-color: @hover-bg-color; + color:#000; + border-radius: 50%; +} +.owl-calendar-year-selected{ + background-color: @guide-color; + color: #fff; + border-radius: 50%; +} +.owl-calendar-year-selected:hover{ + background-color: @guide-color; + color: #fff; + border-radius: 50%; +} +.owl-calendar-month-part{ + width: 42px; + margin-left: 30px; + text-align: center; +} +.owl-calendar-month-part:hover{ + background-color: @hover-bg-color; + color:#000; + border-radius: 50%; +} +.owl-calendar-month-selected{ + background-color: @guide-color; + color: #fff; + border-radius: 50%; +} +.owl-calendar-month-selected:hover{ + background-color: @guide-color; + color: #fff; + border-radius: 50%; +} + +/*# sourceMappingURL=picker.css.map */ + diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/picker.component.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/picker.component.ts new file mode 100644 index 00000000..493e0cb2 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/picker.component.ts @@ -0,0 +1,1712 @@ +/** + * picker.component + */ + +import { + AfterViewInit, + Component, ElementRef, EventEmitter, forwardRef, Input, OnDestroy, OnInit, Output, Renderer2, + ViewChild +} from '@angular/core'; +import { animate, state, style, transition, trigger } from '@angular/animations'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { + parse, + isValid, + startOfMonth, + getDate, + getDay, + addDays, + addMonths, + isSameDay, + isSameMonth, + isToday, + getMonth, + setMonth, + getYear, + addYears, + differenceInCalendarDays, + setYear, + getHours, + setHours, + getMinutes, + setMinutes, + getSeconds, + setSeconds, + isBefore, + isAfter, + compareAsc, + startOfDay, + format, + endOfDay, +} from 'date-fns'; +import { NumberFixedLenPipe } from './numberedFixLen.pipe'; + +export interface LocaleSettings { + firstDayOfWeek?: number; + dayNames: string[]; + dayNamesShort: string[]; + monthNames: string[]; + monthNamesShort: string[]; + dateFns: any; + confirm: string; +} + +export enum DialogType { + Time, + Date, + Month, + Year, +} + +export const DATETIMEPICKER_VALUE_ACCESSOR: any = { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DateTimePickerComponent), + multi: true +}; + +@Component({ + selector: 'plx-datepicker', + templateUrl: './picker.component.html', + styleUrls: ['./picker.component.less'], + providers: [NumberFixedLenPipe, DATETIMEPICKER_VALUE_ACCESSOR], + animations: [ + trigger('fadeInOut', [ + state('hidden', style({ + opacity: 0, + display: 'none' + })), + state('visible', style({ + opacity: 1, + display: 'block' + })), + transition('visible => hidden', animate('200ms ease-in')), + transition('hidden => visible', animate('400ms ease-out')) + ]) + ], +}) + +export class DateTimePickerComponent implements OnInit, AfterViewInit, OnDestroy, ControlValueAccessor { + + @ViewChild('timepicker') public timepicker; + + /** + * Type of the value to write back to ngModel + * @default 'date' -- Javascript Date type + * @type {'string' | 'date'} + * */ + @Input() dataType: 'string' | 'date' = 'date'; + + /** + * Format of the date + * @default 'y/MM/dd' + * @type {String} + * */ + @Input() dateFormat: string = 'YYYY-MM-DD HH:mm'; + /** + * When present, it specifies that the component should be disabled + * @default false + * @type {Boolean} + * */ + @Input() disabled: boolean; + /** + * @default false + * @type {Boolean} + * */ + @Input() supportKeyboardInput: boolean = false; + /** + * Array with dates that should be disabled (not selectable) + * @default null + * @type {Date[]} + * */ + @Input() disabledDates: Date[] = []; + + /** + * Array with weekday numbers that should be disabled (not selectable) + * @default null + * @type {number[]} + * */ + @Input() disabledDays: number[]; + + /** + * When enabled, displays the calendar as inline + * @default false -- popup mode + * @type {boolean} + * */ + @Input() inline: boolean; + + /** + * Identifier of the focus input to match a label defined for the component + * @default null + * @type {String} + * */ + @Input() inputId: string; + + /** + * Inline style of the picker text input + * @default null + * @type {any} + * */ + @Input() inputStyle: any; + + /** + * Style class of the picker text input + * @default null + * @type {String} + * */ + @Input() inputStyleClass: string; + + /** + * Maximum number of selectable dates in multiple mode + * @default null + * @type {number} + * */ + @Input() maxDateCount: number; + + /** + * The minimum selectable date time + * @default null + * @type {Date | string} + * */ + private _max: Date; + @Input() + get max() { + return this._max; + } + + set max(val: Date | string) { + this._max = this.parseToDate(val); + } + + @Input() + get maxDate() { + return this._max; + } + + set maxDate(val: Date | string) { + this._max = this.parseToDate(val); + } + + /** + * The maximum selectable date time + * @default null + * @type {Date | string } + * */ + private _min: Date; + @Input() + get min() { + return this._min; + } + + set min(val: Date | string) { + this._min = this.parseToDate(val); + } + @Input() + get minDate() { + return this._min; + } + + set minDate(val: Date | string) { + this._min = this.parseToDate(val); + } + + @Input() + get dateValue() { + return this.value; + } + + set dateValue(val: Date | string) { + let newvalue = this.parseToDate(val); + if(newvalue!==undefined) + { + this.updateModel(newvalue); + this.updateCalendar(newvalue); + this.updateTimer(newvalue); + this.updateFormattedValue(); + } + } + + + /** + * Picker input placeholder value + * @default + * @type {String} + * */ + @Input() placeHolder: string = 'yyyy-mm-dd hh:mm'; + + /** + * When present, it specifies that an input field must be filled out before submitting the form + * @default false + * @type {Boolean} + * */ + @Input() required: boolean; + + /** + * Defines the quantity of the selection + * 'single' -- allow only a date value to be selected + * 'multiple' -- allow multiple date value to be selected + * 'range' -- allow to select an range ot date values + * @default 'single' + * @type {string} + * */ + @Input() selectionMode: 'single' | 'multiple' | 'range' = 'single'; + + /** + * Whether to show the picker dialog header + * @default false + * @type {Boolean} + * */ + @Input() showHeader: boolean; + + @Input() canClear: boolean = true; + + /** + * Whether to show the second's timer + * @default false + * @type {Boolean} + * */ + @Input() showSeconds: boolean; + + /** + * Inline style of the element + * @default null + * @type {any} + * */ + @Input() style: any; + + /** + * Style class of the element + * @default null + * @type {String} + * */ + @Input() styleClass: string; + + /** + * Index of the element in tabbing order + * @default null + * @type {Number} + * */ + @Input() tabIndex: number; + + /** + * Set the type of the dateTime picker + * 'both' -- show both calendar and timer + * 'calendar' -- show only calendar + * 'timer' -- show only timer + * @default 'both' + * @type {'both' | 'calendar' | 'timer'} + * */ + @Input() type: 'both' | 'calendar' | 'timer' = 'calendar'; + + //附加方法 + @Input() + set timeOnly(value: boolean) { + if (value) { + this.type = 'timer'; + } + else { + this.type = "both"; + } + } + + @Input() + set showTime(value: boolean) { + if (value) { + this.type = 'both'; + } + else { + this.type = "calendar"; + } + } + + /** + * An object having regional configuration properties for the dateTimePicker + * */ + @Input() + get locale(): any { + return this._locale; + } + set locale(val: any) { + if (val !== undefined) { + this._locale = val; + this._userlocale = true; + } + } + + @Input() localePrefab: 'Zh' | 'En' = 'En'; + /** + * Determine the hour format (12 or 24) + * @default '24' + * @type {'24'| '12'} + * */ + @Input() hourFormat: '12' | '24' = '24'; + + + /** + * When it is set to false, only show current month's days in calendar + * @default true + * @type {boolean} + * */ + @Input() showOtherMonths: boolean = true; + + /** + * Callback to invoke when dropdown gets focus. + * */ + @Output() onFocus: any = new EventEmitter(); + + /** + * Callback to invoke when dropdown gets focus. + * */ + @Output() onConfirm: any = new EventEmitter(); + + /** + * Callback to invoke when dropdown loses focus. + * */ + @Output() onBlur: any = new EventEmitter(); + + /** + * Callback to invoke when a invalid date is selected. + * */ + @Output() onInvalid: any = new EventEmitter(); + + + + @ViewChild('container') containerElm: ElementRef; + @ViewChild('textInput') textInputElm: ElementRef; + @ViewChild('dialog') dialogElm: ElementRef; + + public calendarDays: Array; + public calendarWeekdays: string[]; + public calendarMonths: Array; + public calendarYears: Array = []; + public dialogType: DialogType = DialogType.Date; + public dialogVisible: boolean; + public focus: boolean; + public formattedValue: string = ''; + public value: any; + public pickerMoment: Date; + public pickerMonth: string; + public pickerYear: string; + + public hourValue: number; + public minValue: number; + public secValue: number; + public meridianValue: string = 'AM'; + private _userlocale: boolean = false; + private _locale: LocaleSettings = { + firstDayOfWeek: 0, + dayNames: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], + dayNamesShort: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'], + //dayNamesShort: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], + monthNames: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'], + monthNamesShort: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], + dateFns: null, + confirm: 'OK' + }; + private _localeEn: LocaleSettings = { + firstDayOfWeek: 0, + dayNames: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], + dayNamesShort: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'], + monthNames: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'], + monthNamesShort: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], + dateFns: null, + confirm: 'OK' + }; + private _localeZh: LocaleSettings = { + firstDayOfWeek: 0, + dayNames: ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'], + dayNamesShort: ['日', '一', '二', '三', '四', '五', '六'], + monthNames: ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'], + monthNamesShort: ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'], + dateFns: null, + confirm: '确定' + }; + private dialogClick: boolean; + private documentClickListener: Function; + private valueIndex: number = 0; + private onModelChange: Function = () => { + // + } + private onModelTouched: Function = () => { + // + } + + constructor(private renderer: Renderer2, + private numFixedLenPipe: NumberFixedLenPipe) { + } + private updateDate(newvalue : Date) + { + if(newvalue!==undefined&&newvalue!==null) + { + if(this.min) + { + newvalue = this._min.getTime()<=newvalue.getTime()?newvalue:new Date(this._min); + } + if(this.max) + { + newvalue = this._max.getTime()>=newvalue.getTime()?newvalue:new Date(this._max); + } + this.updateModel(newvalue); + this.updateCalendar(newvalue); + this.updateTimer(newvalue); + this.updateFormattedValue(); + return; + } + } + public onInputChange(event:any): void { + let newvalue = this.parseToDate(event.target.value); + if(newvalue!==undefined&&newvalue!==null) + { + if(this.min) + { + newvalue = this._min.getTime()<=newvalue.getTime()?newvalue:new Date(this._min); + } + if(this.max) + { + newvalue = this._max.getTime()>=newvalue.getTime()?newvalue:new Date(this._max); + } + this.updateModel(newvalue); + this.updateCalendar(newvalue); + this.updateTimer(newvalue); + this.updateFormattedValue(); + return; + } + this.updateModel(null); + this.updateCalendar(null); + this.updateTimer(null); + this.updateFormattedValue(); + } + public ngOnInit(): void { + + if ((!this._userlocale) || this.locale === null && this.locale === undefined) { + switch (this.localePrefab) { + + case 'Zh': { + this._locale = this._localeZh; + break; + } + case 'En': { + this._locale = this._localeEn; + break; + } + default: + { + this._locale = this._localeEn; + break; + } + } + } + this.pickerMoment = new Date(); + + if (this.type === 'both' || this.type === 'calendar') { + this.generateWeekDays(); + this.generateMonthList(); + } + this.updateTimer(this.value); + } + + public ngAfterViewInit(): void { + this.updateCalendar(this.value); + this.updateTimer(this.value); + } + + public ngOnDestroy(): void { + this.unbindDocumentClickListener(); + } + + public writeValue(obj: any): void { + + if (obj instanceof Array) { + this.value = []; + for (let o of obj) { + let v = this.parseToDate(o); + this.value.push(v); + } + this.updateCalendar(this.value[0]); + this.updateTimer(this.value[0]); + } else { + this.value = this.parseToDate(obj); + this.updateCalendar(this.value); + this.updateTimer(this.value); + } + this.updateFormattedValue(); + } + + public registerOnChange(fn: any): void { + this.onModelChange = fn; + } + + public registerOnTouched(fn: any): void { + this.onModelTouched = fn; + } + + public setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + + private initflag = true; + /** + * Handle click event on the text input + * @param {any} event + * @return {void} + * */ + public onInputClick(event: any): void { + + if (this.timepicker !== undefined && this.initflag) { + this.initflag = false; + if (this.value !== undefined && this.value !== null) { + this.timepicker.updateHour(this.value.getHours()); + this.timepicker.updateMinute(this.value.getMinutes()); + this.timepicker.updateSecond(this.value.getSeconds()); + } + else { + this.timepicker.updateHour(0); + this.timepicker.updateMinute(0); + this.timepicker.updateSecond(0); + this.updateModel(null); + this.updateFormattedValue(); + } + } + if (this.disabled) { + event.preventDefault(); + return; + } + + this.dialogClick = true; + if (!this.dialogVisible) { + this.show(); + } + event.preventDefault(); + return; + } + + /** + * Set the element on focus + * @param {any} event + * @return {void} + * */ + public onInputFocus(event: any): void { + this.focus = true; + this.onFocus.emit(event); + event.preventDefault(); + return; + } + + /** + * Set the element on blur + * @param {any} event + * @return {void} + * */ + public onInputBlur(event: any): void { + this.focus = false; + this.onModelTouched(); + this.onBlur.emit(event); + event.preventDefault(); + return; + } + + /** + * Handle click event on the dialog + * @param {any} event + * @return {void} + * */ + public onDialogClick(event: any): void { + this.dialogClick = true; + } + + /** + * Go to previous month + * @param {any} event + * @return {void} + * */ + public prevMonth(event: any): void { + + if (this.disabled) { + event.preventDefault(); + return; + } + + this.pickerMoment = addMonths(this.pickerMoment, -1); + this.generateCalendar(); + if(this.value!==undefined&&this.value!==null) + { + let nowvalue = new Date(this.value); + nowvalue.setMonth(this.pickerMoment.getMonth()); + this.updateDate(nowvalue); + } + event.preventDefault(); + return; + } + + /** + * Go to next month + * @param {any} event + * @return {void} + * */ + public nextMonth(event: any): void { + + if (this.disabled) { + event.preventDefault(); + return; + } + + this.pickerMoment = addMonths(this.pickerMoment, 1); + this.generateCalendar(); + if(this.value!==undefined&&this.value!==null) + { + let nowvalue = new Date(this.value); + nowvalue.setMonth(this.pickerMoment.getMonth()); + this.updateDate(nowvalue); + } + event.preventDefault(); + return; + } + + /** + * Select a date + * @param {any} event + * @param {Date} date + * @return {void} + * */ + public selectDate(event: any, date: Date): void { + + if (this.disabled || !date) { + event.preventDefault(); + return; + } + + let temp: Date; + // check if the selected date is valid + if (this.isValidValue(date)) { + temp = date; + } else { + if (isSameDay(date, this._min)) { + temp = new Date(this._min); + } else if (isSameDay(date, this._max)) { + temp = new Date(this._max); + } else { + this.onInvalid.emit({ originalEvent: event, value: date }); + return; + } + } + if (this.minValue !== undefined) { + temp.setMinutes(this.minValue); + } + if (this.secValue !== undefined) { + temp.setSeconds(this.secValue); + } + if (this.hourValue !== undefined) { + temp.setHours(this.hourValue); + } + let selected; + if (this.isSingleSelection()) { + if (!isSameDay(this.value, temp)) { + selected = temp; + } + } else if (this.isRangeSelection()) { + if (this.value && this.value.length) { + let startDate = this.value[0]; + let endDate = this.value[1]; + + if (!endDate && temp.getTime() > startDate.getTime()) { + endDate = temp; + this.valueIndex = 1; + } else { + startDate = temp; + endDate = null; + this.valueIndex = 0; + } + selected = [startDate, endDate]; + } else { + selected = [temp, null]; + this.valueIndex = 0; + } + } else if (this.isMultiSelection()) { + + // check if it exceeds the maxDateCount limit + if (this.maxDateCount && this.value && + this.value.length && this.value.length >= this.maxDateCount) { + this.onInvalid.emit({ originalEvent: event, value: 'Exceed max date count.' }); + return; + } + + if (this.isSelectedDay(temp)) { + selected = this.value.filter((d: Date) => { + return !isSameDay(d, temp); + }); + } else { + selected = this.value ? [...this.value, temp] : [temp]; + this.valueIndex = selected.length - 1; + } + } + if (selected) { + this.updateModel(selected); + if (this.value instanceof Array) { + this.updateCalendar(this.value[this.valueIndex]); + this.updateTimer(this.value[this.valueIndex]); + } else { + this.updateCalendar(this.value); + this.updateTimer(this.value); + } + this.updateFormattedValue(); + } + } + + /** + * Set a pickerMoment's month + * @param {Number} monthNum + * @return {void} + * */ + public selectMonth(monthNum: number): void { + this.pickerMoment = setMonth(this.pickerMoment, monthNum); + this.generateCalendar(); + if(this.value!==undefined&&this.value!==null) + { + let nowvalue = new Date(this.value); + nowvalue.setMonth(monthNum); + this.updateDate(nowvalue); + } + this.changeDialogType(DialogType.Month); + } + + /** + * Set a pickerMoment's year + * @param {Number} yearNum + * @return {void} + * */ + public selectYear(yearNum: number): void { + this.pickerMoment = setYear(this.pickerMoment, yearNum); + this.generateCalendar(); + if(this.value!==undefined&&this.value!==null) + { + let nowvalue = new Date(this.value); + nowvalue.setFullYear(yearNum); + this.updateDate(nowvalue); + } + this.changeDialogType(DialogType.Year); + } + + /** + * Set the selected moment's meridian + * @param {any} event + * @return {void} + * */ + public toggleMeridian(event: any): void { + + let value = this.value ? (this.value.length ? this.value[this.valueIndex] : this.value) : null; + + if (this.disabled) { + event.preventDefault(); + return; + } + + if (!value) { + this.meridianValue = this.meridianValue === 'AM' ? 'PM' : 'AM'; + return; + } + + let hours = getHours(value); + if (this.meridianValue === 'AM') { + hours += 12; + } else if (this.meridianValue === 'PM') { + hours -= 12; + } + + let selectedTime = setHours(value, hours); + this.setSelectedTime(selectedTime); + event.preventDefault(); + return; + } + + /** + * Set the selected moment's hour + * @param {any} event + * @param {'increase' | 'decrease' | number} val + * 'increase' -- increase hour value by 1 + * 'decrease' -- decrease hour value by 1 + * number -- set hour value to val + * @param {HTMLInputElement} input -- optional + * @return {boolean} + * */ + public setHours(event: any, val: 'increase' | 'decrease' | number, input?: HTMLInputElement): boolean { + + let value; + if (this.value) { + if (this.value.length) { + value = this.value[this.valueIndex]; + } else { + value = this.value; + } + } else { + if (this.type === 'timer') { + value = new Date(); + } else { + value = null; + } + } + + if (this.disabled || !value) { + event.preventDefault(); + return false; + } + + let hours = getHours(value); + if (val === 'increase') { + hours += 1; + } else if (val === 'decrease') { + hours -= 1; + } else { + hours = val; + } + + if (hours > 23) { + hours = 0; + } else if (hours < 0) { + hours = 23; + } + + let selectedTime = setHours(value, hours); + let done = this.setSelectedTime(selectedTime); + + // Focus the input and select its value when model updated + if (input) { + setTimeout(() => { + input.focus(); + }, 0); + } + + event.preventDefault(); + return done; + } + + /** + * Set the selected moment's minute + * @param {any} event + * @param {'increase' | 'decrease' | number} val + * 'increase' -- increase minute value by 1 + * 'decrease' -- decrease minute value by 1 + * number -- set minute value to val + * @param {HTMLInputElement} input -- optional + * @return {boolean} + * */ + public setMinutes(event: any, val: 'increase' | 'decrease' | number, input?: HTMLInputElement): boolean { + + let value; + if (this.value) { + if (this.value.length) { + value = this.value[this.valueIndex]; + } else { + value = this.value; + } + } else { + if (this.type === 'timer') { + value = new Date(); + } else { + value = null; + } + } + + if (this.disabled || !value) { + event.preventDefault(); + return false; + } + + let minutes = getMinutes(value); + if (val === 'increase') { + minutes += 1; + } else if (val === 'decrease') { + minutes -= 1; + } else { + minutes = val; + } + + if (minutes > 59) { + minutes = 0; + } else if (minutes < 0) { + minutes = 59; + } + + let selectedTime = setMinutes(value, minutes); + let done = this.setSelectedTime(selectedTime); + + // Focus the input and select its value when model updated + if (input) { + setTimeout(() => { + input.focus(); + }, 0); + } + + event.preventDefault(); + return done; + } + + /** + * Set the selected moment's second + * @param {any} event + * @param {'increase' | 'decrease' | number} val + * 'increase' -- increase second value by 1 + * 'decrease' -- decrease second value by 1 + * number -- set second value to val + * @param {HTMLInputElement} input -- optional + * @return {boolean} + * */ + public setSeconds(event: any, val: 'increase' | 'decrease' | number, input?: HTMLInputElement): boolean { + + let value; + if (this.value) { + if (this.value.length) { + value = this.value[this.valueIndex]; + } else { + value = this.value; + } + } else { + if (this.type === 'timer') { + value = new Date(); + } else { + value = null; + } + } + + if (this.disabled || !value) { + event.preventDefault(); + return false; + } + + let seconds = getSeconds(value); + if (val === 'increase') { + seconds = this.secValue + 1; + } else if (val === 'decrease') { + seconds = this.secValue - 1; + } else { + seconds = val; + } + + if (seconds > 59) { + seconds = 0; + } else if (seconds < 0) { + seconds = 59; + } + + let selectedTime = setSeconds(value, seconds); + let done = this.setSelectedTime(selectedTime); + + // Focus the input and select its value when model updated + if (input) { + setTimeout(() => { + input.focus(); + }, 0); + } + + event.preventDefault(); + return done; + } + + /** + * Check if the date is selected + * @param {Date} date + * @return {Boolean} + * */ + public isSelectedDay(date: Date): boolean { + if (this.isSingleSelection()) { + return this.value && isSameDay(this.value, date); + } else if (this.isRangeSelection() && this.value && this.value.length) { + if (this.value[1]) { + return (isSameDay(this.value[0], date) || isSameDay(this.value[1], date) || + this.isDayBetween(this.value[0], this.value[1], date)) && this.isValidDay(date); + } else { + return isSameDay(this.value[0], date); + } + } else if (this.isMultiSelection() && this.value && this.value.length) { + let selected; + for (let d of this.value) { + selected = isSameDay(d, date); + if (selected) { + break; + } + } + return selected; + } + return false; + } + + /** + * Check if a day is between two specific days + * @param {Date} start + * @param {Date} end + * @param {Date} day + * @return {boolean} + * */ + public isDayBetween(start: Date, end: Date, day: Date): boolean { + if (start && end) { + return isAfter(day, start) && isBefore(day, end); + } else { + return false; + } + } + + /** + * Check if the calendar day is a valid day + * @param {Date} date + * @return {Boolean} + * */ + public isValidDay(date: Date): boolean { + let isValid = true; + if (this.disabledDates && this.disabledDates.length) { + for (let disabledDate of this.disabledDates) { + if (isSameDay(disabledDate, date)) { + return false; + } + } + } + + if (isValid && this.disabledDays && this.disabledDays.length) { + let weekdayNum = getDay(date); + isValid = this.disabledDays.indexOf(weekdayNum) === -1; + } + + if (isValid && this.min) { + isValid = isValid && !isBefore(date, startOfDay(this.min)); + } + + if (isValid && this.max) { + isValid = isValid && !isAfter(date, endOfDay(this.max)); + } + return isValid; + } + + /** + * Check if the month is current pickerMoment's month + * @param {Number} monthNum + * @return {Boolean} + * */ + public isCurrentMonth(monthNum: number): boolean { + return getMonth(this.pickerMoment) === monthNum; + } + + /** + * Check if the year is current pickerMoment's year + * @param {Number} yearNum + * @return {Boolean} + * */ + public isCurrentYear(yearNum: any): boolean { + return getYear(this.pickerMoment) === yearNum||(getYear(this.pickerMoment)+"") === yearNum; + } + + /** + * Change the dialog type + * @param {DialogType} type + * @return {void} + * */ + public changeDialogType(type: DialogType): void { + if (this.dialogType === type) { + this.dialogType = DialogType.Date; + return; + } else { + this.dialogType = type; + } + + if (this.dialogType === DialogType.Year) { + this.generateYearList(); + } + } + + /** + * Handle blur event on timer input + * @param {any} event + * @param {HTMLInputElement} input + * @param {string} type + * @param {number} modelValue + * @return {void} + * */ + public onTimerInputBlur(event: any, input: HTMLInputElement, type: string, modelValue: number): void { + let val = +input.value; + + if (this.disabled || val === modelValue) { + event.preventDefault(); + return; + } + + let done; + if (!isNaN(val)) { + switch (type) { + case 'hours': + if (this.hourFormat === '24' && + val >= 0 && val <= 23) { + done = this.setHours(event, val); + } else if (this.hourFormat === '12' + && val >= 1 && val <= 12) { + if (this.meridianValue === 'AM' && val === 12) { + val = 0; + } else if (this.meridianValue === 'PM' && val < 12) { + val = val + 12; + } + done = this.setHours(event, val); + } + break; + case 'minutes': + if (val >= 0 && val <= 59) { + done = this.setMinutes(event, val); + } + break; + case 'seconds': + if (val >= 0 && val <= 59) { + done = this.setSeconds(event, val); + } + break; + } + } + + if (!done) { + input.value = this.numFixedLenPipe.transform(modelValue, 2); + input.focus(); + return; + } + event.preventDefault(); + return; + } + + /** + * Set value to null + * @param {any} event + * @return {void} + * */ + public clearValue(event: any): void { + this.dialogClick = true; + this.updateModel(null); + this.updateTimer(this.value); + if (this.timepicker!==undefined) { + this.timepicker.settime(undefined); + } + this.updateFormattedValue(); + if(this.value!==null) + { + event.date=new Date(this.value); + } + this.onConfirm.emit(event); + event.preventDefault(); + } + + /** + * Show the dialog + * @return {void} + * */ + private show(): void { + this.alignDialog(); + this.dialogVisible = true; + this.dialogType = DialogType.Date; + this.bindDocumentClickListener(); + return; + } + private nextNav(event : any):void { + if( this.dialogType===DialogType.Date|| this.dialogType===DialogType.Month) + { + this.nextMonth(event); + } + else if(this.dialogType===DialogType.Year){ + this.generateYearList('next'); + } + } + private prevNav(event : any):void { + if( this.dialogType===DialogType.Date|| this.dialogType===DialogType.Month) + { + this.prevMonth(event); + } + else if(this.dialogType===DialogType.Year){ + this.generateYearList('prev'); + } + } + /** + * Hide the dialog + * @return {void} + * */ + private hide(): void { + this.dialogVisible = false; + this.timepicker ? this.timepicker.closeProp() : 0; + this.unbindDocumentClickListener(); + if(this.value!==null) + { + event["date"]=new Date(this.value); + } + this.onConfirm.emit(event); + return; + } + + /** + * Set the dialog position + * @return {void} + * */ + private alignDialog(): void { + let element = this.dialogElm.nativeElement; + let target = this.containerElm.nativeElement; + let elementDimensions = element.offsetParent ? { + width: element.offsetWidth, + height: element.offsetHeight + } : this.getHiddenElementDimensions(element); + let targetHeight = target.offsetHeight; + let targetWidth = target.offsetWidth; + let targetOffset = target.getBoundingClientRect(); + let viewport = this.getViewport(); + let top, left; + + if ((targetOffset.top + targetHeight + elementDimensions.height) > viewport.height) { + top = -1 * (elementDimensions.height); + if (targetOffset.top + top < 0) { + top = 0; + } + } else { + top = targetHeight; + } + + + if ((targetOffset.left + elementDimensions.width) > viewport.width) { + left = targetWidth - elementDimensions.width; + } else { + left = 0; + } + + element.style.top = top + 'px'; + element.style.left = left + 'px'; + } + + /** + * Bind click event on document + * @return {void} + * */ + private bindDocumentClickListener(): void { + let firstClick = true; + if (!this.documentClickListener) { + this.documentClickListener = this.renderer.listen('document', 'click', () => { + if (!firstClick && !this.dialogClick) { + this.hide(); + } + + firstClick = false; + this.dialogClick = false; + }); + } + return; + } + + /** + * Unbind click event on document + * @return {void} + * */ + private unbindDocumentClickListener(): void { + if (this.documentClickListener) { + this.documentClickListener(); + this.documentClickListener = null; + } + return; + } + + /** + * Parse a object to Date object + * @param {any} val + * @return {Date} + * */ + private parseToDate(val: any): Date { + if (!val) { + return; + } + + let parsedVal; + if (typeof val === 'string') { + parsedVal = parse(val); + } else { + parsedVal = val; + } + + return isValid(parsedVal) ? parsedVal : null; + } + + /** + * Generate the calendar days array + * @return {void} + * */ + private generateCalendar(): void { + + if (!this.pickerMoment) { + return; + } + + this.calendarDays = []; + let startDateOfMonth = startOfMonth(this.pickerMoment); + let startWeekdayOfMonth = getDay(startDateOfMonth); + + let dayDiff = 0 - (startWeekdayOfMonth + (7 - this.locale.firstDayOfWeek)) % 7; + + for (let i = 1; i < 7; i++) { + let week = []; + for (let j = 0; j < 7; j++) { + let date = addDays(startDateOfMonth, dayDiff); + let inOtherMonth = !isSameMonth(date, this.pickerMoment); + week.push({ + date, + num: getDate(date), + today: isToday(date), + otherMonth: inOtherMonth, + hide: !this.showOtherMonths && inOtherMonth, + }); + dayDiff += 1; + } + this.calendarDays.push(week); + } + + this.pickerMonth = this.locale.monthNames[getMonth(this.pickerMoment)]; + this.pickerYear = getYear(this.pickerMoment).toString(); + } + + /** + * Generate the calendar weekdays array + * */ + private generateWeekDays(): void { + this.calendarWeekdays = []; + let dayIndex = this.locale.firstDayOfWeek; + for (let i = 0; i < 7; i++) { + this.calendarWeekdays.push(this.locale.dayNamesShort[dayIndex]); + dayIndex = (dayIndex === 6) ? 0 : ++dayIndex; + } + } + + /** + * Generate the calendar month array + * @return {void} + * */ + private generateMonthList(): void { + this.calendarMonths = []; + let monthIndex = 0; + for (let i = 0; i < 4; i++) { + let months = []; + for (let j = 0; j < 3; j++) { + months.push(this.locale.monthNamesShort[monthIndex]); + monthIndex += 1; + } + this.calendarMonths.push(months); + } + } + + /** + * Generate the calendar year array + * @return {void} + * */ + public generateYearList(dir?: string): void { + + if (!this.pickerMoment) { + return; + } + let start; + + if (dir === 'prev') { + start = +this.calendarYears[0][0] - 12; + if(start<0) + { + start=0; + } + } else if (dir === 'next') { + start = +this.calendarYears[3][2] + 1; + } else { + start = getYear(addYears(this.pickerMoment, -4)); + } + + for (let i = 0; i < 4; i++) { + let years = []; + for (let j = 0; j < 3; j++) { + let year = (start + i * 3 + j).toString(); + years.push(year); + } + this.calendarYears[i] = years; + } + return; + } + + /** + * Update the calendar + * @param {Date} value + * @return {void} + * */ + private updateCalendar(value: Date): void { + + // if the dateTime picker is only the timer, + // no need to update the update Calendar. + if (this.type === 'timer') { + return; + } + + if (value && (!this.calendarDays || !isSameMonth(value, this.pickerMoment))) { + this.pickerMoment = setMonth(this.pickerMoment, getMonth(value)); + this.pickerMoment = setYear(this.pickerMoment, getYear(value)); + this.generateCalendar(); + } else if (!value && !this.calendarDays) { + this.generateCalendar(); + } + return; + } + + /** + * Update the timer + * @param {Date} value + * @return {boolean} + * */ + private updateTimer(value: Date): boolean { + + // if the dateTime picker is only the calendar, + // no need to update the timer + if (this.type === 'calendar') { + return false; + } + + if (!value) { + this.hourValue = null; + this.minValue = null; + this.secValue = null; + this.mtime.hour = 0; + this.mtime.minute = 0; + this.mtime.second = 0; + return true; + } + this.mtime.hour = value.getHours(); + this.mtime.minute = value.getMinutes(); + this.mtime.second = value.getSeconds();; + + let time = value; + let hours = getHours(time); + if (this.hourFormat === '12') { + if (hours < 12 && hours > 0) { + this.hourValue = hours; + this.meridianValue = 'AM'; + } else if (hours > 12) { + this.hourValue = hours - 12; + this.meridianValue = 'PM'; + } else if (hours === 12) { + this.hourValue = 12; + this.meridianValue = 'PM'; + } else if (hours === 0) { + this.hourValue = 12; + this.meridianValue = 'AM'; + } + } else if (this.hourFormat === '24') { + this.hourValue = hours; + } + + this.minValue = getMinutes(time); + this.secValue = getSeconds(time); + if(this.value!==undefined&&this.timepicker!==undefined) + { + this.timepicker.settime(new Date(this.value)); + } + return true; + } + + /** + * Update ngModel + * @param {Date} value + * @return {Boolean} + * */ + private updateModel(value: Date | Date[]): boolean { + this.value = value; + if (this.dataType === 'date') { + this.onModelChange(this.value); + } else if (this.dataType === 'string') { + if (this.value && this.value.length) { + let formatted = []; + for (let v of this.value) { + if (v) { + formatted.push(format(v, this.dateFormat, { locale: this.locale.dateFns })); + } else { + formatted.push(null); + } + } + this.onModelChange(formatted); + } else { + this.onModelChange(format(this.value, this.dateFormat, { locale: this.locale.dateFns })); + } + } + return true; + } + + /** + * Update variable formattedValue + * @return {void} + * */ + private updateFormattedValue(): void { + let formattedValue = ''; + + if (this.value) { + if (this.isSingleSelection()) { + formattedValue = format(this.value, this.dateFormat, { locale: this.locale.dateFns }); + } else if (this.isRangeSelection()) { + let startDate = this.value[0]; + let endDate = this.value[1]; + + formattedValue = format(startDate, this.dateFormat, { locale: this.locale.dateFns }); + + if (endDate) { + formattedValue += ' - ' + format(endDate, this.dateFormat, { locale: this.locale.dateFns }); + } else { + formattedValue += ' - ' + this.dateFormat; + } + } else if (this.isMultiSelection()) { + for (let i = 0; i < this.value.length; i++) { + let dateAsString = format(this.value[i], this.dateFormat, { locale: this.locale.dateFns }); + formattedValue += dateAsString; + if (i !== (this.value.length - 1)) { + formattedValue += ', '; + } + } + } + } + + this.formattedValue = formattedValue; + + return; + } + + /** + * Set the time + * @param {Date} val + * @return {boolean} + * */ + public setSelectedTime(val: Date): boolean { + let done; + if (this.isValidValue(val)) { + if (this.value instanceof Array) { + this.value[this.valueIndex] = val; + done = this.updateModel(this.value); + done = done && this.updateTimer(this.value[this.valueIndex]); + } else { + done = this.updateModel(val); + done = done && this.updateTimer(this.value); + } + this.updateFormattedValue(); + } else { + this.onInvalid.emit({ originalEvent: event, value: val }); + done = false; + } + return done; + } + + private isValidValue(value: Date): boolean { + let isValid = true; + + if (this.disabledDates && this.disabledDates.length) { + for (let disabledDate of this.disabledDates) { + if (isSameDay(disabledDate, value)) { + return false; + } + } + } + + if (isValid && this.disabledDays && this.disabledDays.length) { + let weekdayNum = getDay(value); + isValid = this.disabledDays.indexOf(weekdayNum) === -1; + } + + if (isValid && this.min) { + isValid = isValid && !isBefore(value, this.min); + } + + if (isValid && this.max) { + isValid = isValid && !isAfter(value, this.max); + } + + return isValid; + } + + /** + * Check if the selection mode is 'single' + * @return {boolean} + * */ + private isSingleSelection(): boolean { + return this.selectionMode === 'single'; + } + + /** + * Check if the selection mode is 'range' + * @return {boolean} + * */ + private isRangeSelection(): boolean { + return this.selectionMode === 'range'; + } + + /** + * Check if the selection mode is 'multiple' + * @return {boolean} + * */ + private isMultiSelection(): boolean { + return this.selectionMode === 'multiple'; + } + + private getHiddenElementDimensions(element: any): any { + let dimensions: any = {}; + element.style.visibility = 'hidden'; + element.style.display = 'block'; + dimensions.width = element.offsetWidth; + dimensions.height = element.offsetHeight; + element.style.display = 'none'; + element.style.visibility = 'visible'; + + return dimensions; + } + + private getViewport(): any { + let win = window, + d = document, + e = d.documentElement, + g = d.getElementsByTagName('body')[0], + w = win.innerWidth || e.clientWidth || g.clientWidth, + h = win.innerHeight || e.clientHeight || g.clientHeight; + + return { width: w, height: h }; + } + public confirm() { + this.hide(); + } + public seconds = false; + public mtime: any = { hour: 0, minute: 0, second: 0 }; + public TimerChange(time: any) { + let value; + if (this.value) { + if (this.value.length) { + value = this.value[this.valueIndex]; + } else { + value = this.value; + } + } else { + if (this.type === 'timer') { + value = new Date(); + } else { + value = new Date(); + } + } + + if (this.disabled || !value) { + event.preventDefault(); + return false; + } + + let minute = time.minute; + let hour = time.hour; + let second = time.second; + this.minValue = minute; + this.hourValue = hour; + this.secValue = second; + let selectedTime = setMinutes(value, minute); + selectedTime = setHours(selectedTime, hour); + selectedTime = setSeconds(selectedTime, second); + let done = this.setSelectedTime(selectedTime); + + // Focus the input and select its value when model updated + + event.preventDefault(); + return done; + } + private mouseIn :boolean = false; + private Mouseout(event:any) + { + this.mouseIn = false; + } + private Mouseover(event:any) + { + this.mouseIn = true; + } +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/picker.module.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/picker.module.ts new file mode 100644 index 00000000..0511ad71 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/picker.module.ts @@ -0,0 +1,27 @@ +/** + * picker.module + */ + +import { NgModule } from '@angular/core'; + +import { DateTimePickerComponent } from './picker.component'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { NumberFixedLenPipe } from './numberedFixLen.pipe'; +import { NgbTimepickerr } from './timepicker'; +import { OesDaterangePopover, OesDaterangePopoverWindow } from './popover'; +import { OesDaterangePopoverConfig } from './popover-config'; +import { NgbTimepickerConfig } from './timepicker-config'; +import { PlxDateRangePickerComponent } from './pickerrange.component' +export {DateTimePickerComponent} from './picker.component'; + +@NgModule({ + imports: [CommonModule, FormsModule], + exports: [DateTimePickerComponent, NgbTimepickerr, OesDaterangePopover,PlxDateRangePickerComponent], + declarations: [DateTimePickerComponent, NumberFixedLenPipe, NgbTimepickerr, OesDaterangePopoverWindow, OesDaterangePopover,PlxDateRangePickerComponent], + providers: [OesDaterangePopoverConfig, NgbTimepickerConfig, OesDaterangePopoverConfig], + entryComponents: [DateTimePickerComponent, OesDaterangePopoverWindow] +}) +export class PlxDatePickerModule { +} + diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/pickerrange.component.html b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/pickerrange.component.html new file mode 100644 index 00000000..2b1986fe --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/pickerrange.component.html @@ -0,0 +1,14 @@ +
+
+ +
+
+{{locale.to}} +
+
+ +
+
+
\ No newline at end of file diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/pickerrange.component.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/pickerrange.component.ts new file mode 100644 index 00000000..a84e0987 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/pickerrange.component.ts @@ -0,0 +1,162 @@ +/** + * picker.component + */ + +import { + AfterViewInit, + Component, ElementRef, EventEmitter, forwardRef, Input, OnDestroy, OnInit, Output, Renderer2, + ViewChild +} from '@angular/core'; +import {animate, state, style, transition, trigger} from '@angular/animations'; +import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; + +export interface LocaleSettings { + firstDayOfWeek?: number; + dayNames: string[]; + dayNamesShort: string[]; + monthNames: string[]; + monthNamesShort: string[]; + dateFns: any; +} + +export enum DialogType { + Time, + Date, + Month, + Year, +} + +@Component({ + selector: 'plx-daterange-picker', + templateUrl: './pickerrange.component.html', + styleUrls: ['./pickerrange.component.css'], + providers: [], +}) + +export class PlxDateRangePickerComponent { + /* +disabled boolean false 设置为true时input框不能输入 +minDate Date null 最小可选日期 +maxDate Date null 最大可选日期 +showTime boolean false 设置为true时显示时间选择器 +showSeconds boolean false 时间选择器显示秒 +timeOnly boolean false 设置为true时只显示时间选择器 +dateFormat string YYYY-MM-DD HH:mm 设置时间选择模式 +locale Object null 设置国际化对象,请参考国际化例子。 +改变组件时间*/ + + @Input() disabled : boolean = false; + @Input() showTime : boolean = false; + @Input() showSeconds : boolean = false; + @Input() timeOnly : boolean = false; + @Input() dateFormat : string = "YYYY-MM-DD HH:mm"; + @Input() placeHolderStartDate : string = ""; + @Input() placeHolderEndDate : string = ""; + @Input() locale : any ={ + firstDayOfWeek: 0, + dayNames: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], + dayNamesShort: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], + monthNames: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'], + monthNamesShort: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], + dateFns: null, + confirm:'OK', + to:"to" + }; + @Input() startDate : Date; + @Input() endDate : Date; + @Input() canClear: boolean = true; + @Input() startMinDate:Date; + @Input() endMaxDate:Date; + /** + * @default false + * @type {Boolean} + * */ + @Input() supportKeyboardInput: boolean = false; + _startSetMaxDate:Date; + _startMaxDate:Date; + @Input() + set startMaxDate( date:Date) + { + this._startSetMaxDate=date; + this.BuildstartMaxDate(); + } + _endSetMinDate:Date; + _endMinDate:Date; + @Input() + set endMinDate( date:Date) + { + this._endSetMinDate=date; + this.BuildendMinDate(); + } + BuildstartMaxDate() + { + if(this._startSetMaxDate===undefined) + { + this._startMaxDate=this.endDate + return; + } + if(this.endDate!==undefined) + { + this._startMaxDate= this.endDatethis._endSetMinDate?this.startDate:this._endSetMinDate; + return; + } + this._endMinDate=this._endSetMinDate; + } + + @Output() + onStartDateClosed: EventEmitter = new EventEmitter(); + @Output() + onEndDateClosed: EventEmitter = new EventEmitter(); + + EvonStartDateClosed(event : any) + { + this.BuildendMinDate(); + if(this.startDate!==null) + { + event.date=new Date(this.startDate); + } + this.onStartDateClosed.emit(event); + event.preventDefault(); + let dd= this; + return; + } + + + EvonEndDateClosed (event : any) + { + + this.BuildstartMaxDate() + if(this.endDate!==null) + { + event.date=new Date(this.endDate); + } + this.onEndDateClosed.emit(event); + event.preventDefault(); + let dd= this; + return; + } + + + public navigateTo (startDate: Date, endDate: Date) + { + this.startDate=startDate; + this.endDate = endDate; + } + + + +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/popover-config.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/popover-config.ts new file mode 100644 index 00000000..5ac773c5 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/popover-config.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@angular/core'; + +/** + * Configuration service for the OesDaterangePopover directive. + * You can inject this service, typically in your root component, and customize the values of its properties in + * order to provide default values for all the popovers used in the application. + */ +@Injectable() +export class OesDaterangePopoverConfig { + public placement: 'top' | 'bottom' | 'left' | 'right' = 'top'; + public triggers = 'click'; + public container: string; +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/popover.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/popover.ts new file mode 100644 index 00000000..3d054120 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/popover.ts @@ -0,0 +1,175 @@ +import { + Component, + Directive, + Input, + Output, + EventEmitter, + ChangeDetectionStrategy, + OnInit, + OnDestroy, + Injector, + Renderer, + ComponentRef, + ElementRef, + TemplateRef, + ViewContainerRef, + ComponentFactoryResolver, + NgZone +} from '@angular/core'; + +import { listenToTriggers } from './util/triggers'; +import { positionElements } from './util/positioning'; +import { PopupService } from './util/popup'; +import { OesDaterangePopoverConfig } from './popover-config'; + +let nextId = 0; + +@Component({ + selector: 'ngb-popover-window', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { '[class]': '"popover show popover-" + placement', 'role': 'tooltip', '[id]': 'id' }, + styles: [` + + .popover-title,.popover-content{ + background-color: #fff; + } + .popover-custom{ + padding:9px 5px !important; + } + + + `], + template: ` +

{{title}}

+ ` +}) +export class OesDaterangePopoverWindow { + @Input() public placement: 'top' | 'bottom' | 'left' | 'right' = 'top'; + @Input() public title: string; + @Input() public id: string; +} + +/** + * A lightweight, extensible directive for fancy oes-popover creation. + */ +@Directive({ selector: '[oesDaterangePopover]', exportAs: 'oesDaterangePopover' }) +export class OesDaterangePopover implements OnInit, OnDestroy { + /** + * Content to be displayed as oes-popover. + */ + @Input() public oesDaterangePopover: string | TemplateRef; + /** + * Title of a oes-popover. + */ + @Input() public popoverTitle: string; + /** + * Placement of a oes-popover. Accepts: "top", "bottom", "left", "right" + */ + @Input() public placement: 'top' | 'bottom' | 'left' | 'right'; + /** + * Specifies events that should trigger. Supports a space separated list of event names. + */ + @Input() public triggers: string; + /** + * A selector specifying the element the oes-popover should be appended to. + * Currently only supports "body". + */ + @Input() public container: string; + /** + * Emits an event when the oes-popover is shown + */ + @Output() public shown = new EventEmitter(); + /** + * Emits an event when the oes-popover is hidden + */ + @Output() public hidden = new EventEmitter(); + + private _OesDaterangePopoverWindowId = `ngb-popover-${nextId++}`; + private _popupService: PopupService; + private _windowRef: ComponentRef; + private _unregisterListenersFn; + private _zoneSubscription: any; + + constructor( + private _elementRef: ElementRef, private _renderer: Renderer, injector: Injector, + componentFactoryResolver: ComponentFactoryResolver, viewContainerRef: ViewContainerRef, config: OesDaterangePopoverConfig, + ngZone: NgZone) { + this.placement = config.placement; + this.triggers = config.triggers; + this.container = config.container; + this._popupService = new PopupService( + OesDaterangePopoverWindow, injector, viewContainerRef, _renderer, componentFactoryResolver); + + this._zoneSubscription = ngZone.onStable.subscribe(() => { + if (this._windowRef) { + positionElements( + this._elementRef.nativeElement, this._windowRef.location.nativeElement, this.placement, + this.container === 'body'); + } + }); + } + + /** + * Opens an element’s oes-popover. This is considered a “manual” triggering of the oes-popover. + * The context is an optional value to be injected into the oes-popover template when it is created. + */ + public open(context?: any) { + if (!this._windowRef) { + this._windowRef = this._popupService.open(this.oesDaterangePopover, context); + this._windowRef.instance.placement = this.placement; + this._windowRef.instance.title = this.popoverTitle; + this._windowRef.instance.id = this._OesDaterangePopoverWindowId; + + this._renderer.setElementAttribute(this._elementRef.nativeElement, 'aria-describedby', this._OesDaterangePopoverWindowId); + + if (this.container === 'body') { + window.document.querySelector(this.container).appendChild(this._windowRef.location.nativeElement); + } + + // we need to manually invoke change detection since events registered via + // Renderer::listen() are not picked up by change detection with the OnPush strategy + this._windowRef.changeDetectorRef.markForCheck(); + this.shown.emit(); + } + } + + /** + * Closes an element’s oes-popover. This is considered a “manual” triggering of the oes-popover. + */ + public close(): void { + if (this._windowRef) { + this._renderer.setElementAttribute(this._elementRef.nativeElement, 'aria-describedby', null); + this._popupService.close(); + this._windowRef = null; + this.hidden.emit(); + } + } + + /** + * Toggles an element’s oes-popover. This is considered a “manual” triggering of the oes-popover. + */ + public toggle(): void { + if (this._windowRef) { + this.close(); + } else { + this.open(); + } + } + + /** + * Returns whether or not the oes-popover is currently being shown + */ + public isOpen(): boolean { return this._windowRef !== null; } + + public ngOnInit() { + this._unregisterListenersFn = listenToTriggers( + this._renderer, this._elementRef.nativeElement, this.triggers, this.open.bind(this), this.close.bind(this), + this.toggle.bind(this)); + } + + public ngOnDestroy() { + this.close(); + this._unregisterListenersFn(); + this._zoneSubscription.unsubscribe(); + } +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/time.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/time.ts new file mode 100644 index 00000000..ab31a498 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/time.ts @@ -0,0 +1,51 @@ +import { isNumber, toInteger } from './util/util'; + +export class NgbTime { + public hour: number; + public minute: number; + public second: number; + + constructor(hour?: number, minute?: number, second?: number) { + this.hour = toInteger(hour); + this.minute = toInteger(minute); + this.second = toInteger(second); + } + + public changeHour(step = 1) { this.updateHour((isNaN(this.hour) ? 0 : this.hour) + step); } + + public updateHour(hour: number) { + if (isNumber(hour)) { + this.hour = (hour < 0 ? 24 + hour : hour) % 24; + } else { + this.hour = NaN; + } + } + + public changeMinute(step = 1) { this.updateMinute((isNaN(this.minute) ? 0 : this.minute) + step); } + + public updateMinute(minute: number) { + if (isNumber(minute)) { + this.minute = minute % 60 < 0 ? 60 + minute % 60 : minute % 60; + this.changeHour(Math.floor(minute / 60)); + } else { + this.minute = NaN; + } + } + + public changeSecond(step = 1) { this.updateSecond((isNaN(this.second) ? 0 : this.second) + step); } + + public updateSecond(second: number) { + if (isNumber(second)) { + this.second = second < 0 ? 60 + second % 60 : second % 60; + this.changeMinute(Math.floor(second / 60)); + } else { + this.second = NaN; + } + } + + public isValid(checkSecs = true) { + return isNumber(this.hour) && isNumber(this.minute) && (checkSecs ? isNumber(this.second) : true); + } + + public toString() { return `${this.hour || 0}:${this.minute || 0}:${this.second || 0}`; } +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/timepicker-config.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/timepicker-config.ts new file mode 100644 index 00000000..8b752866 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/timepicker-config.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@angular/core'; + +/** + * Configuration service for the NgbTimepicker component. + * You can inject this service, typically in your root component, and customize the values of its properties in + * order to provide default values for all the timepickers used in the application. + */ +@Injectable() +export class NgbTimepickerConfig { + public meridian = false; + public spinners = true; + public seconds = false; + public hourStep = 1; + public minuteStep = 1; + public secondStep = 1; + public disabled = false; + public readonlyInputs = false; + public size: 'small' | 'medium' | 'large' = 'medium'; +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/timepicker.less b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/timepicker.less new file mode 100644 index 00000000..60acfa6b --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/timepicker.less @@ -0,0 +1,163 @@ +@import "../../assets/components/themes/default/theme.less"; +@import "../../assets/components/themes/common/plx-input.less"; +@import "../../assets/components/themes/common/plx-button.less"; +.oes-time-table .chevron::before { + border-style: solid; + border-width: 0.29em 0.29em 0 0; + content: ''; + display: inline-block; + height: 0.69em; + left: 0.05em; + position: relative; + top: 0.15em; + transform: rotate(-45deg); + -webkit-transform: rotate(-45deg); + -ms-transform: rotate(-45deg); + vertical-align: middle; + width: 0.71em; +} + +.oes-time-table .chevron.bottom:before { + top: -.3em; + -webkit-transform: rotate(135deg); + -ms-transform: rotate(135deg); + transform: rotate(135deg); +} + +.oes-time-table .btn-link { + border: none!important; + cursor: pointer; + outline: 0; + display: block; +} + +.oes-time-table .btn-link.disabled { + cursor: not-allowed; + opacity: .65; +} + +.oes-time-control { + text-align: center; +} + +.datapicker-form-control { + width: auto !important; + display: inline-block; +} + +.oes-time-table .ict-stretch{ + + font-size: 8px; +} + +.oes-time-table .ict-shrink{ + font-size: 8px; +} +.time-pick-bk{ + background-color: #fff; +} + +.btn-link:focus, .btn-link:hover{ + text-decoration: none; +} +.oes-time-control{ + border: 0; + width: 30px !important; + padding: 3px 0; + margin: 0; + font-size: @font-size; +} + +.oes-time-control:hover{ + background-color: #e6e6e6; + color:#000; + cursor: pointer; +} + + +.oes-time-control-foucs-bk{ + background-color: #00abff !important; + color:#fff!important; + +} + +.oes-time-separator{ + margin: 0 -5px; +} +.oes-time-group,.oes-time-group:hover{ + + border-bottom: 1px solid #ccc; + border-left: 1px solid #ccc; + border-top: 1px solid #ccc; + border-radius: 0.2em; + } + .oes-time-btns,.oes-time-btns:hover{ + + border-bottom: 1px solid #ccc; + border-right: 1px solid #ccc; + border-top: 1px solid #ccc; + border-radius: 0.2em; + padding: 0 0 7px 0 !important; + + } + + .oes-time-btns-wrapper { + margin-top:-3px; + transform:scale(0.6,0.6); + } + + .i18nTimeDes,.i18nTimeDes:hover{ + + padding: 0 5px 0px 0; + + } + + .oes-time-btn{ + + height: 5px; + } + + + .oes-time-table{ + margin-bottom: 10px; + } + +.hour-table{ + + font-size:12px; +} + +.hour-table td{ + + padding: 5px; + padding-top: 3px; + padding-bottom: 3px; + cursor: pointer; +} +.oes-time-btn-shrink{ + position: relative; + top:-5px; + left:0px; + color:#CCC; +} + +.oes-time-btn-stretch{ + position: relative; + left:0px; + color:#CCC; +} +.owl-calendar-timer-invalid{ + color: #acacac; +} +.owl-calendar-timer-selected{ + background-color: #00abff; + color: #FFFFFF; + border-radius: 1.2em; +} +.hour-table td:not(.owl-calendar-timer-selected):not(.owl-calendar-timer-invalid):hover { + background-color: #ebf6fd; + color: #000000; + border-radius: 1.2em; +} + + diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/timepicker.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/timepicker.ts new file mode 100644 index 00000000..45dd7a4a --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/timepicker.ts @@ -0,0 +1,558 @@ +import { Component, Input, Output, forwardRef, OnChanges, EventEmitter, SimpleChanges, ViewChild } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +import { isNumber, padNumber, toInteger, isDefined } from './util/util'; +import { NgbTime } from './time'; +import { NgbTimepickerConfig } from './timepicker-config'; + +const NGB_TIMEPICKER_VALUE_ACCESSOR = { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => NgbTimepickerr), + multi: true +}; + +/** + * A lightweight & configurable timepicker directive. + */ +@Component({ + selector: 'oes-timepickerr', + styleUrls: ['./timepicker.less'], + template: ` + + + + + + + + + + + + +
+ {{i18nTimeDes}} + + +  :  + +  :  + + +
+ + +
+
+ `, + providers: [NGB_TIMEPICKER_VALUE_ACCESSOR] +}) +export class NgbTimepickerr implements ControlValueAccessor, + OnChanges { + public disabled: boolean; + public model: NgbTime; + public datemodel: Date; + @Output() TimerChange = new EventEmitter(); + /** + * Whether to display 12H or 24H mode. + */ + @Input() public meridian: boolean; + + /** + * Whether to display the spinners above and below the inputs. + */ + @Input() public spinners: boolean; + + /** + * Whether to display seconds input. + */ + @Input() public seconds: boolean; + + /** + * Number of hours to increase or decrease when using a button. + */ + @Input() public hourStep: number; + + /** + * Number of minutes to increase or decrease when using a button. + */ + @Input() public minuteStep: number; + + /** + * Number of seconds to increase or decrease when using a button. + */ + @Input() public secondStep: number; + + /** + * To make timepicker readonly + */ + @Input() public readonlyInputs: boolean; + + /** + * To set the size of the inputs and button + */ + @Input() public size: 'small' | 'medium' | 'large'; + + + + private _max: Date; + @Input() + get max() { + return this._max; + } + + set max(val: Date) { + this._max = val; + } + private _min: Date; + @Input() + get min() { + return this._min; + } + + set min(val: Date) { + this._min = val; + } + + /** + * Whether to show the second's timer + * @default false + * @type {Boolean} + * */ + @Input() showSecondsTimer: boolean; + /** + * datePicker的国际化描述 + */ + @Input() public i18nTimeDes: string; + + @ViewChild('hourItem') public hourItem; + + @ViewChild('minuteItem') public minuteItem; + @ViewChild('secondItem') public secondItem; + + @ViewChild('propHour') public propHour; + + @ViewChild('propMin') public propMin; + @ViewChild('propSecond') public propSecond; + + public currSelectedItem: 'hour' | 'minute' | 'second'; + + public hours1 = ['00', '01', '02', '03', '04', '05', '06', '07']; + + public hours2 = ['08', '09', '10', '11', '12', '13', '14', '15']; + + public hours3 = ['16', '17', '18', '19', '20', '21', '22', '23']; + + public minute1 = ['00', '01', '02', '03', '04', '05', '06', '07', '08', '09']; + + public minute2 = ['10', '11', '12', '13', '14', '15', '16', '17', '18', '19']; + + public minute3 = ['20', '21', '22', '23', '24', '25', '26', '27', '28', '29']; + + public minute4 = ['30', '31', '32', '33', '34', '35', '36', '37', '38', '39']; + + public minute5 = ['40', '41', '42', '43', '44', '45', '46', '47', '48', '49']; + + public minute6 = ['50', '51', '52', '53', '54', '55', '56', '57', '58', '59']; + + constructor(config: NgbTimepickerConfig) { + this.meridian = config.meridian; + this.spinners = config.spinners; + this.seconds = config.seconds; + this.hourStep = config.hourStep; + this.minuteStep = config.minuteStep; + this.secondStep = config.secondStep; + this.disabled = config.disabled; + this.readonlyInputs = config.readonlyInputs; + this.size = config.size; + } + + public onChange = (_: any) => { + // TO DO + } + public onTouched = () => { + // TO DO + } + public settime(date : Date) + { + if(date!=null&&date!==undefined) + { + if(this._max!==undefined&&this._max.getTime()date.getTime()) + { + date.setHours(this._min.getHours()); + date.setMinutes(this._min.getMinutes()); + date.setSeconds(this._min.getSeconds()); + this.TimerChange.emit(new NgbTime(date.getHours(),date.getMinutes(),date.getSeconds())); + } + } + if(date!==null&&date!==undefined) + { + let temptime = new NgbTime(date.getHours(),date.getMinutes(),date.getSeconds()) + this.model = temptime; + this.datemodel = date; + } + else + { + let temptime = new NgbTime(0,0,0) + this.model = temptime; + this.datemodel = date; + } + + } + public selectHour(hour: string, event) { + if(!this.isValidHour(parseInt(hour))) + { + return; + } + this.model.hour = parseInt(hour); + this.propHour.close(); + this.propagateModelChange(); + event.stopPropagation(); + } + + public selectMin(minute: string, event) { + if(!this.isValidMin(parseInt(minute))) + { + return; + } + this.model.minute = parseInt(minute); + this.propMin.close(); + this.propagateModelChange(); + + event.stopPropagation(); + } + public selectSecond(second: string, event) { + if(!this.isValidSec(parseInt(second))) + { + return; + } + this.model.second = parseInt(second); + this.propSecond.close(); + this.propagateModelChange(); + + event.stopPropagation(); + } + + /** + * ###描述 + * 单击小时或者分钟选项时触发的事件 + * + * + * */ + + public selectItem(item: 'hour' | 'minute' | 'second') { + + // 切换选中项 + this.currSelectedItem = item; + + if (item === 'hour') { + + this.propMin?this.propMin.close():0; + this.propSecond?this.propSecond.close():0; + } else if (item === 'minute') { + this.propHour?this.propHour.close():0; + this.propSecond?this.propSecond.close():0; + } else if (item === 'second') { + this.propHour?this.propHour.close():0; + this.propMin?this.propMin.close():0; + } + + this.minuteItem.nativeElement.blur(); + this.hourItem.nativeElement.blur(); + + this.secondItem?this.secondItem.nativeElement.blur():0; + + // 弹出时间选择列表 + } + + public changeTime(stepTime) { + + if (this.currSelectedItem === 'hour') { // 如果当前选中的是小时 + + this.changeHour(stepTime); + + } else if (this.currSelectedItem === 'minute') { + + this.changeMinute(stepTime); + } else if (this.currSelectedItem === 'second') { + + this.changeSecond(stepTime); + } + + } + + + public writeValue(value) { + this.model = value ? new NgbTime(value.hour, value.minute, value.second) : new NgbTime(); + if (!this.seconds && (!value || !isNumber(value.second))) { + this.model.second = 0; + } + } + + public registerOnChange(fn: (value: any) => any): void { this.onChange = fn; } + + public registerOnTouched(fn: () => any): void { this.onTouched = fn; } + + public setDisabledState(isDisabled: boolean) { this.disabled = isDisabled; } + + public changeHour(step: number) { + let newDate = new Date(this.datemodel.getTime()); + newDate.setHours(newDate.getHours()+step); + if(!this.isValidDate(newDate)) + { + return; + } + this.model.changeHour(step); + this.propagateModelChange(); + } + + public changeMinute(step: number) { + let newDate = new Date(this.datemodel.getTime()); + newDate.setMinutes(newDate.getMinutes()+step); + if(!this.isValidDate(newDate)) + { + return; + } + this.model.changeMinute(step); + this.propagateModelChange(); + } + + public changeSecond(step: number) { + let newDate = new Date(this.datemodel.getTime()); + newDate.setSeconds(newDate.getSeconds()+step); + if(!this.isValidDate(newDate)) + { + return; + } + this.model.changeSecond(step); + this.propagateModelChange(); + } + + public updateHour(newVal: string) { + this.model.updateHour(toInteger(newVal)); + this.propagateModelChange(); + } + + public updateMinute(newVal: string) { + this.model.updateMinute(toInteger(newVal)); + this.propagateModelChange(); + } + + public updateSecond(newVal: string) { + this.model.updateSecond(toInteger(newVal)); + this.propagateModelChange(); + } + + public toggleMeridian() { + if (this.meridian) { + this.changeHour(12); + } + } + + public formatHour(value: number) { + if (isNumber(value)) { + if (this.meridian) { + return padNumber(value % 12 === 0 ? 12 : value % 12); + } else { + return padNumber(value % 24); + } + } else { + return padNumber(NaN); + } + } + + public formatMinSec(value: number) { return padNumber(value); } + + public setFormControlSize() { return { 'form-control-sm': this.size === 'small', 'form-control-lg': this.size === 'large' }; } + + public setButtonSize() { return { 'btn-sm': this.size === 'small', 'btn-lg': this.size === 'large' }; } + + + public ngOnChanges(changes: SimpleChanges): void { + if (changes['seconds'] && !this.seconds && this.model && !isNumber(this.model.second)) { + this.model.second = 0; + this.propagateModelChange(false); + } + } + + private propagateModelChange(touched = true) { + this.TimerChange.emit(this.model); + if (touched) { + this.onTouched(); + } + if (this.model.isValid(this.seconds)) { + this.onChange({ hour: this.model.hour, minute: this.model.minute, second: this.model.second }); + } else { + this.onChange(null); + } + } + public closeProp() + { + + if(this.propSecond!==undefined) + { + this.propSecond.close(); + } + if(this.propMin!==undefined) + { + this.propMin.close(); + } + if(this.propHour!==undefined) + { + this.propHour.close(); + } + } + private isValidDate(date: Date) + { + let isValid = true; + if (isValid && this._min!==undefined&&this._min!==null) { + isValid = date.getTime()>=this._min.getTime(); + } + if (isValid && this._max!==undefined&&this._max!==null) { + isValid = date.getTime()<=this._max.getTime(); + } + return isValid; + } + private isSelectedMin(strvalue:any): boolean { + let value = parseInt(strvalue); + if(this.model!==null&&this.model!==undefined) + { + return this.model.minute === value; + } + else + { + return false; + } +} + private isValidMin(strvalue:any): boolean { + let value = parseInt(strvalue); + let nowdate = new Date(); + if(this.datemodel===undefined||this.datemodel===null) + { + } + else + { + nowdate = new Date(this.datemodel); + } + nowdate.setMinutes(value); + return this.isValidDate(nowdate); +} +private isSelectedSec(strvalue:any): boolean { + let value = parseInt(strvalue); + if(this.model!==null&&this.model!==undefined) + { + return this.model.second === value; + } + else + { + return false; + } +} +private isValidSec(strvalue:any): boolean { + let value = parseInt(strvalue); + let nowdate = new Date(); + if(this.datemodel===undefined||this.datemodel===null) + { + } + else + { + nowdate = new Date(this.datemodel); + } + nowdate.setSeconds(value); + return this.isValidDate(nowdate); +} +private isSelectedHour(strvalue:any): boolean { + let value = parseInt(strvalue); + if(this.model!==null&&this.model!==undefined) + { + return this.model.hour === value; + } + else + { + return false; + } +} +private isValidHour(strvalue:any): boolean { + debugger; + let value = parseInt(strvalue); + let nowdate = new Date(); + if(this.datemodel===undefined||this.datemodel===null) + { + } + else + { + nowdate = new Date(this.datemodel); + } + nowdate.setHours(value); + return this.isValidDate(nowdate); +} +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/util/popup.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/util/popup.ts new file mode 100644 index 00000000..56c26d62 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/util/popup.ts @@ -0,0 +1,58 @@ +import { + Injector, + TemplateRef, + ViewRef, + ViewContainerRef, + Renderer, + ComponentRef, + ComponentFactory, + ComponentFactoryResolver +} from '@angular/core'; + +export class ContentRef { + constructor(public nodes: any[], public viewRef?: ViewRef, public componentRef?: ComponentRef) {} +} + +export class PopupService { + private _windowFactory: ComponentFactory; + private _windowRef: ComponentRef; + private _contentRef: ContentRef; + + constructor( + type: any, private _injector: Injector, private _viewContainerRef: ViewContainerRef, private _renderer: Renderer, + componentFactoryResolver: ComponentFactoryResolver) { + this._windowFactory = componentFactoryResolver.resolveComponentFactory(type); + } + + public open(content?: string | TemplateRef, context?: any): ComponentRef { + if (!this._windowRef) { + this._contentRef = this._getContentRef(content, context); + this._windowRef = + this._viewContainerRef.createComponent(this._windowFactory, 0, this._injector, this._contentRef.nodes); + } + return this._windowRef; + } + + public close() { + if (this._windowRef) { + this._viewContainerRef.remove(this._viewContainerRef.indexOf(this._windowRef.hostView)); + this._windowRef = null; + + if (this._contentRef.viewRef) { + this._viewContainerRef.remove(this._viewContainerRef.indexOf(this._contentRef.viewRef)); + this._contentRef = null; + } + } + } + + private _getContentRef(content: string | TemplateRef, context?: any): ContentRef { + if (!content) { + return new ContentRef([]); + } else if (content instanceof TemplateRef) { + const viewRef = this._viewContainerRef.createEmbeddedView(>content, context); + return new ContentRef([viewRef.rootNodes], viewRef); + } else { + return new ContentRef([[this._renderer.createText(null, `${content}`)]]); + } + } +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/util/positioning.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/util/positioning.ts new file mode 100644 index 00000000..ed9005c1 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/util/positioning.ts @@ -0,0 +1,153 @@ +// previous version: +// https://github.com/angular-ui/bootstrap/blob/07c31d0731f7cb068a1932b8e01d2312b796b4ec/src/position/position.js +export class Positioning { + private getStyle(element: HTMLElement, prop: string): string { return window.getComputedStyle(element)[prop]; } + + private isStaticPositioned(element: HTMLElement): boolean { + return (this.getStyle(element, 'position') || 'static') === 'static'; + } + + private offsetParent(element: HTMLElement): HTMLElement { + let offsetParentEl = element.offsetParent || document.documentElement; + + while (offsetParentEl && offsetParentEl !== document.documentElement && this.isStaticPositioned(offsetParentEl)) { + offsetParentEl = offsetParentEl.offsetParent; + } + + return offsetParentEl || document.documentElement; + } + + public position(element: HTMLElement, round = true): ClientRect { + let elPosition: ClientRect; + let parentOffset: ClientRect = {width: 0, height: 0, top: 0, bottom: 0, left: 0, right: 0}; + + if (this.getStyle(element, 'position') === 'fixed') { + elPosition = element.getBoundingClientRect(); + } else { + const offsetParentEl = this.offsetParent(element); + + elPosition = this.offset(element, false); + + if (offsetParentEl !== document.documentElement) { + parentOffset = this.offset(offsetParentEl, false); + } + + parentOffset.top += offsetParentEl.clientTop; + parentOffset.left += offsetParentEl.clientLeft; + } + + elPosition.top -= parentOffset.top; + elPosition.bottom -= parentOffset.top; + elPosition.left -= parentOffset.left; + elPosition.right -= parentOffset.left; + + if (round) { + elPosition.top = Math.round(elPosition.top); + elPosition.bottom = Math.round(elPosition.bottom); + elPosition.left = Math.round(elPosition.left); + elPosition.right = Math.round(elPosition.right); + } + + return elPosition; + } + + public offset(element: HTMLElement, round = true): ClientRect { + const elBcr = element.getBoundingClientRect(); + const viewportOffset = { + top: window.pageYOffset - document.documentElement.clientTop, + left: window.pageXOffset - document.documentElement.clientLeft + }; + + let elOffset = { + height: elBcr.height || element.offsetHeight, + width: elBcr.width || element.offsetWidth, + top: elBcr.top + viewportOffset.top, + bottom: elBcr.bottom + viewportOffset.top, + left: elBcr.left + viewportOffset.left, + right: elBcr.right + viewportOffset.left + }; + + if (round) { + elOffset.height = Math.round(elOffset.height); + elOffset.width = Math.round(elOffset.width); + elOffset.top = Math.round(elOffset.top); + elOffset.bottom = Math.round(elOffset.bottom); + elOffset.left = Math.round(elOffset.left); + elOffset.right = Math.round(elOffset.right); + } + + return elOffset; + } + + public positionElements(hostElement: HTMLElement, targetElement: HTMLElement, placement: string, appendToBody?: boolean): + ClientRect { + const hostElPosition = appendToBody ? this.offset(hostElement, false) : this.position(hostElement, false); + const shiftWidth: any = { + left: hostElPosition.left, + left2: (hostElPosition.left - 85), + center: hostElPosition.left + hostElPosition.width / 2 - targetElement.offsetWidth / 2, + right: hostElPosition.left + hostElPosition.width + }; + const shiftHeight: any = { + top: hostElPosition.top, + center: hostElPosition.top + hostElPosition.height / 2 - targetElement.offsetHeight / 2, + bottom: hostElPosition.top + hostElPosition.height + }; + const targetElBCR = targetElement.getBoundingClientRect(); + const placementPrimary = placement.split('-')[0] || 'top'; + const placementSecondary = placement.split('-')[1] || 'center'; + + let targetElPosition: ClientRect = { + height: targetElBCR.height || targetElement.offsetHeight, + width: targetElBCR.width || targetElement.offsetWidth, + top: 0, + bottom: targetElBCR.height || targetElement.offsetHeight, + left: 0, + right: targetElBCR.width || targetElement.offsetWidth + }; + + switch (placementPrimary) { + case 'top': + targetElPosition.top = hostElPosition.top - targetElement.offsetHeight; + targetElPosition.bottom += hostElPosition.top - targetElement.offsetHeight; + targetElPosition.left = shiftWidth[placementSecondary]; + targetElPosition.right += shiftWidth[placementSecondary]; + break; + case 'bottom': + targetElPosition.top = shiftHeight[placementPrimary]; + targetElPosition.bottom += shiftHeight[placementPrimary]; + targetElPosition.left = shiftWidth[placementSecondary]; + targetElPosition.right += shiftWidth[placementSecondary]; + break; + case 'left': + targetElPosition.top = shiftHeight[placementSecondary]; + targetElPosition.bottom += shiftHeight[placementSecondary]; + targetElPosition.left = hostElPosition.left - targetElement.offsetWidth; + targetElPosition.right += hostElPosition.left - targetElement.offsetWidth; + break; + case 'right': + targetElPosition.top = shiftHeight[placementSecondary]; + targetElPosition.bottom += shiftHeight[placementSecondary]; + targetElPosition.left = shiftWidth[placementPrimary]; + targetElPosition.right += shiftWidth[placementPrimary]; + break; + + } + + targetElPosition.top = Math.round(targetElPosition.top); + targetElPosition.bottom = Math.round(targetElPosition.bottom); + targetElPosition.left = Math.round(targetElPosition.left); + targetElPosition.right = Math.round(targetElPosition.right); + + return targetElPosition; + } +} + +const positionService = new Positioning(); +export function positionElements( + hostElement: HTMLElement, targetElement: HTMLElement, placement: string, appendToBody?: boolean): void { + const pos = positionService.positionElements(hostElement, targetElement, placement, appendToBody); + + targetElement.style.top = `${pos.top}px`; + targetElement.style.left = `${pos.left}px`; +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/util/triggers.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/util/triggers.ts new file mode 100644 index 00000000..8197de5b --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/util/triggers.ts @@ -0,0 +1,62 @@ +export class Trigger { + constructor(public open: string, public close?: string) { + if (!close) { + this.close = open; + } + } + + public isManual() { return this.open === 'manual' || this.close === 'manual'; } +} + +const DEFAULT_ALIASES = { + hover: ['mouseenter', 'mouseleave'] +}; + +export function parseTriggers(triggers: string, aliases = DEFAULT_ALIASES): Trigger[] { + const trimmedTriggers = (triggers || '').trim(); + + if (trimmedTriggers.length === 0) { + return []; + } + + const parsedTriggers = trimmedTriggers.split(/\s+/).map(trigger => trigger.split(':')).map((triggerPair) => { + let alias = aliases[triggerPair[0]] || triggerPair; + return new Trigger(alias[0], alias[1]); + }); + + const manualTriggers = parsedTriggers.filter(triggerPair => triggerPair.isManual()); + + if (manualTriggers.length > 1) { + throw 'Triggers parse error: only one manual trigger is allowed'; + } + + if (manualTriggers.length === 1 && parsedTriggers.length > 1) { + throw 'Triggers parse error: manual trigger can\'t be mixed with other triggers'; + } + + return parsedTriggers; +} + +const noopFn = () => { + // TO DO +}; + +export function listenToTriggers(renderer: any, nativeElement: any, triggers: string, openFn, closeFn, toggleFn) { + const parsedTriggers = parseTriggers(triggers); + const listeners = []; + + if (parsedTriggers.length === 1 && parsedTriggers[0].isManual()) { + return noopFn; + } + + parsedTriggers.forEach((trigger: Trigger) => { + if (trigger.open === trigger.close) { + listeners.push(renderer.listen(nativeElement, trigger.open, toggleFn)); + } else { + listeners.push( + renderer.listen(nativeElement, trigger.open, openFn), renderer.listen(nativeElement, trigger.close, closeFn)); + } + }); + + return () => { listeners.forEach(unsubscribeFn => unsubscribeFn()); }; +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/util/util.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/util/util.ts new file mode 100644 index 00000000..fcabe960 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-datepicker/util/util.ts @@ -0,0 +1,39 @@ +export function toInteger(value: any): number { + return parseInt(`${value}`, 10); +} + +export function toString(value: any): string { + return (value !== undefined && value !== null) ? `${value}` : ''; +} + +export function getValueInRange(value: number, max: number, min = 0): number { + return Math.max(Math.min(value, max), min); +} + +export function isString(value: any): boolean { + return typeof value === 'string'; +} + +export function isNumber(value: any): boolean { + return !isNaN(toInteger(value)); +} + +export function isInteger(value: any): boolean { + return typeof value === 'number' && isFinite(value) && Math.floor(value) === value; +} + +export function isDefined(value: any): boolean { + return value !== undefined && value !== null; +} + +export function padNumber(value: number) { + if (isNumber(value)) { + return value > 9? `${value}`.slice(-2):'0' + `${value}`.slice(-2); + } else { + return ''; + } +} + +export function regExpEscape(text) { + return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-backdrop.spec.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-backdrop.spec.ts new file mode 100644 index 00000000..887b66e4 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-backdrop.spec.ts @@ -0,0 +1,16 @@ +import {TestBed} from '@angular/core/testing'; +import {PlxModalBackdrop} from './modal-backdrop'; + +describe('plx-modal-backdrop', () => { + + beforeEach(() => { + TestBed.configureTestingModule({declarations: [PlxModalBackdrop]}); + }); + + it('should render backdrop with required CSS classes', () => { + const fixture = TestBed.createComponent(PlxModalBackdrop); + + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveCssClass('modal-backdrop'); + }); +}); diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-backdrop.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-backdrop.ts new file mode 100644 index 00000000..07e2ff84 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-backdrop.ts @@ -0,0 +1,9 @@ +import {Component} from '@angular/core'; + +@Component({ + selector: 'plx-modal-backdrop', + template: '', + host: {'class': 'modal-backdrop fade show'} +}) +export class PlxModalBackdrop { +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-dismiss-reasons.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-dismiss-reasons.ts new file mode 100644 index 00000000..08395852 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-dismiss-reasons.ts @@ -0,0 +1,4 @@ +export enum ModalDismissReasons { + BACKDROP_CLICK, + ESC +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-ref.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-ref.ts new file mode 100644 index 00000000..061dc70e --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-ref.ts @@ -0,0 +1,109 @@ +import {Injectable, ComponentRef} from '@angular/core'; +import {PlxModalBackdrop} from './modal-backdrop'; +import {PlxModalWindow} from './modal-window'; +import {ContentRef} from '../util/popup'; + +/** + * A reference to an active (currently opened) modal. Instances of this class + * can be injected into components passed as modal content. + */ +@Injectable() +export class PlxActiveModal { + /** + * Can be used to close a modal, passing an optional result. + */ + public close(result?: any): void { + // TO DO + } + + /** + * Can be used to dismiss a modal, passing an optional reason. + */ + public dismiss(reason?: any): void { + // TO DO + } +} + +/** + * A reference to a newly opened modal. + */ +@Injectable() +export class PlxModalRef { + private _resolve: (result?: any) => void; + private _reject: (reason?: any) => void; + + /** + * The instance of component used as modal's content. + * Undefined when a TemplateRef is used as modal's content. + */ + get componentInstance(): any { + if (this._contentRef.componentRef) { + return this._contentRef.componentRef.instance; + } + } + + // only needed to keep TS1.8 compatibility + set componentInstance(instance: any) { + // TO DO + } + + /** + * A promise that is resolved when a modal is closed and rejected when a modal is dismissed. + */ + public result: Promise; + + constructor(private _windowCmptRef: ComponentRef, private _contentRef: ContentRef, + private _backdropCmptRef?: ComponentRef) { + _windowCmptRef.instance.dismissEvent.subscribe((reason: any) => { + this.dismiss(reason); + }); + + this.result = new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + }); + this.result.then(null, () => { + // TO DO + }); + } + + /** + * Can be used to close a modal, passing an optional result. + */ + public close(result?: any): void { + if (this._windowCmptRef) { + this._resolve(result); + this._removeModalElements(); + } + } + + /** + * Can be used to dismiss a modal, passing an optional reason. + */ + public dismiss(reason?: any): void { + if (this._windowCmptRef) { + this._reject(reason); + this._removeModalElements(); + } + } + + private _removeModalElements() { + const windowNativeEl = this._windowCmptRef.location.nativeElement; + windowNativeEl.parentNode.removeChild(windowNativeEl); + this._windowCmptRef.destroy(); + + if (this._backdropCmptRef) { + const backdropNativeEl = this._backdropCmptRef.location.nativeElement; + backdropNativeEl.parentNode.removeChild(backdropNativeEl); + this._backdropCmptRef.destroy(); + } + + if (this._contentRef && this._contentRef.viewRef) { + this._contentRef.viewRef.destroy(); + } + + this._windowCmptRef = null; + this._backdropCmptRef = null; + this._contentRef = null; + } +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-stack.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-stack.ts new file mode 100644 index 00000000..37f5b171 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-stack.ts @@ -0,0 +1,103 @@ +import { + ApplicationRef, + Injectable, + Injector, + ReflectiveInjector, + ComponentFactory, + ComponentFactoryResolver, + ComponentRef, + TemplateRef +} from '@angular/core'; + +import {ContentRef} from '../util/popup'; +import {isDefined, isString} from '../util/util'; + +import {PlxModalBackdrop} from './modal-backdrop'; +import {PlxModalWindow} from './modal-window'; +import {PlxActiveModal, PlxModalRef} from './modal-ref'; + +@Injectable() +export class PlxModalStack { + private _backdropFactory: ComponentFactory; + private _windowFactory: ComponentFactory; + + constructor(private _applicationRef: ApplicationRef, private _injector: Injector, + private _componentFactoryResolver: ComponentFactoryResolver) { + this._backdropFactory = _componentFactoryResolver.resolveComponentFactory(PlxModalBackdrop); + this._windowFactory = _componentFactoryResolver.resolveComponentFactory(PlxModalWindow); + } + + public open(moduleCFR: ComponentFactoryResolver, contentInjector: Injector, content: any, options): PlxModalRef { + const containerSelector = options.container || 'body'; + const containerEl = document.querySelector(containerSelector);// 默认获取到body的DOM + + if (!containerEl) { + throw new Error(`The specified modal container "${containerSelector}" was not found in the DOM.`); + } + + const activeModal = new PlxActiveModal(); + const contentRef = this._getContentRef(moduleCFR, contentInjector, content, activeModal); + + let windowCmptRef: ComponentRef; + let backdropCmptRef: ComponentRef; + let ngbModalRef: PlxModalRef; + + + if (options.backdrop !== false) { + backdropCmptRef = this._backdropFactory.create(this._injector); + this._applicationRef.attachView(backdropCmptRef.hostView); + containerEl.appendChild(backdropCmptRef.location.nativeElement); + } + windowCmptRef = this._windowFactory.create(this._injector, contentRef.nodes); + + /** + * Attaches a view so that it will be dirty checked. + * The view will be automatically detached when it is destroyed. + * This will throw if the view is already attached to a ViewContainer. + */ + this._applicationRef.attachView(windowCmptRef.hostView); + + containerEl.appendChild(windowCmptRef.location.nativeElement); + + ngbModalRef = new PlxModalRef(windowCmptRef, contentRef, backdropCmptRef); + + activeModal.close = (result: any) => { + ngbModalRef.close(result); + }; + activeModal.dismiss = (reason: any) => { + ngbModalRef.dismiss(reason); + }; + + this._applyWindowOptions(windowCmptRef.instance, options); + + return ngbModalRef; + } + + private _applyWindowOptions(windowInstance: PlxModalWindow, options: Object): void { + ['backdrop', 'keyboard', 'size', 'windowClass'].forEach((optionName: string) => { + if (isDefined(options[optionName])) { + windowInstance[optionName] = options[optionName]; + } + }); + } + + private _getContentRef(moduleCFR: ComponentFactoryResolver, contentInjector: Injector, content: any, + context: PlxActiveModal): ContentRef { + if (!content) { + return new ContentRef([]); + } else if (content instanceof TemplateRef) { + const viewRef = content.createEmbeddedView(context); + this._applicationRef.attachView(viewRef); + return new ContentRef([viewRef.rootNodes], viewRef); + } else if (isString(content)) { + return new ContentRef([[document.createTextNode(`${content}`)]]); + } else { + const contentCmptFactory = moduleCFR.resolveComponentFactory(content); + const modalContentInjector = + ReflectiveInjector.resolveAndCreate([{provide: PlxActiveModal, useValue: context}], contentInjector); + const componentRef = contentCmptFactory.create(modalContentInjector); + this._applicationRef.attachView(componentRef.hostView); + return new ContentRef([[componentRef.location.nativeElement]], componentRef.hostView, componentRef); + } + } +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-window.spec.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-window.spec.ts new file mode 100644 index 00000000..5767bfee --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-window.spec.ts @@ -0,0 +1,114 @@ +import {TestBed, ComponentFixture} from '@angular/core/testing'; + +import {PlxModalWindow} from './modal-window'; +import {ModalDismissReasons} from './modal-dismiss-reasons'; + +describe('plx-modal-dialog', () => { + + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({declarations: [PlxModalWindow]}); + fixture = TestBed.createComponent(PlxModalWindow); + }); + + describe('basic rendering functionality', () => { + + it('should render default modal window', () => { + fixture.detectChanges(); + + const modalEl: Element = fixture.nativeElement; + const dialogEl: Element = fixture.nativeElement.querySelector('.modal-dialog'); + + expect(modalEl).toHaveCssClass('modal'); + expect(dialogEl).toHaveCssClass('modal-dialog'); + }); + + it('should render default modal window with a specified size', () => { + fixture.componentInstance.size = 'sm'; + fixture.detectChanges(); + + const dialogEl: Element = fixture.nativeElement.querySelector('.modal-dialog'); + expect(dialogEl).toHaveCssClass('modal-dialog'); + expect(dialogEl).toHaveCssClass('modal-sm'); + }); + + it('should render default modal window with a specified class', () => { + fixture.componentInstance.windowClass = 'custom-class'; + fixture.detectChanges(); + + expect(fixture.nativeElement).toHaveCssClass('custom-class'); + }); + + it('aria attributes', () => { + fixture.detectChanges(); + const dialogEl: Element = fixture.nativeElement.querySelector('.modal-dialog'); + + expect(fixture.nativeElement.getAttribute('role')).toBe('dialog'); + expect(dialogEl.getAttribute('role')).toBe('document'); + }); + }); + + describe('dismiss', () => { + + it('should dismiss on backdrop click by default', (done) => { + fixture.detectChanges(); + + fixture.componentInstance.dismissEvent.subscribe(($event) => { + expect($event).toBe(ModalDismissReasons.BACKDROP_CLICK); + done(); + }); + + fixture.nativeElement.click(); + }); + + it('should not dismiss on modal content click when there is active backdrop', (done) => { + fixture.detectChanges(); + fixture.componentInstance.dismissEvent.subscribe( + () => { + done.fail(new Error('Should not trigger dismiss event')); + }); + + fixture.nativeElement.querySelector('.modal-content').click(); + setTimeout(done, 200); + }); + + it('should ignore backdrop clicks when there is no backdrop', (done) => { + fixture.componentInstance.backdrop = false; + fixture.detectChanges(); + + fixture.componentInstance.dismissEvent.subscribe(($event) => { + expect($event).toBe(ModalDismissReasons.BACKDROP_CLICK); + done.fail(new Error('Should not trigger dismiss event')); + }); + + fixture.nativeElement.querySelector('.modal-dialog').click(); + setTimeout(done, 200); + }); + + it('should ignore backdrop clicks when backdrop is "static"', (done) => { + fixture.componentInstance.backdrop = 'static'; + fixture.detectChanges(); + + fixture.componentInstance.dismissEvent.subscribe(($event) => { + expect($event).toBe(ModalDismissReasons.BACKDROP_CLICK); + done.fail(new Error('Should not trigger dismiss event')); + }); + + fixture.nativeElement.querySelector('.modal-dialog').click(); + setTimeout(done, 200); + }); + + it('should dismiss on esc press by default', (done) => { + fixture.detectChanges(); + + fixture.componentInstance.dismissEvent.subscribe(($event) => { + expect($event).toBe(ModalDismissReasons.ESC); + done(); + }); + + fixture.debugElement.triggerEventHandler('keyup.esc', {}); + }); + }); + +}); diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-window.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-window.ts new file mode 100644 index 00000000..eda5b39f --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal-window.ts @@ -0,0 +1,82 @@ +import { + Component, + Output, + EventEmitter, + Input, + ElementRef, + Renderer, + OnInit, + AfterViewInit, + OnDestroy, ViewEncapsulation +} from '@angular/core'; + +import {ModalDismissReasons} from './modal-dismiss-reasons'; + +@Component({ + selector: 'plx-modal-window', + host: { + '[class]': '"modal plx-modal fade show" + (windowClass ? " " + windowClass : "")', + 'role': 'dialog', + 'tabindex': '-1', + 'style': 'display: block;', + '(keyup.esc)': 'escKey($event)', + '(click)': 'backdropClick($event)' + }, + template: ` +
+ +
+ `, + styleUrls: ['modal.less'], + encapsulation: ViewEncapsulation.None +}) +export class PlxModalWindow implements OnInit, AfterViewInit, OnDestroy { + private _elWithFocus: Element; // element that is focused prior to modal opening + + @Input() public backdrop: boolean | string = true; + @Input() public keyboard = true; + @Input() public size: string; + @Input() public windowClass: string; + + @Output('dismiss') public dismissEvent = new EventEmitter(); + + constructor(private _elRef: ElementRef, private _renderer: Renderer) { + } + + public backdropClick($event): void { + if (this.backdrop === true && this._elRef.nativeElement === $event.target) { + this.dismiss(ModalDismissReasons.BACKDROP_CLICK); + } + } + + public escKey($event): void { + if (this.keyboard && !$event.defaultPrevented) { + this.dismiss(ModalDismissReasons.ESC); + } + } + + public dismiss(reason): void { + this.dismissEvent.emit(reason); + } + + public ngOnInit() { + this._elWithFocus = document.activeElement; + this._renderer.setElementClass(document.body, 'modal-open', true); + } + + public ngAfterViewInit() { + if (!this._elRef.nativeElement.contains(document.activeElement)) { + this._renderer.invokeElementMethod(this._elRef.nativeElement, 'focus', []); + } + } + + public ngOnDestroy() { + if (this._elWithFocus && document.body.contains(this._elWithFocus)) { + this._renderer.invokeElementMethod(this._elWithFocus, 'focus', []); + } else { + this._renderer.invokeElementMethod(document.body, 'focus', []); + } + this._elWithFocus = null; + this._renderer.setElementClass(document.body, 'modal-open', false); + } +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal.less b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal.less new file mode 100644 index 00000000..c17a7fd1 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal.less @@ -0,0 +1,125 @@ +@import "../../assets/components/themes/default/theme.less"; + +plx-modal-window { + .modal { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + display: none; + outline: 0; + z-index: 10000; + } + .modal-dialog { + position: relative; + max-width: 600px; + margin: 30px auto; + &.modal-sm { + max-width: 600px; + } + &.modal-lg { + max-width: 1000px; + } + } + .modal-content { + position: relative; + display: flex; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; + background-color: @component-bg; + background-clip: padding-box; + border-radius: @radius; + box-shadow: 0 5px 15px @shadow-color; + outline: 0; + .modal-header { + border-bottom: 0; + display: flex; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: justify; + -ms-flex-pack: justify; + justify-content: space-between; + padding: 15px; + } + .modal-body { + .form-group:last-child, form:last-child { + margin-bottom: 0; + } + } + .modal-footer { + display: block; + border-top: 0; + margin-top: 0; + padding: 0 15px 15px 15px; + } + .modal-title { + font-size: @font-size-title-level1; + margin-bottom: 0; + line-height: 1.5; + } + .modal-btn { + text-align: center; + font-size: 0; + } + } + .close { + color: @fonticon-color; + font-size: @font-size-title-level2; + text-shadow: none; + width: 24px; + height: 24px; + background: @scene-textcolor; + border-radius: 20px; + padding-bottom: 2px; + outline: none; + &:hover { + color: @fonticon-color; + background: @fonticon-bg-color-hover; + } + } + .alert-modal { + &.row { + margin-left: 100px; + margin-bottom: 30px; + text-align: left; + .tip-img { + display: inline-block; + width: 52px; + height: 52px; + border-radius: 50px; + font-size: 45px; + text-align: center; + line-height: 1; + margin-top: -5px; + margin-right: 15px; + &::before { + content: "!"; + } + } + .tip-info { + width: 300px; + .alert-title { + font-size: @font-size-title-level2; + color: @title-text-color; + } + .alert-result { + margin-top: 5px; + font-size: @font-size; + color: @unselected-text-color; + } + } + .warning { + border: 3px solid @warning-color; + color: @warning-color; + } + .error { + border: 3px solid @error-color; + color: @error-color; + } + } + } +} \ No newline at end of file diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal.module.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal.module.ts new file mode 100644 index 00000000..67fdb478 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal.module.ts @@ -0,0 +1,21 @@ +import {NgModule, ModuleWithProviders} from '@angular/core'; + +import {PlxModalBackdrop} from './modal-backdrop'; +import {PlxModalWindow} from './modal-window'; +import {PlxModalStack} from './modal-stack'; +import {PlxModal} from './modal'; + +export {PlxModal, PlxModalOptions} from './modal'; +export {PlxModalRef, PlxActiveModal} from './modal-ref'; +export {ModalDismissReasons} from './modal-dismiss-reasons'; + +@NgModule({ + declarations: [PlxModalBackdrop, PlxModalWindow], + entryComponents: [PlxModalBackdrop, PlxModalWindow], + providers: [PlxModal] +}) +export class PlxModalModule { + public static forRoot(): ModuleWithProviders { + return {ngModule: PlxModalModule, providers: [PlxModal, PlxModalStack]}; + } +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal.spec.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal.spec.ts new file mode 100644 index 00000000..a99c86b1 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal.spec.ts @@ -0,0 +1,597 @@ +import {Component, Injectable, ViewChild, OnDestroy, NgModule, getDebugNode, DebugElement} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {TestBed, ComponentFixture} from '@angular/core/testing'; + +import {PlxModalModule, PlxModal, PlxActiveModal, PlxModalRef} from './modal.module'; + +const NOOP = () => { +}; + +@Injectable() +class SpyService { + called = false; +} + +describe('plx-modal', () => { + + let fixture: ComponentFixture; + + beforeEach(() => { + jasmine.addMatchers({ + toHaveModal: function (util, customEqualityTests) { + return { + compare: function (actual, content?, selector?) { + const allModalsContent = document.querySelector(selector || 'body').querySelectorAll('.modal-content'); + let pass = true; + let errMsg; + + if (!content) { + pass = allModalsContent.length > 0; + errMsg = 'at least one modal open but found none'; + } else if (Array.isArray(content)) { + pass = allModalsContent.length === content.length; + errMsg = `${content.length} modals open but found ${allModalsContent.length}`; + } else { + pass = allModalsContent.length === 1 && allModalsContent[0].textContent.trim() === content; + errMsg = `exactly one modal open but found ${allModalsContent.length}`; + } + + return {pass: pass, message: `Expected ${actual.outerHTML} to have ${errMsg}`}; + }, + negativeCompare: function (actual) { + const allOpenModals = actual.querySelectorAll('plx-modal-window'); + + return { + pass: allOpenModals.length === 0, + message: `Expected ${actual.outerHTML} not to have any modals open but found ${allOpenModals.length}` + }; + } + }; + } + }); + + jasmine.addMatchers({ + toHaveBackdrop: function (util, customEqualityTests) { + return { + compare: function (actual) { + return { + pass: document.querySelectorAll('plx-modal-backdrop').length === 1, + message: `Expected ${actual.outerHTML} to have exactly one backdrop element` + }; + }, + negativeCompare: function (actual) { + const allOpenModals = document.querySelectorAll('plx-modal-backdrop'); + + return { + pass: allOpenModals.length === 0, + message: `Expected ${actual.outerHTML} not to have any backdrop elements` + }; + } + }; + } + }); + }); + + beforeEach(() => { + TestBed.configureTestingModule({imports: [OesModalTestModule]}); + fixture = TestBed.createComponent(TestComponent); + }); + + afterEach(() => { + // detect left-over modals and close them or report errors when can't + + const remainingModalWindows = document.querySelectorAll('plx-modal-window'); + if (remainingModalWindows.length) { + fail(`${remainingModalWindows.length} modal windows were left in the DOM.`); + } + + const remainingModalBackdrops = document.querySelectorAll('plx-modal-backdrop'); + if (remainingModalBackdrops.length) { + fail(`${remainingModalBackdrops.length} modal backdrops were left in the DOM.`); + } + }); + + describe('basic functionality', () => { + + it('should open and close modal with default options', () => { + const modalInstance = fixture.componentInstance.open('foo'); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('foo'); + + modalInstance.close('some result'); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + + it('should open and close modal from a TemplateRef content', () => { + const modalInstance = fixture.componentInstance.openTpl(); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('Hello, World!'); + + modalInstance.close('some result'); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + + it('should properly destroy TemplateRef content', () => { + const spyService = fixture.debugElement.injector.get(SpyService); + const modalInstance = fixture.componentInstance.openDestroyableTpl(); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('Some content'); + expect(spyService.called).toBeFalsy(); + + modalInstance.close('some result'); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + expect(spyService.called).toBeTruthy(); + }); + + it('should open and close modal from a component type', () => { + const spyService = fixture.debugElement.injector.get(SpyService); + const modalInstance = fixture.componentInstance.openCmpt(DestroyableCmpt); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('Some content'); + expect(spyService.called).toBeFalsy(); + + modalInstance.close('some result'); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + expect(spyService.called).toBeTruthy(); + }); + + it('should inject active modal ref when component is used as content', () => { + fixture.componentInstance.openCmpt(WithActiveModalCmpt); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('Close'); + + (document.querySelector('button.closeFromInside')).click(); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + + it('should expose component used as modal content', () => { + const modalInstance = fixture.componentInstance.openCmpt(WithActiveModalCmpt); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('Close'); + expect(modalInstance.componentInstance instanceof WithActiveModalCmpt).toBeTruthy(); + + modalInstance.close(); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + + it('should open and close modal from inside', () => { + fixture.componentInstance.openTplClose(); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal(); + + (document.querySelector('button#close')).click(); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + + it('should open and dismiss modal from inside', () => { + fixture.componentInstance.openTplDismiss().result.catch(NOOP); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal(); + + (document.querySelector('button#dismiss')).click(); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + + it('should resolve result promise on close', () => { + let resolvedResult; + fixture.componentInstance.openTplClose().result.then((result) => resolvedResult = result); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal(); + + (document.querySelector('button#close')).click(); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + + fixture.whenStable().then(() => { + expect(resolvedResult).toBe('myResult'); + }); + }); + + it('should reject result promise on dismiss', () => { + let rejectReason; + fixture.componentInstance.openTplDismiss().result.catch((reason) => rejectReason = reason); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal(); + + (document.querySelector('button#dismiss')).click(); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + + fixture.whenStable().then(() => { + expect(rejectReason).toBe('myReason'); + }); + }); + + it('should add / remove "modal-open" class to body when modal is open', () => { + const modalRef = fixture.componentInstance.open('bar'); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal(); + expect(document.body).toHaveCssClass('modal-open'); + + modalRef.close('bar result'); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + expect(document.body).not.toHaveCssClass('modal-open'); + }); + + it('should not throw when close called multiple times', () => { + const modalInstance = fixture.componentInstance.open('foo'); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('foo'); + + modalInstance.close('some result'); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + + modalInstance.close('some result'); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + + it('should not throw when dismiss called multiple times', () => { + const modalRef = fixture.componentInstance.open('foo'); + modalRef.result.catch(NOOP); + + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('foo'); + + modalRef.dismiss('some reason'); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + + modalRef.dismiss('some reason'); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + }); + + describe('backdrop options', () => { + + it('should have backdrop by default', () => { + const modalInstance = fixture.componentInstance.open('foo'); + fixture.detectChanges(); + + expect(fixture.nativeElement).toHaveModal('foo'); + expect(fixture.nativeElement).toHaveBackdrop(); + + modalInstance.close('some reason'); + fixture.detectChanges(); + + expect(fixture.nativeElement).not.toHaveModal(); + expect(fixture.nativeElement).not.toHaveBackdrop(); + }); + + it('should open and close modal without backdrop', () => { + const modalInstance = fixture.componentInstance.open('foo', {backdrop: false}); + fixture.detectChanges(); + + expect(fixture.nativeElement).toHaveModal('foo'); + expect(fixture.nativeElement).not.toHaveBackdrop(); + + modalInstance.close('some reason'); + fixture.detectChanges(); + + expect(fixture.nativeElement).not.toHaveModal(); + expect(fixture.nativeElement).not.toHaveBackdrop(); + }); + + it('should open and close modal without backdrop from template content', () => { + const modalInstance = fixture.componentInstance.openTpl({backdrop: false}); + fixture.detectChanges(); + + expect(fixture.nativeElement).toHaveModal('Hello, World!'); + expect(fixture.nativeElement).not.toHaveBackdrop(); + + modalInstance.close('some reason'); + fixture.detectChanges(); + + expect(fixture.nativeElement).not.toHaveModal(); + expect(fixture.nativeElement).not.toHaveBackdrop(); + }); + + it('should dismiss on backdrop click', () => { + fixture.componentInstance.open('foo').result.catch(NOOP); + fixture.detectChanges(); + + expect(fixture.nativeElement).toHaveModal('foo'); + expect(fixture.nativeElement).toHaveBackdrop(); + + (document.querySelector('plx-modal-window')).click(); + fixture.detectChanges(); + + expect(fixture.nativeElement).not.toHaveModal(); + expect(fixture.nativeElement).not.toHaveBackdrop(); + }); + + it('should not dismiss on "static" backdrop click', () => { + const modalInstance = fixture.componentInstance.open('foo', {backdrop: 'static'}); + fixture.detectChanges(); + + expect(fixture.nativeElement).toHaveModal('foo'); + expect(fixture.nativeElement).toHaveBackdrop(); + + (document.querySelector('plx-modal-window')).click(); + fixture.detectChanges(); + + expect(fixture.nativeElement).toHaveModal(); + expect(fixture.nativeElement).toHaveBackdrop(); + + modalInstance.close(); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + + it('should not dismiss on clicks outside content where there is no backdrop', () => { + const modalInstance = fixture.componentInstance.open('foo', {backdrop: false}); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('foo'); + + (document.querySelector('plx-modal-window')).click(); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal(); + + modalInstance.close(); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + + it('should not dismiss on clicks that result in detached elements', () => { + const modalInstance = fixture.componentInstance.openTplIf({}); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal(); + + (document.querySelector('button#if')).click(); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal(); + + modalInstance.close(); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + }); + + describe('container options', () => { + + it('should attach window and backdrop elements to the specified container', () => { + const modalInstance = fixture.componentInstance.open('foo', {container: '#testContainer'}); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('foo', '#testContainer'); + + modalInstance.close(); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + + it('should throw when the specified container element doesnt exist', () => { + const brokenSelector = '#notInTheDOM'; + expect(() => { + fixture.componentInstance.open('foo', {container: brokenSelector}); + }).toThrowError(`The specified modal container "${brokenSelector}" was not found in the DOM.`); + }); + }); + + describe('keyboard options', () => { + + it('should dismiss modals on ESC by default', () => { + fixture.componentInstance.open('foo').result.catch(NOOP); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('foo'); + + (getDebugNode(document.querySelector('plx-modal-window'))).triggerEventHandler('keyup.esc', {}); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + + it('should not dismiss modals on ESC when keyboard option is false', () => { + const modalInstance = fixture.componentInstance.open('foo', {keyboard: false}); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('foo'); + + (getDebugNode(document.querySelector('plx-modal-window'))).triggerEventHandler('keyup.esc', {}); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal(); + + modalInstance.close(); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + + it('should not dismiss modals on ESC when default is prevented', () => { + const modalInstance = fixture.componentInstance.open('foo', {keyboard: true}); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('foo'); + + (getDebugNode(document.querySelector('plx-modal-window'))).triggerEventHandler('keyup.esc', { + defaultPrevented: true + }); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal(); + + modalInstance.close(); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + }); + + describe('size options', () => { + + it('should render modals with specified size', () => { + const modalInstance = fixture.componentInstance.open('foo', {size: 'sm'}); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('foo'); + expect(document.querySelector('.modal-dialog')).toHaveCssClass('modal-sm'); + + modalInstance.close(); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + + }); + + describe('custom class options', () => { + + it('should render modals with the correct custom classes', () => { + const modalInstance = fixture.componentInstance.open('foo', {windowClass: 'bar'}); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('foo'); + expect(document.querySelector('plx-modal-window')).toHaveCssClass('bar'); + + modalInstance.close(); + fixture.detectChanges(); + expect(fixture.nativeElement).not.toHaveModal(); + }); + + }); + + describe('focus management', () => { + + it('should focus modal window and return focus to previously focused element', () => { + fixture.detectChanges(); + const openButtonEl = fixture.nativeElement.querySelector('button#open'); + + openButtonEl.focus(); + openButtonEl.click(); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('from button'); + expect(document.activeElement).toBe(document.querySelector('plx-modal-window')); + + fixture.componentInstance.close(); + expect(fixture.nativeElement).not.toHaveModal(); + expect(document.activeElement).toBe(openButtonEl); + }); + + + it('should return focus to body if no element focused prior to modal opening', () => { + const modalInstance = fixture.componentInstance.open('foo'); + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveModal('foo'); + expect(document.activeElement).toBe(document.querySelector('plx-modal-window')); + + modalInstance.close('ok!'); + expect(document.activeElement).toBe(document.body); + }); + }); + + describe('window element ordering', () => { + it('should place newer windows on top of older ones', () => { + const modalInstance1 = fixture.componentInstance.open('foo', {windowClass: 'window-1'}); + fixture.detectChanges(); + + const modalInstance2 = fixture.componentInstance.open('bar', {windowClass: 'window-2'}); + fixture.detectChanges(); + + let windows = document.querySelectorAll('plx-modal-window'); + expect(windows.length).toBe(2); + expect(windows[0]).toHaveCssClass('window-1'); + expect(windows[1]).toHaveCssClass('window-2'); + + modalInstance2.close(); + modalInstance1.close(); + fixture.detectChanges(); + }); + }); +}); + +@Component({selector: 'destroyable-cmpt', template: 'Some content'}) +export class DestroyableCmpt implements OnDestroy { + constructor(private _spyService: SpyService) { + } + + ngOnDestroy(): void { + this._spyService.called = true; + } +} + +@Component( + {selector: 'modal-content-cmpt', template: ''}) +export class WithActiveModalCmpt { + constructor(public activeModal: PlxActiveModal) { + } + + close() { + this.activeModal.close('from inside'); + } +} + +@Component({ + selector: 'test-cmpt', + template: ` +
+ + + + + + + ` +}) +class TestComponent { + name = 'World'; + openedModal: PlxModalRef; + show = true; + @ViewChild('content') tplContent; + @ViewChild('destroyableContent') tplDestroyableContent; + @ViewChild('contentWithClose') tplContentWithClose; + @ViewChild('contentWithDismiss') tplContentWithDismiss; + @ViewChild('contentWithIf') tplContentWithIf; + + constructor(private modalService: PlxModal) { + } + + open(content: string, options?: Object) { + this.openedModal = this.modalService.open(content, options); + return this.openedModal; + } + + close() { + if (this.openedModal) { + this.openedModal.close('ok'); + } + } + + openTpl(options?: Object) { + return this.modalService.open(this.tplContent, options); + } + + openCmpt(cmptType: any, options?: Object) { + return this.modalService.open(cmptType, options); + } + + openDestroyableTpl(options?: Object) { + return this.modalService.open(this.tplDestroyableContent, options); + } + + openTplClose(options?: Object) { + return this.modalService.open(this.tplContentWithClose, options); + } + + openTplDismiss(options?: Object) { + return this.modalService.open(this.tplContentWithDismiss, options); + } + + openTplIf(options?: Object) { + return this.modalService.open(this.tplContentWithIf, options); + } +} + +@NgModule({ + declarations: [TestComponent, DestroyableCmpt, WithActiveModalCmpt], + exports: [TestComponent, DestroyableCmpt], + imports: [CommonModule, PlxModalModule.forRoot()], + entryComponents: [DestroyableCmpt, WithActiveModalCmpt], + providers: [SpyService] +}) +class OesModalTestModule { +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal.ts new file mode 100644 index 00000000..5935eee6 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-modal/modal.ts @@ -0,0 +1,54 @@ +import {Injectable, Injector, ComponentFactoryResolver} from '@angular/core'; +import {PlxModalStack} from './modal-stack'; +import {PlxModalRef} from './modal-ref'; + +/** + * Represent options available when opening new modal windows. + */ +export interface PlxModalOptions { + /** + * Whether a backdrop element should be created for a given modal (true by default). + * Alternatively, specify 'static' for a backdrop which doesn't close the modal on click. + */ + backdrop?: boolean | 'static'; + + /** + * An element to which to attach newly opened modal windows. + */ + container?: string; + + /** + * Whether to close the modal when escape key is pressed (true by default). + */ + keyboard?: boolean; + + /** + * Size of a new modal window. + */ + size?: 'sm' | 'lg'; + + /** + * Custom class to append to the modal window + */ + windowClass?: string; +} + +/** + * A service to open modal windows. Creating a modal is straightforward: create a template and pass it as an argument to + * the "open" method! + */ +@Injectable() +export class PlxModal { + constructor(private _moduleCFR: ComponentFactoryResolver, private _injector: Injector, private _modalStack: PlxModalStack) { + } + + /** + * Opens a new modal window with the specified content and using supplied options. Content can be provided + * as a TemplateRef or a component type. If you pass a component type as content than instances of those + * components can be injected with an instance of the PlxActiveModal class. You can use methods on the + * PlxActiveModal class to close / dismiss modals from "inside" of a component. + */ + public open(content: any, options: PlxModalOptions = {}): PlxModalRef { + return this._modalStack.open(this._moduleCFR, this._injector, content, options); + } +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/index.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/index.ts new file mode 100644 index 00000000..c677a944 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/index.ts @@ -0,0 +1,8 @@ +export * from './text-input.component'; +export * from './text-input.module'; +export * from './ipv4-validator.directive'; +export * from './ipv6-validator.directive'; +export * from './max-validator.directive'; +export * from './min-validator.directive'; +export * from './text-input-ip.component'; +export * from './text-input-ip-address.component'; diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/ipv4-validator.directive.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/ipv4-validator.directive.ts new file mode 100644 index 00000000..312ea5f3 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/ipv4-validator.directive.ts @@ -0,0 +1,24 @@ +import {Directive, forwardRef} from '@angular/core'; +import {AbstractControl, NG_VALIDATORS, Validators} from '@angular/forms'; + +@Directive({ + selector: '[ipv4][ngModel],[ipv4][formControl],[ipv4][formControlName]', + providers: [{ + provide: NG_VALIDATORS, + useExisting: forwardRef(() => Ipv4ValidatorDirective), + multi: true + }], +}) + +export class Ipv4ValidatorDirective { + validate(c: AbstractControl) { + if (Validators.required(c) !== undefined && + Validators.required(c) !== null) { + return null; + } + const ipv4Reg = + /^((25[0-5]|2[0-4]\d|[0-1]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[0-1]?\d\d?)$/; + let regex = new RegExp(ipv4Reg); + return regex.test(c.value) ? null : {'ipv4': true}; + } +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/ipv6-validator.directive.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/ipv6-validator.directive.ts new file mode 100644 index 00000000..45182036 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/ipv6-validator.directive.ts @@ -0,0 +1,24 @@ +import {Directive, forwardRef} from '@angular/core'; +import {AbstractControl, NG_VALIDATORS, Validators} from '@angular/forms'; + +@Directive({ + selector: '[ipv6][ngModel],[ipv6][formControl],[ipv6][formControlName]', + providers: [{ + provide: NG_VALIDATORS, + useExisting: forwardRef(() => Ipv6ValidatorDirective), + multi: true + }], +}) + +export class Ipv6ValidatorDirective { + validate(c: AbstractControl) { + if (Validators.required(c) !== undefined && + Validators.required(c) !== null) { + return null; + } + const ipv6Reg = + /^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$/; + let regex = new RegExp(ipv6Reg); + return regex.test(c.value) ? null : {'ipv6': true}; + }4 +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/max-validator.directive.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/max-validator.directive.ts new file mode 100644 index 00000000..143dccc6 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/max-validator.directive.ts @@ -0,0 +1,49 @@ +import {AfterViewInit, Directive, ElementRef, forwardRef, Input} from '@angular/core'; +import {AbstractControl, NG_VALIDATORS, ValidatorFn, Validators} from '@angular/forms'; + +import {NumberWrapperParseFloat} from '../core/number-wrapper-parse'; + +@Directive({ + selector: '[max][ngModel],[max][formControl],[max][formControlName]', + providers: [{ + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MaxValidatorDirective), + multi: true + }], +}) + +export class MaxValidatorDirective implements AfterViewInit { + private _validator: ValidatorFn; + private inputElement: any; + constructor(elementRef: ElementRef) { + this.inputElement = elementRef; + } + ngAfterViewInit() { + this.inputElement = this.inputElement.nativeElement.querySelector('input'); + if (this.inputElement && this.inputElement.querySelector('input')) { + this._validator = max(NumberWrapperParseFloat( + this.inputElement.querySelector('input').getAttribute('max'))); + } + } + @Input() + set max(maxValue: string) { + this._validator = max(NumberWrapperParseFloat(maxValue)); + } + + validate(c: AbstractControl): {[key: string]: any} { + return this._validator(c); + } +} + +function max(maxvalue: number): ValidatorFn { + return (control: AbstractControl): {[key: string]: any} => { + if (Validators.required(control) !== undefined && + Validators.required(control) !== null) { + return null; + } + let v: Number = Number(control.value); + return v > maxvalue ? + {'max': {'requiredValue': maxvalue, 'actualValue': v}} : + null; + }; +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/min-validator.directive.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/min-validator.directive.ts new file mode 100644 index 00000000..260a93ed --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/min-validator.directive.ts @@ -0,0 +1,49 @@ +import {AfterViewInit, Directive, ElementRef, forwardRef, Input} from '@angular/core'; +import {AbstractControl, NG_VALIDATORS, ValidatorFn, Validators} from '@angular/forms'; + +import {NumberWrapperParseFloat} from '../core/number-wrapper-parse'; + +@Directive({ + selector: '[min][ngModel],[min][formControl],[min][formControlName]', + providers: [{ + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MinValidatorDirective), + multi: true + }], +}) + +export class MinValidatorDirective implements AfterViewInit { + private _validator: ValidatorFn; + private inputElement: any; + constructor(elementRef: ElementRef) { + this.inputElement = elementRef; + } + ngAfterViewInit() { + this.inputElement = this.inputElement.nativeElement.querySelector('input'); + if (this.inputElement && this.inputElement.querySelector('input')) { + this._validator = min(NumberWrapperParseFloat( + this.inputElement.querySelector('input').getAttribute('min'))); + } + } + @Input() + set min(minValue: string) { + this._validator = min(NumberWrapperParseFloat(minValue)); + } + + validate(c: AbstractControl): {[key: string]: any} { + return this._validator(c); + } +} + +function min(minvalue: number): ValidatorFn { + return (control: AbstractControl): {[key: string]: any} => { + if (Validators.required(control) !== undefined && + Validators.required(control) !== null) { + return null; + } + let v: Number = Number(control.value); + return v < minvalue ? + {'min': {'requiredValue': minvalue, 'actualValue': v}} : + null; + }; +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/text-input-ip-address.component.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/text-input-ip-address.component.ts new file mode 100644 index 00000000..501d2326 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/text-input-ip-address.component.ts @@ -0,0 +1,170 @@ +import {Component, EventEmitter, forwardRef, Input, OnInit, Output} from '@angular/core'; +import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; +import {BooleanFieldValue} from '../core/boolean-field-value'; + +const noop = () => {}; + +export const PX_TEXT_INPUT_IP_ADDRESS_CONTROL_VALUE_ACCESSOR: any = { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => PlxTextInputIpAddressComponent), + multi: true +}; +@Component({ + selector: 'plx-text-input-ip-address', + template: ` +
+ +
{{errMsg}}
+
+ `, + styleUrls: ['text-input.less'], + host: {'style': 'display: inline-block;'}, + providers: [PX_TEXT_INPUT_IP_ADDRESS_CONTROL_VALUE_ACCESSOR] +}) + +export class PlxTextInputIpAddressComponent implements OnInit, ControlValueAccessor { + @Input() lang: string = 'zh'; //zh|en + @Input() size: string; //空代表普通尺寸,sm代表小尺寸 + @Input() ipAddressCheckTip: string = ''; // + + @Input() @BooleanFieldValue() required: boolean = false; + + @Input() public ipValue: string; + @Output() public ipValueChange: EventEmitter = new EventEmitter(); + + @Input() public ipValueFlg : boolean; + @Output() public ipValueFlgChange: EventEmitter = new EventEmitter(); + + private isNull : boolean = true; + + /** Callback registered via registerOnTouched (ControlValueAccessor) */ + private _onTouchedCallback: () => void = noop; + /** Callback registered via registerOnChange (ControlValueAccessor) */ + private _onChangeCallback: (_: any) => void = noop; + + public errMsgs = { + 'zh': { + 'empty': '此项不能为空', + 'invalidate': 'IP格式不对', + 'range': '请输入正确的IPV4地址或IPV6地址', + 'range-IPV4': '请输入正确的IPV4', + 'range-IPV6': '请输入正确的IPV6' + }, + 'en': { + 'empty': 'IP can not be empty', + 'invalidate': 'IP format is incorrect', + 'range': 'IP range is IPV4 or IPV6', + 'range-IPV4': 'IP range is IPV4', + 'range-IPV6': 'IP range is IPV6' + } + }; + public errMsg: string; + public sizeClass: string; + + constructor() { + } + + ngOnInit(): void { + if (this.size === 'sm') { + this.sizeClass = 'plx-input-sm'; + } + this.isNull = this.ipValueFlg; + if(this.repIPStr(this.ipValue) === ''&& !this.ipValueFlg){ + this.ipValueFlg = false; + this.emitValue(); + } + } + + public keyUp(event: any): void { + this.setValueToOutside(this.validate()); + this.emitValue(); + } + + public paste(event: any): void{ + setTimeout(() => { + this.ipValue = event.target.value; + this.setValueToOutside(this.validate()); + this.emitValue(); + }, 0); + } + + private emitValue(){ + this.ipValueChange.emit(this.ipValue); + this.ipValueFlgChange.emit(this.ipValueFlg); + } + + private setValueToOutside(validateFlg: boolean): void { + this.ipValueFlg = validateFlg; + let value; + if (validateFlg) { + if (this.ipValue) { + value = this.ipValue; + } + if(this.ipValue === "" && !this.isNull){ + this.ipValueFlg = false; + } + } else { + value = false; + } + this._onChangeCallback(value); + } + + writeValue(value: any): void { + // + this.errMsg = ''; + this.ipValue = value; + if (value) { + this.validate(); + } + } + + registerOnChange(fn: any) { + this._onChangeCallback = fn; + } + + registerOnTouched(fn: any) { + this._onTouchedCallback = fn; + } + + public validate(): boolean { + this.errMsg = ''; + if (this.required) { + if (!this.ipValue) { + this.errMsg = this.errMsgs[this.lang]['empty']; + return false; + } + } + if ((this.ipValue) && (!this.ipValue)) { + this.errMsg = this.errMsgs[this.lang]['invalidate']; + return false; + } + let blackStr = this.repIPStr(this.ipValue); + if(this.ipAddressCheckTip === ''){ + if(this.ipValue !== '' && blackStr === ''){ + this.errMsg = this.errMsgs[this.lang]['range']; + return false; + } + }else{ + if(this.ipValue !== '' && this.ipAddressCheckTip !== blackStr) { + this.errMsg = this.errMsgs[this.lang]['range-'+ this.ipAddressCheckTip]; + return false; + } + } + return true; + } + + private repIPStr(value: any): string { + let blackStr = ''; + var regip4 = /^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$/; + if (regip4.test(value)) { + return "IPV4"; + } + var regip6 = /^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$/; + if (regip6.test(value)) { + return "IPV6"; + } + return blackStr; + } +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/text-input-ip.component.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/text-input-ip.component.ts new file mode 100644 index 00000000..7c9d616d --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/text-input-ip.component.ts @@ -0,0 +1,189 @@ +import {Component, forwardRef, Input, OnInit} from '@angular/core'; +import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; + +import {BooleanFieldValue} from '../core/boolean-field-value'; + +const noop = () => {}; + +export const PX_TEXT_INPUT_IP_CONTROL_VALUE_ACCESSOR: any = { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => PlxTextInputIpComponent), + multi: true +}; + +@Component({ + selector: 'plx-text-input-ip', + template: ` +
+ + . + + . + + . + +
{{errMsg}}
+
+ `, + styleUrls: ['text-input.less'], + host: {'style': 'display: inline-block;'}, + providers: [PX_TEXT_INPUT_IP_CONTROL_VALUE_ACCESSOR] +}) + +export class PlxTextInputIpComponent implements OnInit, ControlValueAccessor { + @Input() lang: string = 'zh'; //zh|en + @Input() size: string; //空代表普通尺寸,sm代表小尺寸 + @Input() @BooleanFieldValue() required: boolean = false; + /** Callback registered via registerOnTouched (ControlValueAccessor) */ + private _onTouchedCallback: () => void = noop; + /** Callback registered via registerOnChange (ControlValueAccessor) */ + private _onChangeCallback: (_: any) => void = noop; + public ipValue1: number; + public ipValue2: number; + public ipValue3: number; + public ipValue4: number; + public errMsgs = { + 'zh': { + 'empty': 'IP不能为空', + 'invalidate': '非法IP', + 'range': 'IP范围[0.0.0.0]~[255.255.255.255]' + }, + 'en': { + 'empty': 'IP can not be empty', + 'invalidate': 'Invalid IP', + 'range': 'IP range is [0.0.0.0]~[255.255.255.255]' + } + }; + public errMsg: string; + + constructor() { + } + + ngOnInit(): void { + } + + public change(event: any) :void { + event.target.value = this.repNumber(event.target.value); + this.setValueToOutside(this.validate()); + } + + public keyup(event: any, frontElement: any, backElement: any): void { + event.target.value = this.repNumber(event.target.value); + if (((event.keyCode === 13 || event.keyCode === 110 || event.keyCode === 190) + && event.target.value.length !== 0) + || event.target.value.length === 3) { //enter:13,dot:110、190 + if (event.keyCode !== 9) { //tab:9 + if (backElement) { + backElement.autoFocus = true; + backElement.inputViewChild.nativeElement.select(); + } else { + if (event.keyCode !== 110 && event.keyCode !== 190) { + event.target.blur(); + } + } + } + } + + if (event.target.value.length === 0 // backspace:8 delete:46 + && (event.keyCode === 8 || event.keyCode === 46)) { + if (frontElement) { + frontElement.autoFocus = true; + frontElement.inputViewChild.nativeElement.select(); + } + } + + this.setValueToOutside(this.validate()); + } + + private setValueToOutside(validateFlg: boolean): void { + let value; + if (validateFlg) { + if (this.ipValue1 && this.ipValue2 && this.ipValue3 && this.ipValue4) { + value = this.ipValue1 + '.' + + this.ipValue2 + '.'+ this.ipValue3 + '.'+ this.ipValue4; + } + } else { + value = false; + } + this._onChangeCallback(value); + } + + writeValue(value: any): void { + // + this.errMsg = ''; + if (value) { + if (this.isIPStr(value)) { + let ipArr = value.split('.'); + this.ipValue1 = ipArr[0]; + this.ipValue2 = ipArr[1]; + this.ipValue3 = ipArr[2]; + this.ipValue4 = ipArr[3]; + } else { + this.errMsg = this.errMsgs[this.lang]['invalidate'] + ' : ' + value; + } + } + } + + registerOnChange(fn: any) { + this._onChangeCallback = fn; + } + + registerOnTouched(fn: any) { + this._onTouchedCallback = fn; + } + + public validate(): boolean { + this.errMsg = ''; + if (this.required) { + if (!this.ipValue1 && !this.ipValue2 && !this.ipValue3 && !this.ipValue4) { + this.errMsg = this.errMsgs[this.lang]['empty']; + return false; + } + } + if ((this.ipValue1 || this.ipValue2 || this.ipValue3 || this.ipValue4) + && (!this.ipValue1 || !this.ipValue2 || !this.ipValue3 || !this.ipValue4)) { + this.errMsg = this.errMsgs[this.lang]['invalidate']; + return false; + } + if ((this.ipValue1 && (this.ipValue1 < 0 || this.ipValue1 > 255)) + || (this.ipValue2 && (this.ipValue2 < 0 || this.ipValue2 > 255)) + || (this.ipValue3 && (this.ipValue3 < 0 || this.ipValue3 > 255)) + || (this.ipValue4 && (this.ipValue4 < 0 || this.ipValue4 > 255))) { + this.errMsg = this.errMsgs[this.lang]['range']; + return false; + } + + return true; + } + + private repNumber(value: any): number { + var reg = /^[\d]+$/g; + if (!reg.test(value)) { + let txt = value; + txt.replace(/[^0-9]+/, function (char, index, val) { //匹配第一次非数字字符 + value = val.replace(/\D/g, ""); //将非数字字符替换成"" + }) + } + return value; + } + + private isIPStr(value: any): boolean { + var regip4 = /^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$/; + if (regip4.test(value)) { + return true; + } + return false; + } +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/text-input.component.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/text-input.component.ts new file mode 100644 index 00000000..9b5a01e9 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/text-input.component.ts @@ -0,0 +1,765 @@ +import {AfterContentInit, Component, ElementRef, EventEmitter, forwardRef, HostBinding, HostListener, Input, OnInit, Output, Renderer2, ViewChild} from '@angular/core'; +import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; +import {Observable} from 'rxjs/Observable'; + +import {BooleanFieldValue} from '../core/boolean-field-value'; +import {NumberWrapperParseFloat} from '../core/number-wrapper-parse'; +import {UUID} from '../core/uuid'; + +const noop = () => {}; + +export const PX_TEXT_INPUT_CONTROL_VALUE_ACCESSOR: any = { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => PlxTextInputComponent), + multi: true +}; + +@Component({ + selector: 'plx-text-input', + templateUrl: 'text-input.html', + styleUrls: ['text-input.less'], + host: {'style': 'display: inline-block;'}, + providers: [PX_TEXT_INPUT_CONTROL_VALUE_ACCESSOR] +}) + +export class PlxTextInputComponent implements ControlValueAccessor, OnInit, + AfterContentInit { + private _focused: boolean = false; + private _value: any = ''; + /** Callback registered via registerOnTouched (ControlValueAccessor) */ + private _onTouchedCallback: () => void = noop; + /** Callback registered via registerOnChange (ControlValueAccessor) */ + private _onChangeCallback: (_: any) => void = noop; + + /** Readonly properties. */ + get empty() { + return this._value === null || this._value === ''; + } + get inputId(): string { + return `${this.id}`; + } + get isShowHintLabel() { + return this._focused && this.hintLabel !== null; + } + get inputType() { + return this.type === 'number' ? 'text' : this.type; + } + + @Input() id: string = `plx-input-${UUID.UUID()}`; + @Input() name: string = null; + @Input() hintLabel: string = null; + @Input() lang: string = 'zh'; + @Input() @BooleanFieldValue() disabled: boolean = false; + + @Input() numberShowSpinner = true; + @Input() max: string|number = null; + @Input() maxLength: number = 64; + @Input() min: string|number = null; + @Input() minLength: number = null; + @Input() placeholder: string = ''; + @Input() @BooleanFieldValue() readOnly: boolean = false; + @Input() @BooleanFieldValue() required: boolean = false; + @Input() @BooleanFieldValue() notShowOption: boolean = true; + @Input() type: string = 'text'; + @Input() tabIndex: number = null; + @Input() pattern: string = null; + + @Input() @BooleanFieldValue() shortInput: boolean = false; + @Input() unit: string = null; + @Input() unitOptions: string[] = null; + @Input() prefix: string = null; + @Input() suffixList: string[] = null; + + // @Input() precision: number = 0; + @Input() step: number = 1; + @Input() width: string = '400px'; + @Input() unitWidth: string = '45px'; + @Input() unitOptionWidth: string = '84px'; + @Input() prefixWidth: string = '70px'; + @Input() historyList: string[]; + + @Input() prefixOptions: string[] = []; + @Input() prefixOptionWidth: string = '84px'; + + @Input() @BooleanFieldValue() passwordSwitch: boolean = false; + + @ViewChild('input') inputViewChild: ElementRef; + @ViewChild('inputOutter') pxTextInputElement: ElementRef; + + @HostBinding('class.input-invalid') selectClass: boolean = true; + + isDisabledUp: boolean = false; + isDisabledDown: boolean = false; + displayDataList: string[]; + currentPrecision: number = 0; + keyPattern: RegExp = /[0-9\-]/; + langPattern: RegExp = + /[a-zA-Z]|[\u4e00-\u9fa5]|[\uff08\uff09\u300a\u300b\u2014\u2014\uff1a\uff1b\uff0c\u201c\u201d\u2018\u2019\+\=\{\}\u3001\u3002\u3010\u3011\<\>\uff01\uff1f\/\|]/g; + timer: any; + optionalLabel: string = null; + hasSelection = false; + _precision: number = 0; + displayValue: any; + + isOpenDataList: boolean = false; + dataListClicked: boolean = false; + isOpenSuffixList: boolean = false; + suffixListClicked: boolean = false; + + showUnit: string; + isShowUnitOption: boolean = false; + unitOptionClicked: boolean = false; + + prefixOptionClicked: boolean = false; + isShowPrefixOption = false; + showPrefix: string; + showPassword: boolean = false; + tooltipText: string; + tooltipTexts = { + 'zh': { + 'true': '隐藏', + 'false': '显示', + }, + 'en': { + 'true': 'hidden', + 'false': 'show', + } + }; + isPwdSwithHover: boolean = false; + isPwdSwithClick: boolean = false; + + _autoFocus: boolean = false; + @Input() + set autoFocus(value: boolean) { + this._autoFocus = value; + + const that = this; + if (this._autoFocus) { + setTimeout(() => { + that.inputViewChild.nativeElement.focus(); + }, 0); + } + } + get autoFocus() { + return this._autoFocus; + } + + @Input() + set precision(value: string) { + this._precision = parseInt(value); + } + get precision() { + return this._value; + } + + get inputWidth() { + if (this.prefixOptions && this.prefixOptions.length > 0) { + if (this.unitOptions && this.unitOptions.length > 0) { + return `calc(${this.width} - ${this.prefixOptionWidth} - ${this.unitOptionWidth})`; + } else if (this.unit !== null) { + return `calc(${this.width} - ${this.prefixOptionWidth} - ${this.unitWidth})`; + } else { + return `calc(${this.width} - ${this.prefixOptionWidth})`; + } + } + + if (this.unit !== null && this.prefix !== null) { + return `calc(${this.width} - ${this.unitWidth} - ${this.prefixWidth})`; + } + + if (this.unit !== null) { + return `calc(${this.width} - ${this.unitWidth})`; + } + + if (!!this.unitOptions && this.unitOptions.length !== 0 && + this.prefix !== null) { + return `calc(${this.width} - ${this.unitOptionWidth} - ${ + this.prefixWidth + })`; + } + + if (!!this.unitOptions && this.unitOptions.length !== 0) { + return `calc(${this.width} - ${this.unitOptionWidth})`; + } + if (this.prefix !== null) { + return `calc(${this.width} - ${this.prefixWidth})`; + } + return this.width; + } + + + get hasUnit() { + return this.unit !== null; + } + get hasUnitOption() { + return this.showUnit !== undefined; + } + get hasPrefix() { + return this.prefix !== null; + } + get hasPrefixOption() { + return this.prefixOptions && this.prefixOptions.length > 0; + } + get isFocus() { + return this._focused; + } + + private _blurEmitter: EventEmitter = + new EventEmitter(); + private _focusEmitter: EventEmitter = + new EventEmitter(); + private click = new EventEmitter(); + private unitChange = new EventEmitter(); + @Output() public prefixChange = new EventEmitter(); + + @Output('blur') + get onBlur(): Observable { + return this._blurEmitter.asObservable(); + } + + @Output('focus') + get onFocus(): Observable { + return this._focusEmitter.asObservable(); + } + + @HostListener('focus') + onHostFocus() { + this.renderer.addClass(this.el.nativeElement, 'input-focus'); + this.renderer.removeClass(this.el.nativeElement, 'input-blur'); + } + + @HostListener('blur') + onHostBlur() { + this.renderer.addClass(this.el.nativeElement, 'input-blur'); + this.renderer.removeClass(this.el.nativeElement, 'input-focus'); + } + + @Input() + set value(v: any) { + v = this.filterZhChar(v); + v = this._convertValueForInputType(v); + if (v !== this._value) { + this._value = v; + if (this.type === 'number') { + if (this._value === '') { + this._onChangeCallback(null); + } else if (isNaN(this._value)) { + this._onChangeCallback(this._value); + } else { + this._onChangeCallback(NumberWrapperParseFloat(this._value)); + } + } else { + this._onChangeCallback(this._value); + } + } + } + get value(): any { + return this._value; + } + + constructor( + private el: ElementRef, private renderer: Renderer2) {} + + ngOnInit() { + if (this.shortInput) { + this.width = '120px'; + this.unitWidth = '40px'; + this.prefixWidth = '40px'; + } + this.translateLabel(); + + if (!!this.unitOptions && this.unitOptions.length !== 0) { + this.showUnit = this.unitOptions[0]; + } + + if (!!this.prefixOptions && this.prefixOptions.length !== 0) { + this.showPrefix = this.prefixOptions[0]; + } + } + + ngAfterContentInit() { + if (this.pxTextInputElement) { + Array.from(this.pxTextInputElement.nativeElement.childNodes) + .forEach((node: HTMLElement) => { + if (node.nodeType === 3) { + this.pxTextInputElement.nativeElement.removeChild(node); + } + }); + } + } + private translateLabel() { + if (this.lang === 'zh') { + this.optionalLabel = '(可选)'; + } else { + this.optionalLabel = '(Optional)'; + } + } + + _handleFocus(event: FocusEvent) { + this._focused = true; + this._focusEmitter.emit(event); + } + + _handleBlur(event: FocusEvent) { + this._focused = false; + this._onTouchedCallback(); + this._blurEmitter.emit(event); + } + + _checkValueLimit(value: any) { + if (this.type === 'number') { + if ((value === '' || value === undefined || value === null) && + !this.required) { + return ''; + } else if ( + this.min !== null && + NumberWrapperParseFloat(value) < NumberWrapperParseFloat(this.min)) { + return this.min; + } else if ( + this.max !== null && + NumberWrapperParseFloat(value) > NumberWrapperParseFloat(this.max)) { + return this.max; + } else { + return value; + } + } + return value; + } + + _checkValue() { + this.value = this._checkValueLimit(this.value); + this.displayValue = this.value; + } + + _handleChange(event: Event) { + this.value = (event.target).value; + this._onTouchedCallback(); + } + + openDataList() { + this.dataListClicked = true; + if (this.historyList) { + if (this.value) { + this.filterOption(this.value); + } else { + this.displayDataList = this.historyList; + if (!this.isOpenDataList) { + this.isOpenDataList = true; + } + } + } + } + + _handleClick(event: Event) { + if (this.isShowUnitOption) { + this.isShowUnitOption = false; + } + this.click.emit(event); + + if (this.historyList) { + this.openDataList(); + } + } + + _handleSelect(event: Event) { // 输入框文本被选中时处理 + if (!this.hasSelection) { + this.hasSelection = true; + } + } + deleteSelection() { // 删除选中文本, + document.getSelection().deleteFromDocument(); + this.hasSelection = false; + } + + _onWindowClick(event: Event) { + if (this.historyList) { + if (!this.dataListClicked) { + this.isOpenDataList = false; + } + this.dataListClicked = false; + } + + if (this.suffixList) { + if (!this.suffixListClicked) { + this.isOpenSuffixList = false; + } + this.suffixListClicked = false; + } + + if (this.unitOptions) { + if (!this.unitOptionClicked) { + this.isShowUnitOption = false; + } + this.unitOptionClicked = false; + } + + if (this.prefixOptions) { + if (!this.prefixOptionClicked) { + this.isShowPrefixOption = false; + } + this.prefixOptionClicked = false; + } + } + + _showPrefixOption(event: Event) { + this.isShowPrefixOption = !this.isShowPrefixOption; + this.prefixOptionClicked = true; + } + + _showUnitOption(event: Event) { + this.isShowUnitOption = !this.isShowUnitOption; + this.unitOptionClicked = true; + } + + _setUnit(unitValue: string) { + this.unitOptionClicked = true; + this.showUnit = unitValue; + this.unitChange.emit(unitValue); + this.isShowUnitOption = false; + } + + _setPrefix(value: string) { + if (value !== this.showPrefix) { + this.showPrefix = value; + this.prefixChange.emit(value); + } + this.prefixOptionClicked = true; + this.isShowPrefixOption = false; + } + + filterOption(value: any) { + this.displayDataList = []; + this.displayDataList = this.historyList.filter((data: string) => { + return data.toLowerCase().indexOf(value.toLowerCase()) > -1; + }); + + this.isOpenDataList = this.displayDataList.length !== 0; + } + + concatValueAndSuffix(value: string) { + const that = this; + that.displayDataList = []; + let mark = '@'; + if (value === '') { + that.displayDataList = []; + } else if (value.trim().toLowerCase().indexOf(mark) === -1) { + that.displayDataList.push(value); + that.suffixList.map((item: string) => { + let tempValue = value + mark + item; + that.displayDataList.push(tempValue); + }); + } else { + that.suffixList.map((item: string) => { + let tempValue = value.split(mark)[0] + mark + item; + that.displayDataList.push(tempValue); + }); + that.displayDataList = that.displayDataList.filter((item: string) => { + return item.trim().toLowerCase().indexOf(value) > -1; + }); + } + + that.isOpenSuffixList = that.displayDataList.length !== 0; + } + + _handleInput(event: any) { + let inputValue = event.target.value.trim().toLowerCase(); + + if (this.historyList) { + this.filterOption(inputValue); + } + + if (this.suffixList) { + this.concatValueAndSuffix(inputValue); + } + } + + chooseInputData(data: any) { + this.displayValue = data; + this.value = data; + + this.isOpenDataList = false; + this.dataListClicked = true; + this.isOpenSuffixList = false; + this.suffixListClicked = true; + } + + writeValue(value: any) { + this.displayValue = this._checkValueLimit(value); + this._value = this.displayValue; + } + + registerOnChange(fn: any) { + this._onChangeCallback = fn; + } + + registerOnTouched(fn: any) { + this._onTouchedCallback = fn; + } + + private getConvertValue(v: any) { + this.currentPrecision = v.toString().split('.')[1].length; + if (this.currentPrecision === 0) { // 输入小数点,但小数位数为0时 + return v; + } + if (this.currentPrecision < + this._precision) { // 输入小数点,且小数位数不为0 + return this.toFixed(v, this.currentPrecision); + } else { + return this.toFixed(v, this._precision); + } + } + + private filterZhChar(v: any) { + if (this.type === 'number') { + let reg = /[^0-9.-]/; + while (reg.test(v)) { + v = v.replace(reg, ''); + } + } + return v; + } + + private _convertValueForInputType(v: any): any { + switch (this.type) { + case 'number': { + if (v === '' || v === '-') { + return v; + } + + if (v.toString().indexOf('.') === -1) { // 整数 + return this.toFixed(v, 0); + } else { + return this.getConvertValue(v); + } + } + default: + return v; + } + } + + private toFixed(value: number, precision: number) { + return Number(value).toFixed(precision); + } + + repeat(interval: number, dir: number) { + let i = interval || 500; + + this.clearTimer(); + this.timer = setTimeout(() => { + this.repeat(40, dir); + }, i); + + this.spin(dir); + } + + clearTimer() { + if (this.timer) { + clearInterval(this.timer); + } + } + + spin(dir: number) { + let step = this.step * dir; + let currentValue = this._convertValueForInputType(this.value) || 0; + + this.value = Number(currentValue) + step; + + if (this.maxLength !== null && + this.value.toString().length > this.maxLength) { + this.value = currentValue; + } + + if (this.min !== null && this.value <= NumberWrapperParseFloat(this.min)) { + this.value = this.min; + this.isDisabledDown = true; + } + + if (this.max !== null && this.value >= NumberWrapperParseFloat(this.max)) { + this.value = this.max; + this.isDisabledUp = true; + } + this.displayValue = this.value; + this._onChangeCallback(NumberWrapperParseFloat(this.value)); + } + + onUpButtonMousedown(event: Event, input: HTMLInputElement) { + if (!this.disabled && this.type === 'number') { + input.focus(); + this.repeat(null, 1); + event.preventDefault(); + } + } + + onUpButtonMouseup(event: Event) { + if (!this.disabled) { + this.clearTimer(); + } + } + + onUpButtonMouseleave(event: Event) { + if (!this.disabled) { + this.clearTimer(); + } + } + + onDownButtonMousedown(event: Event, input: HTMLInputElement) { + if (!this.disabled && this.type === 'number') { + input.focus(); + this.repeat(null, -1); + event.preventDefault(); + } + } + + onDownButtonMouseup(event: Event) { + if (!this.disabled) { + this.clearTimer(); + } + } + + onDownButtonMouseleave(event: Event) { + if (!this.disabled) { + this.clearTimer(); + } + } + + onInputKeydown(event: KeyboardEvent) { + if (this.type === 'number') { + if (event.which === 229) { + event.preventDefault(); + return; + } else if (event.which === 38) { + this.spin(1); + event.preventDefault(); + } else if (event.which === 40) { + this.spin(-1); + event.preventDefault(); + } + } + } + onInputKeyPress(event: KeyboardEvent) { + let inputChar = String.fromCharCode(event.charCode); + if (this.type === 'number') { + if (event.which === 8) { + return; + } + + // if (!this.isValueLimit()) { + // this.handleSelection(event); + // } + + if (inputChar === '-' && this.min !== null && + NumberWrapperParseFloat(this.min) >= 0) { + event.preventDefault(); + return; + } + if (this.isIllegalNumberInputChar(event) || + this.isIllegalIntergerInput(inputChar)) { + event.preventDefault(); + return; + } + if (this.isIllegalFloatInput( + inputChar)) { // 当该函数返回true时,执行两种情景 + this.handleSelection(event); + } + if (this.hasSelection) { // 文本被选中,执行文本替换 + this.deleteSelection(); + } + } + } + + private handleSelection(event: any) { + if (this.hasSelection) { // 文本被选中,执行文本替换 + this.deleteSelection(); + } else { // 无选中文本,阻止非法输入 + event.preventDefault(); + } + } + // private isValueLimit() { + // if (this.min !== null && NumberWrapperParseFloat(this.value) !== 0 && + // this.value <= NumberWrapperParseFloat(this.min)) { + // return false; + // } + // if (this.max !== null && NumberWrapperParseFloat(this.value) !== 0 && + // this.value >= NumberWrapperParseFloat(this.max)) { + // return false; + // } + // return true; + // } + + private isIllegalNumberInputChar(event: KeyboardEvent) { + /* 8:backspace, 46:. */ + return !this.keyPattern.test(String.fromCharCode(event.charCode)) && + event.which !== 46 && event.which !== 0; + } + + private isIllegalIntergerInput(inputChar: string) { + return this._precision === 0 && + (inputChar === '.' || + (this._value && this._value && this._value.toString().length > 0 && + inputChar === '-')); + } + + private isIllegalFloatInput(inputChar: string) { + return this._precision > 0 && this._value && + ((this._value.toString().length > 0 && inputChar === '-') || + ((this._value.toString() === '' || + this._value.toString().indexOf('.') > 0) && + inputChar === '.') || + (this._value.toString().indexOf('.') > 0 && + this._value.toString().split('.')[1].length === this._precision)); + } + + onInput(event: Event, inputValue: string) { + this.value = inputValue; + } + + //处理鼠标经过上下箭头时,样式设置 + isEmptyValue() { + if (this.value === undefined || this.value === null || this.value === '') { + return true; + } + return false; + } + + isDisabledUpCaret() { + if (this.isEmptyValue()) { + return true; + } else if ( + this.max !== null && + (NumberWrapperParseFloat(this.value) >= + NumberWrapperParseFloat(this.max))) { + return true; + } + return false; + } + + isDisabledDownCaret() { + if (this.isEmptyValue()) { + return true; + } else if ( + this.min !== null && + (NumberWrapperParseFloat(this.value) <= + NumberWrapperParseFloat(this.min))) { + return true; + } + return false; + } + + _handleMouseEnterUp() { + this.isDisabledUp = this.isDisabledUpCaret(); + } + + _handleMouseEnterDown() { + this.isDisabledDown = this.isDisabledDownCaret(); + } + + public switch(): void { + this.showPassword = !this.showPassword; + this.showPassword?this.inputViewChild.nativeElement.type = + 'text':this.inputViewChild.nativeElement.type = 'password'; + } + + private setPasswordTooltip(): void { + this.tooltipTexts[this.lang][this.showPassword.toString()] + } +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/text-input.html b/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/text-input.html new file mode 100644 index 00000000..9065badd --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/text-input.html @@ -0,0 +1,69 @@ +
+
{{showPrefix}}
+
+
  • {{option}}
  • +
    +
    {{prefix}}
    + + + + + + + + +
    {{unit}}
    + +
    {{showUnit}}
    +
    +
  • {{option}}
  • +
    +
    {{optionalLabel}}
    +
    {{hintLabel}}
    +
    +
  • {{data}}
  • +
    + +
    diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/text-input.less b/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/text-input.less new file mode 100644 index 00000000..6a93c1c1 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/text-input.less @@ -0,0 +1,423 @@ +@import "../../assets/components/themes/default/theme.less"; +@import "../../assets/components/themes/common/plx-input.less"; + +@input-short-width: 120px; +@padding-left: 10px; +@padding: 10px; +@border-width: 1px; +@unit-span-width: 45px; +@unit-option-width: 84px; +@short-unit-span-width: 40px; +@prefix-span-width: 70px; +@prefix-option-width: 84px; +@short-prefix-span-width: 40px; +@password-switch: 40px; + +.font { + font-family: @font-family; + font-size: @font-size; +} + +.text-input { + //height: @input-height; + //position: relative; + //margin-bottom: 0; + display: inline-block; + + .caret-down { + display: block; + width: 0; + height: 0; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 4px solid lighten(@fonticon-color, 5%); + margin-top: 4px; + margin-bottom: 10px; + + &.caret-down-hover:hover, &.caret-down-hover:active { + border-top: 4px solid @primary-color; + } + } + .caret-up { + display: block; + width: 0; + height: 0; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-bottom: 4px solid lighten(@fonticon-color, 5%); + margin-top: 10px; + + &.caret-up-hover:hover, &.caret-up-hover:active { + border-bottom: 4px solid @primary-color; + } + } + .toggle { + float: right; + margin-right: 10px; + margin-top: 14px; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 4px solid lighten(@fonticon-color, 5%); + } + .text-input-dataList { + margin-top: 2px; + position: absolute; + z-index: @z-index-dropdown; + border: 1px solid @gray-grade-7; + background: #fff; + cursor: pointer; + border-radius: @radius; + li { + list-style: none; + height: @input-height; + width: @input-width; + padding-left: @padding-left; + &:hover { + background-color: @hover-bg-color; + } + } + } +} + +.input-span { + display: inline-block; + overflow: visible; + padding: 0; + position: relative; +} + +.text-input-with-hint { + margin-bottom: -8px; + :host(.ng-touched.ng-invalid.input-blur) & { + height: auto; + margin-bottom: 0; + } +} + +.plx-text-input-unit-group, .plx-text-input-prefix-group { + position: absolute; + margin-top: @overlay-margin-top; + width: @unit-option-width; + z-index: @z-index-dropdown; + border-radius: @radius; + background: @component-bg; + border: 1px solid @border-color-base; + .shadow; + cursor: pointer; + li { + padding-left: 10px; + height: @input-height; + list-style: none; + line-height: @input-height; + font-size: @font-size; + &:hover { + background-color: @hover-bg-color; + } + &.group-selected, &.group-selected:hover { + background-color: @selected-bg-color; + color: @text-color; + } + } +} + +.text-input-optional { + display: inline-block; + margin-right: 6px; + padding-left:5px; +} + +.input-right-border .plx-input { + border-right: 1px solid @primary-color; +} + +.input-left-border .plx-input { + border-left: 1px solid @primary-color; +} + +.text-input-hint { + top: 42px; + left: 10px; + font-family: @font-family; + font-size: @font-size; + color: @disabled-text-color; + :host(.ng-touched.ng-invalid.input-blur) & { + display: none; + } +} + +.text-input-prefix { + .font; + display: inline-block; + width: @prefix-span-width; + height: @input-height; + text-align: center; + line-height: @input-height; + border-top-left-radius: @radius; + border-bottom-left-radius: @radius; + color: @disabled-text-color; + border: 1px solid @border-color-base; + border-right: 0; + vertical-align: middle; + .short-text-input & { + width: @short-prefix-span-width; + } + .input-span-focus & { + border-color: @primary-color; + } + .input-invalid.ng-dirty.ng-invalid.ng-touched.input-blur &, + .input-invalid.ng-dirty.ng-invalid.ng-touched.input-blur .input-span-focus:focus & { + border-color: @error-color; + } +} + +.input-unit { + .font; + display: inline-block; + height: @input-height; + text-align: center; + line-height: @input-height; + border-top-right-radius: @radius; + border-bottom-right-radius: @radius; +} + +.text-input-unit { + border: 1px solid @border-color-base; + border-left: 0; + .input-unit; + color: @disabled-text-color; + width: @unit-span-width; + text-align: center; + vertical-align: middle; + .short-text-input & { + width: @short-unit-span-width; + } + .input-span-focus & { + border-color: @primary-color; + } +} + +.text-input-prefix-option { + .font; + display: inline-block; + height: @input-height; + text-align: center; + line-height: @input-height; + border-top-left-radius: @radius; + border-bottom-left-radius: @radius; + width: @prefix-option-width; + text-align: left; + padding-left: 10px; + cursor: pointer; + border: 1px solid @border-color-base;; + border-right: 0; + vertical-align: middle; + &.prefix-focus { + border-color: @primary-color; + } +} + +.text-input-unit-option { + &:extend(.input-unit); + width: @unit-option-width; + text-align: left; + padding-left: 10px; + cursor: pointer; + border: 1px solid @border-color-base;; + border-left: 0; + vertical-align: middle; + .input-span-focus & { + border-color: @primary-color; + } +} + +.text-input-with-unitOption { + div.unit-focus { + border-color: @primary-color; + } +} + +.plx-input { + .short-text-input & { + width: @input-short-width; + } + + .text-input-with-unit & { + width: @input-width - @unit-span-width; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + .text-input-with-unitOption & { + width: @input-width - @unit-option-width; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + .text-input-with-prefix & { + width: @input-width - @prefix-span-width; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + .text-input-with-prefixOption & { + width: @input-width - @prefix-option-width; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + .text-input-with-passwordSwith & { + width: @input-width - @password-switch; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + .text-input-with-prefix.text-input-with-unit & { + width: @input-width - @prefix-span-width - @unit-span-width; + } + + .text-input-with-prefix.text-input-with-unitOption & { + width: @input-width - @prefix-span-width - @unit-option-width; + } + + .short-text-input.text-input-with-prefix & { + width: @input-short-width - @short-prefix-span-width; + } + + .short-text-input.text-input-with-unit & { + width: @input-short-width - @short-unit-span-width; + } + + .short-text-input.text-input-with-prefix.text-input-with-unit & { + width: @input-short-width - @short-prefix-span-width - @short-unit-span-width; + } +} + +.input-spinner() { + cursor: pointer; + display: block; + font-size: 12px; + position: absolute; + margin: 0; + right: 0; + overflow: hidden; + border: none; + padding: 0; + text-align: center; + vertical-align: middle; + width: 18px; +} + +.input-spinner-up { + .input-spinner(); + top: 0; +} + +.input-spinner-down { + .input-spinner(); + bottom: 0; +} + +:host(.plx-input-sm) { + .plx-input-sm-common; +} + +.plx-input-sm { + .plx-input-sm-common; +} + +.plx-input-sm-common { + .plx-input { + height: @input-height-sm; + line-height: @input-height-sm; + } + .text-input-prefix, + .text-input-unit, + .text-input-unit-option, + .text-input-prefix-option { + height: @input-height-sm; + line-height: @input-height-sm; + } + div.text-input-dataList { + height: @input-height-sm; + } + .toggle { + margin-top: 11px; + } + .caret-down { + margin-bottom: 8px; + } + .caret-up { + margin-top: 8px; + } + .plx-input-passwordSwitch { + line-height: @input-height-sm - 2px; + } +} + +.plx-input-passwordSwitch { + display: inline-block; + line-height: @input-height - 2px; + width: @password-switch; + text-align: center; + vertical-align: middle; + background-color: @common-color; + border: 1px solid @border-color-base; + border-left: 0; + border-bottom-right-radius: @radius; + border-top-right-radius: @radius; + cursor: pointer; + &:focus, + &:hover { + border-color: @btn-common-color-border-hover; + background-color: @common-color-hover; + &.ict-eye-closed, &.ict-eye { + color: @btn-common-color-text-hover; + } + } + &:active { + background-color: @common-color-click; + border-color: @btn-common-color-border-click; + &.ict-eye-closed, &.ict-eye { + color: @btn-common-color-text-click; + } + } + &.ict-eye-closed, &.ict-eye { + color: @fonticon-color; + font-size: 16px; + } + } +.input-right-border-pwdswith-hover .plx-input { + border-right-color: @btn-common-color-border-hover; +} +.input-right-border-pwdswith-click .plx-input { + border-right-color: @btn-common-color-border-click; +} + +.plx-text-input-ip-dot { + display: inline-block; + vertical-align: bottom; + color: #999; +} + +.plx-text-input-error { + font-size: 12px; + color: @error-color; + margin-top: 5px; +} + +:host(.plx-text-input-ip-invalid) { + .plx-text-input-ip-invalid-common; +} + +.plx-text-input-ip-invalid { + .plx-text-input-ip-invalid-common; +} + +.plx-text-input-ip-invalid-common { + .plx-input { + border-color: @error-color; + } + .input-span-focus .plx-input { + border-color: @primary-color; + } +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/text-input.module.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/text-input.module.ts new file mode 100644 index 00000000..4374770a --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/text-input.module.ts @@ -0,0 +1,31 @@ +import {CommonModule} from '@angular/common'; +import {NgModule} from '@angular/core'; +import {FormsModule} from '@angular/forms'; + +import {PlxTooltipModule} from '../plx-tooltip/plx-tooltip.module'; +import {Ipv4ValidatorDirective} from './ipv4-validator.directive'; +import {Ipv6ValidatorDirective} from './ipv6-validator.directive'; +import {MaxValidatorDirective} from './max-validator.directive'; +import {MinValidatorDirective} from './min-validator.directive'; +import {PlxTextInputComponent} from './text-input.component'; +import {PlxValidateOnBlurDirective} from './validate-on-blur.directive'; +import {PlxTextInputIpComponent} from './text-input-ip.component'; +import {PlxTextInputIpAddressComponent} from './text-input-ip-address.component'; + + +@NgModule({ + imports: [CommonModule, FormsModule, PlxTooltipModule], + declarations: [ + PlxTextInputComponent, Ipv4ValidatorDirective, Ipv6ValidatorDirective, + MaxValidatorDirective, MinValidatorDirective, PlxValidateOnBlurDirective, + PlxTextInputIpComponent, PlxTextInputIpAddressComponent + ], + exports: [ + PlxTextInputComponent, Ipv4ValidatorDirective, Ipv6ValidatorDirective, + MaxValidatorDirective, MinValidatorDirective, PlxValidateOnBlurDirective, + PlxTextInputIpComponent, PlxTextInputIpAddressComponent + ] +}) + +export class PlxTextInputModule { +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/validate-on-blur.directive.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/validate-on-blur.directive.ts new file mode 100644 index 00000000..b4a940c8 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-text-input/validate-on-blur.directive.ts @@ -0,0 +1,18 @@ +import {Directive, HostListener} from '@angular/core'; +import {NgControl} from '@angular/forms'; + +@Directive({selector: '[validateOnBlur]'}) + +export class PlxValidateOnBlurDirective { + constructor(private formControl: NgControl) {} + + @HostListener('focus') + onFocus() { + // this.formControl.control.markAsUntouched(false); + } + + @HostListener('blur') + onBlur() { + // this.formControl.control.markAsTouched(true); + } +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-tooltip/plx-tooltip-config.spec.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-tooltip/plx-tooltip-config.spec.ts new file mode 100644 index 00000000..4a19dd17 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-tooltip/plx-tooltip-config.spec.ts @@ -0,0 +1,11 @@ +import {PlxTooltipConfig} from './plx-tooltip-config'; + +describe('plx-tooltip-config', () => { + it('should have sensible default values', () => { + const config = new PlxTooltipConfig(); + + expect(config.placement).toBe('top'); + expect(config.triggers).toBe('hover'); + expect(config.container).toBeUndefined(); + }); +}); diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-tooltip/plx-tooltip-config.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-tooltip/plx-tooltip-config.ts new file mode 100644 index 00000000..dd1cc254 --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-tooltip/plx-tooltip-config.ts @@ -0,0 +1,13 @@ +import {Injectable} from '@angular/core'; + +/** + * Configuration service for the PlxTooltip directive. + * You can inject this service, typically in your root component, and customize the values of its properties in + * order to provide default values for all the tooltips used in the application. + */ +@Injectable() +export class PlxTooltipConfig { + public placement: 'top' | 'bottom' | 'left' | 'right' = 'top'; + public triggers = 'hover'; + public container: string; +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-tooltip/plx-tooltip.less b/sdc-workflow-designer-ui/src/app/paletx/plx-tooltip/plx-tooltip.less new file mode 100644 index 00000000..d7ce015f --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-tooltip/plx-tooltip.less @@ -0,0 +1,241 @@ +@import "../../assets/components/themes/common/plx-input.less"; + +@tooltip-arrow-border-width: 4px; +@tooltip-arrow-border-width-before: 5px; +@tooltip-arrow-border-height: @tooltip-arrow-border-width-before - @tooltip-arrow-border-width; +@tooltip-arrow-away: 5px; +@tooltip-arrow-background-color: #595959; +@tooltip-arrow-border-color: #595959; +@tooltip-away-host: 3px; + +.plx-tooltip { + font-family: @font-family; + font-size: @font-size; + opacity: 1; + position: absolute; + z-index: 10001; + display: block; + font-style: normal; + font-weight: normal; + letter-spacing: normal; + line-break: auto; + line-height: 1.5; + text-align: left; + text-decoration: none; + text-shadow: none; + text-transform: none; + white-space: normal; + word-break: normal; + word-spacing: normal; + word-wrap: break-word; + &::before, + &::after { + content: ""; + position: absolute; + display: block; + width: 0; + height: 0; + border: solid transparent; + } + &::before { + border-width: @tooltip-arrow-border-width-before; + } + &::after { + border-width: @tooltip-arrow-border-width; + } +} + +.plx-tooltip-inner { + min-width: 60px; + max-width: 200px; + padding: 3px 8px; + color: #fff; + text-align: center; + background-color: #000; +} + +.plx-tooltip.show { + font-size: @font-size; + opacity: 1; +} +.plx-tooltip.show .plx-tooltip-inner { + background-color: #595959; + border-radius: @radius; + padding: 0px 12px; + height: 30px; + line-height: 30px; +} + +.plx-tooltip-top-common { + margin-top: -(@tooltip-arrow-border-width + @tooltip-away-host); + &::before { + border-top-color: @tooltip-arrow-border-color; + border-bottom-width: 0; + bottom: -@tooltip-arrow-border-width-before; + } + &::after { + border-top-color: @tooltip-arrow-background-color; + border-bottom-width: 0; + bottom: -@tooltip-arrow-border-width; + } +} +.plx-tooltip-top { + .plx-tooltip-top-common; + &::before { + left: 50%; + margin-left: -@tooltip-arrow-border-width-before; + } + &::after { + left: 50%; + margin-left: -@tooltip-arrow-border-width; + } +} +.plx-tooltip.plx-tooltip-top-left { + .plx-tooltip-top-common; + &::before { + left: @tooltip-arrow-away; + } + &::after { + left: @tooltip-arrow-away + @tooltip-arrow-border-height; + } +} +.plx-tooltip.plx-tooltip-top-right { + .plx-tooltip-top-common; + &::before { + right: @tooltip-arrow-away; + } + &::after { + right: @tooltip-arrow-away + @tooltip-arrow-border-height; + } +} + +.plx-tooltip-right-common { + margin-left: @tooltip-arrow-border-width + @tooltip-away-host; + &::before { + border-right-color: @tooltip-arrow-border-color; + border-left-width: 0; + left: -@tooltip-arrow-border-width-before; + } + &::after { + border-right-color: @tooltip-arrow-background-color; + border-left-width: 0; + left: -@tooltip-arrow-border-width; + } +} +.plx-tooltip.plx-tooltip-right { + .plx-tooltip-right-common; + &::before { + top: 50%; + margin-top: -@tooltip-arrow-border-width-before; + } + &::after { + top: 50%; + margin-top: -@tooltip-arrow-border-width; + } +} +.plx-tooltip.plx-tooltip-right-top { + .plx-tooltip-right-common; + &::before { + top: @tooltip-arrow-away; + } + &::after { + top: @tooltip-arrow-away + @tooltip-arrow-border-height; + } +} +.plx-tooltip.plx-tooltip-right-bottom { + .plx-tooltip-right-common; + &::before { + bottom: @tooltip-arrow-away; + } + &::after { + bottom: @tooltip-arrow-away + @tooltip-arrow-border-height; + } +} + +.plx-tooltip-bottom-common { + margin-top: @tooltip-arrow-border-width + @tooltip-away-host; + &::before { + border-bottom-color: @tooltip-arrow-border-color; + border-top-width: 0; + top: -@tooltip-arrow-border-width-before; + } + &::after { + border-bottom-color: @tooltip-arrow-background-color; + border-top-width: 0; + top: -@tooltip-arrow-border-width; + } +} +.plx-tooltip.plx-tooltip-bottom { + .plx-tooltip-bottom-common; + &::before { + left: 50%; + margin-left: -@tooltip-arrow-border-width-before; + } + &::after { + left: 50%; + margin-left: -@tooltip-arrow-border-width; + } +} +.plx-tooltip.plx-tooltip-bottom-left { + .plx-tooltip-bottom-common; + &::before { + left: @tooltip-arrow-away; + } + &::after { + left: @tooltip-arrow-away + @tooltip-arrow-border-height; + } +} +.plx-tooltip.plx-tooltip-bottom-right { + .plx-tooltip-bottom-common; + &::before { + right: @tooltip-arrow-away; + } + &::after { + right: @tooltip-arrow-away + @tooltip-arrow-border-height; + } +} + +.plx-tooltip-left-common { + margin-left: -(@tooltip-arrow-border-width + @tooltip-away-host); + &::before { + border-left-color: @tooltip-arrow-border-color; + border-right-width: 0; + right: -@tooltip-arrow-border-width-before; + } + &::after { + border-left-color: @tooltip-arrow-background-color; + border-right-width: 0; + right: -@tooltip-arrow-border-width; + } +} + +.plx-tooltip.plx-tooltip-left { + .plx-tooltip-left-common; + &::before { + top: 50%; + margin-top: -@tooltip-arrow-border-width-before; + } + &::after { + top: 50%; + margin-top: -@tooltip-arrow-border-width; + } +} + +.plx-tooltip.plx-tooltip-left-top { + .plx-tooltip-left-common; + &::before { + top: @tooltip-arrow-away; + } + &::after { + top: @tooltip-arrow-away + @tooltip-arrow-border-height; + } +} +.plx-tooltip.plx-tooltip-left-bottom { + .plx-tooltip-left-common; + &::before { + bottom: @tooltip-arrow-away; + } + &::after { + bottom: @tooltip-arrow-away + @tooltip-arrow-border-height; + } +} \ No newline at end of file diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-tooltip/plx-tooltip.module.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-tooltip/plx-tooltip.module.ts new file mode 100644 index 00000000..062ded1c --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-tooltip/plx-tooltip.module.ts @@ -0,0 +1,12 @@ +import {NgModule, ModuleWithProviders} from '@angular/core'; + +import {PlxTooltip, PlxTooltipWindow} from './plx-tooltip'; +import {PlxTooltipConfig} from './plx-tooltip-config'; + +export {PlxTooltipConfig} from './plx-tooltip-config'; +export {PlxTooltip} from './plx-tooltip'; + +@NgModule({declarations: [PlxTooltip, PlxTooltipWindow], exports: [PlxTooltip], entryComponents: [PlxTooltipWindow]}) +export class PlxTooltipModule { + public static forRoot(): ModuleWithProviders { return {ngModule: PlxTooltipModule, providers: [PlxTooltipConfig]}; } +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-tooltip/plx-tooltip.spec.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-tooltip/plx-tooltip.spec.ts new file mode 100644 index 00000000..cd2d8a6a --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-tooltip/plx-tooltip.spec.ts @@ -0,0 +1,485 @@ +import {TestBed, ComponentFixture, inject} from '@angular/core/testing'; +import {createGenericTestComponent} from '../test/common'; + +import {By} from '@angular/platform-browser'; +import {Component, ViewChild, ChangeDetectionStrategy} from '@angular/core'; + +import {PlxTooltipModule} from './plx-tooltip.module'; +import {PlxTooltipWindow, PlxTooltip} from './plx-tooltip'; +import {PlxTooltipConfig} from './plx-tooltip-config'; + +const createTestComponent = + (html: string) => >createGenericTestComponent(html, TestComponent); + +const createOnPushTestComponent = + (html: string) => >createGenericTestComponent(html, TestOnPushComponent); + +describe('plx-tooltip-window', () => { + beforeEach(() => { TestBed.configureTestingModule({imports: [PlxTooltipModule.forRoot()]}); }); + + it('should render tooltip on top by default', () => { + const fixture = TestBed.createComponent(PlxTooltipWindow); + fixture.detectChanges(); + + expect(fixture.nativeElement).toHaveCssClass('tooltip'); + expect(fixture.nativeElement).toHaveCssClass('tooltip-top'); + expect(fixture.nativeElement.getAttribute('role')).toBe('tooltip'); + }); + + it('should position tooltips as requested', () => { + const fixture = TestBed.createComponent(PlxTooltipWindow); + fixture.componentInstance.placement = 'left'; + fixture.detectChanges(); + expect(fixture.nativeElement).toHaveCssClass('tooltip-left'); + }); +}); + +describe('plx-tooltip', () => { + + beforeEach(() => { + TestBed.configureTestingModule( + {declarations: [TestComponent, TestOnPushComponent], imports: [PlxTooltipModule.forRoot()]}); + }); + + function getWindow(element) { return element.querySelector('plx-tooltip-window'); } + + describe('basic functionality', () => { + + it('should open and close a tooltip - default settings and content as string', () => { + const fixture = createTestComponent(`
    `); + const directive = fixture.debugElement.query(By.directive(PlxTooltip)); + const defaultConfig = new PlxTooltipConfig(); + + directive.triggerEventHandler('mouseenter', {}); + fixture.detectChanges(); + const windowEl = getWindow(fixture.nativeElement); + + expect(windowEl).toHaveCssClass('tooltip'); + expect(windowEl).toHaveCssClass(`tooltip-${defaultConfig.placement}`); + expect(windowEl.textContent.trim()).toBe('Great tip!'); + expect(windowEl.getAttribute('role')).toBe('tooltip'); + expect(windowEl.getAttribute('id')).toBe('plx-tooltip-0'); + expect(windowEl.parentNode).toBe(fixture.nativeElement); + expect(directive.nativeElement.getAttribute('aria-describedby')).toBe('plx-tooltip-0'); + + directive.triggerEventHandler('mouseleave', {}); + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).toBeNull(); + expect(directive.nativeElement.getAttribute('aria-describedby')).toBeNull(); + }); + + it('should open and close a tooltip - default settings and content from a template', () => { + const fixture = createTestComponent(`
    `); + const directive = fixture.debugElement.query(By.directive(PlxTooltip)); + + directive.triggerEventHandler('mouseenter', {}); + fixture.detectChanges(); + const windowEl = getWindow(fixture.nativeElement); + + expect(windowEl).toHaveCssClass('tooltip'); + expect(windowEl).toHaveCssClass('tooltip-top'); + expect(windowEl.textContent.trim()).toBe('Hello, World!'); + expect(windowEl.getAttribute('role')).toBe('tooltip'); + expect(windowEl.getAttribute('id')).toBe('plx-tooltip-1'); + expect(windowEl.parentNode).toBe(fixture.nativeElement); + expect(directive.nativeElement.getAttribute('aria-describedby')).toBe('plx-tooltip-1'); + + directive.triggerEventHandler('mouseleave', {}); + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).toBeNull(); + expect(directive.nativeElement.getAttribute('aria-describedby')).toBeNull(); + }); + + it('should open and close a tooltip - default settings, content from a template and context supplied', () => { + const fixture = createTestComponent(`
    `); + const directive = fixture.debugElement.query(By.directive(PlxTooltip)); + + directive.context.tooltip.open({name: 'John'}); + fixture.detectChanges(); + const windowEl = getWindow(fixture.nativeElement); + + expect(windowEl).toHaveCssClass('tooltip'); + expect(windowEl).toHaveCssClass('tooltip-top'); + expect(windowEl.textContent.trim()).toBe('Hello, John!'); + expect(windowEl.getAttribute('role')).toBe('tooltip'); + expect(windowEl.getAttribute('id')).toBe('plx-tooltip-2'); + expect(windowEl.parentNode).toBe(fixture.nativeElement); + expect(directive.nativeElement.getAttribute('aria-describedby')).toBe('plx-tooltip-2'); + + directive.triggerEventHandler('mouseleave', {}); + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).toBeNull(); + expect(directive.nativeElement.getAttribute('aria-describedby')).toBeNull(); + }); + + it('should not open a tooltip if content is falsy', () => { + const fixture = createTestComponent(`
    `); + const directive = fixture.debugElement.query(By.directive(PlxTooltip)); + + directive.triggerEventHandler('mouseenter', {}); + fixture.detectChanges(); + const windowEl = getWindow(fixture.nativeElement); + + expect(windowEl).toBeNull(); + }); + + it('should close the tooltip tooltip if content becomes falsy', () => { + const fixture = createTestComponent(`
    `); + const directive = fixture.debugElement.query(By.directive(PlxTooltip)); + + directive.triggerEventHandler('mouseenter', {}); + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).not.toBeNull(); + + fixture.componentInstance.name = null; + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).toBeNull(); + }); + + it('should allow re-opening previously closed tooltips', () => { + const fixture = createTestComponent(`
    `); + const directive = fixture.debugElement.query(By.directive(PlxTooltip)); + + directive.triggerEventHandler('mouseenter', {}); + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).not.toBeNull(); + + directive.triggerEventHandler('mouseleave', {}); + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).toBeNull(); + + directive.triggerEventHandler('mouseenter', {}); + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).not.toBeNull(); + }); + + it('should not leave dangling tooltips in the DOM', () => { + const fixture = createTestComponent(``); + const directive = fixture.debugElement.query(By.directive(PlxTooltip)); + + directive.triggerEventHandler('mouseenter', {}); + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).not.toBeNull(); + + fixture.componentInstance.show = false; + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).toBeNull(); + }); + + it('should properly cleanup tooltips with manual triggers', () => { + const fixture = createTestComponent(` + `); + const directive = fixture.debugElement.query(By.directive(PlxTooltip)); + + directive.triggerEventHandler('mouseenter', {}); + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).not.toBeNull(); + + fixture.componentInstance.show = false; + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).toBeNull(); + }); + + describe('positioning', () => { + + it('should use requested position', () => { + const fixture = createTestComponent(`
    `); + const directive = fixture.debugElement.query(By.directive(PlxTooltip)); + + directive.triggerEventHandler('mouseenter', {}); + fixture.detectChanges(); + const windowEl = getWindow(fixture.nativeElement); + + expect(windowEl).toHaveCssClass('tooltip'); + expect(windowEl).toHaveCssClass('tooltip-left'); + expect(windowEl.textContent.trim()).toBe('Great tip!'); + }); + + it('should properly position tooltips when a component is using the OnPush strategy', () => { + const fixture = createOnPushTestComponent(`
    `); + const directive = fixture.debugElement.query(By.directive(PlxTooltip)); + + directive.triggerEventHandler('mouseenter', {}); + fixture.detectChanges(); + const windowEl = getWindow(fixture.nativeElement); + + expect(windowEl).toHaveCssClass('tooltip'); + expect(windowEl).toHaveCssClass('tooltip-left'); + expect(windowEl.textContent.trim()).toBe('Great tip!'); + }); + }); + + describe('triggers', () => { + + it('should support toggle triggers', () => { + const fixture = createTestComponent(`
    `); + const directive = fixture.debugElement.query(By.directive(PlxTooltip)); + + directive.triggerEventHandler('click', {}); + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).not.toBeNull(); + + directive.triggerEventHandler('click', {}); + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).toBeNull(); + }); + + it('should non-default toggle triggers', () => { + const fixture = createTestComponent(`
    `); + const directive = fixture.debugElement.query(By.directive(PlxTooltip)); + + directive.triggerEventHandler('mouseenter', {}); + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).not.toBeNull(); + + directive.triggerEventHandler('click', {}); + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).toBeNull(); + }); + + it('should support multiple triggers', () => { + const fixture = + createTestComponent(`
    `); + const directive = fixture.debugElement.query(By.directive(PlxTooltip)); + + directive.triggerEventHandler('mouseenter', {}); + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).not.toBeNull(); + + directive.triggerEventHandler('click', {}); + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).toBeNull(); + }); + + it('should not use default for manual triggers', () => { + const fixture = createTestComponent(`
    `); + const directive = fixture.debugElement.query(By.directive(PlxTooltip)); + + directive.triggerEventHandler('mouseenter', {}); + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).toBeNull(); + }); + + it('should allow toggling for manual triggers', () => { + const fixture = createTestComponent(` +
    + `); + const button = fixture.nativeElement.querySelector('button'); + + button.click(); + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).not.toBeNull(); + + button.click(); + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).toBeNull(); + }); + + it('should allow open / close for manual triggers', () => { + const fixture = createTestComponent(` +
    + + `); + + const buttons = fixture.nativeElement.querySelectorAll('button'); + + buttons[0].click(); // open + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).not.toBeNull(); + + buttons[1].click(); // close + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).toBeNull(); + }); + + it('should not throw when open called for manual triggers and open tooltip', () => { + const fixture = createTestComponent(` +
    + `); + const button = fixture.nativeElement.querySelector('button'); + + button.click(); // open + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).not.toBeNull(); + + button.click(); // open + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).not.toBeNull(); + }); + + it('should not throw when closed called for manual triggers and closed tooltip', () => { + const fixture = createTestComponent(` +
    + `); + + const button = fixture.nativeElement.querySelector('button'); + + button.click(); // close + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).toBeNull(); + }); + }); + }); + + describe('container', () => { + + it('should be appended to the element matching the selector passed to "container"', () => { + const selector = 'body'; + const fixture = createTestComponent(`
    `); + const directive = fixture.debugElement.query(By.directive(PlxTooltip)); + + directive.triggerEventHandler('mouseenter', {}); + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).toBeNull(); + expect(getWindow(document.querySelector(selector))).not.toBeNull(); + }); + + it('should properly destroy tooltips when the "container" option is used', () => { + const selector = 'body'; + const fixture = + createTestComponent(`
    `); + const directive = fixture.debugElement.query(By.directive(PlxTooltip)); + + directive.triggerEventHandler('mouseenter', {}); + fixture.detectChanges(); + + expect(getWindow(document.querySelector(selector))).not.toBeNull(); + fixture.componentRef.instance.show = false; + fixture.detectChanges(); + expect(getWindow(document.querySelector(selector))).toBeNull(); + }); + }); + + describe('visibility', () => { + it('should emit events when showing and hiding popover', () => { + const fixture = createTestComponent( + `
    `); + const directive = fixture.debugElement.query(By.directive(PlxTooltip)); + + let shownSpy = spyOn(fixture.componentInstance, 'shown'); + let hiddenSpy = spyOn(fixture.componentInstance, 'hidden'); + + directive.triggerEventHandler('click', {}); + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).not.toBeNull(); + expect(shownSpy).toHaveBeenCalled(); + + directive.triggerEventHandler('click', {}); + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).toBeNull(); + expect(hiddenSpy).toHaveBeenCalled(); + }); + + it('should not emit close event when already closed', () => { + const fixture = createTestComponent( + `
    `); + + let shownSpy = spyOn(fixture.componentInstance, 'shown'); + let hiddenSpy = spyOn(fixture.componentInstance, 'hidden'); + + fixture.componentInstance.tooltip.open(); + fixture.detectChanges(); + + fixture.componentInstance.tooltip.open(); + fixture.detectChanges(); + + expect(getWindow(fixture.nativeElement)).not.toBeNull(); + expect(shownSpy).toHaveBeenCalled(); + expect(shownSpy.calls.count()).toEqual(1); + expect(hiddenSpy).not.toHaveBeenCalled(); + }); + + it('should not emit open event when already opened', () => { + const fixture = createTestComponent( + `
    `); + + let shownSpy = spyOn(fixture.componentInstance, 'shown'); + let hiddenSpy = spyOn(fixture.componentInstance, 'hidden'); + + fixture.componentInstance.tooltip.close(); + fixture.detectChanges(); + expect(getWindow(fixture.nativeElement)).toBeNull(); + expect(shownSpy).not.toHaveBeenCalled(); + expect(hiddenSpy).toHaveBeenCalled(); + }); + + it('should report correct visibility', () => { + const fixture = createTestComponent(`
    `); + fixture.detectChanges(); + + expect(fixture.componentInstance.tooltip.isOpen()).toBeFalsy(); + + fixture.componentInstance.tooltip.open(); + fixture.detectChanges(); + expect(fixture.componentInstance.tooltip.isOpen()).toBeTruthy(); + + fixture.componentInstance.tooltip.close(); + fixture.detectChanges(); + expect(fixture.componentInstance.tooltip.isOpen()).toBeFalsy(); + }); + }); + + describe('Custom config', () => { + let config: PlxTooltipConfig; + + beforeEach(() => { + TestBed.configureTestingModule({imports: [PlxTooltipModule.forRoot()]}); + TestBed.overrideComponent(TestComponent, {set: {template: `
    `}}); + }); + + beforeEach(inject([PlxTooltipConfig], (c: PlxTooltipConfig) => { + config = c; + config.placement = 'bottom'; + config.triggers = 'click'; + config.container = 'body'; + })); + + it('should initialize inputs with provided config', () => { + const fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + const tooltip = fixture.componentInstance.tooltip; + + expect(tooltip.placement).toBe(config.placement); + expect(tooltip.triggers).toBe(config.triggers); + expect(tooltip.container).toBe(config.container); + }); + }); + + describe('Custom config as provider', () => { + let config = new PlxTooltipConfig(); + config.placement = 'bottom'; + config.triggers = 'click'; + config.container = 'body'; + + beforeEach(() => { + TestBed.configureTestingModule( + {imports: [PlxTooltipModule.forRoot()], providers: [{provide: PlxTooltipConfig, useValue: config}]}); + }); + + it('should initialize inputs with provided config as provider', () => { + const fixture = createTestComponent(`
    `); + const tooltip = fixture.componentInstance.tooltip; + + expect(tooltip.placement).toBe(config.placement); + expect(tooltip.triggers).toBe(config.triggers); + expect(tooltip.container).toBe(config.container); + }); + }); +}); + +@Component({selector: 'test-cmpt', template: ``}) +export class TestComponent { + name = 'World'; + show = true; + + @ViewChild(PlxTooltip) tooltip: PlxTooltip; + + shown() {} + hidden() {} +} + +@Component({selector: 'test-onpush-cmpt', changeDetection: ChangeDetectionStrategy.OnPush, template: ``}) +export class TestOnPushComponent { +} diff --git a/sdc-workflow-designer-ui/src/app/paletx/plx-tooltip/plx-tooltip.ts b/sdc-workflow-designer-ui/src/app/paletx/plx-tooltip/plx-tooltip.ts new file mode 100644 index 00000000..f52cc11d --- /dev/null +++ b/sdc-workflow-designer-ui/src/app/paletx/plx-tooltip/plx-tooltip.ts @@ -0,0 +1,176 @@ +import { + Component, + Directive, + Input, + Output, + EventEmitter, + ChangeDetectionStrategy, + OnInit, + OnDestroy, + Injector, + Renderer, + ComponentRef, + ElementRef, + TemplateRef, + ViewContainerRef, + ComponentFactoryResolver, + NgZone, ViewEncapsulation +} from '@angular/core'; +import {listenToTriggers} from '../util/triggers'; +import {positionElements, getPlacement} from '../util/positioning'; +import {PopupService} from '../util/popup'; +import {PlxTooltipConfig} from './plx-tooltip-config'; + +let nextId = 0; + +@Component({ + selector: 'plx-tooltip-window', + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + host: {'[class]': '"plx-tooltip show plx-tooltip-" + placement', 'role': 'tooltip', '[id]': 'id'}, + template: ` +
    + `, + styleUrls: ['./plx-tooltip.less'] +}) +export class PlxTooltipWindow { + @Input() public placement: 'top' | 'bottom' | 'left' | 'right' = 'top'; + @Input() public id: string; +} + +/** + * A lightweight, extensible directive for fancy tooltip creation. + */ +@Directive({selector: '[plxTooltip]', exportAs: 'plxTooltip'}) +export class PlxTooltip implements OnInit, OnDestroy { + /** + * Placement of a tooltip. Accepts: "top", "bottom", "left", "right" + */ + @Input() public placement: 'top' | 'bottom' | 'left' | 'right'; + /** + * Specifies events that should trigger. Supports a space separated list of event names. + */ + @Input() public triggers: string; + /** + * A selector specifying the element the tooltip should be appended to. + * Currently only supports "body". + */ + @Input() public container: string; + /** + * Emits an event when the tooltip is shown + */ + @Output() public shown = new EventEmitter(); + /** + * Emits an event when the tooltip is hidden + */ + @Output() public hidden = new EventEmitter(); + + private _plxTooltip: string | TemplateRef; + private _plxTooltipWindowId = `plx-tooltip-${nextId++}`; + private _popupService: PopupService; + private _windowRef: ComponentRef; + private _unregisterListenersFn; + private _zoneSubscription: any; + + constructor(private _elementRef: ElementRef, private _renderer: Renderer, injector: Injector, + componentFactoryResolver: ComponentFactoryResolver, viewContainerRef: ViewContainerRef, config: PlxTooltipConfig, + ngZone: NgZone) { + this.placement = config.placement; + this.triggers = config.triggers; + this.container = config.container; + this._popupService = new PopupService( + PlxTooltipWindow, injector, viewContainerRef, _renderer, componentFactoryResolver); + + this._zoneSubscription = ngZone.onStable.subscribe(() => { + if (this._windowRef) { + positionElements( + this._elementRef.nativeElement, this._windowRef.location.nativeElement, this.placement, + this.container === 'body'); + let tmpPlace = getPlacement(this._elementRef.nativeElement, this._windowRef.location.nativeElement, this.placement); + this._windowRef.instance.placement = tmpPlace; + this._windowRef.changeDetectorRef.detectChanges(); + } + }); + } + + /** + * Content to be displayed as tooltip. If falsy, the tooltip won't open. + */ + @Input() + set plxTooltip(value: string | TemplateRef) { + this._plxTooltip = value; + if (!value && this._windowRef) { + this.close(); + } + } + + get plxTooltip() { + return this._plxTooltip; + } + + /** + * Opens an element’s tooltip. This is considered a “manual” triggering of the tooltip. + * The context is an optional value to be injected into the tooltip template when it is created. + */ + public open(context?: any) { + if (!this._windowRef && this._plxTooltip) { + this._windowRef = this._popupService.open(this._plxTooltip, context); + // let tmpPlace = getPlacement(this._elementRef.nativeElement, this._windowRef.location.nativeElement, this.placement); + this._windowRef.instance.placement = this.placement; + this._windowRef.instance.id = this._plxTooltipWindowId; + + this._renderer.setElementAttribute(this._elementRef.nativeElement, 'aria-describedby', this._plxTooltipWindowId); + + if (this.container === 'body') { + window.document.querySelector(this.container).appendChild(this._windowRef.location.nativeElement); + } + + // we need to manually invoke change detection since events registered via + // Renderer::listen() - to be determined if this is a bug in the Angular itself + this._windowRef.changeDetectorRef.markForCheck(); + this.shown.emit(); + } + } + + /** + * Closes an element’s tooltip. This is considered a “manual” triggering of the tooltip. + */ + public close(): void { + if (this._windowRef !== null) { + this._renderer.setElementAttribute(this._elementRef.nativeElement, 'aria-describedby', null); + this._popupService.close(); + this._windowRef = null; + this.hidden.emit(); + } + } + + /** + * Toggles an element’s tooltip. This is considered a “manual” triggering of the tooltip. + */ + public toggle(): void { + if (this._windowRef) { + this.close(); + } else { + this.open(); + } + } + + /** + * Returns whether or not the tooltip is currently being shown + */ + public isOpen(): boolean { + return !!this._windowRef; + } + + public ngOnInit() { + this._unregisterListenersFn = listenToTriggers( + this._renderer, this._elementRef.nativeElement, this.triggers, this.open.bind(this), this.close.bind(this), + this.toggle.bind(this)); + } + + public ngOnDestroy() { + this.close(); + this._unregisterListenersFn(); + this._zoneSubscription.unsubscribe(); + } +} -- cgit 1.2.3-korg