From a71e0241ecc53db48f68c52d8b8f7c75ead4fa1f Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Tue, 16 Jun 2026 16:37:40 +0300 Subject: [PATCH 1/7] Use a default state for qualifiers and update sidebar with modal date Extract the detault qualifiers into a shared constant. Add a display update in the sidebar when the modal saves a new date. Issue: PER-10642 --- .../edit-date-time-modal.component.ts | 41 ++++----------- .../sidebar-date-picker.component.ts | 25 ++++----- .../sidebar/sidebar.component.spec.ts | 52 +++++++++++++++++++ .../components/sidebar/sidebar.component.ts | 33 ++++++------ .../services/edtf-service/edtf.service.ts | 6 +++ 5 files changed, 96 insertions(+), 61 deletions(-) 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/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/services/edtf-service/edtf.service.ts b/src/app/shared/services/edtf-service/edtf.service.ts index c29fe554a..6a47fd92d 100644 --- a/src/app/shared/services/edtf-service/edtf.service.ts +++ b/src/app/shared/services/edtf-service/edtf.service.ts @@ -38,6 +38,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; From c9d828e7bd430bf91c25dc7b2b4b41e5ff4c17fa Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Thu, 11 Jun 2026 15:18:00 +0300 Subject: [PATCH 2/7] Improve EDTF serialization rules and add a segment validation helper Left-pad single-digit month and day values with zero instead of X-padding them, since a single 1-9 digit can only be a complete value ('5' -> '05', no longer '5X'). Require a complete date whenever a time is provided and surface a dedicated friendly error message for that case. Add a generic getSegmentError helper that the datepicker and timepicker inputs use to build their inline field error messages (invalid characters and out-of-range checks, skipped while the value is still being typed). Issue: PER-10643 --- .../edtf-service/edtf.service.spec.ts | 188 +++++++++++++++--- .../services/edtf-service/edtf.service.ts | 46 ++++- 2 files changed, 197 insertions(+), 37 deletions(-) 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 6a47fd92d..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: '', @@ -184,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}`; @@ -218,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); @@ -349,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.'; } @@ -526,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; + } } From b2714046ea4d90a2c35a61c69d0a3acb872800aa Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Thu, 11 Jun 2026 15:34:42 +0300 Subject: [PATCH 3/7] Show inline validation errors in the datepicker input Instead of silently rejecting invalid input, the year, month and day segments now emit whatever the user typed and surface a per-field error message below the input (invalid characters, out-of-range month or day). The day is re-validated when the month changes, and picking a date from the calendar clears all errors. The error styling lives in new shared input-error-state and input-error-message mixins. Issue: PER-10643 --- .../datepicker-input.component.html | 100 ++++++----- .../datepicker-input.component.scss | 16 +- .../datepicker-input.component.spec.ts | 165 +++++++++++++----- .../datepicker-input.component.ts | 107 +++++++++--- src/styles/_mixins.scss | 11 ++ 5 files changed, 280 insertions(+), 119 deletions(-) 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/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; From 1c74479f7c7dcc6b768abbbd7930c4fe8638c585 Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Thu, 11 Jun 2026 15:45:53 +0300 Subject: [PATCH 4/7] Show inline validation errors in the timepicker input Instead of silently rejecting invalid input, the hours, minutes and seconds segments now emit whatever the user typed and surface a per-field error message below the input. The hour is re-validated when the AM/PM/24H format changes. Issue: PER-10643 --- .../timepicker-input.component.html | 108 +++++---- .../timepicker-input.component.scss | 16 +- .../timepicker-input.component.spec.ts | 225 +++++++++++++----- .../timepicker-input.component.ts | 100 ++++++-- 4 files changed, 319 insertions(+), 130 deletions(-) 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(); } } From 1b3fa3eea30396226dca5491130d92986453f6fd Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Tue, 24 Mar 2026 14:46:31 +0200 Subject: [PATCH 5/7] Add support for date range updates using EDTF intervals for folder display dates Add new getStelaFolderVOs method to retrieve updated folder information from the v2 API endpoint. This method handles both single and multiple folder requests with optional share token support. Add updateStelaFolder method to update folder date ranges by combining start and end dates into EDTF interval format before sending to the server. These changes enable proper synchronization of folder date information between the client and server when users edit date ranges in the UI. ISSUE: PER-10475 --- src/app/shared/services/api/folder.repo.ts | 2 +- src/app/shared/services/data/data.service.spec.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app/shared/services/api/folder.repo.ts b/src/app/shared/services/api/folder.repo.ts index 87ffa4ae3..5338f2ce7 100644 --- a/src/app/shared/services/api/folder.repo.ts +++ b/src/app/shared/services/api/folder.repo.ts @@ -201,7 +201,7 @@ export class FolderRepo extends BaseRepo { const queryData = { folderIds: folderVOs.map((currentFolder) => currentFolder.folderId), }; - let folderResponse: PagedStelaResponse; + let folderResponse: PagedStelaResponse | any; if (shareToken) { folderResponse = ( await firstValueFrom( diff --git a/src/app/shared/services/data/data.service.spec.ts b/src/app/shared/services/data/data.service.spec.ts index 7ade58a76..7a0c55b0e 100644 --- a/src/app/shared/services/data/data.service.spec.ts +++ b/src/app/shared/services/data/data.service.spec.ts @@ -272,6 +272,11 @@ describe('DataService', () => { service.registerItem(item); }); + const leanItemsResponse = new FolderResponse(getLeanItemsData); + spyOn(api.folder, 'getWithChildren').and.returnValue( + Promise.resolve(leanItemsResponse), + ); + service .fetchLeanItems(currentFolder.ChildItemVOs) .then(() => { From 59a19573f978601b7eda280dc59708e88e021343 Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Fri, 1 May 2026 15:32:01 +0300 Subject: [PATCH 6/7] Integrate new edit date time Issue: PER-10415 --- .../file-viewer/file-viewer.component.html | 28 +++-------- .../file-viewer/file-viewer.component.scss | 6 --- .../file-viewer/file-viewer.component.spec.ts | 15 ++++++ .../file-viewer/file-viewer.component.ts | 50 +++++++++++++++++-- 4 files changed, 68 insertions(+), 31 deletions(-) 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..f810c0d65 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 @@ -2,7 +2,6 @@ class="file-viewer" [ngClass]="{ minimal: useMinimalView, - 'editing-date': editingDate, 'can-edit': canEdit }" > @@ -125,26 +124,15 @@ > }
+
+ +
- - - - diff --git a/src/app/file-browser/components/file-viewer/file-viewer.component.scss b/src/app/file-browser/components/file-viewer/file-viewer.component.scss index 57dc32446..df156506a 100644 --- a/src/app/file-browser/components/file-viewer/file-viewer.component.scss +++ b/src/app/file-browser/components/file-viewer/file-viewer.component.scss @@ -185,12 +185,6 @@ replay-web-page { display: none; } -.editing-date pr-inline-value-edit[type='date'] { - left: -20%; - top: 3em; - margin-bottom: 3em; -} - .can-edit { pr-tags, .location-container { 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..c16c8230e 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,9 @@ 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 { 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 +231,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(); 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..837042308 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', @@ -77,7 +83,6 @@ export class FileViewerComponent implements OnInit, OnDestroy { // UI public useMinimalView = false; - public editingDate: boolean = false; private bodyScrollTop: number; private itemTagsSubscription: Subscription; private tagsSubscription: Subscription; @@ -88,6 +93,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,6 +103,8 @@ 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; @@ -432,6 +440,42 @@ export class FileViewerComponent implements OnInit, OnDestroy { } } + get displayTimeObject(): DateTimeModel | null { + try { + const timeSource = + this.currentRecord?.displayTime || this.currentRecord?.displayDT; + return timeSource ? this.edtfService.toDateTimeModel(timeSource) : null; + } catch (err) { + this.message.showError({ message: err?.message }); + } + } + + public async onDateSaved(result: DateTimeModel): Promise { + try { + const newDisplayTime = this.edtfService.toEdtfDate(result); + this.onFinishEditing('displayTime', newDisplayTime); + } catch (err) { + this.message.showError({ message: err?.message }); + } + } + + public async onDateMoreOptions(modalData: DateTimeModel): Promise { + const dialogRef = this.editDateTimeModalService.open(modalData); + + dialogRef.closed.subscribe((result) => { + if (result) { + if (result) { + try { + const newDisplayTime = this.edtfService.toEdtfDate(result); + this.onFinishEditing('displayTime', newDisplayTime); + } catch (err) { + this.message.showError({ message: err?.message }); + } + } + } + }); + } + public async onFinishEditing( property: KeysOfType, value: string, @@ -455,10 +499,6 @@ export class FileViewerComponent implements OnInit, OnDestroy { } } - public onDateToggle(active: boolean): void { - this.editingDate = active; - } - public onDownloadClick(): void { this.dataService.downloadFile(this.currentRecord); } From b3401aa3967a7657a4776d98c1364bed1197b513 Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Wed, 17 Jun 2026 11:44:41 +0300 Subject: [PATCH 7/7] Gate the record view date picker behind the edtf-date flag Show the EDTF picker only when the flag is on, else the legacy date field. Port the sidebar fixes: cached displayTimeObject, re-sync after save, and a tracked modal subscription. Issue: PER-10641 --- .../file-viewer/file-viewer.component.html | 40 ++++++++--- .../file-viewer/file-viewer.component.scss | 6 ++ .../file-viewer/file-viewer.component.spec.ts | 66 +++++++++++++++++++ .../file-viewer/file-viewer.component.ts | 57 ++++++++++------ src/app/shared/services/api/folder.repo.ts | 2 +- .../shared/services/data/data.service.spec.ts | 5 -- 6 files changed, 143 insertions(+), 33 deletions(-) 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 f810c0d65..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 @@ -2,6 +2,7 @@ class="file-viewer" [ngClass]="{ minimal: useMinimalView, + 'editing-date': editingDate, 'can-edit': canEdit }" > @@ -124,15 +125,38 @@ > } -
- -
+ @if (showEdtfDatePicker) { +
+ +
+ }
+ @if (!showEdtfDatePicker) { + + + + + } diff --git a/src/app/file-browser/components/file-viewer/file-viewer.component.scss b/src/app/file-browser/components/file-viewer/file-viewer.component.scss index df156506a..57dc32446 100644 --- a/src/app/file-browser/components/file-viewer/file-viewer.component.scss +++ b/src/app/file-browser/components/file-viewer/file-viewer.component.scss @@ -185,6 +185,12 @@ replay-web-page { display: none; } +.editing-date pr-inline-value-edit[type='date'] { + left: -20%; + top: 3em; + margin-bottom: 3em; +} + .can-edit { pr-tags, .location-container { 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 c16c8230e..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 @@ -16,6 +16,10 @@ import { FeatureFlagService } from '@root/app/feature-flag/services/feature-flag 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'; @@ -259,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 837042308..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 @@ -68,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; @@ -86,6 +92,7 @@ export class FileViewerComponent implements OnInit, OnDestroy { private bodyScrollTop: number; private itemTagsSubscription: Subscription; private tagsSubscription: Subscription; + private dateModalSubscription?: Subscription; private isUnlistedShare = true; constructor( @@ -109,6 +116,8 @@ export class FileViewerComponent implements OnInit, OnDestroy { // 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(); @@ -194,6 +203,7 @@ export class FileViewerComponent implements OnInit, OnDestroy { }); this.itemTagsSubscription.unsubscribe(); this.tagsSubscription.unsubscribe(); + this.dateModalSubscription?.unsubscribe(); } private setRecordsToPreview(resolvedRecord: RecordVO) { @@ -252,6 +262,7 @@ export class FileViewerComponent implements OnInit, OnDestroy { this.replayUrl = this.getReplayUrl(); } this.setCurrentTags(); + this.updateDisplayTimeObject(); } toggleSwipe(value: boolean) { @@ -440,42 +451,50 @@ export class FileViewerComponent implements OnInit, OnDestroy { } } - get displayTimeObject(): DateTimeModel | null { + private updateDisplayTimeObject(): void { + const timeSource = + this.currentRecord?.displayTime || this.currentRecord?.displayDT; try { - const timeSource = - this.currentRecord?.displayTime || this.currentRecord?.displayDT; - return timeSource ? this.edtfService.toDateTimeModel(timeSource) : null; + 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 { - try { - const newDisplayTime = this.edtfService.toEdtfDate(result); - this.onFinishEditing('displayTime', newDisplayTime); - } catch (err) { - this.message.showError({ message: err?.message }); - } + await this.saveDisplayTime(result); } public async onDateMoreOptions(modalData: DateTimeModel): Promise { const dialogRef = this.editDateTimeModalService.open(modalData); - dialogRef.closed.subscribe((result) => { + this.dateModalSubscription = dialogRef.closed.subscribe(async (result) => { if (result) { - if (result) { - try { - const newDisplayTime = this.edtfService.toEdtfDate(result); - this.onFinishEditing('displayTime', newDisplayTime); - } catch (err) { - this.message.showError({ message: err?.message }); - } - } + 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, diff --git a/src/app/shared/services/api/folder.repo.ts b/src/app/shared/services/api/folder.repo.ts index 5338f2ce7..87ffa4ae3 100644 --- a/src/app/shared/services/api/folder.repo.ts +++ b/src/app/shared/services/api/folder.repo.ts @@ -201,7 +201,7 @@ export class FolderRepo extends BaseRepo { const queryData = { folderIds: folderVOs.map((currentFolder) => currentFolder.folderId), }; - let folderResponse: PagedStelaResponse | any; + let folderResponse: PagedStelaResponse; if (shareToken) { folderResponse = ( await firstValueFrom( diff --git a/src/app/shared/services/data/data.service.spec.ts b/src/app/shared/services/data/data.service.spec.ts index 7a0c55b0e..7ade58a76 100644 --- a/src/app/shared/services/data/data.service.spec.ts +++ b/src/app/shared/services/data/data.service.spec.ts @@ -272,11 +272,6 @@ describe('DataService', () => { service.registerItem(item); }); - const leanItemsResponse = new FolderResponse(getLeanItemsData); - spyOn(api.folder, 'getWithChildren').and.returnValue( - Promise.resolve(leanItemsResponse), - ); - service .fetchLeanItems(currentFolder.ChildItemVOs) .then(() => {