* Create a Moment from an ISO string
*/
momentizeIsoString(isoString: string, timezone: string): moment.Moment {
- return (isoString.length) ? moment(isoString, timezone) : moment();
+ return (isoString?.length) ? moment(isoString).tz(timezone) : moment();
}
/**
const moment = service.momentizeDateTimeString('7/3/12, 6:06 PM', 'Africa/Addis_Ababa', false, 'fr-CA');
expect(moment.isValid()).toBe(true);
});
+ it('can momentize ISO strings', () => {
+ const moment = service.momentizeIsoString('2022-07-29T17:56:00.000Z', 'America/New_York');
+ expect(moment.isValid()).toBe(true);
+ expect(moment.format('YYYY')).toBe('2022');
+ });
});
--- /dev/null
+import * as moment from "moment";
+import { DateTimeSelectComponent } from "./datetime-select.component";
+
+
+describe('DateTimeSelectComponent', () => {
+ const mockFormatService = jasmine.createSpyObj('FormatService', ['transform', 'momentizeIsoString']);
+ mockFormatService.momentizeIsoString.and.returnValue(moment('2020-12-11T01:30:05.606Z').tz('America/Vancouver'));
+ const mockDateTimeValidator = jasmine.createSpyObj('DateTimeValidator', ['']);
+ const mockNgControl = jasmine.createSpyObj('ngControl', ['']);
+ const component = new DateTimeSelectComponent(mockFormatService, mockDateTimeValidator, mockNgControl);
+
+ it('accepts an initialIso input and converts it to the correct timezone', () => {
+ component.initialIso = '2020-12-11T01:30:05.606Z';
+ component.timezone = 'America/Vancouver';
+ component.ngOnInit();
+ expect(component.date.value).toEqual({year: 2020, month: 12, day: 10});
+ expect(component.time.value).toEqual({hour: 17, minute: 30, second: 0});
+ });
+
+});
-import {Component, EventEmitter, Input, Output, forwardRef, ViewChild, OnInit, Optional, Self} from '@angular/core';
+import {Component, EventEmitter, Input, Output, ViewChild, OnInit, Optional, Self} from '@angular/core';
import {FormatService} from '@eg/core/format.service';
import {AbstractControl, ControlValueAccessor, FormControl, FormGroup, NgControl} from '@angular/forms';
-import {NgbDatepicker, NgbTimeStruct, NgbDateStruct} from '@ng-bootstrap/ng-bootstrap';
import {DatetimeValidator} from '@eg/share/validators/datetime_validator.directive';
import * as moment from 'moment-timezone';
import {DateUtil} from '@eg/share/util/date';
}
- writeValue(value: moment.Moment) {
+ writeValue(value: moment.Moment|string) {
+ if (typeof value === 'string') {
+ if (value.length === 0) {
+ return;
+ };
+ value = this.format.momentizeIsoString(value, this.timezone);
+ }
+
if (value !== undefined && value !== null) {
this.dateTimeForm.patchValue({
stringVersion: this.format.transform({value: value, datatype: 'timestamp', datePlusTime: true})});
<div class="modal-header bg-info" *ngIf="!hideBanner">
<h4 class="modal-title" i18n>Record Editor: {{recordLabel}}</h4>
<ng-container *ngIf="isDialog()">
- <button type="button" class="close"
+ <button type="button" class="close"
i18n-aria-label aria-label="Close" (click)="closeEditor()">
<span aria-hidden="true">×</span>
</button>
</ng-container>
</div>
<div class="modal-body">
- <form #fmEditForm="ngForm" role="form" class="form-validated common-form striped-odd">
+ <form #fmEditForm="ngForm" role="form"
+ class="form-validated common-form striped-odd"
+ [egDateFieldOrderList]="dateFieldOrderList">
<ng-container *ngIf="!record">
- <!-- display a progress dialog while the editor
+ <!-- display a progress dialog while the editor
fetches the needed data -->
- <eg-progress-inline></eg-progress-inline>
+ <eg-progress-inline></eg-progress-inline>
</ng-container>
<ng-container *ngIf="record">
+ <div role="alert" class="alert alert-danger" *ngIf="fmEditForm.errors?.['datesOutOfOrder'] && (fmEditForm.touched || fmEditForm.dirty)">
+ <span class="material-icons" aria-hidden="true">error</span>
+ <span i18n>Dates must be in the correct order</span>
+ </div>
+
<div class="form-group row" *ngFor="let field of fields">
<div class="col-lg-3">
<label for="{{idPrefix}}-{{field.name}}">{{field.label}}</label>
<ng-container *ngSwitchCase="'template'">
<ng-container
*ngTemplateOutlet="field.template; context:customTemplateFieldContext(field)">
- </ng-container>
- </ng-container>
+ </ng-container>
+ </ng-container>
<ng-container *ngSwitchCase="'readonly'">
<span>{{record[field.name]()}}</span>
<eg-date-select
domId="{{idPrefix}}-{{field.name}}"
[readOnly]="field.readOnly"
+ [ngModel]="record[field.name]()"
+ name="{{field.name}}"
(onChangeAsIso)="record[field.name]($event)"
initialIso="{{record[field.name]()}}">
</eg-date-select>
(onChangeAsIso)="record[field.name]($event)"
i18n-validatorError
[readOnly]="field.readOnly"
+ [ngModel]="record[field.name]()"
+ name="{{field.name}}"
initialIso="{{record[field.name]()}}">
</eg-datetime-select>
</ng-container>
(onChange)="record[field.name]($event)">
</eg-org-select>
</ng-container>
-
+
<ng-container *ngSwitchCase="'money'">
<input
class="form-control"
[ngModel]="record[field.name]()"
(ngModelChange)="record[field.name]($event)"/>
</ng-container>
-
+
<ng-container *ngSwitchCase="'readonly-au'">
<ng-container *ngIf="field.linkedValues">
<a href="/eg/staff/circ/patron/{{field.linkedValues[0].id}}/checkout" target="_blank">{{field.linkedValues[0].label}}
<ng-container *ngSwitchCase="'link'">
<eg-combobox
id="{{idPrefix}}-{{field.name}}" name="{{field.name}}"
- placeholder="{{field.label}}..." i18n-placeholder
+ placeholder="{{field.label}}..." i18n-placeholder
[required]="field.isRequired()"
[idlClass]="field.class" [asyncSupportsEmptyTermClick]="true"
[idlBaseQuery]="field.idlBaseQuery"
<ng-container *ngSwitchCase="'list'">
<eg-combobox
domId="{{idPrefix}}-{{field.name}}" name="{{field.name}}"
- placeholder="{{field.label}}..." i18n-placeholder
+ placeholder="{{field.label}}..." i18n-placeholder
[required]="field.isRequired()"
[entries]="field.linkedValues"
[asyncDataSource]="field.linkedValuesSource"
[disabled]="record && record.isnew()" i18n>Delete</button>
</ng-container>
- <button type="button" class="btn btn-info"
+ <button type="button" class="btn btn-info"
[disabled]="fmEditForm.invalid" *ngIf="mode !== 'view'"
(click)="save()" i18n>Save</button>
</div>
// do not close dialog on error saving record
@Input() remainOpenOnError: false;
+ // if date fields need to be in a specific order (e.g.
+ // start date before end date), specify them in a comma-
+ // separated list here.
+ @Input() dateFieldOrderList: '';
+
// Emit the modified object when the save action completes.
@Output() recordSaved = new EventEmitter<IdlObject>();
--- /dev/null
+import { Directive, Input } from "@angular/core";
+import { AbstractControl, NG_VALIDATORS, ValidationErrors, Validator, ValidatorFn } from "@angular/forms";
+import * as moment from "moment";
+
+export function datesInOrderValidator(fieldNames: string[]): ValidatorFn {
+ return (control: AbstractControl): {[key: string]: any} | null => {
+ if (fieldsAreInOrder(fieldNames, control)) return null;
+ return {datesOutOfOrder: 'Dates should be in order'};
+ };
+}
+
+function fieldsAreInOrder(fieldNames: string[], control: AbstractControl): boolean {
+ if (fieldNames.length === 0) return true;
+ return fieldNames.every((field, index) => {
+ // No need to compare field[0] to the field before it
+ if (index === 0) return true;
+
+ const previousValue = moment(control.get(fieldNames[index - 1])?.value);
+ const currentValue = moment(control.get(field)?.value);
+
+ // If either field is invalid, return true -- there should be other
+ // validation that can catch that
+ if (!previousValue.isValid() || !currentValue.isValid()) return true;
+
+ // Check each field against its predecessor
+ return previousValue.isSameOrBefore(currentValue);
+ });
+}
+
+@Directive({
+ selector: '[egDateFieldOrderList]',
+ providers: [{ provide: NG_VALIDATORS, useExisting: DatesInOrderValidatorDirective, multi: true }]
+ })
+ export class DatesInOrderValidatorDirective implements Validator {
+ @Input('egDateFieldOrderList') dateFieldOrderList = '';
+ validate(control: AbstractControl): ValidationErrors | null {
+ if (this.dateFieldOrderList?.length > 0) {
+ return datesInOrderValidator(this.dateFieldOrderList.split(','))(control);
+ } else {
+ // Don't run validations if we have no fields to examine
+ return () => {null}
+ }
+ }
+ }
--- /dev/null
+import { AbstractControl } from "@angular/forms";
+import { datesInOrderValidator } from "./dates_in_order_validator.directive";
+
+describe('datesInOrderValidator', () => {
+ const mockForm = jasmine.createSpyObj<AbstractControl>(['get']);
+ const mockEarlierDateInput = jasmine.createSpyObj<AbstractControl>('AbstractControl', [], {value: '2020-10-12'});
+ const mockLaterDateInput = jasmine.createSpyObj<AbstractControl>('AbstractControl', [], {value: '2030-01-01'});
+ it('returns null if two fields are in order', () => {
+ mockForm.get.and.returnValues(mockEarlierDateInput, mockLaterDateInput);
+ expect(datesInOrderValidator(['startDate', 'endDate'])(mockForm)).toEqual(null);
+ });
+ it('returns an object if fields are out of order', () => {
+ mockForm.get.and.returnValues(mockLaterDateInput, mockEarlierDateInput);
+ expect(datesInOrderValidator(['startDate', 'endDate'])(mockForm)).toEqual({ datesOutOfOrder: 'Dates should be in order' });
+ });
+});
<ng-template ngbNavContent>
<div class="col-lg-6 offset-lg-3 mt-3">
<div style="text-align: center;">
- <button class="p-2 mb-3 btn btn-danger btn-lg"
+ <button class="p-2 mb-3 btn btn-danger btn-lg"
(click)="endSurvey()" i18n>
End Survey Now
</button>
</div>
- <eg-fm-record-editor displayMode="inline"
+ <eg-fm-record-editor displayMode="inline"
hiddenFieldsList="id"
datetimeFieldsList="start_date,end_date"
+ dateFieldOrderList="start_date,end_date"
fieldOrder="name,description,owner,start_date,end_date,opac,poll,required,usr_summary"
idlClass="asv"
mode="update"
<input type="text" [(ngModel)]="question.words" class="form-control"
name="question-{{questionIndex}}">
<span class="input-group-append">
- <button class="ml-2 btn btn-info"
+ <button class="ml-2 btn btn-info"
(click)="updateQuestion(question)" i18n>
Save
</button>
</button>
</span>
</div>
- <div *ngFor="let answer of question.answers; let answerIndex = index;"
+ <div *ngFor="let answer of question.answers; let answerIndex = index;"
class="mb-2 input-group">
- <input class="form-control" type="text"
+ <input class="form-control" type="text"
[(ngModel)]="answer.words"
name="answer-{{questionIndex}}-{{answerIndex}}">
<span class="input-group-append">
- <button class="ml-2 btn btn-info"
+ <button class="ml-2 btn btn-info"
(click)="updateAnswer(answer, question, questionIndex, answerIndex)"
i18n>
Save
</span>
</div>
<div class="mb-2 input-group">
- <input class="form-control" type="text"
+ <input class="form-control" type="text"
[(ngModel)]="newAnswerArray[questionIndex].inputText"
value="">
<span class="input-group-append">
- <button class="ml-2 btn btn-info"
+ <button class="ml-2 btn btn-info"
(click)="createAnswer(newAnswerArray[questionIndex].inputText, question)"
i18n>
Add Answer
<label class="input-group-text">
<b>New Question</b>
</label>
- <input #newQuestionInput
- class="form-control"
- type="text"
+ <input #newQuestionInput
+ class="form-control"
+ type="text"
[(ngModel)]="newQuestionText"
name="question-new" value="">
<span class="input-group-append">
</eg-string>
<eg-string #updateQuestionFailStr i18n-text text="Survey Question update failed">
</eg-string>
-<eg-string #endSurveyFailedString i18n-text
+<eg-string #endSurveyFailedString i18n-text
text="Ending Survey failed or was not allowed"></eg-string>
<eg-string #endSurveySuccessString i18n-text text="Survey ended"></eg-string>
-<eg-string #questionAlreadyStartedErrString i18n-text
+<eg-string #questionAlreadyStartedErrString i18n-text
text="The survey Start Date must be set for the future to add new questions or modify existing questions.">
- </eg-string>
\ No newline at end of file
+ </eg-string>
<eg-staff-banner bannerText="Survey Configuration" i18n-bannerText>
</eg-staff-banner>
-<eg-grid #grid idlClass="asv" [dataSource]="gridDataSource"
+<eg-grid #grid idlClass="asv" [dataSource]="gridDataSource"
[sortable]="true">
<eg-grid-toolbar-button label="New Survey" i18n-label [action]="createNew">
</eg-grid-toolbar-button>
<eg-grid-toolbar-action label="Edit Selected" i18n-label [action]="editSelected">
</eg-grid-toolbar-action>
- <eg-grid-toolbar-action label="Delete Selected" i18n-label
+ <eg-grid-toolbar-action label="Delete Selected" i18n-label
(onClick)="deleteSelected($event)"></eg-grid-toolbar-action>
- <eg-grid-toolbar-action label="End Survey Now" i18n-label
+ <eg-grid-toolbar-action label="End Survey Now" i18n-label
(onClick)="endSurvey($event)"></eg-grid-toolbar-action>
</eg-grid>
-<eg-fm-record-editor
+<eg-fm-record-editor
datetimeFieldsList="start_date,end_date"
+ dateFieldOrderList="start_date,end_date"
fieldOrder="name,description,owner,start_date,end_date,opac,poll,required,usr_summary"
hiddenFieldsList="id"
- #editDialog
+ #editDialog
+ [defaultNewRecord]="defaultNewRecord"
idlClass="asv">
</eg-fm-record-editor>
<eg-string #createString i18n-text text="New Survey Added"></eg-string>
<eg-string #createErrString i18n-text text="Failed to Create New Survey">
</eg-string>
-<eg-string #endSurveyFailedString i18n-text
+<eg-string #endSurveyFailedString i18n-text
text="Ending Survey failed or was not allowed"></eg-string>
<eg-string #endSurveySuccessString i18n-text text="Survey ended">
</eg-string>
-<eg-string #deleteFailedString i18n-text
+<eg-string #deleteFailedString i18n-text
text="Delete of Survey failed or was not allowed"></eg-string>
<eg-string #deleteSuccessString i18n-text text="Delete of Survey succeeded">
</eg-string>
import {GridComponent} from '@eg/share/grid/grid.component';
import {GridDataSource} from '@eg/share/grid/grid';
import {Router} from '@angular/router';
-import {IdlObject} from '@eg/core/idl.service';
+import {IdlObject, IdlService} from '@eg/core/idl.service';
import {PcrudService} from '@eg/core/pcrud.service';
import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
import {StringComponent} from '@eg/share/string/string.component';
export class SurveyComponent implements OnInit {
+ defaultNewRecord: IdlObject;
gridDataSource: GridDataSource;
@ViewChild('editDialog', { static: true }) editDialog: FmRecordEditorComponent;
constructor(
private auth: AuthService,
+ private idl: IdlService,
private net: NetService,
private pcrud: PcrudService,
private toast: ToastService,
this.navigateToEditPage(idToEdit);
}
);
+
+ this.defaultNewRecord = this.idl.create('asv');
+ const nextWeek = new Date();
+ nextWeek.setDate(nextWeek.getDate() + 7);
+ this.defaultNewRecord.end_date(nextWeek.toISOString());
}
showEditDialog(idlThing: IdlObject): Promise<any> {
formControlName="endTime"
[timezone]="timezone">
</eg-datetime-select>
- <div role="alert" class="alert alert-danger offset-lg-4" *ngIf="create.errors && create.errors.startTimeNotBeforeEndTime">
+ <div role="alert" class="alert alert-danger offset-lg-4" *ngIf="create.errors?.datesOutOfOrder">
<span class="material-icons" aria-hidden="true">error</span>
<span i18n>Start time must be before end time</span>
</div>
import {AlertDialogComponent} from '@eg/share/dialog/alert.component';
import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
import * as moment from 'moment-timezone';
-
-const startTimeIsBeforeEndTimeValidator: ValidatorFn = (fg: FormGroup): ValidationErrors | null => {
- const start = fg.get('startTime').value;
- const end = fg.get('endTime').value;
- return start !== null && end !== null &&
- start.isBefore(end)
- ? null
- : { startTimeNotBeforeEndTime: true };
-};
+import { datesInOrderValidator } from '@eg/share/validators/dates_in_order_validator.directive';
@Component({
selector: 'eg-create-reservation-dialog',
'endTime': new FormControl(),
'resourceList': new FormControl(),
'note': new FormControl(),
- }, [startTimeIsBeforeEndTimeValidator]
+ }, [datesInOrderValidator(['startTime', 'endTime'])]
);
if (this.patronId) {
this.pcrud.search('au', {id: this.patronId}, {
import {MultiSelectComponent} from '@eg/share/multi-select/multi-select.component';
import {TextMultiSelectComponent} from '@eg/share/text-multi-select/text-multi-select.component';
import {NotBeforeMomentValidatorDirective} from '@eg/share/validators/not_before_moment_validator.directive';
+import {DatesInOrderValidatorDirective} from '@eg/share/validators/dates_in_order_validator.directive';
import {PatronBarcodeValidatorDirective} from '@eg/share/validators/patron_barcode_validator.directive';
import {BroadcastService} from '@eg/share/util/broadcast.service';
import {CourseService} from './share/course.service';
MultiSelectComponent,
TextMultiSelectComponent,
NotBeforeMomentValidatorDirective,
+ DatesInOrderValidatorDirective,
PatronBarcodeValidatorDirective,
],
imports: [
MultiSelectComponent,
TextMultiSelectComponent,
NotBeforeMomentValidatorDirective,
+ DatesInOrderValidatorDirective,
PatronBarcodeValidatorDirective
]
})