diff --git a/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.ts b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.ts index 2ac136f29..6fa8227ab 100644 --- a/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.ts +++ b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.ts @@ -19,6 +19,7 @@ import { TimeModel, DateTimeModel, DEFAULT_TIME, + DEFAULT_DATE_QUALIFIERS, UNKNOWN_VALUE, } from '@shared/services/edtf-service/edtf.service'; @@ -43,17 +44,9 @@ interface SavedSideState { export class EditDateTimeModalComponent implements OnInit { readonly DateQualifier = DateQualifier; - qualifiers = signal({ - approximate: false, - uncertain: false, - unknown: false, - }); + qualifiers = signal({ ...DEFAULT_DATE_QUALIFIERS }); - endQualifiers = signal({ - approximate: false, - uncertain: false, - unknown: false, - }); + endQualifiers = signal({ ...DEFAULT_DATE_QUALIFIERS }); savedStartState = signal(null); savedEndState = signal(null); @@ -116,11 +109,7 @@ export class EditDateTimeModalComponent implements OnInit { ngOnInit(): void { if (this.data) { this.qualifiers.set( - this.data.qualifiers ?? { - approximate: false, - uncertain: false, - unknown: false, - }, + this.data.qualifiers ?? { ...DEFAULT_DATE_QUALIFIERS }, ); this.date.set(this.data.date ?? { year: '', month: '', day: '' }); this.time.set(this.data.time ?? { ...DEFAULT_TIME }); @@ -130,17 +119,13 @@ export class EditDateTimeModalComponent implements OnInit { this.endDate.set(this.data.endDate); this.endTime.set(this.data.endTime ?? { ...DEFAULT_TIME }); this.endQualifiers.set( - this.data.endQualifiers ?? { - approximate: false, - uncertain: false, - unknown: false, - }, + this.data.endQualifiers ?? { ...DEFAULT_DATE_QUALIFIERS }, ); } if (this.data.qualifiers?.unknown) { this.savedStartState.set({ - qualifiers: { approximate: false, uncertain: false, unknown: false }, + qualifiers: { ...DEFAULT_DATE_QUALIFIERS }, date: { ...this.date() }, time: { ...this.time() }, }); @@ -148,7 +133,7 @@ export class EditDateTimeModalComponent implements OnInit { if (this.data.endQualifiers?.unknown) { this.savedEndState.set({ - qualifiers: { approximate: false, uncertain: false, unknown: false }, + qualifiers: { ...DEFAULT_DATE_QUALIFIERS }, date: { ...this.endDate() }, time: { ...this.endTime() }, }); @@ -255,22 +240,14 @@ export class EditDateTimeModalComponent implements OnInit { } clearStart(): void { - this.qualifiers.set({ - approximate: false, - uncertain: false, - unknown: false, - }); + this.qualifiers.set({ ...DEFAULT_DATE_QUALIFIERS }); this.date.set({ year: '', month: '', day: '' }); this.time.set({ ...DEFAULT_TIME }); this.savedStartState.set(null); } clearEnd(): void { - this.endQualifiers.set({ - approximate: false, - uncertain: false, - unknown: false, - }); + this.endQualifiers.set({ ...DEFAULT_DATE_QUALIFIERS }); this.endDate.set({ year: '', month: '', day: '' }); this.endTime.set({ ...DEFAULT_TIME }); this.savedEndState.set(null); diff --git a/src/app/file-browser/components/file-viewer/file-viewer.component.html b/src/app/file-browser/components/file-viewer/file-viewer.component.html index 632471ff6..7d19ad537 100644 --- a/src/app/file-browser/components/file-viewer/file-viewer.component.html +++ b/src/app/file-browser/components/file-viewer/file-viewer.component.html @@ -125,26 +125,38 @@ > } + @if (showEdtfDatePicker) { +
+ +
+ } - - - - + @if (!showEdtfDatePicker) { + + + + + } diff --git a/src/app/file-browser/components/file-viewer/file-viewer.component.spec.ts b/src/app/file-browser/components/file-viewer/file-viewer.component.spec.ts index 7bc80b80e..db8cb326d 100644 --- a/src/app/file-browser/components/file-viewer/file-viewer.component.spec.ts +++ b/src/app/file-browser/components/file-viewer/file-viewer.component.spec.ts @@ -15,7 +15,13 @@ import { ApiService } from '@shared/services/api/api.service'; import { FeatureFlagService } from '@root/app/feature-flag/services/feature-flag.service'; import { MockComponent } from 'ng-mocks'; import { GetThumbnailPipe } from '@shared/pipes/get-thumbnail.pipe'; +import { MessageService } from '@shared/services/message/message.service'; +import { + DateTimeModel, + EdtfService, +} from '@shared/services/edtf-service/edtf.service'; import { TagsComponent } from '../../../shared/components/tags/tags.component'; +import { EditDateTimeModalService } from '../edit-date-time-modal/edit-date-time-modal.service'; import { FileViewerComponent } from './file-viewer.component'; @Pipe({ name: 'dsFileSize', standalone: false }) @@ -229,6 +235,19 @@ describe('FileViewerComponent', () => { isEnabled: (flag: string) => featureFlagsEnabled.get(flag) ?? false, }, }, + { + provide: MessageService, + useValue: { + showError: () => {}, + showMessage: () => {}, + }, + }, + { + provide: EditDateTimeModalService, + useValue: { + open: () => ({ closed: { subscribe: () => {} } }), + }, + }, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], }).compileComponents(); @@ -244,6 +263,68 @@ describe('FileViewerComponent', () => { expect(component).not.toBeNull(); }); + describe('edtf-date feature flag', () => { + it('should show the EDTF date picker when the edtf-date flag is enabled', async () => { + featureFlagsEnabled.set('edtf-date', true); + await recreateComponent(); + + expect(component.showEdtfDatePicker).toBe(true); + expect( + fixture.nativeElement.querySelector('pr-sidebar-date-picker'), + ).toBeTruthy(); + }); + + it('should show the legacy date field and hide the EDTF picker when the edtf-date flag is disabled', async () => { + featureFlagsEnabled.set('edtf-date', false); + await recreateComponent(); + + expect(component.showEdtfDatePicker).toBe(false); + expect( + fixture.nativeElement.querySelector('pr-sidebar-date-picker'), + ).toBeNull(); + + const dateRowLabel = Array.from( + fixture.nativeElement.querySelectorAll('.metadata-table td'), + ).find((td: HTMLElement) => td.textContent?.trim() === 'Date'); + + expect(dateRowLabel).toBeTruthy(); + }); + }); + + describe('EDTF date handling', () => { + const recordWithDate = () => + new RecordVO({ + type: 'document', + displayName: 'Dated Doc', + TagVOs: [], + displayTime: '1985-05-20', + }); + + it('should compute the cached display time from the record on init', async () => { + activatedRouteData.currentRecord = recordWithDate(); + await recreateComponent(); + + expect(component.displayTimeObject?.date.year).toBe('1985'); + }); + + it('should reset the cached display time and show one error when an invalid date is saved', async () => { + activatedRouteData.currentRecord = recordWithDate(); + await recreateComponent(); + + const edtfService = TestBed.inject(EdtfService); + spyOn(edtfService, 'toEdtfDate').and.throwError('invalid date'); + const showErrorSpy = spyOn(TestBed.inject(MessageService), 'showError'); + + await component.onDateSaved({ + date: { year: 'bad' } as never, + time: { format: 'am' }, + } as DateTimeModel); + + expect(showErrorSpy).toHaveBeenCalledTimes(1); + expect(component.displayTimeObject?.date.year).toBe('1985'); + }); + }); + it('should have two tags components', () => { const tagsComponents = fixture.nativeElement.querySelectorAll('pr-tags'); diff --git a/src/app/file-browser/components/file-viewer/file-viewer.component.ts b/src/app/file-browser/components/file-viewer/file-viewer.component.ts index 9dd8b679a..c918383e8 100644 --- a/src/app/file-browser/components/file-viewer/file-viewer.component.ts +++ b/src/app/file-browser/components/file-viewer/file-viewer.component.ts @@ -30,7 +30,13 @@ import { GetAccessFile } from '@models/get-access-file'; import { ShareLinksService } from '@root/app/share-links/services/share-links.service'; import { ApiService } from '@shared/services/api/api.service'; import { FeatureFlagService } from '@root/app/feature-flag/services/feature-flag.service'; +import { + DateTimeModel, + EdtfService, +} from '@shared/services/edtf-service/edtf.service'; +import { MessageService } from '@shared/services/message/message.service'; import { TagsService } from '../../../core/services/tags/tags.service'; +import { EditDateTimeModalService } from '../edit-date-time-modal/edit-date-time-modal.service'; @Component({ selector: 'pr-file-viewer', @@ -62,6 +68,12 @@ export class FileViewerComponent implements OnInit, OnDestroy { public canEdit: boolean; + public showEdtfDatePicker = false; + + public editingDate: boolean = false; + + public displayTimeObject: DateTimeModel | null = null; + // Swiping private touchElement: HTMLElement; private thumbElement: HTMLElement; @@ -77,10 +89,10 @@ export class FileViewerComponent implements OnInit, OnDestroy { // UI public useMinimalView = false; - public editingDate: boolean = false; private bodyScrollTop: number; private itemTagsSubscription: Subscription; private tagsSubscription: Subscription; + private dateModalSubscription?: Subscription; private isUnlistedShare = true; constructor( @@ -88,6 +100,7 @@ export class FileViewerComponent implements OnInit, OnDestroy { private route: ActivatedRoute, private element: ElementRef, private dataService: DataService, + private message: MessageService, @Inject(DOCUMENT) private document: any, public sanitizer: DomSanitizer, private accountService: AccountService, @@ -97,10 +110,14 @@ export class FileViewerComponent implements OnInit, OnDestroy { private shareLinksService: ShareLinksService, private api: ApiService, private feature: FeatureFlagService, + private edtfService: EdtfService, + private editDateTimeModalService: EditDateTimeModalService, ) { // store current scroll position in file list this.bodyScrollTop = window.scrollY; + this.showEdtfDatePicker = this.feature.isEnabled('edtf-date'); + const resolvedRecord = route.snapshot.data.currentRecord; this.allTags = tagsService.getTags(); @@ -186,6 +203,7 @@ export class FileViewerComponent implements OnInit, OnDestroy { }); this.itemTagsSubscription.unsubscribe(); this.tagsSubscription.unsubscribe(); + this.dateModalSubscription?.unsubscribe(); } private setRecordsToPreview(resolvedRecord: RecordVO) { @@ -244,6 +262,7 @@ export class FileViewerComponent implements OnInit, OnDestroy { this.replayUrl = this.getReplayUrl(); } this.setCurrentTags(); + this.updateDisplayTimeObject(); } toggleSwipe(value: boolean) { @@ -432,6 +451,50 @@ export class FileViewerComponent implements OnInit, OnDestroy { } } + private updateDisplayTimeObject(): void { + const timeSource = + this.currentRecord?.displayTime || this.currentRecord?.displayDT; + try { + this.displayTimeObject = timeSource + ? this.edtfService.toDateTimeModel(timeSource) + : null; + } catch (err) { + this.displayTimeObject = null; + this.message.showError({ message: err?.message }); + } + } + + public async onDateSaved(result: DateTimeModel): Promise { + await this.saveDisplayTime(result); + } + + public async onDateMoreOptions(modalData: DateTimeModel): Promise { + const dialogRef = this.editDateTimeModalService.open(modalData); + + this.dateModalSubscription = dialogRef.closed.subscribe(async (result) => { + if (result) { + await this.saveDisplayTime(result); + } + }); + } + + private async saveDisplayTime(result: DateTimeModel): Promise { + try { + const newDisplayTime = this.edtfService.toEdtfDate(result); + await this.onFinishEditing('displayTime', newDisplayTime); + } catch (err) { + this.message.showError({ message: err?.message }); + } finally { + // Recompute so the picker re-syncs to the stored value, whether the + // save came from the inline picker or the modal, and on failure too. + this.updateDisplayTimeObject(); + } + } + + public onDateToggle(active: boolean): void { + this.editingDate = active; + } + public async onFinishEditing( property: KeysOfType, value: string, @@ -455,10 +518,6 @@ export class FileViewerComponent implements OnInit, OnDestroy { } } - public onDateToggle(active: boolean): void { - this.editingDate = active; - } - public onDownloadClick(): void { this.dataService.downloadFile(this.currentRecord); } diff --git a/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.ts b/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.ts index c950082e1..6274fbcb1 100644 --- a/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.ts +++ b/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.ts @@ -21,6 +21,7 @@ import { DateModel, TimeModel, DateQualifierFlags, + DEFAULT_DATE_QUALIFIERS, } from '@shared/services/edtf-service/edtf.service'; import { DatepickerInputComponent } from '@shared/components/datepicker-input/datepicker-input.component'; import { TimepickerInputComponent } from '@shared/components/timepicker-input/timepicker-input.component'; @@ -34,12 +35,6 @@ const EMPTY_TIME: TimeModel = { format: 'am', }; -const EMPTY_QUALIFIERS: DateQualifierFlags = { - approximate: false, - uncertain: false, - unknown: false, -}; - interface SidebarDateRow { prefixLabel?: string; text?: string; @@ -75,8 +70,8 @@ export class SidebarDatePickerComponent implements OnInit, OnChanges { _time = signal({ ...EMPTY_TIME }); _endDate = signal({ ...EMPTY_DATE }); _endTime = signal({ ...EMPTY_TIME }); - _qualifiers = signal({ ...EMPTY_QUALIFIERS }); - _endQualifiers = signal({ ...EMPTY_QUALIFIERS }); + _qualifiers = signal({ ...DEFAULT_DATE_QUALIFIERS }); + _endQualifiers = signal({ ...DEFAULT_DATE_QUALIFIERS }); _isOpenStart = signal(false); _isOpenEnd = signal(false); @@ -264,8 +259,8 @@ export class SidebarDatePickerComponent implements OnInit, OnChanges { this._time.set({ ...EMPTY_TIME }); this._endDate.set({ ...EMPTY_DATE }); this._endTime.set({ ...EMPTY_TIME }); - this._qualifiers.set({ ...EMPTY_QUALIFIERS }); - this._endQualifiers.set({ ...EMPTY_QUALIFIERS }); + this._qualifiers.set({ ...DEFAULT_DATE_QUALIFIERS }); + this._endQualifiers.set({ ...DEFAULT_DATE_QUALIFIERS }); this._isOpenStart.set(false); this._isOpenEnd.set(false); } @@ -328,8 +323,8 @@ export class SidebarDatePickerComponent implements OnInit, OnChanges { this._time.set({ ...EMPTY_TIME }); this._endDate.set({ ...EMPTY_DATE }); this._endTime.set({ ...EMPTY_TIME }); - this._qualifiers.set({ ...EMPTY_QUALIFIERS }); - this._endQualifiers.set({ ...EMPTY_QUALIFIERS }); + this._qualifiers.set({ ...DEFAULT_DATE_QUALIFIERS }); + this._endQualifiers.set({ ...DEFAULT_DATE_QUALIFIERS }); this._isOpenStart.set(false); this._isOpenEnd.set(false); return; @@ -337,8 +332,10 @@ export class SidebarDatePickerComponent implements OnInit, OnChanges { const startDate = this.displayTime.date ?? { ...EMPTY_DATE }; const endDate = this.displayTime.endDate; - const startQualifiers = this.displayTime.qualifiers ?? EMPTY_QUALIFIERS; - const endQualifiers = this.displayTime.endQualifiers ?? EMPTY_QUALIFIERS; + const startQualifiers = + this.displayTime.qualifiers ?? DEFAULT_DATE_QUALIFIERS; + const endQualifiers = + this.displayTime.endQualifiers ?? DEFAULT_DATE_QUALIFIERS; const isInterval = !!endDate; const isStartEmpty = !startDate.year && !startDate.month && !startDate.day; const isEndEmpty = diff --git a/src/app/file-browser/components/sidebar/sidebar.component.spec.ts b/src/app/file-browser/components/sidebar/sidebar.component.spec.ts index eefc4a473..a3364b71f 100644 --- a/src/app/file-browser/components/sidebar/sidebar.component.spec.ts +++ b/src/app/file-browser/components/sidebar/sidebar.component.spec.ts @@ -581,6 +581,58 @@ describe('SidebarComponent', () => { ); }); + it('should refresh the cached display time after saving from the modal', async () => { + spyOn(mockEditService, 'saveItemVoProperty').and.callFake( + async (item: any, prop: any, value: any) => { + item[prop] = value; + }, + ); + + component.selectedItem = new RecordVO({ displayTime: '1985-05-20' }); + + component.onDateMoreOptions({ + date: { year: '1985', month: '05', day: '20' }, + time: { format: 'am' }, + } as DateTimeModel); + + closedSubject.next({ + date: { year: '2000', month: '03', day: '15' }, + time: { format: 'am' }, + } as DateTimeModel); + await fixture.whenStable(); + + expect(component.displayTimeObject?.date.year).toBe('2000'); + }); + + it('should not save when the modal closes after the sidebar is destroyed', () => { + const saveSpy = spyOn(mockEditService, 'saveItemVoProperty'); + + const modalData: DateTimeModel = { + date: { year: '1985', month: '05', day: '' }, + time: { + hours: '', + minutes: '', + seconds: '', + format: 'am', + }, + }; + + component.onDateMoreOptions(modalData); + component.ngOnDestroy(); + + closedSubject.next({ + date: { year: '2000', month: '03', day: '15' }, + time: { + hours: '', + minutes: '', + seconds: '', + format: 'am', + }, + }); + + expect(saveSpy).not.toHaveBeenCalled(); + }); + it('should not save when modal is dismissed', () => { const saveSpy = spyOn( mockEditService, diff --git a/src/app/file-browser/components/sidebar/sidebar.component.ts b/src/app/file-browser/components/sidebar/sidebar.component.ts index 392902fa9..70e731109 100644 --- a/src/app/file-browser/components/sidebar/sidebar.component.ts +++ b/src/app/file-browser/components/sidebar/sidebar.component.ts @@ -250,31 +250,34 @@ export class SidebarComponent implements OnDestroy, HasSubscriptions { } async onDateSaved(result: DateTimeModel) { + await this.saveDisplayTime(result); + } + + async onDateMoreOptions(modalData: DateTimeModel): Promise { + const dialogRef = this.editDateTimeModalService.open(modalData); + + this.subscriptions.push( + dialogRef.closed.subscribe(async (result) => { + if (result) { + await this.saveDisplayTime(result); + } + }), + ); + } + + private async saveDisplayTime(result: DateTimeModel): Promise { try { const newDisplayTime = this.edtfService.toEdtfDate(result); await this.onFinishEditing('displayTime', newDisplayTime); } catch (err) { this.message.showError({ message: err?.message }); } finally { + // Recompute so the picker re-syncs to the stored value, whether the + // save came from the inline picker or the modal, and on failure too. this.updateDisplayTimeObject(); } } - async onDateMoreOptions(modalData: DateTimeModel): Promise { - const dialogRef = this.editDateTimeModalService.open(modalData); - - dialogRef.closed.subscribe(async (result) => { - if (result) { - try { - const newDisplayTime = this.edtfService.toEdtfDate(result); - await this.onFinishEditing('displayTime', newDisplayTime); - } catch (err) { - this.message.showError({ message: err?.message }); - } - } - }); - } - onLocationClick() { if (this.canEdit) { this.editService.openLocationDialog(this.selectedItem); diff --git a/src/app/shared/components/datepicker-input/datepicker-input.component.html b/src/app/shared/components/datepicker-input/datepicker-input.component.html index 01c69a8d7..7e0a610ab 100644 --- a/src/app/shared/components/datepicker-input/datepicker-input.component.html +++ b/src/app/shared/components/datepicker-input/datepicker-input.component.html @@ -1,51 +1,57 @@ -
- - / - - / - -
- - calendar_today - +
+
+ + / + + / + +
+ + calendar_today + +
+ + @if (currentError()) { + {{ currentError() }} + }
@if (showDatepicker()) { diff --git a/src/app/shared/components/datepicker-input/datepicker-input.component.scss b/src/app/shared/components/datepicker-input/datepicker-input.component.scss index cb93355ef..da4439631 100644 --- a/src/app/shared/components/datepicker-input/datepicker-input.component.scss +++ b/src/app/shared/components/datepicker-input/datepicker-input.component.scss @@ -4,7 +4,13 @@ :host { position: relative; display: block; - height: 40px; + min-height: 40px; +} + +.pr-date-input-wrap { + display: flex; + flex-direction: column; + gap: 6px; } .pr-date-input-group { @@ -15,6 +21,14 @@ &.active { @include input-focus-state; } + + &.has-error { + @include input-error-state; + } +} + +.pr-input-error { + @include input-error-message; } .pr-date-segment { diff --git a/src/app/shared/components/datepicker-input/datepicker-input.component.spec.ts b/src/app/shared/components/datepicker-input/datepicker-input.component.spec.ts index 946195d53..bbdca5373 100644 --- a/src/app/shared/components/datepicker-input/datepicker-input.component.spec.ts +++ b/src/app/shared/components/datepicker-input/datepicker-input.component.spec.ts @@ -1,7 +1,12 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Component } from '@angular/core'; import { DateModel } from '@shared/services/edtf-service/edtf.service'; -import { DatepickerInputComponent } from './datepicker-input.component'; +import { + DatepickerInputComponent, + DAY_RANGE_ERROR, + INVALID_CHARS_ERROR, + MONTH_RANGE_ERROR, +} from './datepicker-input.component'; @Component({ standalone: true, @@ -46,22 +51,20 @@ describe('DatepickerInputComponent', () => { const mockEvent = (value: string): Event => ({ target: { value } }) as unknown as Event; + // --- Valid input --- + it('should accept valid 4-digit year and emit', () => { component.updateYear(mockEvent('2026')); expect(hostComponent.lastEmittedDate?.year).toBe('2026'); + expect(component.fieldErrors.year()).toBeNull(); }); it('should emit incomplete year as raw digits (no X in input)', () => { component.updateYear(mockEvent('202')); expect(hostComponent.lastEmittedDate?.year).toBe('202'); - }); - - it('should reject non-numeric year', () => { - component.updateYear(mockEvent('20ab')); - - expect(hostComponent.lastEmittedDate).toBeNull(); + expect(component.fieldErrors.year()).toBeNull(); }); it('should accept year padded with leading zeros (ISO 8601)', () => { @@ -74,40 +77,72 @@ describe('DatepickerInputComponent', () => { component.updateMonth(mockEvent('06')); expect(hostComponent.lastEmittedDate?.month).toBe('06'); + expect(component.fieldErrors.month()).toBeNull(); }); - it('should emit single digit month', () => { + it('should accept single digit month', () => { component.updateMonth(mockEvent('1')); expect(hostComponent.lastEmittedDate?.month).toBe('1'); + expect(component.fieldErrors.month()).toBeNull(); + }); + + it('should accept day 29 for February in leap year', () => { + hostComponent.date = { year: '2024', month: '02', day: '' }; + fixture.detectChanges(); + component.updateDay(mockEvent('29')); + + expect(hostComponent.lastEmittedDate?.day).toBe('29'); + expect(component.fieldErrors.day()).toBeNull(); }); - it('should not emit or auto-focus for invalid month', () => { + // --- Invalid input now emits AND surfaces an error --- + + it('should emit invalid characters in the year and surface the invalid-characters error', () => { + component.updateYear(mockEvent('20ab')); + + expect(hostComponent.lastEmittedDate?.year).toBe('20ab'); + expect(component.fieldErrors.year()).toBe(INVALID_CHARS_ERROR); + expect(component.currentError()).toBe(INVALID_CHARS_ERROR); + }); + + it('should emit out-of-range month and surface the month error', () => { component.updateMonth(mockEvent('13')); - expect(hostComponent.lastEmittedDate).toBeNull(); + expect(hostComponent.lastEmittedDate?.month).toBe('13'); + expect(component.fieldErrors.month()).toBe(MONTH_RANGE_ERROR); }); - it('should reject non-numeric month', () => { - component.updateMonth(mockEvent('ab')); + it('should NOT surface the month range error while only one digit has been typed', () => { + component.updateMonth(mockEvent('5')); - expect(hostComponent.lastEmittedDate).toBeNull(); + expect(hostComponent.lastEmittedDate?.month).toBe('5'); + expect(component.fieldErrors.month()).toBeNull(); }); - it('should accept valid 2-digit day for month and emit', () => { - hostComponent.date = { year: '2026', month: '01', day: '' }; + it('should NOT surface the day range error while only one digit has been typed', () => { + hostComponent.date = { year: '2026', month: '02', day: '' }; fixture.detectChanges(); - component.updateDay(mockEvent('31')); + component.updateDay(mockEvent('9')); - expect(hostComponent.lastEmittedDate?.day).toBe('31'); + expect(hostComponent.lastEmittedDate?.day).toBe('9'); + expect(component.fieldErrors.day()).toBeNull(); }); - it('should emit single digit day', () => { - hostComponent.date = { year: '2026', month: '01', day: '' }; + it('should emit invalid characters in the month and surface the invalid-characters error', () => { + component.updateMonth(mockEvent('ab')); + + expect(hostComponent.lastEmittedDate?.month).toBe('ab'); + expect(component.fieldErrors.month()).toBe(INVALID_CHARS_ERROR); + }); + + it('should emit day 31 in April and surface the day error', () => { + hostComponent.date = { year: '2026', month: '04', day: '' }; fixture.detectChanges(); - component.updateDay(mockEvent('3')); + component.updateDay(mockEvent('31')); - expect(hostComponent.lastEmittedDate?.day).toBe('3'); + expect(hostComponent.lastEmittedDate?.day).toBe('31'); + expect(component.fieldErrors.day()).toBe(DAY_RANGE_ERROR); }); it('should allow backspacing a left-padded day down to a single "0" without reverting', () => { @@ -121,55 +156,84 @@ describe('DatepickerInputComponent', () => { expect(hostComponent.lastEmittedDate?.day).toBe('0'); }); - it('should not emit day greater than max for month', () => { + it('should emit day 30 in February and surface the day error', () => { hostComponent.date = { year: '2026', month: '02', day: '' }; fixture.detectChanges(); component.updateDay(mockEvent('30')); - expect(hostComponent.lastEmittedDate).toBeNull(); + expect(hostComponent.lastEmittedDate?.day).toBe('30'); + expect(component.fieldErrors.day()).toBe(DAY_RANGE_ERROR); }); - it('should accept day 29 for February in leap year', () => { - hostComponent.date = { year: '2024', month: '02', day: '' }; + it('should emit day 29 in February of a non-leap year and surface the day error', () => { + hostComponent.date = { year: '2025', month: '02', day: '' }; fixture.detectChanges(); component.updateDay(mockEvent('29')); expect(hostComponent.lastEmittedDate?.day).toBe('29'); + expect(component.fieldErrors.day()).toBe(DAY_RANGE_ERROR); }); - it('should not emit day 29 for February in non-leap year', () => { - hostComponent.date = { year: '2025', month: '02', day: '' }; + it('should re-validate the day when the month changes', () => { + hostComponent.date = { year: '2026', month: '01', day: '31' }; fixture.detectChanges(); - component.updateDay(mockEvent('29')); - expect(hostComponent.lastEmittedDate).toBeNull(); + expect(component.fieldErrors.day()).toBeNull(); + + component.updateMonth(mockEvent('04')); + + expect(component.fieldErrors.day()).toBe(DAY_RANGE_ERROR); }); - it('should not emit day 31 for 30-day months', () => { - hostComponent.date = { year: '2026', month: '04', day: '' }; - fixture.detectChanges(); - component.updateDay(mockEvent('31')); + it('should clear the year error when the field is cleared', () => { + component.updateYear(mockEvent('20ab')); + + expect(component.fieldErrors.year()).not.toBeNull(); + + component.updateYear(mockEvent('')); - expect(hostComponent.lastEmittedDate).toBeNull(); + expect(component.fieldErrors.year()).toBeNull(); }); - it('should allow typing first digit 0 or 1 for month', () => { - const input = { value: '0' } as HTMLInputElement; - component.updateMonth({ target: input } as unknown as Event); + // --- Auto-focus behavior --- - expect(input.value).toBe('0'); + it('should auto-focus the month input after a valid 4-digit year', () => { + const focusSpy = spyOn( + component.monthInput.nativeElement, + 'focus', + ).and.callThrough(); + component.updateYear(mockEvent('2026')); + + expect(focusSpy).toHaveBeenCalled(); }); - it('should reject first digit > 1 for month', () => { - hostComponent.date = { year: '', month: '', day: '' }; - fixture.detectChanges(); - const input = { value: '5' } as HTMLInputElement; - component.updateMonth({ target: input } as unknown as Event); + it('should NOT auto-focus the month input when the year has invalid characters', () => { + const focusSpy = spyOn(component.monthInput.nativeElement, 'focus'); + component.updateYear(mockEvent('20ab')); + + expect(focusSpy).not.toHaveBeenCalled(); + }); - expect(input.value).toBe(''); + it('should auto-focus the day input after a valid 2-digit month', () => { + const focusSpy = spyOn( + component.dayInput.nativeElement, + 'focus', + ).and.callThrough(); + component.updateMonth(mockEvent('06')); + + expect(focusSpy).toHaveBeenCalled(); }); - it('should emit when clearing year field', () => { + it('should NOT auto-focus the day input when the month is out of range', () => { + const focusSpy = spyOn(component.dayInput.nativeElement, 'focus'); + component.updateMonth(mockEvent('13')); + + expect(focusSpy).not.toHaveBeenCalled(); + }); + + // --- Misc --- + + it('should emit when clearing the year field', () => { hostComponent.date = { year: '2026', month: '02', day: '18' }; fixture.detectChanges(); component.updateYear(mockEvent('')); @@ -201,8 +265,9 @@ describe('DatepickerInputComponent', () => { expect(component.showDatepicker()).toBeFalse(); }); - it('should emit date and close datepicker on date select', () => { + it('should emit date and clear errors on date select', () => { component.showDatepicker.set(true); + component.fieldErrors.month.set(MONTH_RANGE_ERROR); component.onDateSelect({ year: 2026, month: 3, day: 15 }); expect(hostComponent.lastEmittedDate).toEqual({ @@ -212,6 +277,7 @@ describe('DatepickerInputComponent', () => { }); expect(component.showDatepicker()).toBeFalse(); + expect(component.currentError()).toBeNull(); }); it('should return null datepickerModel for incomplete date', () => { @@ -229,4 +295,11 @@ describe('DatepickerInputComponent', () => { expect(component.showDatepicker()).toBeFalse(); }); + + it('should refresh errors from incoming @Input date on changes', () => { + hostComponent.date = { year: '2026', month: '13', day: '' }; + fixture.detectChanges(); + + expect(component.fieldErrors.month()).toBe(MONTH_RANGE_ERROR); + }); }); diff --git a/src/app/shared/components/datepicker-input/datepicker-input.component.ts b/src/app/shared/components/datepicker-input/datepicker-input.component.ts index 5cdb954fe..27d30b239 100644 --- a/src/app/shared/components/datepicker-input/datepicker-input.component.ts +++ b/src/app/shared/components/datepicker-input/datepicker-input.component.ts @@ -4,12 +4,14 @@ import { Output, EventEmitter, signal, + computed, HostListener, ElementRef, ViewChild, OnChanges, SimpleChanges, OnInit, + WritableSignal, } from '@angular/core'; import { CommonModule } from '@angular/common'; import { NgbDatepicker, NgbDateStruct } from '@ng-bootstrap/ng-bootstrap'; @@ -18,6 +20,12 @@ import { EdtfService, } from '@shared/services/edtf-service/edtf.service'; +export const INVALID_CHARS_ERROR = 'The date contains invalid characters.'; +export const MONTH_RANGE_ERROR = 'Month must be between 1 and 12.'; +export const DAY_RANGE_ERROR = 'Day must be between 1 and 31.'; + +type DateFieldKey = keyof DateModel; + @Component({ selector: 'pr-datepicker-input', standalone: true, @@ -38,6 +46,18 @@ export class DatepickerInputComponent implements OnInit, OnChanges { showDatepicker = signal(false); datepickerModel = signal(null); + readonly fieldErrors: Record> = { + year: signal(null), + month: signal(null), + day: signal(null), + }; + currentError = computed( + () => + this.fieldErrors.year() ?? + this.fieldErrors.month() ?? + this.fieldErrors.day(), + ); + constructor( private elementRef: ElementRef, private edtfService: EdtfService, @@ -45,11 +65,13 @@ export class DatepickerInputComponent implements OnInit, OnChanges { ngOnInit(): void { this.updateDatepickerModel(this.date); + this.refreshErrorsFromInput(); } ngOnChanges(changes: SimpleChanges): void { if (changes.date) { this.updateDatepickerModel(this.date); + this.refreshErrorsFromInput(); } } @@ -64,6 +86,46 @@ export class DatepickerInputComponent implements OnInit, OnChanges { } } + private refreshErrorsFromInput(): void { + (Object.keys(this.fieldErrors) as DateFieldKey[]).forEach((datePropKey) => + this.fieldErrors[datePropKey].set( + this.getFieldError(datePropKey, this.date[datePropKey] ?? ''), + ), + ); + } + + private getFieldError( + datePropKey: DateFieldKey, + value: string, + ): string | null { + if (datePropKey === 'year') return this.getYearError(value); + if (datePropKey === 'month') return this.getMonthError(value); + return this.getDayError(value, this.date.month ?? ''); + } + + private getYearError(value: string): string | null { + return this.edtfService.getSegmentError(value, { + invalidCharsMessage: INVALID_CHARS_ERROR, + }); + } + + private getMonthError(value: string): string | null { + return this.edtfService.getSegmentError(value, { + invalidCharsMessage: INVALID_CHARS_ERROR, + isWithinRange: (month) => this.edtfService.isValidMonth(month), + rangeMessage: MONTH_RANGE_ERROR, + }); + } + + private getDayError(value: string, month: string): string | null { + return this.edtfService.getSegmentError(value, { + invalidCharsMessage: INVALID_CHARS_ERROR, + isWithinRange: (day) => + this.edtfService.isValidDay(day, this.date.year, month), + rangeMessage: DAY_RANGE_ERROR, + }); + } + @HostListener('document:click', ['$event']) onDocumentClick(event: MouseEvent): void { if (!this.elementRef.nativeElement.contains(event.target)) { @@ -77,45 +139,34 @@ export class DatepickerInputComponent implements OnInit, OnChanges { } updateYear(event: Event): void { - const input = event.target as HTMLInputElement; - const value = input.value; - - if (!this.edtfService.isValidYear(value)) { - input.value = this.date.year; - return; - } + const value = (event.target as HTMLInputElement).value; + const error = this.getFieldError('year', value); + this.fieldErrors.year.set(error); this.dateChange.emit({ ...this.date, year: value }); - if (value.length === 4) { + if (!error && value.length === 4) { this.monthInput.nativeElement.focus(); } } updateMonth(event: Event): void { - const input = event.target as HTMLInputElement; - const value = input.value; - - if (!this.edtfService.isValidMonth(value)) { - input.value = this.date.month ?? ''; - return; - } + const value = (event.target as HTMLInputElement).value; + const error = this.getFieldError('month', value); + this.fieldErrors.month.set(error); this.dateChange.emit({ ...this.date, month: value }); - if (value.length === 2) { + + // Re-validate day because its bounds depend on the month. + this.fieldErrors.day.set(this.getDayError(this.date.day ?? '', value)); + + if (!error && value.length === 2) { this.dayInput.nativeElement.focus(); } } updateDay(event: Event): void { - const input = event.target as HTMLInputElement; - const value = input.value; - - if ( - !this.edtfService.isValidDay(value, this.date.year, this.date.month ?? '') - ) { - input.value = this.date.day ?? ''; - return; - } + const value = (event.target as HTMLInputElement).value; + this.fieldErrors.day.set(this.getFieldError('day', value)); this.dateChange.emit({ ...this.date, day: value }); } @@ -126,6 +177,7 @@ export class DatepickerInputComponent implements OnInit, OnChanges { if (target.value !== '') return; event.preventDefault(); const newYear = (this.date.year ?? '').slice(0, -1); + this.fieldErrors.year.set(this.getFieldError('year', newYear)); this.dateChange.emit({ ...this.date, year: newYear }); this.yearInput.nativeElement.focus(); } @@ -136,6 +188,8 @@ export class DatepickerInputComponent implements OnInit, OnChanges { if (target.value !== '') return; event.preventDefault(); const newMonth = (this.date.month ?? '').slice(0, -1); + this.fieldErrors.month.set(this.getFieldError('month', newMonth)); + this.fieldErrors.day.set(this.getDayError(this.date.day ?? '', newMonth)); this.dateChange.emit({ ...this.date, month: newMonth }); this.monthInput.nativeElement.focus(); } @@ -148,6 +202,9 @@ export class DatepickerInputComponent implements OnInit, OnChanges { day: String(newDate.day).padStart(2, '0'), }; this.date = updatedDate; + Object.values(this.fieldErrors).forEach((fieldError) => + fieldError.set(null), + ); this.dateChange.emit(updatedDate); this.showDatepicker.set(false); } diff --git a/src/app/shared/components/timepicker-input/timepicker-input.component.html b/src/app/shared/components/timepicker-input/timepicker-input.component.html index d23a855a1..b90666cfc 100644 --- a/src/app/shared/components/timepicker-input/timepicker-input.component.html +++ b/src/app/shared/components/timepicker-input/timepicker-input.component.html @@ -1,54 +1,64 @@ -
- - : - - : - - -
- +
+ + : + + : + + +
+ + access_time + +
+ + @if (currentError()) { + {{ currentError() }} + }
@if (showTimepicker()) { diff --git a/src/app/shared/components/timepicker-input/timepicker-input.component.scss b/src/app/shared/components/timepicker-input/timepicker-input.component.scss index fc34d247b..dc14c5706 100644 --- a/src/app/shared/components/timepicker-input/timepicker-input.component.scss +++ b/src/app/shared/components/timepicker-input/timepicker-input.component.scss @@ -4,7 +4,13 @@ :host { position: relative; display: block; - height: 40px; + min-height: 40px; +} + +.pr-time-input-wrap { + display: flex; + flex-direction: column; + gap: 6px; } .pr-time-input-group { @@ -15,6 +21,14 @@ &.active { @include input-focus-state; } + + &.has-error { + @include input-error-state; + } +} + +.pr-input-error { + @include input-error-message; } .pr-time-segment { diff --git a/src/app/shared/components/timepicker-input/timepicker-input.component.spec.ts b/src/app/shared/components/timepicker-input/timepicker-input.component.spec.ts index a172e3a31..ffe1ea92b 100644 --- a/src/app/shared/components/timepicker-input/timepicker-input.component.spec.ts +++ b/src/app/shared/components/timepicker-input/timepicker-input.component.spec.ts @@ -1,7 +1,14 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Component } from '@angular/core'; import { TimeModel } from '@shared/services/edtf-service/edtf.service'; -import { TimepickerInputComponent } from './timepicker-input.component'; +import { + HOUR_12_RANGE_ERROR, + HOUR_24_RANGE_ERROR, + INVALID_CHARS_ERROR, + MINUTES_RANGE_ERROR, + SECONDS_RANGE_ERROR, + TimepickerInputComponent, +} from './timepicker-input.component'; @Component({ template: ` { const mockEvent = (value: string): Event => ({ target: { value } }) as unknown as Event; + const switchToH24 = (): void => { + hostComponent.time = { + hours: '', + minutes: '', + seconds: '', + format: 'h24', + }; + fixture.detectChanges(); + }; + // --- Basic rendering --- it('should create', () => { @@ -80,107 +97,120 @@ describe('TimepickerInputComponent', () => { expect(component.showTimepicker()).toBeFalse(); }); - // --- Hour validation (12-hour) --- + // --- Hour validation (12-hour mode) --- - it('should accept valid hour', () => { + it('should accept valid hour and clear hours error', () => { component.updateTime(mockEvent('10'), 'hours'); expect(hostComponent.lastEmittedTime?.hours).toBe('10'); + expect(component.fieldErrors.hours()).toBeNull(); }); - it('should reject hour greater than 12', () => { + it('should emit hour > 12 in 12-hour mode AND surface 1-12 error', () => { component.updateTime(mockEvent('13'), 'hours'); - expect(hostComponent.lastEmittedTime).toBeNull(); + expect(hostComponent.lastEmittedTime?.hours).toBe('13'); + expect(component.fieldErrors.hours()).toBe(HOUR_12_RANGE_ERROR); }); - it('should reject non-numeric hour', () => { + it('should emit non-numeric hour AND surface the invalid-characters error', () => { component.updateTime(mockEvent('ab'), 'hours'); - expect(hostComponent.lastEmittedTime).toBeNull(); + expect(hostComponent.lastEmittedTime?.hours).toBe('ab'); + expect(component.fieldErrors.hours()).toBe(INVALID_CHARS_ERROR); }); - it('should accept single digit 0 or 1 for hours', () => { + it('should accept single digit 0 or 1 for hours in 12-hour mode', () => { component.updateTime(mockEvent('1'), 'hours'); expect(hostComponent.lastEmittedTime?.hours).toBe('1'); + expect(component.fieldErrors.hours()).toBeNull(); }); - it('should reject single digit greater than 1 for hours', () => { + it('should NOT surface the hours range error while only one digit has been typed', () => { component.updateTime(mockEvent('2'), 'hours'); - expect(hostComponent.lastEmittedTime).toBeNull(); + expect(hostComponent.lastEmittedTime?.hours).toBe('2'); + expect(component.fieldErrors.hours()).toBeNull(); + }); + + it('should NOT surface the minutes range error while only one digit has been typed', () => { + component.updateTime(mockEvent('9'), 'minutes'); + + expect(hostComponent.lastEmittedTime?.minutes).toBe('9'); + expect(component.fieldErrors.minutes()).toBeNull(); + }); + + it('should NOT surface the seconds range error while only one digit has been typed', () => { + component.updateTime(mockEvent('9'), 'seconds'); + + expect(hostComponent.lastEmittedTime?.seconds).toBe('9'); + expect(component.fieldErrors.seconds()).toBeNull(); }); - // --- Hour validation (24-hour) --- + // --- Hour validation (24-hour mode) --- it('should accept hours 00-23 in h24 mode', () => { - hostComponent.time = { - hours: '', - minutes: '', - seconds: '', - format: 'h24', - }; - fixture.detectChanges(); + switchToH24(); component.updateTime(mockEvent('00'), 'hours'); expect(hostComponent.lastEmittedTime?.hours).toBe('00'); - - component.updateTime(mockEvent('13'), 'hours'); - - expect(hostComponent.lastEmittedTime?.hours).toBe('13'); + expect(component.fieldErrors.hours()).toBeNull(); component.updateTime(mockEvent('23'), 'hours'); expect(hostComponent.lastEmittedTime?.hours).toBe('23'); + expect(component.fieldErrors.hours()).toBeNull(); }); - it('should reject hours 24 and above in h24 mode', () => { - hostComponent.time = { - hours: '12', - minutes: '', - seconds: '', - format: 'h24', - }; - fixture.detectChanges(); - hostComponent.lastEmittedTime = null; + it('should emit hour 24 in h24 mode AND surface 0-23 error', () => { + switchToH24(); component.updateTime(mockEvent('24'), 'hours'); - expect(hostComponent.lastEmittedTime).toBeNull(); - - component.updateTime(mockEvent('30'), 'hours'); - - expect(hostComponent.lastEmittedTime).toBeNull(); + expect(hostComponent.lastEmittedTime?.hours).toBe('24'); + expect(component.fieldErrors.hours()).toBe(HOUR_24_RANGE_ERROR); }); - it('should accept single digit 0-2 for hours in h24 mode', () => { + it('should re-validate the hour when the format toggles (15 valid in h24, invalid in 12-hour)', () => { hostComponent.time = { - hours: '', + hours: '15', minutes: '', seconds: '', format: 'h24', }; fixture.detectChanges(); - component.updateTime(mockEvent('2'), 'hours'); + expect(component.fieldErrors.hours()).toBeNull(); - expect(hostComponent.lastEmittedTime?.hours).toBe('2'); + component.cycleFormat(); + + expect(component.fieldErrors.hours()).toBe(HOUR_12_RANGE_ERROR); }); - it('should reject single digit greater than 2 for hours in h24 mode', () => { + it('should clear the hour error when the format toggles to one where the value is valid', () => { hostComponent.time = { - hours: '', + hours: '15', minutes: '', seconds: '', - format: 'h24', + format: 'am', }; fixture.detectChanges(); - component.updateTime(mockEvent('3'), 'hours'); + expect(component.fieldErrors.hours()).toBe(HOUR_12_RANGE_ERROR); - expect(hostComponent.lastEmittedTime).toBeNull(); + // am -> pm: still 12-hour, still invalid + component.cycleFormat(); + fixture.detectChanges(); + + expect(component.fieldErrors.hours()).toBe(HOUR_12_RANGE_ERROR); + + // pm -> h24: now valid + component.cycleFormat(); + fixture.detectChanges(); + + expect(component.fieldErrors.hours()).toBeNull(); }); // --- Minute validation --- @@ -189,24 +219,21 @@ describe('TimepickerInputComponent', () => { component.updateTime(mockEvent('30'), 'minutes'); expect(hostComponent.lastEmittedTime?.minutes).toBe('30'); + expect(component.fieldErrors.minutes()).toBeNull(); }); - it('should reject minutes greater than 59', () => { + it('should emit minutes 60 AND surface the minutes error', () => { component.updateTime(mockEvent('60'), 'minutes'); - expect(hostComponent.lastEmittedTime).toBeNull(); + expect(hostComponent.lastEmittedTime?.minutes).toBe('60'); + expect(component.fieldErrors.minutes()).toBe(MINUTES_RANGE_ERROR); }); - it('should accept single digit 0-5 for minutes', () => { - component.updateTime(mockEvent('5'), 'minutes'); + it('should emit non-numeric minutes AND surface the invalid-characters error', () => { + component.updateTime(mockEvent('a5'), 'minutes'); - expect(hostComponent.lastEmittedTime?.minutes).toBe('5'); - }); - - it('should reject single digit greater than 5 for minutes', () => { - component.updateTime(mockEvent('6'), 'minutes'); - - expect(hostComponent.lastEmittedTime).toBeNull(); + expect(hostComponent.lastEmittedTime?.minutes).toBe('a5'); + expect(component.fieldErrors.minutes()).toBe(INVALID_CHARS_ERROR); }); // --- Second validation --- @@ -215,17 +242,19 @@ describe('TimepickerInputComponent', () => { component.updateTime(mockEvent('45'), 'seconds'); expect(hostComponent.lastEmittedTime?.seconds).toBe('45'); + expect(component.fieldErrors.seconds()).toBeNull(); }); - it('should reject seconds greater than 59', () => { + it('should emit seconds 60 AND surface the seconds error', () => { component.updateTime(mockEvent('60'), 'seconds'); - expect(hostComponent.lastEmittedTime).toBeNull(); + expect(hostComponent.lastEmittedTime?.seconds).toBe('60'); + expect(component.fieldErrors.seconds()).toBe(SECONDS_RANGE_ERROR); }); - // --- Empty values --- + // --- Empty values clear errors --- - it('should allow clearing fields', () => { + it('should allow clearing fields and clear errors', () => { hostComponent.time = { hours: '10', minutes: '30', @@ -236,6 +265,69 @@ describe('TimepickerInputComponent', () => { component.updateTime(mockEvent(''), 'hours'); expect(hostComponent.lastEmittedTime?.hours).toBe(''); + expect(component.fieldErrors.hours()).toBeNull(); + }); + + // --- currentError priority --- + + it('should surface hours error first when both hours and minutes are invalid', () => { + component.updateTime(mockEvent('13'), 'hours'); + component.updateTime(mockEvent('60'), 'minutes'); + + expect(component.currentError()).toBe(HOUR_12_RANGE_ERROR); + }); + + // --- Auto-focus --- + + it('should auto-focus the next field after a valid 2-digit hour', () => { + const focusSpy = spyOn( + component.minutesInput.nativeElement, + 'focus', + ).and.callThrough(); + component.updateTime( + mockEvent('10'), + 'hours', + component.minutesInput.nativeElement, + ); + + expect(focusSpy).toHaveBeenCalled(); + }); + + it('should NOT auto-focus the next field when the value is out of range', () => { + const focusSpy = spyOn(component.minutesInput.nativeElement, 'focus'); + component.updateTime( + mockEvent('13'), + 'hours', + component.minutesInput.nativeElement, + ); + + expect(focusSpy).not.toHaveBeenCalled(); + }); + + // Dispatches real DOM input events so the template bindings themselves are + // exercised — a regression test for the minutes -> seconds focus jump. + it('should auto-focus the seconds input after a valid 2-digit minutes value via the template', () => { + const [, minutesElement, secondsElement] = Array.from( + fixture.nativeElement.querySelectorAll('.pr-time-segment'), + ); + const focusSpy = spyOn(secondsElement, 'focus'); + + minutesElement.value = '30'; + minutesElement.dispatchEvent(new Event('input')); + + expect(focusSpy).toHaveBeenCalled(); + }); + + it('should auto-focus the minutes input after a valid 2-digit hour via the template', () => { + const [hoursElement, minutesElement] = Array.from( + fixture.nativeElement.querySelectorAll('.pr-time-segment'), + ); + const focusSpy = spyOn(minutesElement, 'focus'); + + hoursElement.value = '10'; + hoursElement.dispatchEvent(new Event('input')); + + expect(focusSpy).toHaveBeenCalled(); }); // --- Format cycle --- @@ -504,4 +596,17 @@ describe('TimepickerInputComponent', () => { expect(hostComponent.lastEmittedTime?.timezoneOffset).toBe('+05:30'); }); + // --- Initial / @Input-driven error sync --- + + it('should surface errors derived from incoming @Input time', () => { + hostComponent.time = { + hours: '25', + minutes: '', + seconds: '', + format: 'h24', + }; + fixture.detectChanges(); + + expect(component.fieldErrors.hours()).toBe(HOUR_24_RANGE_ERROR); + }); }); diff --git a/src/app/shared/components/timepicker-input/timepicker-input.component.ts b/src/app/shared/components/timepicker-input/timepicker-input.component.ts index 6ce01d446..b986f50b1 100644 --- a/src/app/shared/components/timepicker-input/timepicker-input.component.ts +++ b/src/app/shared/components/timepicker-input/timepicker-input.component.ts @@ -12,6 +12,7 @@ import { OnDestroy, ViewChild, ElementRef, + WritableSignal, } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ReactiveFormsModule, FormControl } from '@angular/forms'; @@ -25,6 +26,14 @@ import { EdtfService, } from '@shared/services/edtf-service/edtf.service'; +export const INVALID_CHARS_ERROR = 'The time contains invalid characters.'; +export const HOUR_24_RANGE_ERROR = 'Hour must be between 0 and 23.'; +export const HOUR_12_RANGE_ERROR = 'Hour must be between 1 and 12.'; +export const MINUTES_RANGE_ERROR = 'Minutes must be between 0 and 59.'; +export const SECONDS_RANGE_ERROR = 'Seconds must be between 0 and 59.'; + +type TimeFieldKey = keyof Pick; + @Component({ selector: 'pr-timepicker-input', standalone: true, @@ -51,6 +60,18 @@ export class TimepickerInputComponent implements OnInit, OnChanges, OnDestroy { formatLabel = computed(() => TIME_FORMAT_LABEL[this.timeSignal().format]); is24Hour = computed(() => this.timeSignal().format === 'h24'); + readonly fieldErrors: Record> = { + hours: signal(null), + minutes: signal(null), + seconds: signal(null), + }; + currentError = computed( + () => + this.fieldErrors.hours() ?? + this.fieldErrors.minutes() ?? + this.fieldErrors.seconds(), + ); + private destroy$ = new Subject(); constructor( @@ -62,6 +83,7 @@ export class TimepickerInputComponent implements OnInit, OnChanges, OnDestroy { this.timepickerControl.valueChanges .pipe(takeUntil(this.destroy$)) .subscribe((ngbTime) => this.onTimeSelect(ngbTime)); + this.refreshErrorsFromInput(); } ngOnChanges(changes: SimpleChanges): void { @@ -72,6 +94,7 @@ export class TimepickerInputComponent implements OnInit, OnChanges, OnDestroy { if (!this.ngbTimeEquals(model, current)) { this.timepickerControl.setValue(model, { emitEvent: false }); } + this.refreshErrorsFromInput(); } } @@ -80,6 +103,51 @@ export class TimepickerInputComponent implements OnInit, OnChanges, OnDestroy { this.destroy$.complete(); } + private refreshErrorsFromInput(): void { + (Object.keys(this.fieldErrors) as TimeFieldKey[]).forEach((timePropKey) => + this.fieldErrors[timePropKey].set( + this.getFieldError(timePropKey, this.time[timePropKey] ?? ''), + ), + ); + } + + private getFieldError( + timePropKey: TimeFieldKey, + value: string, + ): string | null { + if (timePropKey === 'hours') { + return this.getHoursError(value, this.is24Hour()); + } + if (timePropKey === 'minutes') return this.getMinutesError(value); + return this.getSecondsError(value); + } + + private getHoursError(value: string, is24Hour: boolean): string | null { + return this.edtfService.getSegmentError(value, { + invalidCharsMessage: INVALID_CHARS_ERROR, + isWithinRange: (hours) => this.edtfService.isValidHour(hours, is24Hour), + rangeMessage: is24Hour ? HOUR_24_RANGE_ERROR : HOUR_12_RANGE_ERROR, + }); + } + + private getMinutesError(value: string): string | null { + return this.edtfService.getSegmentError(value, { + invalidCharsMessage: INVALID_CHARS_ERROR, + isWithinRange: (minutes) => + this.edtfService.isValidMinutesSeconds(minutes), + rangeMessage: MINUTES_RANGE_ERROR, + }); + } + + private getSecondsError(value: string): string | null { + return this.edtfService.getSegmentError(value, { + invalidCharsMessage: INVALID_CHARS_ERROR, + isWithinRange: (seconds) => + this.edtfService.isValidMinutesSeconds(seconds), + rangeMessage: SECONDS_RANGE_ERROR, + }); + } + @HostListener('document:click', ['$event']) onDocumentClick(event: MouseEvent): void { if (!this.elementRef.nativeElement.contains(event.target)) { @@ -126,6 +194,10 @@ export class TimepickerInputComponent implements OnInit, OnChanges, OnDestroy { const nextFormat = this.FORMAT_CYCLE[(currentIndex + 1) % this.FORMAT_CYCLE.length]; this.timeChange.emit({ ...this.time, format: nextFormat }); + // Hour validity depends on the format — re-check against the new format. + this.fieldErrors.hours.set( + this.getHoursError(this.time.hours ?? '', nextFormat === 'h24'), + ); } onMinutesKeydown(event: KeyboardEvent): void { @@ -134,6 +206,7 @@ export class TimepickerInputComponent implements OnInit, OnChanges, OnDestroy { if (target.value !== '') return; event.preventDefault(); const newHours = (this.time.hours ?? '').slice(0, -1); + this.fieldErrors.hours.set(this.getFieldError('hours', newHours)); this.timeChange.emit({ ...this.time, hours: newHours }); this.hoursInput.nativeElement.focus(); } @@ -144,37 +217,24 @@ export class TimepickerInputComponent implements OnInit, OnChanges, OnDestroy { if (target.value !== '') return; event.preventDefault(); const newMinutes = (this.time.minutes ?? '').slice(0, -1); + this.fieldErrors.minutes.set(this.getFieldError('minutes', newMinutes)); this.timeChange.emit({ ...this.time, minutes: newMinutes }); this.minutesInput.nativeElement.focus(); } updateTime( event: Event, - timePropKey: keyof Pick, + timePropKey: TimeFieldKey, nextField?: HTMLInputElement, ): void { - const input = event.target as HTMLInputElement; - const value = input.value; - - if (value !== '') { - const isValid = - timePropKey === 'hours' - ? this.edtfService.isValidHour(value, this.is24Hour()) - : this.edtfService.isValidMinutesSeconds(value); - if (!isValid) { - input.value = this.time[timePropKey] ?? ''; - return; - } - } + const value = (event.target as HTMLInputElement).value; + const error = this.getFieldError(timePropKey, value); + this.fieldErrors[timePropKey].set(error); this.timeChange.emit({ ...this.time, [timePropKey]: value }); - if (nextField && value.length === 2) { - const isComplete = - timePropKey === 'hours' - ? this.edtfService.isValidHour(value, this.is24Hour()) - : this.edtfService.isValidMinutesSeconds(value); - if (isComplete) nextField.focus(); + if (!error && nextField && value.length === 2) { + nextField.focus(); } } diff --git a/src/app/shared/services/edtf-service/edtf.service.spec.ts b/src/app/shared/services/edtf-service/edtf.service.spec.ts index 2f86e5f4a..b012c571c 100644 --- a/src/app/shared/services/edtf-service/edtf.service.spec.ts +++ b/src/app/shared/services/edtf-service/edtf.service.spec.ts @@ -335,6 +335,51 @@ describe('EdtfService', () => { expect(service.toEdtfDate(model)).toBe('1985-05-20'); }); + + it('should left-pad a single-digit month with zero', () => { + const model: DateTimeModel = { + date: { year: '1985', month: '5' }, + time: { format: 'am' }, + }; + + expect(service.toEdtfDate(model)).toBe('1985-05'); + }); + + it('should left-pad a single-digit month with zero when a day is present', () => { + const model: DateTimeModel = { + date: { year: '1985', month: '5', day: '20' }, + time: { format: 'am' }, + }; + + expect(service.toEdtfDate(model)).toBe('1985-05-20'); + }); + + it('should left-pad single-digit month 1 with zero (January)', () => { + const model: DateTimeModel = { + date: { year: '1985', month: '1' }, + time: { format: 'am' }, + }; + + expect(service.toEdtfDate(model)).toBe('1985-01'); + }); + + it('should left-pad a single-digit day with zero', () => { + const model: DateTimeModel = { + date: { year: '1985', month: '05', day: '2' }, + time: { format: 'am' }, + }; + + expect(service.toEdtfDate(model)).toBe('1985-05-02'); + }); + + it('should left-pad single-digit day 1 with zero', () => { + const model: DateTimeModel = { + date: { year: '1985', month: '05', day: '1' }, + time: { format: 'am' }, + }; + + expect(service.toEdtfDate(model)).toBe('1985-05-01'); + }); }); describe('unspecified-digit (X-padding)', () => { @@ -383,44 +428,45 @@ describe('EdtfService', () => { expect(service.toEdtfDate(model)).toBe('1985-XX-20'); }); - it('should zero-pad a single-digit day on the left', () => { - const model: DateTimeModel = { - date: { year: '1985', month: '05', day: '2' }, - time: { format: 'am' }, - }; - - expect(service.toEdtfDate(model)).toBe('1985-05-02'); - }); - - it('should zero-pad a single-digit day that would be an invalid X-range', () => { + it('should combine partial year, full month, and full day', () => { const model: DateTimeModel = { - date: { year: '1985', month: '05', day: '9' }, + date: { year: '198', month: '05', day: '20' }, time: { format: 'am' }, }; - expect(service.toEdtfDate(model)).toBe('1985-05-09'); + expect(service.toEdtfDate(model)).toBe('198X-05-20'); }); + }); - it('should pad single-digit month with one X', () => { + describe('time building', () => { + it('should left-pad single-digit hour, minute and second with zero', () => { const model: DateTimeModel = { - date: { year: '1985', month: '1' }, - time: { format: 'am' }, + date: { year: '1985', month: '05', day: '20' }, + time: { + hours: '5', + minutes: '7', + seconds: '9', + format: 'am', + }, }; - expect(service.toEdtfDate(model)).toBe('1985-1X'); + expect(service.toEdtfDate(model)).toContain('T05:07:09'); }); - it('should combine partial year, full month, and full day', () => { + it('should left-pad a single-digit hour in 24-hour mode', () => { const model: DateTimeModel = { - date: { year: '198', month: '05', day: '20' }, - time: { format: 'am' }, + date: { year: '1985', month: '05', day: '20' }, + time: { + hours: '5', + minutes: '30', + seconds: '00', + format: 'h24', + }, }; - expect(service.toEdtfDate(model)).toBe('198X-05-20'); + expect(service.toEdtfDate(model)).toContain('T05:30:00'); }); - }); - describe('time building', () => { it('should build date with PM time', () => { const model: DateTimeModel = { date: { year: '1985', month: '05', day: '20' }, @@ -779,6 +825,48 @@ describe('EdtfService', () => { /Please check the values/, ); }); + + it('should throw when time is filled but the date is missing the day', () => { + const model: DateTimeModel = { + date: { year: '1985', month: '05', day: '' }, + time: { hours: '10', minutes: '30', seconds: '00', format: 'am' }, + }; + + expect(() => service.toEdtfDate(model)).toThrowError( + /complete date is required/i, + ); + }); + + it('should throw when time is filled but the date is missing the month', () => { + const model: DateTimeModel = { + date: { year: '1985', month: '', day: '20' }, + time: { hours: '10', minutes: '30', seconds: '00', format: 'am' }, + }; + + expect(() => service.toEdtfDate(model)).toThrowError( + /complete date is required/i, + ); + }); + + it('should throw when time is filled but the date has only a year', () => { + const model: DateTimeModel = { + date: { year: '1985', month: '', day: '' }, + time: { hours: '10', minutes: '30', seconds: '00', format: 'am' }, + }; + + expect(() => service.toEdtfDate(model)).toThrowError( + /complete date is required/i, + ); + }); + + it('should still emit a partial date when no time is provided', () => { + const model: DateTimeModel = { + date: { year: '1985', month: '05', day: '' }, + time: { format: 'am' }, + }; + + expect(service.toEdtfDate(model)).toBe('1985-05'); + }); }); }); @@ -1161,6 +1249,44 @@ describe('EdtfService', () => { }); }); + describe('getSegmentError', () => { + const INVALID_MESSAGE = 'invalid characters'; + const RANGE_MESSAGE = 'out of range'; + const rangeOptions = { + invalidCharsMessage: INVALID_MESSAGE, + isWithinRange: (value: string): boolean => parseInt(value, 10) <= 12, + rangeMessage: RANGE_MESSAGE, + }; + + it('should return null for an empty value', () => { + expect(service.getSegmentError('', rangeOptions)).toBeNull(); + }); + + it('should flag non-numeric characters with the provided message', () => { + expect(service.getSegmentError('a5', rangeOptions)).toBe(INVALID_MESSAGE); + }); + + it('should NOT range-check a partially-typed single digit', () => { + expect(service.getSegmentError('9', rangeOptions)).toBeNull(); + }); + + it('should flag a complete out-of-range value with the provided message', () => { + expect(service.getSegmentError('13', rangeOptions)).toBe(RANGE_MESSAGE); + }); + + it('should return null for a complete in-range value', () => { + expect(service.getSegmentError('12', rangeOptions)).toBeNull(); + }); + + it('should skip the range check when no range options are provided', () => { + expect( + service.getSegmentError('9999', { + invalidCharsMessage: INVALID_MESSAGE, + }), + ).toBeNull(); + }); + }); + describe('roundtrip', () => { it('should roundtrip a full date', () => { const edtfString = '1985-05-20'; @@ -1266,15 +1392,6 @@ describe('EdtfService', () => { expect(result).toBe(edtfString); }); - it('should normalize a partial day (1985-05-2X) to a discrete zero-padded day', () => { - // A day is treated as a discrete value, not an unspecified-digit - // range, so 2X collapses to the 2nd rather than round-tripping. - const model = service.toDateTimeModel('1985-05-2X'); - const result = service.toEdtfDate(model); - - expect(result).toBe('1985-05-02'); - }); - it('should roundtrip partial year with full month and day (198X-05-20)', () => { const edtfString = '198X-05-20'; const model = service.toDateTimeModel(edtfString); @@ -1282,5 +1399,14 @@ describe('EdtfService', () => { expect(result).toBe(edtfString); }); + + it('should canonicalize a partial day from "1985-05-2X" to "1985-05-02"', () => { + // The parser strips the trailing X, leaving day "2"; the serializer now + // treats a single 1–9 digit as a complete day and left-pads with zero. + const model = service.toDateTimeModel('1985-05-2X'); + const result = service.toEdtfDate(model); + + expect(result).toBe('1985-05-02'); + }); }); }); diff --git a/src/app/shared/services/edtf-service/edtf.service.ts b/src/app/shared/services/edtf-service/edtf.service.ts index c29fe554a..7af58fb10 100644 --- a/src/app/shared/services/edtf-service/edtf.service.ts +++ b/src/app/shared/services/edtf-service/edtf.service.ts @@ -25,6 +25,8 @@ export enum EdtfPrecision { export const UNKNOWN_VALUE = 'XXXX-XX-XX'; +const DIGITS_ONLY = /^\d*$/; + export const DEFAULT_TIME: TimeModel = { hours: '', minutes: '', @@ -38,6 +40,12 @@ export interface DateQualifierFlags { unknown: boolean; } +export const DEFAULT_DATE_QUALIFIERS: DateQualifierFlags = { + approximate: false, + uncertain: false, + unknown: false, +}; + export interface DateModel { year: string; month?: string; @@ -178,13 +186,19 @@ export class EdtfService { time: TimeModel, qualifiers?: DateQualifierFlags, ): string { + const hasCompleteDate = !!(date.year && date.month && date.day); + const hasTime = !!time?.hours; + + if (hasTime && !hasCompleteDate) { + throw new Error('A complete date is required when time is provided.'); + } + const dateStr = this.buildDateString(date); const edtfObject = edtf(dateStr); // Strip any time/timezone the library may append (e.g. T00:00:00.000Z) let result = edtfObject.toEDTF().replace(/T.*$/, ''); - const hasCompleteDate = !!(date.year && date.month && date.day); - const timeStr = hasCompleteDate ? this.buildTimeString(time) : ''; + const timeStr = hasTime ? this.buildTimeString(time) : ''; if (timeStr) { result = `${result}${timeStr}`; @@ -212,15 +226,20 @@ export class EdtfService { const year = this.padWithX(date.year, 4); if (!hasMonth && !hasDay) return year; - const month = hasMonth ? this.padWithX(date.month, 2) : 'XX'; + const month = hasMonth ? this.padMonthOrDay(date.month) : 'XX'; if (!hasDay) return `${year}-${month}`; - // A day is a discrete value, so a single digit is zero-padded on the - // left ("9" -> "09"); X-padding would produce an invalid day (90-99). - const day = date.day.padStart(2, '0'); + const day = this.padMonthOrDay(date.day); return `${year}-${month}-${day}`; } + private padMonthOrDay(value: string): string { + // A single 1–9 digit is treated as a complete value (e.g. '5' → '05'), + // since the user can't be mid-typing a two-digit value starting with 2–9. + if (/^[1-9]$/.test(value)) return `0${value}`; + return this.padWithX(value, 2); + } + private padWithX(value: string, width: number): string { const v = value ?? ''; return v.length >= width ? v : v + 'X'.repeat(width - v.length); @@ -343,6 +362,10 @@ export class EdtfService { return 'The date range is not valid. Please make sure the start date is before the end date.'; } + if (message.includes('complete date is required')) { + return 'A complete date is required when time is provided.'; + } + return 'The date entered is not valid. Please check the values and try again.'; } @@ -520,4 +543,21 @@ export class EdtfService { parse(`${yearStr}-${monthStr}-${dayStr}`, 'yyyy-MM-dd', new Date()), ); } + + getSegmentError( + value: string, + options: { + invalidCharsMessage: string; + isWithinRange?: (completeValue: string) => boolean; + rangeMessage?: string; + }, + ): string | null { + if (value === '') return null; + if (!DIGITS_ONLY.test(value)) return options.invalidCharsMessage; + if (value.length < 2) return null; + if (options.isWithinRange && !options.isWithinRange(value)) { + return options.rangeMessage ?? null; + } + return null; + } } diff --git a/src/styles/_mixins.scss b/src/styles/_mixins.scss index 51d51de94..564686cfb 100644 --- a/src/styles/_mixins.scss +++ b/src/styles/_mixins.scss @@ -44,6 +44,17 @@ box-shadow: 0px 0px 0px 4px #131b4a08; } +@mixin input-error-state { + border-color: $red; + background: rgba($red, 0.06); +} + +@mixin input-error-message { + display: block; + font-size: 12px; + color: $red; +} + @mixin icon-wrapper { background: $PR-blue-25; border-radius: 0 8px 8px 0;