From b92fc672126b8a2b765dad3531da44fed1fea13a Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Tue, 24 Mar 2026 14:46:31 +0200 Subject: [PATCH 01/13] 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 8c8eb7ecee599baf292c654405e54b7906f8ad4b Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Tue, 24 Mar 2026 14:49:45 +0200 Subject: [PATCH 02/13] Add date range display support with EDTF interval parsing Add displayTime and displayEndTime getters to parse EDTF interval strings from the server. These getters split interval values like "1985-05-20/1990-06-15" into separate start and end dates for display in the UI. Update template to use the new getters instead of directly accessing displayDT and displayEndDT properties. Add fallback logic to use legacy properties when the displayTime interval is not available. ISSUE: PER-10475 --- .../sidebar/sidebar.component.spec.ts | 66 ------------------- src/app/shared/services/api/folder.repo.ts | 2 +- 2 files changed, 1 insertion(+), 67 deletions(-) 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 653db2799..3ec1e8fa3 100644 --- a/src/app/file-browser/components/sidebar/sidebar.component.spec.ts +++ b/src/app/file-browser/components/sidebar/sidebar.component.spec.ts @@ -337,70 +337,4 @@ describe('SidebarComponent', () => { expect(component.displayEndTime).toBe(''); }); }); - - describe('onDateEditing', () => { - beforeEach(() => { - mockEditService.saveItemVoProperty.calls.reset(); - }); - - it('should build EDTF interval when setting start date and end date exists', async () => { - const item = new RecordVO({ displayTime: '1985-05-20/1990-06-15' }); - component.selectedItem = item; - - await component.onDateEditing('start', '2000-01-01'); - - expect(mockEditService.saveItemVoProperty).toHaveBeenCalledWith( - item, - 'displayTime', - '2000-01-01/1990-06-15', - ); - }); - - it('should build EDTF interval when setting end date and start date exists', async () => { - const item = new RecordVO({ displayTime: '1985-05-20' }); - component.selectedItem = item; - - await component.onDateEditing('end', '2025-12-31'); - - expect(mockEditService.saveItemVoProperty).toHaveBeenCalledWith( - item, - 'displayTime', - '1985-05-20/2025-12-31', - ); - }); - - it('should set only start date when no end date is provided', async () => { - const item = new RecordVO({ displayTime: '1985-05-20' }); - component.selectedItem = item; - - await component.onDateEditing('start', '2000-01-01'); - - expect(mockEditService.saveItemVoProperty).toHaveBeenCalledWith( - item, - 'displayTime', - '2000-01-01', - ); - }); - - it('should set displayTime to null when start date is cleared', async () => { - const item = new RecordVO({ displayTime: '1985-05-20/1990-06-15' }); - component.selectedItem = item; - - await component.onDateEditing('start', ''); - - expect(mockEditService.saveItemVoProperty).toHaveBeenCalledWith( - item, - 'displayTime', - null, - ); - }); - - it('should not call saveItemVoProperty when selectedItem is null', async () => { - component.selectedItem = null; - - await component.onDateEditing('start', '2000-01-01'); - - expect(mockEditService.saveItemVoProperty).not.toHaveBeenCalled(); - }); - }); }); 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( From 11413a891e7a8b29fb59ff106a8a5fed89236f12 Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Sun, 22 Feb 2026 16:19:08 +0200 Subject: [PATCH 03/13] Add a datepicker component This new component will be presentational, it will get a date input and it will also return a selected date. It includes a calendar. Issue: PER-10416 --- .../datepicker-input.component.html | 49 +++++ .../datepicker-input.component.scss | 115 ++++++++++ .../datepicker-input.component.spec.ts | 208 ++++++++++++++++++ .../datepicker-input.component.ts | 171 ++++++++++++++ 4 files changed, 543 insertions(+) create mode 100644 src/app/shared/components/datepicker-input/datepicker-input.component.html create mode 100644 src/app/shared/components/datepicker-input/datepicker-input.component.scss create mode 100644 src/app/shared/components/datepicker-input/datepicker-input.component.spec.ts create mode 100644 src/app/shared/components/datepicker-input/datepicker-input.component.ts diff --git a/src/app/shared/components/datepicker-input/datepicker-input.component.html b/src/app/shared/components/datepicker-input/datepicker-input.component.html new file mode 100644 index 000000000..78a11b312 --- /dev/null +++ b/src/app/shared/components/datepicker-input/datepicker-input.component.html @@ -0,0 +1,49 @@ +
+ + / + + / + + + calendar_today + +
+ +@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 new file mode 100644 index 000000000..6d90e2844 --- /dev/null +++ b/src/app/shared/components/datepicker-input/datepicker-input.component.scss @@ -0,0 +1,115 @@ +@import 'colors'; + +:host { + position: relative; + display: block; +} + +.pr-date-input-group { + display: flex; + align-items: center; + background: $white; + border: 1px solid $PR-blue-100; + border-radius: 8px; + padding: 0 12px; + height: 40px; + flex: 1; +} + +.pr-date-segment { + border: none; + background: transparent; + text-align: center; + font-size: 14px; + color: $PR-blue; + outline: none; + width: 36px; + + &.year { + width: 48px; + } + + &::placeholder { + color: $PR-blue-400; + } +} + +.pr-separator { + color: $PR-blue-400; + font-size: 14px; + margin: 0 2px; +} + +.pr-icon-button { + background: none; + border: none; + cursor: pointer; + padding: 4px; + color: $PR-blue-600; + display: flex; + align-items: center; + margin-left: auto; + + &:hover { + color: $PR-blue; + } + + &.disabled { + opacity: 0.5; + pointer-events: none; + } + + .material-icons { + font-size: 20px; + } +} + +.pr-datepicker-dropdown { + position: absolute; + top: 100%; + left: 0; + z-index: 10; + margin-top: 4px; + width: fit-content; + background: $white; + border: 1px solid $PR-blue-100; + border-radius: 8px; + box-shadow: 0 4px 16px rgba($black, 0.12); + padding: 8px; + + ::ng-deep ngb-datepicker { + border: none; + background: transparent; + + .ngb-dp-header { + padding-bottom: 4px; + } + + .ngb-dp-weekday { + color: $PR-blue-600; + font-size: 12px; + } + + .ngb-dp-day { + width: 2.2rem; + height: 2.2rem; + + .btn-light { + width: 100%; + height: 100%; + border-radius: 6px; + font-size: 13px; + + &:hover { + background: $PR-blue-25; + } + + &.bg-primary { + background: $PR-blue !important; + color: $white; + border-radius: 6px; + } + } + } + } +} 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 new file mode 100644 index 000000000..4fbc0ba9c --- /dev/null +++ b/src/app/shared/components/datepicker-input/datepicker-input.component.spec.ts @@ -0,0 +1,208 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Component } from '@angular/core'; +import { + DatepickerInputComponent, + DateInputObject, +} from './datepicker-input.component'; + +@Component({ + standalone: true, + imports: [DatepickerInputComponent], + template: ``, +}) +class TestHostComponent { + date: DateInputObject = { year: '', month: '', day: '' }; + disabled = false; + lastEmittedDate: DateInputObject | null = null; + + onDateChange(newDate: DateInputObject): void { + this.lastEmittedDate = newDate; + this.date = newDate; + } +} + +describe('DatepickerInputComponent', () => { + let hostComponent: TestHostComponent; + let fixture: ComponentFixture; + let component: DatepickerInputComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestHostComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + hostComponent = fixture.componentInstance; + fixture.detectChanges(); + component = fixture.debugElement.children[0].componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + const mockEvent = (value: string): Event => + ({ target: { value } }) as unknown as Event; + + // --- Year validation --- + + it('should accept valid year', () => { + component.updateYear(mockEvent('2026')); + + expect(hostComponent.lastEmittedDate?.year).toBe('2026'); + }); + + it('should reject non-numeric year', () => { + component.updateYear(mockEvent('20ab')); + + expect(hostComponent.lastEmittedDate).toBeNull(); + }); + + it('should reject year starting with 0', () => { + component.updateYear(mockEvent('0123')); + + expect(hostComponent.lastEmittedDate).toBeNull(); + }); + + // --- Month validation --- + + it('should accept valid month', () => { + component.updateMonth(mockEvent('06')); + + expect(hostComponent.lastEmittedDate?.month).toBe('06'); + }); + + it('should reject month greater than 12', () => { + component.updateMonth(mockEvent('13')); + + expect(hostComponent.lastEmittedDate).toBeNull(); + }); + + it('should reject non-numeric month', () => { + component.updateMonth(mockEvent('ab')); + + expect(hostComponent.lastEmittedDate).toBeNull(); + }); + + // --- Day validation --- + + it('should accept valid day for month', () => { + hostComponent.date = { year: '2026', month: '01', day: '' }; + fixture.detectChanges(); + component.updateDay(mockEvent('31')); + + expect(hostComponent.lastEmittedDate?.day).toBe('31'); + }); + + it('should reject day greater than max for month', () => { + hostComponent.date = { year: '2026', month: '02', day: '' }; + fixture.detectChanges(); + component.updateDay(mockEvent('30')); + + expect(hostComponent.lastEmittedDate?.day).toBeUndefined(); + }); + + 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'); + }); + + it('should reject day 29 for February in non-leap year', () => { + hostComponent.date = { year: '2025', month: '02', day: '' }; + fixture.detectChanges(); + component.updateDay(mockEvent('29')); + + expect(hostComponent.lastEmittedDate?.day).toBeUndefined(); + }); + + it('should reject day 31 for 30-day months', () => { + hostComponent.date = { year: '2026', month: '04', day: '' }; + fixture.detectChanges(); + component.updateDay(mockEvent('31')); + + expect(hostComponent.lastEmittedDate?.day).toBeUndefined(); + }); + + // --- Day clamping --- + + it('should clamp day when month changes to shorter month', () => { + hostComponent.date = { year: '2026', month: '01', day: '31' }; + fixture.detectChanges(); + component.updateMonth(mockEvent('02')); + + expect(hostComponent.lastEmittedDate?.day).toBe('28'); + }); + + it('should clamp day when year changes making Feb shorter', () => { + hostComponent.date = { year: '2024', month: '02', day: '29' }; + fixture.detectChanges(); + component.updateYear(mockEvent('2025')); + + expect(hostComponent.lastEmittedDate?.day).toBe('28'); + }); + + // --- Empty values --- + + it('should allow clearing fields', () => { + hostComponent.date = { year: '2026', month: '02', day: '18' }; + fixture.detectChanges(); + component.updateYear(mockEvent('')); + + expect(hostComponent.lastEmittedDate?.year).toBe(''); + }); + + // --- Datepicker --- + + it('should toggle datepicker', () => { + component.toggleDatepicker(); + + expect(component.showDatepicker()).toBeTrue(); + component.toggleDatepicker(); + + expect(component.showDatepicker()).toBeFalse(); + }); + + it('should not toggle datepicker when disabled', () => { + hostComponent.disabled = true; + fixture.detectChanges(); + component.toggleDatepicker(); + + expect(component.showDatepicker()).toBeFalse(); + }); + + it('should emit date and close datepicker on date select', () => { + component.showDatepicker.set(true); + component.onDateSelect({ year: 2026, month: 3, day: 15 }); + + expect(hostComponent.lastEmittedDate).toEqual({ + year: '2026', + month: '03', + day: '15', + }); + + expect(component.showDatepicker()).toBeFalse(); + }); + + it('should return null datepickerModel for incomplete date', () => { + hostComponent.date = { year: '2026', month: '', day: '' }; + fixture.detectChanges(); + + expect(component.datepickerModel()).toBeNull(); + }); + + it('should close datepicker on outside click', () => { + component.showDatepicker.set(true); + component.onDocumentClick({ + target: document.body, + } as unknown as MouseEvent); + + expect(component.showDatepicker()).toBeFalse(); + }); +}); diff --git a/src/app/shared/components/datepicker-input/datepicker-input.component.ts b/src/app/shared/components/datepicker-input/datepicker-input.component.ts new file mode 100644 index 000000000..0b6812b25 --- /dev/null +++ b/src/app/shared/components/datepicker-input/datepicker-input.component.ts @@ -0,0 +1,171 @@ +import { + Component, + Input, + Output, + EventEmitter, + signal, + computed, + HostListener, + ElementRef, + ViewChild, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { NgbDatepicker, NgbDateStruct } from '@ng-bootstrap/ng-bootstrap'; + +export interface DateInputObject { + year: string; + month: string; + day: string; +} + +const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + +@Component({ + selector: 'pr-datepicker-input', + standalone: true, + imports: [CommonModule, NgbDatepicker], + templateUrl: './datepicker-input.component.html', + styleUrls: ['./datepicker-input.component.scss'], +}) +export class DatepickerInputComponent { + @Input() date: DateInputObject = { year: '', month: '', day: '' }; + @Input() disabled = false; + + @Output() dateChange = new EventEmitter(); + + @ViewChild('monthInput') monthInput!: ElementRef; + @ViewChild('dayInput') dayInput!: ElementRef; + + showDatepicker = signal(false); + + constructor(private elementRef: ElementRef) {} + + datepickerModel = computed(() => { + const d = this.date; + const year = parseInt(d.year, 10); + const month = parseInt(d.month, 10); + const day = parseInt(d.day, 10); + if (year && month && day) { + return { year, month, day }; + } + return null; + }); + + @HostListener('document:click', ['$event']) + onDocumentClick(event: MouseEvent): void { + if (!this.elementRef.nativeElement.contains(event.target)) { + this.showDatepicker.set(false); + } + } + + toggleDatepicker(): void { + if (this.disabled) return; + this.showDatepicker.update((v) => !v); + } + + updateYear(event: Event): void { + const input = event.target as HTMLInputElement; + const value = input.value; + + if (value !== '' && !this.isValidYear(value)) { + input.value = this.date.year; + return; + } + + const updated: DateInputObject = { ...this.date, year: value }; + const maxDay = this.getMaxDaysInMonth(updated.year, updated.month); + if (updated.day && parseInt(updated.day, 10) > maxDay) { + updated.day = String(maxDay).padStart(2, '0'); + } + + this.dateChange.emit(updated); + + if (value.length === 4) { + this.monthInput.nativeElement.focus(); + } + } + + updateMonth(event: Event): void { + const input = event.target as HTMLInputElement; + const value = input.value; + + if (value !== '' && !this.isValidMonth(value)) { + input.value = this.date.month; + return; + } + + const updated: DateInputObject = { ...this.date, month: value }; + const maxDay = this.getMaxDaysInMonth(updated.year, updated.month); + if (updated.day && parseInt(updated.day, 10) > maxDay) { + updated.day = String(maxDay).padStart(2, '0'); + } + + this.dateChange.emit(updated); + + const num = parseInt(value, 10); + if (value.length === 2 && num >= 1 && num <= 12) { + this.dayInput.nativeElement.focus(); + } + } + + updateDay(event: Event): void { + const input = event.target as HTMLInputElement; + const value = input.value; + + if (value !== '' && !this.isValidDay(this.date, value)) { + input.value = this.date.day; + return; + } + + this.dateChange.emit({ ...this.date, day: value }); + } + + onDateSelect(newDate: NgbDateStruct): void { + this.dateChange.emit({ + year: String(newDate.year), + month: String(newDate.month).padStart(2, '0'), + day: String(newDate.day).padStart(2, '0'), + }); + this.showDatepicker.set(false); + } + + private isValidYear(value: string): boolean { + return this.isNumeric(value) && value.length <= 4 && !value.startsWith('0'); + } + + private isValidMonth(value: string): boolean { + if (!this.isNumeric(value) || value.length > 2) return false; + const num = parseInt(value, 10); + if (value.length === 1) return num >= 0 && num <= 1; + return num >= 1 && num <= 12; + } + + private isValidDay(currentDate: DateInputObject, value: string): boolean { + if (!this.isNumeric(value) || value.length > 2) return false; + const num = parseInt(value, 10); + const maxDay = this.getMaxDaysInMonth(currentDate.year, currentDate.month); + if (value.length === 1) return num >= 0 && num <= Math.floor(maxDay / 10); + return num >= 1 && num <= maxDay; + } + + private getMaxDaysInMonth(year: string, month: string): number { + const y = parseInt(year, 10); + const m = parseInt(month, 10); + + if (!m || m < 1 || m > 12) return 31; + + if (m === 2 && y) { + return this.isLeapYear(y) ? 29 : 28; + } + + return DAYS_IN_MONTH[m - 1]; + } + + private isNumeric(value: string): boolean { + return /^\d+$/.test(value); + } + + private isLeapYear(year: number): boolean { + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; + } +} From 461d147f78b5be6376fdeaf282adb7f8ec3ca3e3 Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Sun, 22 Feb 2026 16:20:36 +0200 Subject: [PATCH 04/13] Add a time picker input The time picker is a presentational component, its input si a time object and it returns a time object as well. It contains a time selector. It works with meridian. Issue: PER-10416 --- .../timepicker-input.component.html | 56 +++ .../timepicker-input.component.scss | 155 +++++++++ .../timepicker-input.component.spec.ts | 327 ++++++++++++++++++ .../timepicker-input.component.ts | 188 ++++++++++ 4 files changed, 726 insertions(+) create mode 100644 src/app/shared/components/timepicker-input/timepicker-input.component.html create mode 100644 src/app/shared/components/timepicker-input/timepicker-input.component.scss create mode 100644 src/app/shared/components/timepicker-input/timepicker-input.component.spec.ts create mode 100644 src/app/shared/components/timepicker-input/timepicker-input.component.ts diff --git a/src/app/shared/components/timepicker-input/timepicker-input.component.html b/src/app/shared/components/timepicker-input/timepicker-input.component.html new file mode 100644 index 000000000..a55a50a04 --- /dev/null +++ b/src/app/shared/components/timepicker-input/timepicker-input.component.html @@ -0,0 +1,56 @@ +
+ + : + + @if (showSeconds) { + : + + } + + + access_time + +
+ +@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 new file mode 100644 index 000000000..9fd496a15 --- /dev/null +++ b/src/app/shared/components/timepicker-input/timepicker-input.component.scss @@ -0,0 +1,155 @@ +@import 'colors'; + +:host { + position: relative; + display: block; +} + +.pr-time-input-group { + display: flex; + align-items: center; + background: $white; + border: 1px solid $PR-blue-100; + border-radius: 8px; + padding: 0 12px; + height: 40px; + flex: 1; +} + +.pr-time-segment { + border: none; + background: transparent; + text-align: center; + font-size: 14px; + color: $PR-blue; + outline: none; + width: 32px; + + &::placeholder { + color: $PR-blue-400; + } +} + +.pr-colon { + color: $PR-blue-400; + font-size: 14px; + margin: 0 2px; +} + +.pr-am-pm-toggle { + background: $PR-blue-50; + border: 1px solid $PR-blue-100; + border-radius: 4px; + padding: 4px 8px; + font-size: 12px; + font-weight: 600; + color: $PR-blue; + cursor: pointer; + margin-left: 4px; + + &:hover { + background: $PR-blue-100; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } +} + +.pr-icon-button { + background: none; + border: none; + cursor: pointer; + padding: 4px; + color: $PR-blue-600; + display: flex; + align-items: center; + margin-left: auto; + + &:hover { + color: $PR-blue; + } + + &.disabled { + opacity: 0.5; + pointer-events: none; + } + + .material-icons { + font-size: 20px; + } +} + +.pr-timepicker-dropdown { + position: absolute; + top: 100%; + left: 0; + z-index: 10; + margin-top: 4px; + width: fit-content; + background: $white; + border: 1px solid $PR-blue-100; + border-radius: 8px; + box-shadow: 0 4px 16px rgba($black, 0.12); + padding: 12px; + + ::ng-deep ngb-timepicker { + .visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + .ngb-tp { + align-items: center; + } + + .ngb-tp-input-container { + width: 4em; + } + + .ngb-tp-input { + font-size: 14px; + text-align: center; + border: 1px solid $PR-blue-100; + border-radius: 6px; + padding: 4px; + + &:focus { + border-color: $PR-blue; + outline: none; + } + } + + .btn-link { + color: $PR-blue-600; + + &:hover { + color: $PR-blue; + } + } + + .ngb-tp-meridian { + button { + background: $PR-blue-50; + border: 1px solid $PR-blue-100; + border-radius: 4px; + padding: 4px 12px; + font-size: 12px; + font-weight: 600; + color: $PR-blue; + + &:hover { + background: $PR-blue-100; + } + } + } + } +} 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 new file mode 100644 index 000000000..31ae22487 --- /dev/null +++ b/src/app/shared/components/timepicker-input/timepicker-input.component.spec.ts @@ -0,0 +1,327 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Component } from '@angular/core'; +import { + TimepickerInputComponent, + TimeInputObject, + Meridian, +} from './timepicker-input.component'; + +@Component({ + template: ``, + standalone: true, + imports: [TimepickerInputComponent], +}) +class TestHostComponent { + time: TimeInputObject = { + hours: '', + minutes: '', + seconds: '', + amPm: Meridian.AM, + }; + disabled = false; + showSeconds = true; + lastEmittedTime: TimeInputObject | null = null; + + onTimeChange(time: TimeInputObject): void { + this.lastEmittedTime = time; + this.time = time; + } +} + +describe('TimepickerInputComponent', () => { + let fixture: ComponentFixture; + let hostComponent: TestHostComponent; + let component: TimepickerInputComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestHostComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + hostComponent = fixture.componentInstance; + fixture.detectChanges(); + component = fixture.debugElement.children[0].componentInstance; + }); + + const mockEvent = (value: string): Event => + ({ target: { value } }) as unknown as Event; + + // --- Basic rendering --- + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render time inputs', () => { + const inputs = fixture.nativeElement.querySelectorAll('.pr-time-segment'); + + expect(inputs.length).toBe(3); + }); + + it('should hide seconds input when showSeconds is false', () => { + hostComponent.showSeconds = false; + fixture.detectChanges(); + const inputs = fixture.nativeElement.querySelectorAll('.pr-time-segment'); + + expect(inputs.length).toBe(2); + }); + + // --- Disabled state --- + + it('should not toggle timepicker when disabled', () => { + hostComponent.disabled = true; + fixture.detectChanges(); + component.toggleTimepicker(); + + expect(component.showTimepicker()).toBeFalse(); + }); + + // --- Timepicker toggle --- + + it('should toggle timepicker', () => { + component.toggleTimepicker(); + + expect(component.showTimepicker()).toBeTrue(); + component.toggleTimepicker(); + + expect(component.showTimepicker()).toBeFalse(); + }); + + // --- Hour validation --- + + it('should accept valid hour', () => { + component.updateTime(mockEvent('10'), 'hours'); + + expect(hostComponent.lastEmittedTime?.hours).toBe('10'); + }); + + it('should reject hour greater than 12', () => { + component.updateTime(mockEvent('13'), 'hours'); + + expect(hostComponent.lastEmittedTime).toBeNull(); + }); + + it('should reject non-numeric hour', () => { + component.updateTime(mockEvent('ab'), 'hours'); + + expect(hostComponent.lastEmittedTime).toBeNull(); + }); + + it('should accept single digit 0 or 1 for hours', () => { + component.updateTime(mockEvent('1'), 'hours'); + + expect(hostComponent.lastEmittedTime?.hours).toBe('1'); + }); + + it('should reject single digit greater than 1 for hours', () => { + component.updateTime(mockEvent('2'), 'hours'); + + expect(hostComponent.lastEmittedTime).toBeNull(); + }); + + // --- Minute validation --- + + it('should accept valid minutes', () => { + component.updateTime(mockEvent('30'), 'minutes'); + + expect(hostComponent.lastEmittedTime?.minutes).toBe('30'); + }); + + it('should reject minutes greater than 59', () => { + component.updateTime(mockEvent('60'), 'minutes'); + + expect(hostComponent.lastEmittedTime).toBeNull(); + }); + + it('should accept single digit 0-5 for minutes', () => { + component.updateTime(mockEvent('5'), '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(); + }); + + // --- Second validation --- + + it('should accept valid seconds', () => { + component.updateTime(mockEvent('45'), 'seconds'); + + expect(hostComponent.lastEmittedTime?.seconds).toBe('45'); + }); + + it('should reject seconds greater than 59', () => { + component.updateTime(mockEvent('60'), 'seconds'); + + expect(hostComponent.lastEmittedTime).toBeNull(); + }); + + // --- Empty values --- + + it('should allow clearing fields', () => { + hostComponent.time = { + hours: '10', + minutes: '30', + seconds: '00', + amPm: Meridian.AM, + }; + fixture.detectChanges(); + component.updateTime(mockEvent(''), 'hours'); + + expect(hostComponent.lastEmittedTime?.hours).toBe(''); + }); + + // --- AM/PM toggle --- + + it('should toggle AM to PM', () => { + hostComponent.time = { + hours: '10', + minutes: '30', + seconds: '', + amPm: Meridian.AM, + }; + fixture.detectChanges(); + component.toggleAmPm(); + + expect(hostComponent.lastEmittedTime?.amPm).toBe(Meridian.PM); + }); + + it('should toggle PM to AM', () => { + hostComponent.time = { + hours: '10', + minutes: '30', + seconds: '', + amPm: Meridian.PM, + }; + fixture.detectChanges(); + component.toggleAmPm(); + + expect(hostComponent.lastEmittedTime?.amPm).toBe(Meridian.AM); + }); + + // --- Outside click --- + + it('should close timepicker on outside click', () => { + component.showTimepicker.set(true); + component.onDocumentClick({ + target: document.body, + } as unknown as MouseEvent); + + expect(component.showTimepicker()).toBeFalse(); + }); + + // --- FormControl sync --- + + it('should sync FormControl from input time (PM)', () => { + hostComponent.time = { + hours: '02', + minutes: '30', + seconds: '15', + amPm: Meridian.PM, + }; + fixture.detectChanges(); + + expect(component.timepickerControl.value).toEqual({ + hour: 14, + minute: 30, + second: 15, + }); + }); + + it('should sync FormControl from input time (12 AM = 0)', () => { + hostComponent.time = { + hours: '12', + minutes: '00', + seconds: '00', + amPm: Meridian.AM, + }; + fixture.detectChanges(); + + expect(component.timepickerControl.value).toEqual({ + hour: 0, + minute: 0, + second: 0, + }); + }); + + it('should sync FormControl from input time (12 PM = 12)', () => { + hostComponent.time = { + hours: '12', + minutes: '00', + seconds: '00', + amPm: Meridian.PM, + }; + fixture.detectChanges(); + + expect(component.timepickerControl.value).toEqual({ + hour: 12, + minute: 0, + second: 0, + }); + }); + + it('should set FormControl to null for empty time', () => { + hostComponent.time = { + hours: '', + minutes: '', + seconds: '', + amPm: Meridian.AM, + }; + fixture.detectChanges(); + + expect(component.timepickerControl.value).toBeNull(); + }); + + // --- NgbTimepicker selection --- + + it('should emit time on ngb-timepicker select for AM', () => { + component.onTimeSelect({ hour: 9, minute: 15, second: 30 }); + + expect(hostComponent.lastEmittedTime).toEqual({ + hours: '09', + minutes: '15', + seconds: '30', + amPm: Meridian.AM, + }); + }); + + it('should emit time on ngb-timepicker select for PM', () => { + component.onTimeSelect({ hour: 14, minute: 45, second: 0 }); + + expect(hostComponent.lastEmittedTime).toEqual({ + hours: '02', + minutes: '45', + seconds: '00', + amPm: Meridian.PM, + }); + }); + + it('should emit 12 PM for hour 12', () => { + component.onTimeSelect({ hour: 12, minute: 0, second: 0 }); + + expect(hostComponent.lastEmittedTime?.hours).toBe('12'); + expect(hostComponent.lastEmittedTime?.amPm).toBe(Meridian.PM); + }); + + it('should emit 12 AM for hour 0', () => { + component.onTimeSelect({ hour: 0, minute: 0, second: 0 }); + + expect(hostComponent.lastEmittedTime?.hours).toBe('12'); + expect(hostComponent.lastEmittedTime?.amPm).toBe(Meridian.AM); + }); + + it('should not emit on null ngb-timepicker value', () => { + component.onTimeSelect(null); + + expect(hostComponent.lastEmittedTime).toBeNull(); + }); +}); diff --git a/src/app/shared/components/timepicker-input/timepicker-input.component.ts b/src/app/shared/components/timepicker-input/timepicker-input.component.ts new file mode 100644 index 000000000..574419240 --- /dev/null +++ b/src/app/shared/components/timepicker-input/timepicker-input.component.ts @@ -0,0 +1,188 @@ +import { + Component, + Input, + Output, + EventEmitter, + signal, + HostListener, + OnInit, + OnChanges, + SimpleChanges, + OnDestroy, + ViewChild, + ElementRef, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule, FormControl } from '@angular/forms'; +import { NgbTimepicker, NgbTimeStruct } from '@ng-bootstrap/ng-bootstrap'; +import { Subject, takeUntil } from 'rxjs'; + +export enum Meridian { + AM = 'AM', + PM = 'PM', +} + +export interface TimeInputObject { + hours: string; + minutes: string; + seconds: string; + amPm: Meridian; +} + +@Component({ + selector: 'pr-timepicker-input', + standalone: true, + imports: [CommonModule, ReactiveFormsModule, NgbTimepicker], + templateUrl: './timepicker-input.component.html', + styleUrls: ['./timepicker-input.component.scss'], +}) +export class TimepickerInputComponent implements OnInit, OnChanges, OnDestroy { + @Input() time: TimeInputObject = { + hours: '', + minutes: '', + seconds: '', + amPm: Meridian.AM, + }; + @Input() disabled = false; + @Input() showSeconds = true; + + @Output() timeChange = new EventEmitter(); + + @ViewChild('secondsInput') secondsInput?: ElementRef; + + showTimepicker = signal(false); + timepickerControl = new FormControl(null); + + private destroy$ = new Subject(); + + constructor(private elementRef: ElementRef) {} + + ngOnInit(): void { + this.timepickerControl.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe((ngbTime) => this.onTimeSelect(ngbTime)); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.time) { + const model = this.to24HourTime(this.time); + const current = this.timepickerControl.value; + if (!this.ngbTimeEquals(model, current)) { + this.timepickerControl.setValue(model, { emitEvent: false }); + } + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + @HostListener('document:click', ['$event']) + onDocumentClick(event: MouseEvent): void { + if (!this.elementRef.nativeElement.contains(event.target)) { + this.showTimepicker.set(false); + } + } + + toggleTimepicker(): void { + if (this.disabled) return; + this.showTimepicker.update((v) => !v); + } + + onTimeSelect(ngbTime: NgbTimeStruct | null): void { + if (!ngbTime) return; + + const amPm = ngbTime.hour >= 12 ? Meridian.PM : Meridian.AM; + const displayHour = ngbTime.hour % 12 || 12; + + this.timeChange.emit({ + hours: String(displayHour).padStart(2, '0'), + minutes: String(ngbTime.minute).padStart(2, '0'), + seconds: this.showSeconds + ? String(ngbTime.second ?? 0).padStart(2, '0') + : this.time.seconds, + amPm, + }); + } + + toggleAmPm(): void { + this.timeChange.emit({ + ...this.time, + amPm: this.time.amPm === Meridian.AM ? Meridian.PM : Meridian.AM, + }); + } + + updateTime( + event: Event, + timePropKey: keyof TimeInputObject, + nextField?: HTMLInputElement, + ): void { + const input = event.target as HTMLInputElement; + const value = input.value; + + if (value !== '') { + const isValid = + timePropKey === 'hours' + ? this.isValidHour(value) + : this.isValidMinutesSeconds(value); + if (!isValid) { + input.value = this.time[timePropKey]; + return; + } + } + + this.timeChange.emit({ ...this.time, [timePropKey]: value }); + + if (nextField && value.length === 2) { + const isComplete = + timePropKey === 'hours' + ? this.isValidHour(value) + : this.isValidMinutesSeconds(value); + if (isComplete) nextField.focus(); + } + } + + private to24HourTime(time: TimeInputObject): NgbTimeStruct | null { + const hour = this.to24Hour(parseInt(time.hours, 10), time.amPm); + const minute = parseInt(time.minutes, 10); + const second = parseInt(time.seconds, 10); + + if (isNaN(hour) || isNaN(minute)) return null; + + return { hour, minute, second: isNaN(second) ? 0 : second }; + } + + private ngbTimeEquals( + a: NgbTimeStruct | null, + b: NgbTimeStruct | null, + ): boolean { + if (a === b) return true; + if (!a || !b) return false; + return a.hour === b.hour && a.minute === b.minute && a.second === b.second; + } + + private isValidHour(value: string): boolean { + if (!this.isNumeric(value) || value.length > 2) return false; + const num = parseInt(value, 10); + if (value.length === 1) return num >= 0 && num <= 1; + return num >= 1 && num <= 12; + } + + private isValidMinutesSeconds(value: string): boolean { + if (!this.isNumeric(value) || value.length > 2) return false; + const num = parseInt(value, 10); + if (value.length === 1) return num >= 0 && num <= 5; + return num >= 0 && num <= 59; + } + + private to24Hour(hour12: number, amPm: Meridian): number { + if (isNaN(hour12)) return NaN; + if (amPm === Meridian.AM) return hour12 === 12 ? 0 : hour12; + return hour12 === 12 ? 12 : hour12 + 12; + } + + private isNumeric(value: string): boolean { + return /^\d+$/.test(value); + } +} From 685e4d14f9d2b95c7ead8f665a963b1bf98157bf Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Sun, 22 Feb 2026 16:23:02 +0200 Subject: [PATCH 05/13] Add an edit date and time component The edit date and time component is smart, because it manages its own state, but also the states of the time and date pickers. But still the state is isolated to this specific component, it gets as input a date and time object and returns back a date and time object. It handles the time picker, the date picker, the date qualifiers, start and end time and also shows an edtf calculated value. Issue: PER-10416 --- .../edit-date-time.component.html | 208 ++++++++ .../edit-date-time.component.scss | 363 +++++++++++++ .../edit-date-time.component.spec.ts | 492 ++++++++++++++++++ .../edit-date-time.component.ts | 320 ++++++++++++ .../edit-date-time/edit-date-time.model.ts | 78 +++ src/styles/_colors.scss | 84 ++- 6 files changed, 1528 insertions(+), 17 deletions(-) create mode 100644 src/app/file-browser/components/edit-date-time/edit-date-time.component.html create mode 100644 src/app/file-browser/components/edit-date-time/edit-date-time.component.scss create mode 100644 src/app/file-browser/components/edit-date-time/edit-date-time.component.spec.ts create mode 100644 src/app/file-browser/components/edit-date-time/edit-date-time.component.ts create mode 100644 src/app/file-browser/components/edit-date-time/edit-date-time.model.ts diff --git a/src/app/file-browser/components/edit-date-time/edit-date-time.component.html b/src/app/file-browser/components/edit-date-time/edit-date-time.component.html new file mode 100644 index 000000000..ab601d13c --- /dev/null +++ b/src/app/file-browser/components/edit-date-time/edit-date-time.component.html @@ -0,0 +1,208 @@ +
+
+

Edit date and time

+ +
+ +
+ +
+ This date is: + +
+ Approximate + +
+ +
+ Uncertain + +
+ +
+ Unknown + +
+
+ + +
+ + + +
+ + +
+
+ {{ + time().timezoneOffset || 'Select timezone' + }} + @if (time().timezoneName) { + {{ time().timezoneName }} + } + + unfold_more + +
+ @if (activeOverlay() === 'timezoneDropdown') { +
+ +
+
+ Select timezone +
+ @for (tz of filteredTimezones(); track tz.offset) { +
+ {{ tz.offset }} + {{ tz.name }} +
+ } +
+
+ } +
+ + + @if (useDateRange()) { +
TO
+ +
+ + + +
+ + +
+
+ {{ + endTime().timezoneOffset || 'Select timezone' + }} + @if (endTime().timezoneName) { + {{ endTime().timezoneName }} + } + + unfold_more + +
+ @if (activeOverlay() === 'endTimezoneDropdown') { +
+ +
+
+ Select timezone +
+ @for (tz of filteredTimezones(); track tz.offset) { +
+ {{ tz.offset }} + {{ tz.name }} +
+ } +
+
+ } +
+ } + + +
+ Use a date range + +
+
+ + + +
diff --git a/src/app/file-browser/components/edit-date-time/edit-date-time.component.scss b/src/app/file-browser/components/edit-date-time/edit-date-time.component.scss new file mode 100644 index 000000000..426f41085 --- /dev/null +++ b/src/app/file-browser/components/edit-date-time/edit-date-time.component.scss @@ -0,0 +1,363 @@ +@import 'colors'; + +.pr-edit-date-time-dialog { + background: $white; + border-radius: 12px; + min-width: 650px; + box-shadow: 0 8px 32px rgba($black, 0.12); + font-family: 'Inter', sans-serif; + + // Header + .pr-dialog-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + padding: 20px 24px; + border-bottom: 1px solid $PR-blue-100; + + h2 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: $PR-blue; + } + + .pr-close-button { + background: none; + border: none; + cursor: pointer; + padding: 4px; + color: $PR-blue-600; + display: flex; + align-items: center; + font-size: 20px; + + &:hover { + color: $PR-blue; + } + } + } + + // Content + .pr-dialog-content { + display: flex; + flex-direction: column; + gap: 16px; + padding: 20px 24px; + } + + // Qualifiers row + .pr-qualifiers-row { + display: flex; + align-items: center; + gap: 0; + + .pr-qualifiers-label { + font-size: 14px; + color: $PR-blue-600; + white-space: nowrap; + margin-right: 16px; + } + + .pr-qualifier-option { + display: flex; + align-items: center; + gap: 8px; + padding: 0 20px; + border-right: 1px solid $PR-blue-100; + + &:last-child { + border-right: none; + } + + span { + font-size: 14px; + color: $PR-blue; + } + } + } + + // Toggle switch + .pr-toggle { + position: relative; + display: inline-block; + width: 44px; + height: 24px; + flex-shrink: 0; + + input { + opacity: 0; + width: 0; + height: 0; + } + + .pr-toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: $toggle; + border-radius: 24px; + transition: 0.2s; + + &::before { + content: ''; + position: absolute; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: $white; + border-radius: 50%; + transition: 0.2s; + } + } + + input:checked + .pr-toggle-slider { + background-color: $toggle-checked; + } + + input:checked + .pr-toggle-slider::before { + transform: translateX(20px); + } + } + + // Date and time row + .pr-datetime-row { + display: flex; + gap: 16px; + position: relative; + } + + .pr-icon-button { + background: none; + border: none; + cursor: pointer; + padding: 4px; + color: $PR-blue; + display: flex; + align-items: center; + margin-left: auto; + + i { + font-size: 20px; + } + } + + // Timezone row + .pr-timezone-row { + position: relative; + + .pr-timezone-select { + display: flex; + align-items: center; + background: $white; + border: 1px solid $PR-blue-100; + border-radius: 8px; + padding: 0 12px; + height: 40px; + cursor: pointer; + + &:hover { + border-color: $PR-blue-300; + } + + .pr-timezone-value { + font-size: 14px; + font-weight: 600; + color: $PR-blue; + margin-right: 8px; + } + + .pr-timezone-label { + font-size: 14px; + color: $PR-blue-600; + } + + .pr-chevron { + margin-left: auto; + color: $PR-blue-600; + } + } + + .pr-timezone-dropdown { + position: absolute; + top: 52px; + left: 0; + right: 0; + background: $white; + border: 1px solid $PR-blue-100; + border-radius: 8px; + box-shadow: 0 4px 16px rgba($black, 0.12); + z-index: 10; + overflow: hidden; + + .pr-timezone-search { + width: 100%; + padding: 10px 12px; + border: none; + border-bottom: 1px solid $PR-blue-100; + font-size: 14px; + outline: none; + color: $PR-blue; + box-sizing: border-box; + + &::placeholder { + color: $PR-blue-400; + } + } + + .pr-timezone-options { + max-height: 200px; + overflow-y: auto; + } + + .pr-timezone-option { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + cursor: pointer; + transition: background 0.1s ease; + + &:hover { + background: $PR-blue-25; + } + + &.selected { + background: $PR-blue-25; + } + + .pr-tz-offset { + font-size: 13px; + font-weight: 600; + color: $PR-blue; + min-width: 90px; + } + + .pr-tz-name { + font-size: 13px; + color: $PR-blue-600; + + &.pr-placeholder { + color: $PR-blue-400; + font-style: italic; + } + } + } + } + } + + // Date range row + .pr-date-range-row { + display: flex; + align-items: center; + gap: 12px; + + span { + font-size: 14px; + color: $PR-blue; + } + } + + // Range label + .pr-range-label { + font-size: 12px; + font-weight: 600; + color: $PR-blue-600; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + // Footer + .pr-dialog-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 24px; + border-top: 1px solid $PR-blue-100; + background: $PR-blue-25; + border-radius: 0 0 12px 12px; + + .pr-edtf-display { + display: flex; + align-items: center; + gap: 10px; + + .pr-edtf-badge { + font-size: 12px; + font-weight: 400; + color: $PR-blue; + background: $white; + border-radius: 4px; + padding: 0 8px; + height: 20px; + display: flex; + align-items: center; + letter-spacing: 1px; + } + + .pr-edtf-value { + font-size: 13px; + color: $PR-blue-600; + font-family: monospace; + } + } + + .pr-dialog-actions { + display: flex; + gap: 12px; + } + + .pr-btn-cancel { + padding: 16px 24px; + border: none; + border-radius: 6px; + background: $white; + font-size: 14px; + font-weight: 500; + color: $PR-blue; + cursor: pointer; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + } + + .pr-btn-save { + padding: 16px 24px; + border: none; + border-radius: 6px; + background: $PR-blue; + font-size: 14px; + font-weight: 500; + color: $white; + cursor: pointer; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + + &:hover { + background: $PR-blue-800; + } + } + } + + // Disabled state + .disabled, + .pr-datetime-row.disabled { + opacity: 0.5; + pointer-events: none; + } + + .pr-timezone-select.disabled { + opacity: 0.5; + pointer-events: none; + } +} diff --git a/src/app/file-browser/components/edit-date-time/edit-date-time.component.spec.ts b/src/app/file-browser/components/edit-date-time/edit-date-time.component.spec.ts new file mode 100644 index 000000000..b7f8c8814 --- /dev/null +++ b/src/app/file-browser/components/edit-date-time/edit-date-time.component.spec.ts @@ -0,0 +1,492 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; +import { EditDateTimeComponent } from './edit-date-time.component'; +import { + EditDateModel, + DateQualifier, + TIMEZONES, + Meridian, +} from './edit-date-time.model'; + +describe('EditDateTimeComponent', () => { + let component: EditDateTimeComponent; + let fixture: ComponentFixture; + let dialogRefSpy: jasmine.SpyObj; + + const mockDialogData: EditDateModel = { + qualifiers: { + approximate: true, + uncertain: false, + unknown: false, + }, + date: { year: '1930', month: '', day: '' }, + time: { + hours: '11', + minutes: '', + seconds: '', + amPm: Meridian.AM, + timezoneOffset: 'GMT+01:00', + timezoneName: 'Central European Standard Time', + }, + }; + + beforeEach(async () => { + dialogRefSpy = jasmine.createSpyObj('DialogRef', ['close']); + + await TestBed.configureTestingModule({ + imports: [EditDateTimeComponent], + providers: [ + { provide: DialogRef, useValue: dialogRefSpy }, + { provide: DIALOG_DATA, useValue: mockDialogData }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(EditDateTimeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should inject dialog data', () => { + expect(component.data).toEqual(mockDialogData); + }); + + // --- Initialization --- + + it('should initialize fields from dialog data', () => { + expect(component.date().year).toBe('1930'); + expect(component.time().hours).toBe('11'); + expect(component.time().amPm).toBe(Meridian.AM); + expect(component.qualifiers().approximate).toBeTrue(); + expect(component.qualifiers().uncertain).toBeFalse(); + expect(component.qualifiers().unknown).toBeFalse(); + expect(component.time().timezoneOffset).toBe('GMT+01:00'); + expect(component.time().timezoneName).toBe( + 'Central European Standard Time', + ); + + expect(component.useDateRange()).toBeFalse(); + }); + + it('should enable useDateRange when endDate is provided', () => { + component.data = { + ...mockDialogData, + endDate: { year: '2026', month: '01', day: '15' }, + }; + component.ngOnInit(); + + expect(component.useDateRange()).toBeTrue(); + expect(component.endDate().year).toBe('2026'); + }); + + // --- Time updates via onTimeChange --- + + it('should update time fields via onTimeChange', () => { + component.onTimeChange( + { hours: '02', minutes: '30', seconds: '15', amPm: Meridian.PM }, + component.time, + ); + + expect(component.time().hours).toBe('02'); + expect(component.time().minutes).toBe('30'); + expect(component.time().seconds).toBe('15'); + expect(component.time().amPm).toBe(Meridian.PM); + }); + + it('should update end time fields via onTimeChange', () => { + component.onTimeChange( + { hours: '06', minutes: '45', seconds: '00', amPm: Meridian.AM }, + component.endTime, + ); + + expect(component.endTime().hours).toBe('06'); + expect(component.endTime().minutes).toBe('45'); + }); + + it('should preserve timezone when updating time via onTimeChange', () => { + component.time.set({ + hours: '10', + minutes: '00', + seconds: '00', + amPm: Meridian.AM, + timezoneOffset: 'GMT-05:00', + timezoneName: 'Eastern Standard Time', + }); + component.onTimeChange( + { hours: '11', minutes: '30', seconds: '00', amPm: Meridian.AM }, + component.time, + ); + + expect(component.time().hours).toBe('11'); + expect(component.time().timezoneOffset).toBe('GMT-05:00'); + expect(component.time().timezoneName).toBe('Eastern Standard Time'); + }); + + // --- Date range toggle --- + + it('should toggle date range', () => { + expect(component.useDateRange()).toBeFalse(); + component.toggleDateRange(); + + expect(component.useDateRange()).toBeTrue(); + component.toggleDateRange(); + + expect(component.useDateRange()).toBeFalse(); + }); + + // --- Qualifiers --- + + it('should toggle approximate on and off', () => { + component.qualifiers.set({ + approximate: false, + uncertain: false, + unknown: false, + }); + component.onQualifierChange(DateQualifier.Approximate); + + expect(component.qualifiers().approximate).toBeTrue(); + + component.onQualifierChange(DateQualifier.Approximate); + + expect(component.qualifiers().approximate).toBeFalse(); + }); + + it('should allow approximate and uncertain to be active at the same time', () => { + component.qualifiers.set({ + approximate: false, + uncertain: false, + unknown: false, + }); + + component.onQualifierChange(DateQualifier.Approximate); + component.onQualifierChange(DateQualifier.Uncertain); + + expect(component.qualifiers().approximate).toBeTrue(); + expect(component.qualifiers().uncertain).toBeTrue(); + }); + + it('should turn off approximate and uncertain when unknown is toggled on', () => { + component.qualifiers.set({ + approximate: true, + uncertain: true, + unknown: false, + }); + + component.onQualifierChange(DateQualifier.Unknown); + + expect(component.qualifiers().unknown).toBeTrue(); + expect(component.qualifiers().approximate).toBeFalse(); + expect(component.qualifiers().uncertain).toBeFalse(); + }); + + it('should turn off unknown when approximate is toggled on', () => { + component.qualifiers.set({ + approximate: false, + uncertain: false, + unknown: true, + }); + + component.onQualifierChange(DateQualifier.Approximate); + + expect(component.qualifiers().approximate).toBeTrue(); + expect(component.qualifiers().unknown).toBeFalse(); + }); + + it('should reset form and disable fields when unknown is toggled on', () => { + component.date.set({ year: '2026', month: '02', day: '18' }); + component.time.update((t) => ({ ...t, hours: '10' })); + + component.onQualifierChange(DateQualifier.Unknown); + + expect(component.qualifiers().unknown).toBeTrue(); + expect(component.fieldsDisabled()).toBeTrue(); + expect(component.date().year).toBe(''); + expect(component.date().month).toBe(''); + expect(component.time().hours).toBe(''); + }); + + it('should save form state before setting unknown', () => { + component.date.set({ year: '2026', month: '02', day: '18' }); + component.time.update((t) => ({ ...t, hours: '10' })); + + component.onQualifierChange(DateQualifier.Unknown); + + const saved = component.savedFormState(); + + expect(saved).not.toBeNull(); + expect(saved!.date.year).toBe('2026'); + expect(saved!.time.hours).toBe('10'); + }); + + it('should restore form state including qualifiers when unknown is toggled off', () => { + component.qualifiers.set({ + approximate: true, + uncertain: true, + unknown: false, + }); + component.date.set({ year: '2026', month: '02', day: '18' }); + component.time.update((t) => ({ ...t, hours: '10' })); + + component.onQualifierChange(DateQualifier.Unknown); + + expect(component.qualifiers().unknown).toBeTrue(); + expect(component.qualifiers().approximate).toBeFalse(); + expect(component.qualifiers().uncertain).toBeFalse(); + expect(component.date().year).toBe(''); + + component.onQualifierChange(DateQualifier.Unknown); + + expect(component.qualifiers().unknown).toBeFalse(); + expect(component.qualifiers().approximate).toBeTrue(); + expect(component.qualifiers().uncertain).toBeTrue(); + expect(component.fieldsDisabled()).toBeFalse(); + expect(component.date().year).toBe('2026'); + expect(component.date().month).toBe('02'); + expect(component.time().hours).toBe('10'); + expect(component.savedFormState()).toBeNull(); + }); + + it('should show xxxx-xx-xx as EDTF value when unknown is on', () => { + component.date.set({ year: '2026', month: '02', day: '18' }); + component.onQualifierChange(DateQualifier.Unknown); + + expect(component.edtfValue()).toBe('xxxx-xx-xx'); + }); + + it('should restore EDTF value when unknown is toggled off', () => { + component.date.set({ year: '2026', month: '02', day: '18' }); + component.time.set({ + hours: '', + minutes: '', + seconds: '', + amPm: Meridian.AM, + timezoneOffset: 'GMT+01:00', + timezoneName: 'Central European Standard Time', + }); + component.qualifiers.set({ + approximate: false, + uncertain: false, + unknown: false, + }); + + component.onQualifierChange(DateQualifier.Unknown); + + expect(component.edtfValue()).toBe('xxxx-xx-xx'); + + component.onQualifierChange(DateQualifier.Unknown); + + expect(component.edtfValue()).toBe('2026-02-18'); + }); + + // --- Timezone dropdown --- + + it('should open timezone dropdown', () => { + expect(component.activeOverlay()).toBeNull(); + component.toggleOverlay('timezoneDropdown'); + + expect(component.activeOverlay()).toBe('timezoneDropdown'); + }); + + it('should close timezone dropdown on second toggle', () => { + component.toggleOverlay('timezoneDropdown'); + component.toggleOverlay('timezoneDropdown'); + + expect(component.activeOverlay()).toBeNull(); + }); + + it('should close end timezone dropdown when opening start timezone dropdown', () => { + component.activeOverlay.set('endTimezoneDropdown'); + component.toggleOverlay('timezoneDropdown'); + + expect(component.activeOverlay()).toBe('timezoneDropdown'); + }); + + it('should close start timezone dropdown when opening end timezone dropdown', () => { + component.activeOverlay.set('timezoneDropdown'); + component.toggleOverlay('endTimezoneDropdown'); + + expect(component.activeOverlay()).toBe('endTimezoneDropdown'); + }); + + it('should reset filter when toggling timezone dropdown', () => { + component.timezoneFilter.set('pacific'); + component.toggleOverlay('timezoneDropdown'); + + expect(component.timezoneFilter()).toBe(''); + }); + + it('should select a timezone and update time signal', () => { + const tz = { offset: 'GMT-05:00', name: 'Eastern Standard Time' }; + component.selectTimezone(tz, component.time); + + expect(component.time().timezoneOffset).toBe('GMT-05:00'); + expect(component.time().timezoneName).toBe('Eastern Standard Time'); + expect(component.activeOverlay()).toBeNull(); + expect(component.timezoneFilter()).toBe(''); + }); + + it('should select an end timezone and update endTime signal', () => { + const tz = { offset: 'GMT+09:00', name: 'Japan Standard Time' }; + component.selectTimezone(tz, component.endTime); + + expect(component.endTime().timezoneOffset).toBe('GMT+09:00'); + expect(component.endTime().timezoneName).toBe('Japan Standard Time'); + expect(component.activeOverlay()).toBeNull(); + }); + + it('should filter timezones by name', () => { + component.timezoneFilter.set('pacific'); + const filtered = component.filteredTimezones(); + + expect(filtered.length).toBeGreaterThan(0); + expect( + filtered.every((tz) => tz.name.toLowerCase().includes('pacific')), + ).toBeTrue(); + }); + + it('should filter timezones by offset', () => { + component.timezoneFilter.set('GMT+09'); + const filtered = component.filteredTimezones(); + + expect(filtered.length).toBeGreaterThan(0); + expect(filtered.every((tz) => tz.offset.includes('GMT+09'))).toBeTrue(); + }); + + it('should return all timezones when filter is empty', () => { + component.timezoneFilter.set(''); + + expect(component.filteredTimezones().length).toBe(TIMEZONES.length); + }); + + // --- Dialog actions --- + + it('should close dialog on cancel', () => { + component.onCancel(); + + expect(dialogRefSpy.close).toHaveBeenCalledWith(); + }); + + it('should close dialog with form data on save', () => { + component.onSave(); + + expect(dialogRefSpy.close).toHaveBeenCalledWith( + jasmine.objectContaining({ + qualifiers: { approximate: true, uncertain: false, unknown: false }, + date: { year: '1930', month: '', day: '' }, + }), + ); + }); + + it('should include endDate and endTime in save when useDateRange is on', () => { + component.useDateRange.set(true); + component.endDate.set({ year: '2026', month: '12', day: '31' }); + component.onSave(); + + const result = dialogRefSpy.close.calls.mostRecent() + .args[0] as EditDateModel; + + expect(result.endDate).toEqual({ year: '2026', month: '12', day: '31' }); + }); + + it('should not include endDate in save when useDateRange is off', () => { + component.useDateRange.set(false); + component.onSave(); + + const result = dialogRefSpy.close.calls.mostRecent() + .args[0] as EditDateModel; + + expect(result.endDate).toBeUndefined(); + }); + + // --- EDTF computed --- + + it('should compute EDTF value from date and time', () => { + component.date.set({ year: '2026', month: '02', day: '18' }); + component.time.set({ + hours: '10', + minutes: '30', + seconds: '', + amPm: Meridian.AM, + timezoneOffset: 'GMT+01:00', + timezoneName: 'Central European Standard Time', + }); + component.qualifiers.set({ + approximate: false, + uncertain: false, + unknown: false, + }); + + expect(component.edtfValue()).toBe('2026-02-18T10:30'); + }); + + it('should compute EDTF with date only', () => { + component.date.set({ year: '2026', month: '', day: '' }); + component.time.set({ + hours: '', + minutes: '', + seconds: '', + amPm: Meridian.AM, + timezoneOffset: 'GMT+01:00', + timezoneName: 'Central European Standard Time', + }); + component.qualifiers.set({ + approximate: false, + uncertain: false, + unknown: false, + }); + + expect(component.edtfValue()).toBe('2026'); + }); + + it('should include end date in EDTF when useDateRange is on', () => { + component.date.set({ year: '2026', month: '01', day: '01' }); + component.time.set({ + hours: '', + minutes: '', + seconds: '', + amPm: Meridian.AM, + timezoneOffset: 'GMT+01:00', + timezoneName: 'Central European Standard Time', + }); + component.qualifiers.set({ + approximate: false, + uncertain: false, + unknown: false, + }); + component.useDateRange.set(true); + component.endDate.set({ year: '2026', month: '12', day: '31' }); + component.endTime.set({ + hours: '11', + minutes: '59', + seconds: '', + amPm: Meridian.PM, + timezoneOffset: 'GMT+01:00', + timezoneName: 'Central European Standard Time', + }); + + expect(component.edtfValue()).toBe('2026-01-01/2026-12-31T23:59'); + }); + + it('should not include end date in EDTF when useDateRange is off', () => { + component.date.set({ year: '2026', month: '01', day: '01' }); + component.time.set({ + hours: '', + minutes: '', + seconds: '', + amPm: Meridian.AM, + timezoneOffset: 'GMT+01:00', + timezoneName: 'Central European Standard Time', + }); + component.qualifiers.set({ + approximate: false, + uncertain: false, + unknown: false, + }); + component.useDateRange.set(false); + + expect(component.edtfValue()).toBe('2026-01-01'); + }); +}); diff --git a/src/app/file-browser/components/edit-date-time/edit-date-time.component.ts b/src/app/file-browser/components/edit-date-time/edit-date-time.component.ts new file mode 100644 index 000000000..7e7df448e --- /dev/null +++ b/src/app/file-browser/components/edit-date-time/edit-date-time.component.ts @@ -0,0 +1,320 @@ +import { + Component, + Inject, + OnInit, + signal, + computed, + WritableSignal, + HostListener, + ViewChild, + ElementRef, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; +import { DatepickerInputComponent } from '@shared/components/datepicker-input/datepicker-input.component'; +import { + TimepickerInputComponent, + TimeInputObject, +} from '@shared/components/timepicker-input/timepicker-input.component'; +import { + EditDateModel, + DateQualifierObject, + DateObject, + TimeObject, + TimezoneOption, + TIMEZONES, + DateQualifier, + Meridian, +} from './edit-date-time.model'; + +type OverlayKey = 'timezoneDropdown' | 'endTimezoneDropdown'; + +interface SavedFormState { + qualifiers: DateQualifierObject; + date: DateObject; + time: TimeObject; + endDate: DateObject; + endTime: TimeObject; + useDateRange: boolean; +} + +const DEFAULT_TIME: TimeObject = { + hours: '', + minutes: '', + seconds: '', + amPm: Meridian.AM, + timezoneOffset: '', + timezoneName: '', +}; + +@Component({ + selector: 'pr-edit-date-time', + standalone: true, + imports: [CommonModule, DatepickerInputComponent, TimepickerInputComponent], + templateUrl: './edit-date-time.component.html', + styleUrls: ['./edit-date-time.component.scss'], +}) +export class EditDateTimeComponent implements OnInit { + readonly DateQualifier = DateQualifier; + timezones = TIMEZONES; + timezoneFilter = signal(''); + + activeOverlay = signal(null); + + @ViewChild('timezoneRow') timezoneRow?: ElementRef; + @ViewChild('endTimezoneRow') endTimezoneRow?: ElementRef; + qualifiers = signal({ + approximate: false, + uncertain: false, + unknown: false, + }); + + savedFormState = signal(null); + fieldsDisabled = computed(() => this.qualifiers().unknown); + + date = signal({ year: '', month: '', day: '' }); + + time = signal({ ...DEFAULT_TIME }); + + useDateRange = signal(false); + + endDate = signal({ year: '', month: '', day: '' }); + + endTime = signal({ ...DEFAULT_TIME }); + + edtfValue = computed(() => { + if (this.qualifiers().unknown) { + return 'xxxx-xx-xx'; + } + + let computedEdtfValue = ''; + const selectedDate = this.date(); + const selectedTime = this.time(); + + computedEdtfValue = [ + selectedDate.year, + selectedDate.month, + selectedDate.day, + ] + .filter(Boolean) + .join('-'); + const timePart = this.buildTimePart(selectedTime); + + if (timePart) { + computedEdtfValue += `T${timePart}`; + } + + if (this.useDateRange()) { + const selectedEndDate = this.endDate(); + const selectedEndTime = this.endTime(); + const endTimePart = this.buildTimePart(selectedEndTime); + let endEdtf = [ + selectedEndDate.year, + selectedEndDate.month, + selectedEndDate.day, + ] + .filter(Boolean) + .join('-'); + + if (endTimePart) { + endEdtf += `T${endTimePart}`; + } + + if (endEdtf) { + computedEdtfValue += `/${endEdtf}`; + } + } + + return computedEdtfValue; + }); + + filteredTimezones = computed(() => { + const filter = this.timezoneFilter().toLowerCase(); + if (!filter) return this.timezones; + return this.timezones.filter( + (tz) => + tz.name.toLowerCase().includes(filter) || + tz.offset.toLowerCase().includes(filter), + ); + }); + + @HostListener('document:click', ['$event']) + onDocumentClick(event: MouseEvent): void { + const target = event.target as Node; + const insideTimezone = this.timezoneRow?.nativeElement.contains(target); + const insideEndTimezone = + this.endTimezoneRow?.nativeElement.contains(target); + + if (!insideTimezone && !insideEndTimezone) { + this.closeAllOverlays(); + } + } + + closeAllOverlays(): void { + this.activeOverlay.set(null); + this.timezoneFilter.set(''); + } + + constructor( + public dialogRef: DialogRef, + @Inject(DIALOG_DATA) public data: EditDateModel, + ) {} + + ngOnInit(): void { + if (this.data) { + this.qualifiers.set( + this.data.qualifiers ?? { + approximate: false, + uncertain: false, + unknown: false, + }, + ); + this.date.set(this.data.date ?? { year: '', month: '', day: '' }); + this.time.set(this.data.time ?? { ...DEFAULT_TIME }); + + if (this.data.endDate) { + this.useDateRange.set(true); + this.endDate.set(this.data.endDate); + this.endTime.set(this.data.endTime ?? { ...DEFAULT_TIME }); + } + } + } + + onTimeChange( + timeInputValue: TimeInputObject, + currentTime: WritableSignal, + ): void { + currentTime.update((t) => ({ + ...t, + hours: timeInputValue.hours, + minutes: timeInputValue.minutes, + seconds: timeInputValue.seconds, + amPm: timeInputValue.amPm, + })); + } + + onQualifierChange(newDateQualifier: DateQualifier): void { + const currentlySelectedQualifiers = this.qualifiers(); + + if (newDateQualifier === DateQualifier.Unknown) { + if (currentlySelectedQualifiers.unknown) { + this.restoreForm(); + } else { + this.saveForm(); + this.qualifiers.set({ + approximate: false, + uncertain: false, + unknown: true, + }); + this.resetForm(); + } + return; + } + + if (newDateQualifier === DateQualifier.Approximate) { + this.qualifiers.update((a) => ({ + ...a, + approximate: !a.approximate, + unknown: !a.approximate || a.uncertain ? false : a.unknown, + })); + } + + if (newDateQualifier === DateQualifier.Uncertain) { + this.qualifiers.update((a) => ({ + ...a, + uncertain: !a.uncertain, + unknown: a.approximate || !a.uncertain ? false : a.unknown, + })); + } + } + + private saveForm(): void { + this.savedFormState.set({ + qualifiers: { ...this.qualifiers() }, + date: { ...this.date() }, + time: { ...this.time() }, + endDate: { ...this.endDate() }, + endTime: { ...this.endTime() }, + useDateRange: this.useDateRange(), + }); + } + + private restoreForm(): void { + const saved = this.savedFormState(); + if (saved) { + this.qualifiers.set(saved.qualifiers); + this.date.set(saved.date); + this.time.set(saved.time); + this.endDate.set(saved.endDate); + this.endTime.set(saved.endTime); + this.useDateRange.set(saved.useDateRange); + this.savedFormState.set(null); + } + } + + resetForm(): void { + this.date.set({ year: '', month: '', day: '' }); + this.time.set({ ...DEFAULT_TIME }); + this.endDate.set({ year: '', month: '', day: '' }); + this.endTime.set({ ...DEFAULT_TIME }); + this.useDateRange.set(false); + } + + toggleDateRange(): void { + this.useDateRange.update((v) => !v); + } + + toggleOverlay(key: OverlayKey): void { + if (this.activeOverlay() === key) { + this.closeAllOverlays(); + } else { + this.activeOverlay.set(key); + this.timezoneFilter.set(''); + } + } + + selectTimezone( + timezoneOption: TimezoneOption, + currentTimezone: WritableSignal, + ): void { + currentTimezone.update((t) => ({ + ...t, + timezoneOffset: timezoneOption.offset, + timezoneName: timezoneOption.name, + })); + this.closeAllOverlays(); + } + + private buildTimePart(time: TimeObject): string { + if (!time.hours) return ''; + const hour12 = parseInt(time.hours, 10); + const hour24 = this.to24Hour(hour12, time.amPm); + const hours24 = String(hour24).padStart(2, '0'); + const parts = [hours24, time.minutes, time.seconds].filter(Boolean); + return parts.length > 1 ? parts.join(':') : ''; + } + + private to24Hour(hour12: number, amPm: Meridian): number { + if (amPm === Meridian.AM) return hour12 === 12 ? 0 : hour12; + return hour12 === 12 ? 12 : hour12 + 12; + } + + onCancel(): void { + this.dialogRef.close(); + } + + onSave(): void { + const newDateModel: EditDateModel = { + qualifiers: { ...this.qualifiers() }, + date: { ...this.date() }, + time: { ...this.time() }, + }; + + if (this.useDateRange()) { + newDateModel.endDate = { ...this.endDate() }; + newDateModel.endTime = { ...this.endTime() }; + } + + this.dialogRef.close(newDateModel); + } +} diff --git a/src/app/file-browser/components/edit-date-time/edit-date-time.model.ts b/src/app/file-browser/components/edit-date-time/edit-date-time.model.ts new file mode 100644 index 000000000..094db8459 --- /dev/null +++ b/src/app/file-browser/components/edit-date-time/edit-date-time.model.ts @@ -0,0 +1,78 @@ +import { Meridian } from '@shared/components/timepicker-input/timepicker-input.component'; + +export enum DateQualifier { + Approximate = 'approximate', + Uncertain = 'uncertain', + Unknown = 'unknown', +} + +export interface DateQualifierObject { + approximate: boolean; + uncertain: boolean; + unknown: boolean; +} + +export interface DateObject { + year: string; + month: string; + day: string; +} + +export { Meridian }; + +export interface TimeObject { + hours: string; + minutes: string; + seconds: string; + amPm: Meridian; + timezoneOffset: string; + timezoneName: string; +} + +export interface EditDateModel { + qualifiers: DateQualifierObject; + date: DateObject; + time: TimeObject; + endDate?: DateObject; + endTime?: TimeObject; +} + +export interface TimezoneOption { + offset: string; + name: string; +} + +export const TIMEZONES: TimezoneOption[] = [ + { offset: 'GMT-12:00', name: 'International Date Line West' }, + { offset: 'GMT-11:00', name: 'Samoa Standard Time' }, + { offset: 'GMT-10:00', name: 'Hawaii-Aleutian Standard Time' }, + { offset: 'GMT-09:00', name: 'Alaska Standard Time' }, + { offset: 'GMT-08:00', name: 'Pacific Standard Time' }, + { offset: 'GMT-07:00', name: 'Mountain Standard Time' }, + { offset: 'GMT-06:00', name: 'Central Standard Time' }, + { offset: 'GMT-05:00', name: 'Eastern Standard Time' }, + { offset: 'GMT-04:00', name: 'Atlantic Standard Time' }, + { offset: 'GMT-03:30', name: 'Newfoundland Standard Time' }, + { offset: 'GMT-03:00', name: 'Argentina Standard Time' }, + { offset: 'GMT-02:00', name: 'Mid-Atlantic Standard Time' }, + { offset: 'GMT-01:00', name: 'Azores Standard Time' }, + { offset: 'GMT+00:00', name: 'Greenwich Mean Time' }, + { offset: 'GMT+01:00', name: 'Central European Standard Time' }, + { offset: 'GMT+02:00', name: 'Eastern European Standard Time' }, + { offset: 'GMT+03:00', name: 'Moscow Standard Time' }, + { offset: 'GMT+03:30', name: 'Iran Standard Time' }, + { offset: 'GMT+04:00', name: 'Gulf Standard Time' }, + { offset: 'GMT+04:30', name: 'Afghanistan Time' }, + { offset: 'GMT+05:00', name: 'Pakistan Standard Time' }, + { offset: 'GMT+05:30', name: 'India Standard Time' }, + { offset: 'GMT+05:45', name: 'Nepal Time' }, + { offset: 'GMT+06:00', name: 'Bangladesh Standard Time' }, + { offset: 'GMT+07:00', name: 'Indochina Time' }, + { offset: 'GMT+08:00', name: 'China Standard Time' }, + { offset: 'GMT+09:00', name: 'Japan Standard Time' }, + { offset: 'GMT+09:30', name: 'Australian Central Standard Time' }, + { offset: 'GMT+10:00', name: 'Australian Eastern Standard Time' }, + { offset: 'GMT+11:00', name: 'Solomon Islands Time' }, + { offset: 'GMT+12:00', name: 'New Zealand Standard Time' }, + { offset: 'GMT+13:00', name: 'Tonga Standard Time' }, +]; diff --git a/src/styles/_colors.scss b/src/styles/_colors.scss index 42c05a46e..aeec1a169 100644 --- a/src/styles/_colors.scss +++ b/src/styles/_colors.scss @@ -1,25 +1,73 @@ -//BRAND COLORS -$PR-blue: #131b4a; +// PERMANENT COLORS +$PR-white: #ffffff; +$PR-black: #000000; $PR-purple: #800080; $PR-orange: #ff9933; -// VARIATIONS -$PR-blue-light: #5261b7; -$PR-blue-lightest: #41496e; +// PERMANENT BLUE +$PR-blue-25: #f4f6fd; +$PR-blue-50: #e7ebed; +$PR-blue-100: #d0d1db; +$PR-blue-200: #9b8bc9; +$PR-blue-300: #a1a4b7; +$PR-blue-400: #898da4; +$PR-blue-500: #717692; +$PR-blue-600: #5a5f80; +$PR-blue-700: #42496e; +$PR-blue-800: #2b325c; +$PR-blue-900: #131b4a; +// SUCCESS +$success-25: #f6fef9; +$success-50: #ecfdf3; +$success-100: #d1fadf; +$success-200: #a6f4c5; +$success-300: #6ce9a6; +$success-400: #32d583; +$success-500: #12b76a; +$success-600: #039855; +$success-700: #027a48; +$success-800: #05603a; +$success-900: #054f31; + +// WARNING +$warning-25: #fffcf5; +$warning-50: #fffaeb; +$warning-100: #fef0c7; +$warning-200: #fedfb9; +$warning-300: #fec84b; +$warning-400: #fdb022; +$warning-500: #f79009; +$warning-600: #dc6803; +$warning-700: #b54708; +$warning-800: #93370d; +$warning-900: #7a2e0e; + +// ERROR +$error-25: #fffbfa; +$error-50: #fef3f2; +$error-100: #fee4e2; +$error-200: #fecdca; +$error-300: #fda29b; +$error-400: #f97066; +$error-500: #f04438; +$error-600: #d92d20; +$error-700: #b42318; +$error-800: #912018; +$error-900: #7a271a; + +// ALIASES (backward compatibility) +$PR-blue: $PR-blue-900; +$PR-blue-light: $PR-blue-600; +$PR-blue-lightest: $PR-blue-700; $PR-orange-light: #ffc779; $PR-orange-lightest: #ffead5; - $PR-purple-light: #e2c1ea; // UI COLORS $folder-icon: #f7c985; -$gray-dark: #646464; -$gray-light: #c8c8c8; -$red: #a10000; - -//BOOTSTRAP COLORS -$white: #fff !default; +$white: $PR-white !default; +$black: $PR-black !default; $gray-100: #f8f9fa !default; $gray-200: #e9ecef !default; $gray-300: #dee2e6 !default; @@ -29,22 +77,24 @@ $gray-600: #6c757d !default; $gray-700: #495057 !default; $gray-800: #343a40 !default; $gray-900: #212529 !default; -$black: #000 !default; +$gray-dark: #646464; +$gray-light: #c8c8c8; +$red: #a10000; // THIRD PARTY COLORS $familysearch-brand: #87ba41; $facebook-brand: #1877f2; -$toggle-checked: #12b76a; +$toggle-checked: $success-500; $toggle: #e7e8ed; $theme-colors: ( 'primary': $PR-blue, 'secondary': $gray-600, - 'success': #198754, + 'success': $success-600, 'info': #0dcaf0, - 'warning': #ffc107, - 'danger': $red, + 'warning': $warning-500, + 'danger': $error-600, 'light': $gray-100, 'dark': $gray-900, 'primary-light': $PR-blue-light, From 4092a090532fb6bbd27bbf50fdaaa6bc578acd7c Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Sun, 22 Feb 2026 16:26:30 +0200 Subject: [PATCH 06/13] Add the edit date time service that will open the edit date time modal The service has the resposability to open the modal for the edit date time component and provide the initial data. Issue: PER-10416 --- .../edit-date-time.service.spec.ts | 65 +++++++++++++++++++ .../edit-date-time/edit-date-time.service.ts | 24 +++++++ 2 files changed, 89 insertions(+) create mode 100644 src/app/file-browser/components/edit-date-time/edit-date-time.service.spec.ts create mode 100644 src/app/file-browser/components/edit-date-time/edit-date-time.service.ts diff --git a/src/app/file-browser/components/edit-date-time/edit-date-time.service.spec.ts b/src/app/file-browser/components/edit-date-time/edit-date-time.service.spec.ts new file mode 100644 index 000000000..9da3c9733 --- /dev/null +++ b/src/app/file-browser/components/edit-date-time/edit-date-time.service.spec.ts @@ -0,0 +1,65 @@ +import { TestBed } from '@angular/core/testing'; +import { DialogRef } from '@angular/cdk/dialog'; +import { DialogCdkService } from '@root/app/dialog-cdk/dialog-cdk.service'; +import { EditDateTimeService } from './edit-date-time.service'; +import { EditDateTimeComponent } from './edit-date-time.component'; +import { EditDateModel, Meridian } from './edit-date-time.model'; + +describe('EditDateTimeService', () => { + let service: EditDateTimeService; + let dialogCdkServiceSpy: jasmine.SpyObj; + let mockDialogRef: jasmine.SpyObj< + DialogRef + >; + + const mockData: EditDateModel = { + qualifiers: { approximate: false, uncertain: false, unknown: false }, + date: { year: '2026', month: '02', day: '18' }, + time: { + hours: '10', + minutes: '30', + seconds: '', + amPm: Meridian.AM, + timezoneOffset: 'GMT+01:00', + timezoneName: 'Central European Standard Time', + }, + }; + + beforeEach(() => { + mockDialogRef = jasmine.createSpyObj('DialogRef', ['close']); + dialogCdkServiceSpy = jasmine.createSpyObj('DialogCdkService', ['open']); + dialogCdkServiceSpy.open.and.returnValue(mockDialogRef); + + TestBed.configureTestingModule({ + providers: [ + EditDateTimeService, + { provide: DialogCdkService, useValue: dialogCdkServiceSpy }, + ], + }); + + service = TestBed.inject(EditDateTimeService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should open EditDateTimeComponent via DialogCdkService', () => { + service.open(mockData); + + expect(dialogCdkServiceSpy.open).toHaveBeenCalledWith( + EditDateTimeComponent, + jasmine.objectContaining({ + data: mockData, + hasBackdrop: true, + panelClass: 'edit-date-time-dialog-panel', + }), + ); + }); + + it('should return a DialogRef', () => { + const result = service.open(mockData); + + expect(result).toBe(mockDialogRef); + }); +}); diff --git a/src/app/file-browser/components/edit-date-time/edit-date-time.service.ts b/src/app/file-browser/components/edit-date-time/edit-date-time.service.ts new file mode 100644 index 000000000..8255dd5fc --- /dev/null +++ b/src/app/file-browser/components/edit-date-time/edit-date-time.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; +import { DialogRef } from '@angular/cdk/dialog'; +import { DialogCdkService } from '@root/app/dialog-cdk/dialog-cdk.service'; +import { EditDateTimeComponent } from './edit-date-time.component'; +import { EditDateModel } from './edit-date-time.model'; + +@Injectable({ + providedIn: 'root', +}) +export class EditDateTimeService { + constructor(private dialogCdkService: DialogCdkService) {} + + open(data: EditDateModel): DialogRef { + return this.dialogCdkService.open< + EditDateTimeComponent, + EditDateModel, + EditDateModel + >(EditDateTimeComponent, { + data, + hasBackdrop: true, + panelClass: 'edit-date-time-dialog-panel', + }); + } +} From 89f1fd86fb2e1fbde23e2c6ce5fe3557006cbd8b Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Tue, 24 Feb 2026 19:07:31 +0200 Subject: [PATCH 07/13] Add timezone dropdown component Because we are using the timezone dropdown component in three places now, extracting it into a presentational component for reusability seems the right approach. It will be a generic isolated component that will provide an input and a dropdown for choosing the timezone. Issue: PER-10416 --- .../timezone-dropdown.component.html | 47 ++++++++ .../timezone-dropdown.component.scss | 109 +++++++++++++++++ .../timezone-dropdown.component.spec.ts | 111 ++++++++++++++++++ .../timezone-dropdown.component.ts | 109 +++++++++++++++++ 4 files changed, 376 insertions(+) create mode 100644 src/app/shared/components/timezone-dropdown/timezone-dropdown.component.html create mode 100644 src/app/shared/components/timezone-dropdown/timezone-dropdown.component.scss create mode 100644 src/app/shared/components/timezone-dropdown/timezone-dropdown.component.spec.ts create mode 100644 src/app/shared/components/timezone-dropdown/timezone-dropdown.component.ts diff --git a/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.html b/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.html new file mode 100644 index 000000000..058869921 --- /dev/null +++ b/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.html @@ -0,0 +1,47 @@ +
+
+ {{ + selectedOffset() || 'Select timezone' + }} + @if (selectedName()) { + {{ selectedName() }} + } + + unfold_more + +
+ @if (isOpen()) { +
+ +
+
+ Select timezone +
+ @for (tz of filteredTimezones(); track tz.offset) { +
+ {{ tz.offset }} + {{ tz.name }} +
+ } +
+
+ } +
diff --git a/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.scss b/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.scss new file mode 100644 index 000000000..a47a1feaf --- /dev/null +++ b/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.scss @@ -0,0 +1,109 @@ +@import 'colors'; + +.pr-timezone-row { + position: relative; + + .pr-timezone-select { + display: flex; + align-items: center; + background: $white; + border: 1px solid $PR-blue-100; + border-radius: 8px; + padding: 0 12px; + height: 40px; + cursor: pointer; + + &:hover { + border-color: $PR-blue-300; + } + + &.disabled { + opacity: 0.5; + pointer-events: none; + } + + .pr-timezone-value { + font-size: 14px; + font-weight: 600; + color: $PR-blue; + margin-right: 8px; + } + + .pr-timezone-label { + font-size: 14px; + color: $PR-blue-600; + } + + .pr-chevron { + margin-left: auto; + color: $PR-blue-600; + } + } + + .pr-timezone-dropdown { + position: absolute; + top: 52px; + left: 0; + right: 0; + background: $white; + border: 1px solid $PR-blue-100; + border-radius: 8px; + box-shadow: 0 4px 16px rgba($black, 0.12); + z-index: 10; + overflow: hidden; + + .pr-timezone-search { + width: 100%; + padding: 10px 12px; + border: none; + border-bottom: 1px solid $PR-blue-100; + font-size: 14px; + outline: none; + color: $PR-blue; + box-sizing: border-box; + + &::placeholder { + color: $PR-blue-400; + } + } + + .pr-timezone-options { + max-height: 200px; + overflow-y: auto; + } + + .pr-timezone-option { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + cursor: pointer; + transition: background 0.1s ease; + + &:hover { + background: $PR-blue-25; + } + + &.selected { + background: $PR-blue-25; + } + + .pr-tz-offset { + font-size: 13px; + font-weight: 600; + color: $PR-blue; + min-width: 90px; + } + + .pr-tz-name { + font-size: 13px; + color: $PR-blue-600; + + &.pr-placeholder { + color: $PR-blue-400; + font-style: italic; + } + } + } + } +} diff --git a/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.spec.ts b/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.spec.ts new file mode 100644 index 000000000..9578da63d --- /dev/null +++ b/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.spec.ts @@ -0,0 +1,111 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TimezoneDropdownComponent } from './timezone-dropdown.component'; +import { TIMEZONES } from './timezone-dropdown.component'; + +describe('TimezoneDropdownComponent', () => { + let component: TimezoneDropdownComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TimezoneDropdownComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TimezoneDropdownComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + // --- Toggle behaviour --- + + it('should open dropdown on toggle', () => { + expect(component.isOpen()).toBeFalse(); + component.toggle(); + + expect(component.isOpen()).toBeTrue(); + }); + + it('should close dropdown on second toggle', () => { + component.toggle(); + component.toggle(); + + expect(component.isOpen()).toBeFalse(); + }); + + it('should not open when disabled', () => { + fixture.componentRef.setInput('disabled', true); + fixture.detectChanges(); + component.toggle(); + + expect(component.isOpen()).toBeFalse(); + }); + + it('should reset filter when opening', () => { + component.filter.set('pacific'); + component.toggle(); + + expect(component.filter()).toBe(''); + }); + + it('should reset filter when closing', () => { + component.toggle(); + component.filter.set('pacific'); + component.close(); + + expect(component.filter()).toBe(''); + }); + + // --- Filtering --- + + it('should return all timezones when filter is empty', () => { + component.filter.set(''); + + expect(component.filteredTimezones().length).toBe(TIMEZONES.length); + }); + + it('should filter timezones by name', () => { + component.filter.set('pacific'); + const filtered = component.filteredTimezones(); + + expect(filtered.length).toBeGreaterThan(0); + expect( + filtered.every((tz) => tz.name.toLowerCase().includes('pacific')), + ).toBeTrue(); + }); + + it('should filter timezones by offset', () => { + component.filter.set('GMT+09'); + const filtered = component.filteredTimezones(); + + expect(filtered.length).toBeGreaterThan(0); + expect(filtered.every((tz) => tz.offset.includes('GMT+09'))).toBeTrue(); + }); + + // --- Selection --- + + it('should emit timezoneChange and close on select', () => { + spyOn(component.timezoneChange, 'emit'); + component.toggle(); + + const tz = { offset: 'GMT-05:00', name: 'Eastern Standard Time' }; + component.select(tz); + + expect(component.timezoneChange.emit).toHaveBeenCalledWith(tz); + expect(component.isOpen()).toBeFalse(); + expect(component.filter()).toBe(''); + }); + + it('should emit empty timezone for placeholder selection', () => { + spyOn(component.timezoneChange, 'emit'); + component.select({ offset: '', name: '' }); + + expect(component.timezoneChange.emit).toHaveBeenCalledWith({ + offset: '', + name: '', + }); + }); +}); diff --git a/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.ts b/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.ts new file mode 100644 index 000000000..14fd5343f --- /dev/null +++ b/src/app/shared/components/timezone-dropdown/timezone-dropdown.component.ts @@ -0,0 +1,109 @@ +import { + Component, + input, + output, + signal, + computed, + ElementRef, + ViewChild, + HostListener, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +export interface TimezoneOption { + offset: string; + name: string; +} + +export const TIMEZONES: TimezoneOption[] = [ + { offset: 'GMT-12:00', name: 'International Date Line West' }, + { offset: 'GMT-11:00', name: 'Samoa Standard Time' }, + { offset: 'GMT-10:00', name: 'Hawaii-Aleutian Standard Time' }, + { offset: 'GMT-09:00', name: 'Alaska Standard Time' }, + { offset: 'GMT-08:00', name: 'Pacific Standard Time' }, + { offset: 'GMT-07:00', name: 'Mountain Standard Time' }, + { offset: 'GMT-06:00', name: 'Central Standard Time' }, + { offset: 'GMT-05:00', name: 'Eastern Standard Time' }, + { offset: 'GMT-04:00', name: 'Atlantic Standard Time' }, + { offset: 'GMT-03:30', name: 'Newfoundland Standard Time' }, + { offset: 'GMT-03:00', name: 'Argentina Standard Time' }, + { offset: 'GMT-02:00', name: 'Mid-Atlantic Standard Time' }, + { offset: 'GMT-01:00', name: 'Azores Standard Time' }, + { offset: 'GMT+00:00', name: 'Greenwich Mean Time' }, + { offset: 'GMT+01:00', name: 'Central European Standard Time' }, + { offset: 'GMT+02:00', name: 'Eastern European Standard Time' }, + { offset: 'GMT+03:00', name: 'Moscow Standard Time' }, + { offset: 'GMT+03:30', name: 'Iran Standard Time' }, + { offset: 'GMT+04:00', name: 'Gulf Standard Time' }, + { offset: 'GMT+04:30', name: 'Afghanistan Time' }, + { offset: 'GMT+05:00', name: 'Pakistan Standard Time' }, + { offset: 'GMT+05:30', name: 'India Standard Time' }, + { offset: 'GMT+05:45', name: 'Nepal Time' }, + { offset: 'GMT+06:00', name: 'Bangladesh Standard Time' }, + { offset: 'GMT+07:00', name: 'Indochina Time' }, + { offset: 'GMT+08:00', name: 'China Standard Time' }, + { offset: 'GMT+09:00', name: 'Japan Standard Time' }, + { offset: 'GMT+09:30', name: 'Australian Central Standard Time' }, + { offset: 'GMT+10:00', name: 'Australian Eastern Standard Time' }, + { offset: 'GMT+11:00', name: 'Solomon Islands Time' }, + { offset: 'GMT+12:00', name: 'New Zealand Standard Time' }, + { offset: 'GMT+13:00', name: 'Tonga Standard Time' }, +]; + +@Component({ + selector: 'pr-timezone-dropdown', + standalone: true, + imports: [CommonModule], + templateUrl: './timezone-dropdown.component.html', + styleUrls: ['./timezone-dropdown.component.scss'], +}) +export class TimezoneDropdownComponent { + selectedOffset = input(''); + selectedName = input(''); + disabled = input(false); + timezoneChange = output(); + + @ViewChild('dropdownContainer') dropdownContainer?: ElementRef; + + isOpen = signal(false); + filter = signal(''); + + timezones = TIMEZONES; + + filteredTimezones = computed(() => { + const term = this.filter().toLowerCase(); + if (!term) return this.timezones; + return this.timezones.filter( + (tz) => + tz.name.toLowerCase().includes(term) || + tz.offset.toLowerCase().includes(term), + ); + }); + + @HostListener('document:click', ['$event']) + onDocumentClick(event: MouseEvent): void { + const target = event.target as Node; + if (!this.dropdownContainer?.nativeElement.contains(target)) { + this.close(); + } + } + + toggle(): void { + if (this.disabled()) return; + if (this.isOpen()) { + this.close(); + } else { + this.isOpen.set(true); + this.filter.set(''); + } + } + + close(): void { + this.isOpen.set(false); + this.filter.set(''); + } + + select(tz: TimezoneOption): void { + this.timezoneChange.emit(tz); + this.close(); + } +} From 3efdc0fde94af0be119cbae5b7404f3ea507ad9f Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Tue, 3 Mar 2026 12:55:44 +0200 Subject: [PATCH 08/13] Add the date time sidebar component This component will be the smaller version to set the date and time, it will open the modal editor for more comporehensive choices. Issue: PER-10416 --- .../edit-date-time-mapping.service.ts | 265 +++++++++++ .../edit-date-time-modal.component.html | 126 ++++++ .../edit-date-time-modal.component.scss} | 109 ----- .../edit-date-time-modal.component.spec.ts} | 123 ++--- .../edit-date-time-modal.component.ts} | 145 ++---- .../edit-date-time-modal.service.spec.ts} | 20 +- .../edit-date-time-modal.service.ts} | 14 +- .../edit-date-time.model.ts | 38 ++ .../edit-date-time.component.html | 208 --------- .../edit-date-time/edit-date-time.model.ts | 78 ---- .../sidebar-date-picker.component.html | 79 ++++ .../sidebar-date-picker.component.scss | 171 +++++++ .../sidebar-date-picker.component.spec.ts | 424 ++++++++++++++++++ .../sidebar-date-picker.component.ts | 349 ++++++++++++++ .../file-browser-components.module.ts | 2 + .../edtf-date/edtf-date.service.spec.ts | 194 ++++++++ .../services/edtf-date/edtf-date.service.ts | 316 +++++++++++++ 17 files changed, 2047 insertions(+), 614 deletions(-) create mode 100644 src/app/file-browser/components/edit-date-time-modal/edit-date-time-mapping.service.ts create mode 100644 src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.html rename src/app/file-browser/components/{edit-date-time/edit-date-time.component.scss => edit-date-time-modal/edit-date-time-modal.component.scss} (69%) rename src/app/file-browser/components/{edit-date-time/edit-date-time.component.spec.ts => edit-date-time-modal/edit-date-time-modal.component.spec.ts} (80%) rename src/app/file-browser/components/{edit-date-time/edit-date-time.component.ts => edit-date-time-modal/edit-date-time-modal.component.ts} (62%) rename src/app/file-browser/components/{edit-date-time/edit-date-time.service.spec.ts => edit-date-time-modal/edit-date-time-modal.service.spec.ts} (72%) rename src/app/file-browser/components/{edit-date-time/edit-date-time.service.ts => edit-date-time-modal/edit-date-time-modal.service.ts} (57%) create mode 100644 src/app/file-browser/components/edit-date-time-modal/edit-date-time.model.ts delete mode 100644 src/app/file-browser/components/edit-date-time/edit-date-time.component.html delete mode 100644 src/app/file-browser/components/edit-date-time/edit-date-time.model.ts create mode 100644 src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.html create mode 100644 src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.scss create mode 100644 src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.spec.ts create mode 100644 src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.ts create mode 100644 src/app/shared/services/edtf-date/edtf-date.service.spec.ts create mode 100644 src/app/shared/services/edtf-date/edtf-date.service.ts diff --git a/src/app/file-browser/components/edit-date-time-modal/edit-date-time-mapping.service.ts b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-mapping.service.ts new file mode 100644 index 000000000..d17823f18 --- /dev/null +++ b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-mapping.service.ts @@ -0,0 +1,265 @@ +import { Injectable } from '@angular/core'; +import { Meridian } from '@shared/components/timepicker-input/timepicker-input.component'; +import { + EditDateModel, + DateObject, + TimeObject, + DateQualifierObject, +} from './edit-date-time.model'; + +@Injectable({ providedIn: 'root' }) +export class EditDateTimeMappingService { + static to12Hour(hour24: number): { hours: string; amPm: Meridian } { + if (hour24 === 0) { + return { hours: '12', amPm: Meridian.AM }; + } + if (hour24 < 12) { + return { hours: String(hour24).padStart(2, '0'), amPm: Meridian.AM }; + } + if (hour24 === 12) { + return { hours: '12', amPm: Meridian.PM }; + } + return { hours: String(hour24 - 12).padStart(2, '0'), amPm: Meridian.PM }; + } + + static to24Hour(hour12: number, amPm: Meridian): number { + if (amPm === Meridian.AM) return hour12 === 12 ? 0 : hour12; + return hour12 === 12 ? 12 : hour12 + 12; + } + + private static readonly OFFSET_ABBREVIATIONS: Record = { + 'GMT-12:00': 'IDLW', + 'GMT-11:00': 'SST', + 'GMT-10:00': 'HST', + 'GMT-09:00': 'AKST', + 'GMT-08:00': 'PST', + 'GMT-07:00': 'MST', + 'GMT-06:00': 'CST', + 'GMT-05:00': 'EST', + 'GMT-04:00': 'AST', + 'GMT-03:30': 'NST', + 'GMT-03:00': 'ART', + 'GMT+00:00': 'GMT', + 'GMT+01:00': 'CET', + 'GMT+02:00': 'EET', + 'GMT+03:00': 'MSK', + 'GMT+03:30': 'IRST', + 'GMT+04:00': 'GST', + 'GMT+04:30': 'AFT', + 'GMT+05:00': 'PKT', + 'GMT+05:30': 'IST', + 'GMT+05:45': 'NPT', + 'GMT+06:00': 'BST', + 'GMT+07:00': 'ICT', + 'GMT+08:00': 'HKT', + 'GMT+09:00': 'JST', + 'GMT+09:30': 'ACST', + 'GMT+10:00': 'AEST', + 'GMT+11:00': 'SBT', + 'GMT+12:00': 'NZST', + 'GMT+13:00': 'TOT', + }; + + static offsetToAbbreviation(offset: string): string { + if (EditDateTimeMappingService.OFFSET_ABBREVIATIONS[offset]) { + return EditDateTimeMappingService.OFFSET_ABBREVIATIONS[offset]; + } + + const match = offset.match(/GMT([+-])(\d{2}):(\d{2})/); + if (!match) return offset; + const sign = match[1]; + const hrs = parseInt(match[2], 10); + const mins = parseInt(match[3], 10); + return mins === 0 + ? `UTC${sign}${hrs}` + : `UTC${sign}${hrs}:${String(mins).padStart(2, '0')}`; + } + + static buildTimeString(time: { + hours: string; + minutes: string; + amPm: Meridian; + }): string { + if (!time.hours) return ''; + const hour12 = parseInt(time.hours, 10); + const hour24 = EditDateTimeMappingService.to24Hour(hour12, time.amPm); + const hours24 = String(hour24).padStart(2, '0'); + const parts = [hours24, time.minutes].filter(Boolean); + return parts.length > 1 ? parts.join(':') : ''; + } + + static qualifierSuffix(qualifiers?: DateQualifierObject): string { + if (!qualifiers) return ''; + if (qualifiers.approximate && qualifiers.uncertain) return '%'; + if (qualifiers.approximate) return '~'; + if (qualifiers.uncertain) return '?'; + return ''; + } + + /** + * Parses an EDTF string into an EditDateModel. + * + * Supported formats (start portion only — range end is ignored): + * YYYY, YYYY-MM, YYYY-MM-DD + * YYYY-MM-DDTHH:MM, YYYY-MM-DDTHH:MM:SS + * Trailing qualifiers (?, ~, %) are detected and returned. + * Range separator (/) — only the start portion is parsed. + * "xxxx-xx-xx" (unknown) returns null. + */ + static parseEdtf(edtf: string): EditDateModel | null { + if (!edtf || edtf === 'xxxx-xx-xx') { + return null; + } + + // Take only the start portion of a range + const startPart = edtf.split('/')[0]; + + // Detect trailing qualifiers before stripping + const qualifiers: DateQualifierObject = { + approximate: false, + uncertain: false, + unknown: false, + }; + const qualifierMatch = startPart.match(/[?~%]+$/); + if (qualifierMatch) { + const chars = qualifierMatch[0]; + if (chars.includes('%')) { + qualifiers.approximate = true; + qualifiers.uncertain = true; + } else { + qualifiers.approximate = chars.includes('~'); + qualifiers.uncertain = chars.includes('?'); + } + } + + // Strip trailing qualifiers (?, ~, %): e.g. "2026-02-18?~" → "2026-02-18" + const cleaned = startPart.replace(/[?~%]+$/, ''); + + // Split date and time on 'T' + const [datePart, timePart] = cleaned.split('T'); + + // Parse date segments + const dateSegments = datePart.split('-'); + const year = dateSegments[0] || ''; + const month = dateSegments[1] || ''; + const day = dateSegments[2] || ''; + + // Parse time segments (24h format → 12h) and timezone + let hours = ''; + let minutes = ''; + let seconds = ''; + let amPm = Meridian.AM; + let timezoneOffset = ''; + let timezoneName = ''; + + if (timePart) { + // Extract timezone from Z+offset suffix (e.g. "14:30:45.123Z+5") + const zMatch = timePart.match(/Z([+-]\d{1,2}(?::\d{2})?)$/); + if (zMatch) { + const tzParts = zMatch[1].match(/^([+-])(\d{1,2})(?::(\d{2}))?$/); + if (tzParts) { + const sign = tzParts[1]; + const hrs = tzParts[2].padStart(2, '0'); + const mins = tzParts[3] || '00'; + timezoneOffset = `GMT${sign}${hrs}:${mins}`; + timezoneName = + EditDateTimeMappingService.offsetToAbbreviation(timezoneOffset); + } + } + + // Extract timezone from bare ISO offset (e.g. "14:30:00-05:00") + if (!timezoneOffset) { + const bareMatch = timePart.match(/([+-]\d{2}:\d{2})$/); + if (bareMatch) { + timezoneOffset = `GMT${bareMatch[1]}`; + timezoneName = + EditDateTimeMappingService.offsetToAbbreviation(timezoneOffset); + } + } + + // Strip timezone info for clean time parsing + const cleanTimePart = timePart + .replace(/Z.*$/, '') + .replace(/[+-]\d{2}:\d{2}$/, ''); + const timeSegments = cleanTimePart.split(':'); + const hour24 = parseInt(timeSegments[0], 10); + + if (!isNaN(hour24)) { + const converted = EditDateTimeMappingService.to12Hour(hour24); + hours = converted.hours; + amPm = converted.amPm; + } + + minutes = timeSegments[1] || ''; + seconds = (timeSegments[2] || '').replace(/\.\d+$/, ''); + } + + return { + qualifiers, + date: { year, month, day }, + time: { + hours, + minutes, + seconds, + amPm, + timezoneOffset, + timezoneName, + }, + }; + } + + static buildDisplayDT(date: DateObject, time: TimeObject): string { + const year = date.year.padStart(4, '0'); + const month = (date.month || '01').padStart(2, '0'); + const day = (date.day || '01').padStart(2, '0'); + + const hour12 = parseInt(time.hours || '0', 10); + const hour24 = EditDateTimeMappingService.to24Hour(hour12, time.amPm); + + const hours = String(hour24).padStart(2, '0'); + const minutes = (time.minutes || '00').padStart(2, '0'); + const seconds = (time.seconds || '00').padStart(2, '0'); + + const isoOffset = time.timezoneOffset + ? time.timezoneOffset.replace('GMT', '') + : '+00:00'; + + const utcString = new Date( + `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${isoOffset}`, + ).toISOString(); + + const tzSuffix = EditDateTimeMappingService.buildTzSuffix( + time.timezoneOffset, + ); + return tzSuffix ? `${utcString}${tzSuffix}` : utcString; + } + + static buildTzSuffix(tzOffset: string): string { + if (!tzOffset) return ''; + const match = tzOffset.match(/GMT([+-])(\d{2}):(\d{2})/); + if (!match) return ''; + const sign = match[1]; + const hrs = parseInt(match[2], 10); + const mins = parseInt(match[3], 10); + return mins === 0 + ? `${sign}${hrs}` + : `${sign}${hrs}:${String(mins).padStart(2, '0')}`; + } + + static buildEdtf( + date: DateObject, + time: TimeObject, + qualifiers?: DateQualifierObject, + ): string { + let edtf = [date.year, date.month, date.day].filter(Boolean).join('-'); + + const timePart = EditDateTimeMappingService.buildTimeString(time); + if (timePart) { + edtf += `T${timePart}`; + } + + edtf += EditDateTimeMappingService.qualifierSuffix(qualifiers); + + return edtf; + } +} diff --git a/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.html b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.html new file mode 100644 index 000000000..1ed18c5c5 --- /dev/null +++ b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.html @@ -0,0 +1,126 @@ +
+
+

Edit date and time

+ +
+ +
+ +
+ This date is: + +
+ Approximate + +
+ +
+ Uncertain + +
+ +
+ Unknown + +
+
+ + +
+ + + +
+ + + + + + @if (useDateRange()) { +
TO
+ +
+ + + +
+ + + + } + + +
+ Use a date range + +
+
+ + + +
diff --git a/src/app/file-browser/components/edit-date-time/edit-date-time.component.scss b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.scss similarity index 69% rename from src/app/file-browser/components/edit-date-time/edit-date-time.component.scss rename to src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.scss index 426f41085..dd16a9fc5 100644 --- a/src/app/file-browser/components/edit-date-time/edit-date-time.component.scss +++ b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.scss @@ -147,110 +147,6 @@ } } - // Timezone row - .pr-timezone-row { - position: relative; - - .pr-timezone-select { - display: flex; - align-items: center; - background: $white; - border: 1px solid $PR-blue-100; - border-radius: 8px; - padding: 0 12px; - height: 40px; - cursor: pointer; - - &:hover { - border-color: $PR-blue-300; - } - - .pr-timezone-value { - font-size: 14px; - font-weight: 600; - color: $PR-blue; - margin-right: 8px; - } - - .pr-timezone-label { - font-size: 14px; - color: $PR-blue-600; - } - - .pr-chevron { - margin-left: auto; - color: $PR-blue-600; - } - } - - .pr-timezone-dropdown { - position: absolute; - top: 52px; - left: 0; - right: 0; - background: $white; - border: 1px solid $PR-blue-100; - border-radius: 8px; - box-shadow: 0 4px 16px rgba($black, 0.12); - z-index: 10; - overflow: hidden; - - .pr-timezone-search { - width: 100%; - padding: 10px 12px; - border: none; - border-bottom: 1px solid $PR-blue-100; - font-size: 14px; - outline: none; - color: $PR-blue; - box-sizing: border-box; - - &::placeholder { - color: $PR-blue-400; - } - } - - .pr-timezone-options { - max-height: 200px; - overflow-y: auto; - } - - .pr-timezone-option { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 12px; - cursor: pointer; - transition: background 0.1s ease; - - &:hover { - background: $PR-blue-25; - } - - &.selected { - background: $PR-blue-25; - } - - .pr-tz-offset { - font-size: 13px; - font-weight: 600; - color: $PR-blue; - min-width: 90px; - } - - .pr-tz-name { - font-size: 13px; - color: $PR-blue-600; - - &.pr-placeholder { - color: $PR-blue-400; - font-style: italic; - } - } - } - } - } - // Date range row .pr-date-range-row { display: flex; @@ -355,9 +251,4 @@ opacity: 0.5; pointer-events: none; } - - .pr-timezone-select.disabled { - opacity: 0.5; - pointer-events: none; - } } diff --git a/src/app/file-browser/components/edit-date-time/edit-date-time.component.spec.ts b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.spec.ts similarity index 80% rename from src/app/file-browser/components/edit-date-time/edit-date-time.component.spec.ts rename to src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.spec.ts index b7f8c8814..ce61e1d8b 100644 --- a/src/app/file-browser/components/edit-date-time/edit-date-time.component.spec.ts +++ b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.spec.ts @@ -1,16 +1,11 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; -import { EditDateTimeComponent } from './edit-date-time.component'; -import { - EditDateModel, - DateQualifier, - TIMEZONES, - Meridian, -} from './edit-date-time.model'; - -describe('EditDateTimeComponent', () => { - let component: EditDateTimeComponent; - let fixture: ComponentFixture; +import { EditDateTimeModalComponent } from './edit-date-time-modal.component'; +import { EditDateModel, DateQualifier, Meridian } from './edit-date-time.model'; + +describe('EditDateTimeModalComponent', () => { + let component: EditDateTimeModalComponent; + let fixture: ComponentFixture; let dialogRefSpy: jasmine.SpyObj; const mockDialogData: EditDateModel = { @@ -34,14 +29,14 @@ describe('EditDateTimeComponent', () => { dialogRefSpy = jasmine.createSpyObj('DialogRef', ['close']); await TestBed.configureTestingModule({ - imports: [EditDateTimeComponent], + imports: [EditDateTimeModalComponent], providers: [ { provide: DialogRef, useValue: dialogRefSpy }, { provide: DIALOG_DATA, useValue: mockDialogData }, ], }).compileComponents(); - fixture = TestBed.createComponent(EditDateTimeComponent); + fixture = TestBed.createComponent(EditDateTimeModalComponent); component = fixture.componentInstance; fixture.detectChanges(); }); @@ -125,6 +120,28 @@ describe('EditDateTimeComponent', () => { expect(component.time().timezoneName).toBe('Eastern Standard Time'); }); + // --- Timezone updates via onTimezoneChange --- + + it('should update timezone on time signal via onTimezoneChange', () => { + component.onTimezoneChange( + { offset: 'GMT-05:00', name: 'Eastern Standard Time' }, + component.time, + ); + + expect(component.time().timezoneOffset).toBe('GMT-05:00'); + expect(component.time().timezoneName).toBe('Eastern Standard Time'); + }); + + it('should update timezone on endTime signal via onTimezoneChange', () => { + component.onTimezoneChange( + { offset: 'GMT+09:00', name: 'Japan Standard Time' }, + component.endTime, + ); + + expect(component.endTime().timezoneOffset).toBe('GMT+09:00'); + expect(component.endTime().timezoneName).toBe('Japan Standard Time'); + }); + // --- Date range toggle --- it('should toggle date range', () => { @@ -281,86 +298,6 @@ describe('EditDateTimeComponent', () => { expect(component.edtfValue()).toBe('2026-02-18'); }); - // --- Timezone dropdown --- - - it('should open timezone dropdown', () => { - expect(component.activeOverlay()).toBeNull(); - component.toggleOverlay('timezoneDropdown'); - - expect(component.activeOverlay()).toBe('timezoneDropdown'); - }); - - it('should close timezone dropdown on second toggle', () => { - component.toggleOverlay('timezoneDropdown'); - component.toggleOverlay('timezoneDropdown'); - - expect(component.activeOverlay()).toBeNull(); - }); - - it('should close end timezone dropdown when opening start timezone dropdown', () => { - component.activeOverlay.set('endTimezoneDropdown'); - component.toggleOverlay('timezoneDropdown'); - - expect(component.activeOverlay()).toBe('timezoneDropdown'); - }); - - it('should close start timezone dropdown when opening end timezone dropdown', () => { - component.activeOverlay.set('timezoneDropdown'); - component.toggleOverlay('endTimezoneDropdown'); - - expect(component.activeOverlay()).toBe('endTimezoneDropdown'); - }); - - it('should reset filter when toggling timezone dropdown', () => { - component.timezoneFilter.set('pacific'); - component.toggleOverlay('timezoneDropdown'); - - expect(component.timezoneFilter()).toBe(''); - }); - - it('should select a timezone and update time signal', () => { - const tz = { offset: 'GMT-05:00', name: 'Eastern Standard Time' }; - component.selectTimezone(tz, component.time); - - expect(component.time().timezoneOffset).toBe('GMT-05:00'); - expect(component.time().timezoneName).toBe('Eastern Standard Time'); - expect(component.activeOverlay()).toBeNull(); - expect(component.timezoneFilter()).toBe(''); - }); - - it('should select an end timezone and update endTime signal', () => { - const tz = { offset: 'GMT+09:00', name: 'Japan Standard Time' }; - component.selectTimezone(tz, component.endTime); - - expect(component.endTime().timezoneOffset).toBe('GMT+09:00'); - expect(component.endTime().timezoneName).toBe('Japan Standard Time'); - expect(component.activeOverlay()).toBeNull(); - }); - - it('should filter timezones by name', () => { - component.timezoneFilter.set('pacific'); - const filtered = component.filteredTimezones(); - - expect(filtered.length).toBeGreaterThan(0); - expect( - filtered.every((tz) => tz.name.toLowerCase().includes('pacific')), - ).toBeTrue(); - }); - - it('should filter timezones by offset', () => { - component.timezoneFilter.set('GMT+09'); - const filtered = component.filteredTimezones(); - - expect(filtered.length).toBeGreaterThan(0); - expect(filtered.every((tz) => tz.offset.includes('GMT+09'))).toBeTrue(); - }); - - it('should return all timezones when filter is empty', () => { - component.timezoneFilter.set(''); - - expect(component.filteredTimezones().length).toBe(TIMEZONES.length); - }); - // --- Dialog actions --- it('should close dialog on cancel', () => { diff --git a/src/app/file-browser/components/edit-date-time/edit-date-time.component.ts b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.ts similarity index 62% rename from src/app/file-browser/components/edit-date-time/edit-date-time.component.ts rename to src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.ts index 7e7df448e..15addaf1e 100644 --- a/src/app/file-browser/components/edit-date-time/edit-date-time.component.ts +++ b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.ts @@ -5,9 +5,6 @@ import { signal, computed, WritableSignal, - HostListener, - ViewChild, - ElementRef, } from '@angular/core'; import { CommonModule } from '@angular/common'; import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; @@ -16,18 +13,19 @@ import { TimepickerInputComponent, TimeInputObject, } from '@shared/components/timepicker-input/timepicker-input.component'; +import { + TimezoneDropdownComponent, + TimezoneOption, +} from '@shared/components/timezone-dropdown/timezone-dropdown.component'; import { EditDateModel, DateQualifierObject, DateObject, TimeObject, - TimezoneOption, - TIMEZONES, DateQualifier, Meridian, } from './edit-date-time.model'; - -type OverlayKey = 'timezoneDropdown' | 'endTimezoneDropdown'; +import { EditDateTimeMappingService } from './edit-date-time-mapping.service'; interface SavedFormState { qualifiers: DateQualifierObject; @@ -48,21 +46,20 @@ const DEFAULT_TIME: TimeObject = { }; @Component({ - selector: 'pr-edit-date-time', + selector: 'pr-edit-date-time-modal', standalone: true, - imports: [CommonModule, DatepickerInputComponent, TimepickerInputComponent], - templateUrl: './edit-date-time.component.html', - styleUrls: ['./edit-date-time.component.scss'], + imports: [ + CommonModule, + DatepickerInputComponent, + TimepickerInputComponent, + TimezoneDropdownComponent, + ], + templateUrl: './edit-date-time-modal.component.html', + styleUrls: ['./edit-date-time-modal.component.scss'], }) -export class EditDateTimeComponent implements OnInit { +export class EditDateTimeModalComponent implements OnInit { readonly DateQualifier = DateQualifier; - timezones = TIMEZONES; - timezoneFilter = signal(''); - - activeOverlay = signal(null); - @ViewChild('timezoneRow') timezoneRow?: ElementRef; - @ViewChild('endTimezoneRow') endTimezoneRow?: ElementRef; qualifiers = signal({ approximate: false, uncertain: false, @@ -87,38 +84,17 @@ export class EditDateTimeComponent implements OnInit { return 'xxxx-xx-xx'; } - let computedEdtfValue = ''; - const selectedDate = this.date(); - const selectedTime = this.time(); - - computedEdtfValue = [ - selectedDate.year, - selectedDate.month, - selectedDate.day, - ] - .filter(Boolean) - .join('-'); - const timePart = this.buildTimePart(selectedTime); - - if (timePart) { - computedEdtfValue += `T${timePart}`; - } + let computedEdtfValue = EditDateTimeMappingService.buildEdtf( + this.date(), + this.time(), + this.qualifiers(), + ); if (this.useDateRange()) { - const selectedEndDate = this.endDate(); - const selectedEndTime = this.endTime(); - const endTimePart = this.buildTimePart(selectedEndTime); - let endEdtf = [ - selectedEndDate.year, - selectedEndDate.month, - selectedEndDate.day, - ] - .filter(Boolean) - .join('-'); - - if (endTimePart) { - endEdtf += `T${endTimePart}`; - } + const endEdtf = EditDateTimeMappingService.buildEdtf( + this.endDate(), + this.endTime(), + ); if (endEdtf) { computedEdtfValue += `/${endEdtf}`; @@ -128,33 +104,6 @@ export class EditDateTimeComponent implements OnInit { return computedEdtfValue; }); - filteredTimezones = computed(() => { - const filter = this.timezoneFilter().toLowerCase(); - if (!filter) return this.timezones; - return this.timezones.filter( - (tz) => - tz.name.toLowerCase().includes(filter) || - tz.offset.toLowerCase().includes(filter), - ); - }); - - @HostListener('document:click', ['$event']) - onDocumentClick(event: MouseEvent): void { - const target = event.target as Node; - const insideTimezone = this.timezoneRow?.nativeElement.contains(target); - const insideEndTimezone = - this.endTimezoneRow?.nativeElement.contains(target); - - if (!insideTimezone && !insideEndTimezone) { - this.closeAllOverlays(); - } - } - - closeAllOverlays(): void { - this.activeOverlay.set(null); - this.timezoneFilter.set(''); - } - constructor( public dialogRef: DialogRef, @Inject(DIALOG_DATA) public data: EditDateModel, @@ -193,6 +142,17 @@ export class EditDateTimeComponent implements OnInit { })); } + onTimezoneChange( + timezoneOption: TimezoneOption, + currentTime: WritableSignal, + ): void { + currentTime.update((t) => ({ + ...t, + timezoneOffset: timezoneOption.offset, + timezoneName: timezoneOption.name, + })); + } + onQualifierChange(newDateQualifier: DateQualifier): void { const currentlySelectedQualifiers = this.qualifiers(); @@ -264,41 +224,6 @@ export class EditDateTimeComponent implements OnInit { this.useDateRange.update((v) => !v); } - toggleOverlay(key: OverlayKey): void { - if (this.activeOverlay() === key) { - this.closeAllOverlays(); - } else { - this.activeOverlay.set(key); - this.timezoneFilter.set(''); - } - } - - selectTimezone( - timezoneOption: TimezoneOption, - currentTimezone: WritableSignal, - ): void { - currentTimezone.update((t) => ({ - ...t, - timezoneOffset: timezoneOption.offset, - timezoneName: timezoneOption.name, - })); - this.closeAllOverlays(); - } - - private buildTimePart(time: TimeObject): string { - if (!time.hours) return ''; - const hour12 = parseInt(time.hours, 10); - const hour24 = this.to24Hour(hour12, time.amPm); - const hours24 = String(hour24).padStart(2, '0'); - const parts = [hours24, time.minutes, time.seconds].filter(Boolean); - return parts.length > 1 ? parts.join(':') : ''; - } - - private to24Hour(hour12: number, amPm: Meridian): number { - if (amPm === Meridian.AM) return hour12 === 12 ? 0 : hour12; - return hour12 === 12 ? 12 : hour12 + 12; - } - onCancel(): void { this.dialogRef.close(); } diff --git a/src/app/file-browser/components/edit-date-time/edit-date-time.service.spec.ts b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.service.spec.ts similarity index 72% rename from src/app/file-browser/components/edit-date-time/edit-date-time.service.spec.ts rename to src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.service.spec.ts index 9da3c9733..05ab497ed 100644 --- a/src/app/file-browser/components/edit-date-time/edit-date-time.service.spec.ts +++ b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.service.spec.ts @@ -1,15 +1,15 @@ import { TestBed } from '@angular/core/testing'; import { DialogRef } from '@angular/cdk/dialog'; import { DialogCdkService } from '@root/app/dialog-cdk/dialog-cdk.service'; -import { EditDateTimeService } from './edit-date-time.service'; -import { EditDateTimeComponent } from './edit-date-time.component'; +import { EditDateTimeModalService } from './edit-date-time-modal.service'; +import { EditDateTimeModalComponent } from './edit-date-time-modal.component'; import { EditDateModel, Meridian } from './edit-date-time.model'; -describe('EditDateTimeService', () => { - let service: EditDateTimeService; +describe('EditDateTimeModalService', () => { + let service: EditDateTimeModalService; let dialogCdkServiceSpy: jasmine.SpyObj; let mockDialogRef: jasmine.SpyObj< - DialogRef + DialogRef >; const mockData: EditDateModel = { @@ -32,27 +32,27 @@ describe('EditDateTimeService', () => { TestBed.configureTestingModule({ providers: [ - EditDateTimeService, + EditDateTimeModalService, { provide: DialogCdkService, useValue: dialogCdkServiceSpy }, ], }); - service = TestBed.inject(EditDateTimeService); + service = TestBed.inject(EditDateTimeModalService); }); it('should be created', () => { expect(service).toBeTruthy(); }); - it('should open EditDateTimeComponent via DialogCdkService', () => { + it('should open EditDateTimeModalComponent via DialogCdkService', () => { service.open(mockData); expect(dialogCdkServiceSpy.open).toHaveBeenCalledWith( - EditDateTimeComponent, + EditDateTimeModalComponent, jasmine.objectContaining({ data: mockData, hasBackdrop: true, - panelClass: 'edit-date-time-dialog-panel', + panelClass: 'edit-date-time-modal-dialog-panel', }), ); }); diff --git a/src/app/file-browser/components/edit-date-time/edit-date-time.service.ts b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.service.ts similarity index 57% rename from src/app/file-browser/components/edit-date-time/edit-date-time.service.ts rename to src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.service.ts index 8255dd5fc..e44693882 100644 --- a/src/app/file-browser/components/edit-date-time/edit-date-time.service.ts +++ b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.service.ts @@ -1,24 +1,26 @@ import { Injectable } from '@angular/core'; import { DialogRef } from '@angular/cdk/dialog'; import { DialogCdkService } from '@root/app/dialog-cdk/dialog-cdk.service'; -import { EditDateTimeComponent } from './edit-date-time.component'; +import { EditDateTimeModalComponent } from './edit-date-time-modal.component'; import { EditDateModel } from './edit-date-time.model'; @Injectable({ providedIn: 'root', }) -export class EditDateTimeService { +export class EditDateTimeModalService { constructor(private dialogCdkService: DialogCdkService) {} - open(data: EditDateModel): DialogRef { + open( + data: EditDateModel, + ): DialogRef { return this.dialogCdkService.open< - EditDateTimeComponent, + EditDateTimeModalComponent, EditDateModel, EditDateModel - >(EditDateTimeComponent, { + >(EditDateTimeModalComponent, { data, hasBackdrop: true, - panelClass: 'edit-date-time-dialog-panel', + panelClass: 'edit-date-time-modal-dialog-panel', }); } } diff --git a/src/app/file-browser/components/edit-date-time-modal/edit-date-time.model.ts b/src/app/file-browser/components/edit-date-time-modal/edit-date-time.model.ts new file mode 100644 index 000000000..adab5a9db --- /dev/null +++ b/src/app/file-browser/components/edit-date-time-modal/edit-date-time.model.ts @@ -0,0 +1,38 @@ +import { Meridian } from '@shared/components/timepicker-input/timepicker-input.component'; + +export enum DateQualifier { + Approximate = 'approximate', + Uncertain = 'uncertain', + Unknown = 'unknown', +} + +export interface DateQualifierObject { + approximate: boolean; + uncertain: boolean; + unknown: boolean; +} + +export interface DateObject { + year: string; + month: string; + day: string; +} + +export { Meridian }; + +export interface TimeObject { + hours: string; + minutes: string; + seconds: string; + amPm: Meridian; + timezoneOffset: string; + timezoneName: string; +} + +export interface EditDateModel { + qualifiers?: DateQualifierObject; + date: DateObject; + time: TimeObject; + endDate?: DateObject; + endTime?: TimeObject; +} diff --git a/src/app/file-browser/components/edit-date-time/edit-date-time.component.html b/src/app/file-browser/components/edit-date-time/edit-date-time.component.html deleted file mode 100644 index ab601d13c..000000000 --- a/src/app/file-browser/components/edit-date-time/edit-date-time.component.html +++ /dev/null @@ -1,208 +0,0 @@ -
-
-

Edit date and time

- -
- -
- -
- This date is: - -
- Approximate - -
- -
- Uncertain - -
- -
- Unknown - -
-
- - -
- - - -
- - -
-
- {{ - time().timezoneOffset || 'Select timezone' - }} - @if (time().timezoneName) { - {{ time().timezoneName }} - } - - unfold_more - -
- @if (activeOverlay() === 'timezoneDropdown') { -
- -
-
- Select timezone -
- @for (tz of filteredTimezones(); track tz.offset) { -
- {{ tz.offset }} - {{ tz.name }} -
- } -
-
- } -
- - - @if (useDateRange()) { -
TO
- -
- - - -
- - -
-
- {{ - endTime().timezoneOffset || 'Select timezone' - }} - @if (endTime().timezoneName) { - {{ endTime().timezoneName }} - } - - unfold_more - -
- @if (activeOverlay() === 'endTimezoneDropdown') { -
- -
-
- Select timezone -
- @for (tz of filteredTimezones(); track tz.offset) { -
- {{ tz.offset }} - {{ tz.name }} -
- } -
-
- } -
- } - - -
- Use a date range - -
-
- - - -
diff --git a/src/app/file-browser/components/edit-date-time/edit-date-time.model.ts b/src/app/file-browser/components/edit-date-time/edit-date-time.model.ts deleted file mode 100644 index 094db8459..000000000 --- a/src/app/file-browser/components/edit-date-time/edit-date-time.model.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { Meridian } from '@shared/components/timepicker-input/timepicker-input.component'; - -export enum DateQualifier { - Approximate = 'approximate', - Uncertain = 'uncertain', - Unknown = 'unknown', -} - -export interface DateQualifierObject { - approximate: boolean; - uncertain: boolean; - unknown: boolean; -} - -export interface DateObject { - year: string; - month: string; - day: string; -} - -export { Meridian }; - -export interface TimeObject { - hours: string; - minutes: string; - seconds: string; - amPm: Meridian; - timezoneOffset: string; - timezoneName: string; -} - -export interface EditDateModel { - qualifiers: DateQualifierObject; - date: DateObject; - time: TimeObject; - endDate?: DateObject; - endTime?: TimeObject; -} - -export interface TimezoneOption { - offset: string; - name: string; -} - -export const TIMEZONES: TimezoneOption[] = [ - { offset: 'GMT-12:00', name: 'International Date Line West' }, - { offset: 'GMT-11:00', name: 'Samoa Standard Time' }, - { offset: 'GMT-10:00', name: 'Hawaii-Aleutian Standard Time' }, - { offset: 'GMT-09:00', name: 'Alaska Standard Time' }, - { offset: 'GMT-08:00', name: 'Pacific Standard Time' }, - { offset: 'GMT-07:00', name: 'Mountain Standard Time' }, - { offset: 'GMT-06:00', name: 'Central Standard Time' }, - { offset: 'GMT-05:00', name: 'Eastern Standard Time' }, - { offset: 'GMT-04:00', name: 'Atlantic Standard Time' }, - { offset: 'GMT-03:30', name: 'Newfoundland Standard Time' }, - { offset: 'GMT-03:00', name: 'Argentina Standard Time' }, - { offset: 'GMT-02:00', name: 'Mid-Atlantic Standard Time' }, - { offset: 'GMT-01:00', name: 'Azores Standard Time' }, - { offset: 'GMT+00:00', name: 'Greenwich Mean Time' }, - { offset: 'GMT+01:00', name: 'Central European Standard Time' }, - { offset: 'GMT+02:00', name: 'Eastern European Standard Time' }, - { offset: 'GMT+03:00', name: 'Moscow Standard Time' }, - { offset: 'GMT+03:30', name: 'Iran Standard Time' }, - { offset: 'GMT+04:00', name: 'Gulf Standard Time' }, - { offset: 'GMT+04:30', name: 'Afghanistan Time' }, - { offset: 'GMT+05:00', name: 'Pakistan Standard Time' }, - { offset: 'GMT+05:30', name: 'India Standard Time' }, - { offset: 'GMT+05:45', name: 'Nepal Time' }, - { offset: 'GMT+06:00', name: 'Bangladesh Standard Time' }, - { offset: 'GMT+07:00', name: 'Indochina Time' }, - { offset: 'GMT+08:00', name: 'China Standard Time' }, - { offset: 'GMT+09:00', name: 'Japan Standard Time' }, - { offset: 'GMT+09:30', name: 'Australian Central Standard Time' }, - { offset: 'GMT+10:00', name: 'Australian Eastern Standard Time' }, - { offset: 'GMT+11:00', name: 'Solomon Islands Time' }, - { offset: 'GMT+12:00', name: 'New Zealand Standard Time' }, - { offset: 'GMT+13:00', name: 'Tonga Standard Time' }, -]; diff --git a/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.html b/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.html new file mode 100644 index 000000000..f2be29f50 --- /dev/null +++ b/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.html @@ -0,0 +1,79 @@ +
+
+ Date + @if (activeQualifiers().length) { + + · {{ activeQualifiers().join(', ') }}: + + } +
+ +
+
+ From + @if (stringStartDate()) { + + {{ stringStartDate() }} + + } @else { + + {{ disabled ? 'No date' : 'Click to add date' }} + + } + @if (!disabled) { + + edit + + } +
+ + @if (stringEndDate()) { +
+ To + + {{ stringEndDate() }} + +
+ } +
+ + @if (isDropdownOpen()) { +
+
+ + + + + +
+ + +
+ } +
diff --git a/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.scss b/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.scss new file mode 100644 index 000000000..a5dc2c08a --- /dev/null +++ b/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.scss @@ -0,0 +1,171 @@ +@import 'colors'; + +.pr-sidebar-date-picker { + position: relative; + + .pr-sidebar-date-picker-header { + font-size: 13px; + color: $PR-blue-600; + margin-bottom: 4px; + } + + .pr-sidebar-date-picker-label { + font-weight: 500; + } + + .pr-sidebar-date-picker-qualifiers { + color: $PR-blue-400; + } + + .pr-sidebar-date-picker-rows { + border-radius: 8px; + padding: 4px 8px; + transition: background-color 0.15s ease; + + &.can-edit { + cursor: pointer; + + &:hover { + background-color: $PR-blue-25; + + .pr-sidebar-date-picker-edit-icon { + color: $PR-blue; + } + } + } + } + + .pr-sidebar-date-picker-row { + display: flex; + align-items: center; + } + + .pr-sidebar-date-picker-row-label { + font-size: 14px; + color: $PR-blue-400; + margin-right: 8px; + min-width: 36px; + } + + .pr-sidebar-date-picker-row-value { + flex: 1; + font-size: 14px; + color: $PR-blue; + font-weight: 500; + } + + .pr-sidebar-date-picker-row-placeholder { + flex: 1; + font-size: 14px; + color: $PR-blue-400; + } + + .pr-sidebar-date-picker-edit-icon { + padding: 4px; + color: $PR-blue-400; + display: flex; + align-items: center; + + .material-icons { + font-size: 18px; + } + } + + .pr-sidebar-date-picker-to-row { + cursor: default; + } + + // Dropdown panel + .pr-sidebar-date-picker-panel { + position: absolute; + top: 0; + left: -16px; + right: -16px; + z-index: 20; + background: $white; + border: 1px solid $PR-blue-100; + border-radius: 12px; + box-shadow: 0 8px 24px rgba($black, 0.12); + overflow: visible; + } + + .pr-sidebar-date-picker-fields { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px; + } + + .pr-sidebar-date-picker-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-top: 1px solid $PR-blue-100; + background: $PR-blue-25; + border-radius: 0 0 12px 12px; + } + + .pr-more-options-btn { + display: flex; + align-items: center; + gap: 6px; + background: none; + border: none; + cursor: pointer; + font-size: 14px; + font-weight: 500; + color: $PR-blue; + padding: 0; + + &:hover { + color: $PR-blue-800; + } + + .material-icons { + font-size: 18px; + } + } + + .pr-sidebar-date-picker-actions { + display: flex; + align-items: center; + gap: 8px; + } + + .pr-btn-cancel { + background: none; + border: none; + cursor: pointer; + font-size: 14px; + font-weight: 500; + color: $PR-blue; + padding: 6px 12px; + + &:hover { + color: $PR-blue-800; + } + } + + .pr-btn-save { + display: flex; + align-items: center; + justify-content: center; + background: $PR-blue; + border: none; + border-radius: 6px; + cursor: pointer; + color: $white; + width: 36px; + height: 36px; + padding: 0; + + &:hover { + background: $PR-blue-800; + } + + .material-icons { + font-size: 18px; + } + } +} diff --git a/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.spec.ts b/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.spec.ts new file mode 100644 index 000000000..75e2aefbc --- /dev/null +++ b/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.spec.ts @@ -0,0 +1,424 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CUSTOM_ELEMENTS_SCHEMA, Component } from '@angular/core'; +import { Meridian } from '@shared/components/timepicker-input/timepicker-input.component'; +import { EditDateModel } from '../edit-date-time-modal/edit-date-time.model'; +import { SidebarDatePickerComponent } from './sidebar-date-picker.component'; + +@Component({ + template: ` + + `, + standalone: false, +}) +class TestHostComponent { + displayDT: string | null = null; + displayEndDT: string | null = null; + disabled = false; + savedValue: string | null = null; + savedEndDateValue: string | null | undefined = undefined; + moreOptionsData: EditDateModel | null = null; + + onSaveClicked(value: { displayDT: string; displayEndDT?: string | null }) { + this.savedValue = value.displayDT; + if (value.displayEndDT !== undefined) { + this.savedEndDateValue = value.displayEndDT; + } + } + onMoreOptionsClicked(data: EditDateModel) { + this.moreOptionsData = data; + } +} + +describe('SidebarDatePickerComponent', () => { + let host: TestHostComponent; + let fixture: ComponentFixture; + let component: SidebarDatePickerComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SidebarDatePickerComponent], + declarations: [TestHostComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + component = fixture.debugElement.children[0].componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('display', () => { + it('should show placeholder when no date is set', () => { + const placeholder = fixture.nativeElement.querySelector( + '.pr-sidebar-date-picker-row-placeholder', + ); + + expect(placeholder).toBeTruthy(); + expect(placeholder.textContent.trim()).toBe('Click to add date'); + }); + + it('should show "No date" placeholder when disabled and no date', () => { + host.disabled = true; + fixture.detectChanges(); + + const placeholder = fixture.nativeElement.querySelector( + '.pr-sidebar-date-picker-row-placeholder', + ); + + expect(placeholder.textContent.trim()).toBe('No date'); + }); + + it('should display formatted from date when displayDT is set', () => { + host.displayDT = '1985-05'; + fixture.detectChanges(); + + const value = fixture.nativeElement.querySelector( + '.pr-sidebar-date-picker-row-value', + ); + + expect(value).toBeTruthy(); + expect(value.textContent.trim()).toBe('May 1985'); + }); + + it('should display year-only date', () => { + host.displayDT = '1985'; + fixture.detectChanges(); + + const value = fixture.nativeElement.querySelector( + '.pr-sidebar-date-picker-row-value', + ); + + expect(value.textContent.trim()).toBe('1985'); + }); + + it('should display full date with day', () => { + host.displayDT = '1985-05-20'; + fixture.detectChanges(); + + const value = fixture.nativeElement.querySelector( + '.pr-sidebar-date-picker-row-value', + ); + + expect(value.textContent.trim()).toBe('May 20, 1985'); + }); + + it('should display date with time', () => { + host.displayDT = '1985-05-20T14:30'; + fixture.detectChanges(); + + const value = fixture.nativeElement.querySelector( + '.pr-sidebar-date-picker-row-value', + ); + + expect(value.textContent.trim()).toContain('May 20, 1985'); + expect(value.textContent.trim()).toContain('2:30:00 PM'); + }); + + it('should not show To row when no end date', () => { + host.displayDT = '1985-05'; + fixture.detectChanges(); + + const toRow = fixture.nativeElement.querySelector( + '.pr-sidebar-date-picker-to-row', + ); + + expect(toRow).toBeFalsy(); + }); + + it('should show To row when end date is set', () => { + host.displayDT = '1985-05'; + host.displayEndDT = '1990-06'; + fixture.detectChanges(); + + const toRow = fixture.nativeElement.querySelector( + '.pr-sidebar-date-picker-to-row', + ); + + expect(toRow).toBeTruthy(); + + const toValue = toRow.querySelector('.pr-sidebar-date-picker-row-value'); + + expect(toValue.textContent.trim()).toBe('June 1990'); + }); + }); + + describe('qualifiers', () => { + it('should not show qualifiers when none are active', () => { + host.displayDT = '1985-05'; + fixture.detectChanges(); + + const qualifiers = fixture.nativeElement.querySelector( + '.pr-sidebar-date-picker-qualifiers', + ); + + expect(qualifiers).toBeFalsy(); + }); + + it('should display qualifiers from EDTF string with ~ (approximate)', () => { + host.displayDT = '1985-05~'; + fixture.detectChanges(); + + const qualifiers = fixture.nativeElement.querySelector( + '.pr-sidebar-date-picker-qualifiers', + ); + + expect(qualifiers).toBeTruthy(); + expect(qualifiers.textContent).toContain('Approximate'); + }); + + it('should display qualifiers from EDTF string with ? (uncertain)', () => { + host.displayDT = '1985-05?'; + fixture.detectChanges(); + + const qualifiers = fixture.nativeElement.querySelector( + '.pr-sidebar-date-picker-qualifiers', + ); + + expect(qualifiers).toBeTruthy(); + expect(qualifiers.textContent).toContain('Uncertain'); + }); + + it('should display both qualifiers from EDTF string with % (approximate + uncertain)', () => { + host.displayDT = '1985-05%'; + fixture.detectChanges(); + + const qualifiers = fixture.nativeElement.querySelector( + '.pr-sidebar-date-picker-qualifiers', + ); + + expect(qualifiers).toBeTruthy(); + expect(qualifiers.textContent).toContain('Approximate'); + expect(qualifiers.textContent).toContain('Uncertain'); + }); + }); + + describe('toggle and dropdown', () => { + it('should not open when disabled', () => { + host.disabled = true; + fixture.detectChanges(); + + component.toggle(); + + expect(component.isDropdownOpen()).toBeFalse(); + }); + + it('should open dropdown on toggle', () => { + host.displayDT = '1985-05'; + fixture.detectChanges(); + + component.toggle(); + + expect(component.isDropdownOpen()).toBeTrue(); + }); + + it('should close dropdown on second toggle', () => { + host.displayDT = '1985-05'; + fixture.detectChanges(); + + component.toggle(); + + expect(component.isDropdownOpen()).toBeTrue(); + + component.toggle(); + + expect(component.isDropdownOpen()).toBeFalse(); + }); + + it('should show panel when open', () => { + host.displayDT = '1985-05'; + fixture.detectChanges(); + + component.toggle(); + fixture.detectChanges(); + + const panel = fixture.nativeElement.querySelector( + '.pr-sidebar-date-picker-panel', + ); + + expect(panel).toBeTruthy(); + }); + + it('should hide panel when closed', () => { + const panel = fixture.nativeElement.querySelector( + '.pr-sidebar-date-picker-panel', + ); + + expect(panel).toBeFalsy(); + }); + + it('should not show edit icon when disabled', () => { + host.disabled = true; + fixture.detectChanges(); + + const editIcon = fixture.nativeElement.querySelector( + '.pr-sidebar-date-picker-edit-icon', + ); + + expect(editIcon).toBeFalsy(); + }); + + it('should show edit icon when not disabled', () => { + const editIcon = fixture.nativeElement.querySelector( + '.pr-sidebar-date-picker-edit-icon', + ); + + expect(editIcon).toBeTruthy(); + }); + }); + + describe('onSave', () => { + it('should emit saveClicked and close dropdown', () => { + host.displayDT = '1985-05-20T14:30'; + fixture.detectChanges(); + + component.toggle(); + fixture.detectChanges(); + + component.onSave(); + fixture.detectChanges(); + + expect(host.savedValue).toBeTruthy(); + expect(component.isDropdownOpen()).toBeFalse(); + }); + }); + + describe('onCancel', () => { + it('should close dropdown and reset to input values', () => { + host.displayDT = '1985-05'; + fixture.detectChanges(); + + component.toggle(); + fixture.detectChanges(); + + component.onDateChange({ year: '2000', month: '01', day: '15' }); + fixture.detectChanges(); + + component.onCancel(); + fixture.detectChanges(); + + expect(component.isDropdownOpen()).toBeFalse(); + expect(component._date().year).toBe('1985'); + expect(component._date().month).toBe('05'); + }); + }); + + describe('onMoreOptions', () => { + it('should emit moreOptionsClicked with current data', () => { + host.displayDT = '1985-05'; + fixture.detectChanges(); + + component.toggle(); + fixture.detectChanges(); + component.onMoreOptions(); + + expect(host.moreOptionsData).toBeTruthy(); + expect(host.moreOptionsData.date.year).toBe('1985'); + expect(host.moreOptionsData.date.month).toBe('05'); + }); + + it('should include end date in emitted data when present', () => { + host.displayDT = '1985-05'; + host.displayEndDT = '1990-06'; + fixture.detectChanges(); + + component.toggle(); + fixture.detectChanges(); + component.onMoreOptions(); + + expect(host.moreOptionsData.endDate).toBeTruthy(); + expect(host.moreOptionsData.endDate.year).toBe('1990'); + expect(host.moreOptionsData.endDate.month).toBe('06'); + }); + + it('should close dropdown when emitting', () => { + host.displayDT = '1985-05'; + fixture.detectChanges(); + + component.toggle(); + fixture.detectChanges(); + component.onMoreOptions(); + + expect(component.isDropdownOpen()).toBeFalse(); + }); + }); + + describe('outside click', () => { + it('should close dropdown on outside click', () => { + host.displayDT = '1985-05'; + fixture.detectChanges(); + + component.toggle(); + + expect(component.isDropdownOpen()).toBeTrue(); + + const outsideEl = document.createElement('div'); + document.body.appendChild(outsideEl); + + component.onDocumentClick({ + target: outsideEl, + } as any); + + outsideEl.remove(); + + expect(component.isDropdownOpen()).toBeFalse(); + }); + + it('should not close dropdown on inside click', () => { + host.displayDT = '1985-05'; + fixture.detectChanges(); + + component.toggle(); + fixture.detectChanges(); + + const container = fixture.nativeElement.querySelector( + '.pr-sidebar-date-picker', + ); + component.onDocumentClick({ target: container } as any); + + expect(component.isDropdownOpen()).toBeTrue(); + }); + }); + + describe('field updates', () => { + it('should update date on onDateChange', () => { + component.onDateChange({ year: '2000', month: '01', day: '15' }); + + expect(component._date().year).toBe('2000'); + expect(component._date().month).toBe('01'); + expect(component._date().day).toBe('15'); + }); + + it('should update time on onTimeChange', () => { + component.onTimeChange({ + hours: '10', + minutes: '30', + seconds: '00', + amPm: Meridian.PM, + }); + + expect(component._time().hours).toBe('10'); + expect(component._time().minutes).toBe('30'); + expect(component._time().amPm).toBe(Meridian.PM); + }); + + it('should update timezone on onTimezoneChange', () => { + component.onTimezoneChange({ + offset: 'GMT+01:00', + name: 'CET', + }); + + expect(component._time().timezoneOffset).toBe('GMT+01:00'); + expect(component._time().timezoneName).toBe('CET'); + }); + }); +}); 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 new file mode 100644 index 000000000..b6822cc20 --- /dev/null +++ b/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.ts @@ -0,0 +1,349 @@ +import { + Component, + Input, + Output, + EventEmitter, + signal, + computed, + OnInit, + OnChanges, + SimpleChanges, + HostListener, + ViewChild, + ElementRef, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + parseEdtf, + formatEdtfDate, +} from '@shared/services/edtf-date/edtf-date.service'; +import { + DatepickerInputComponent, + DateInputObject, +} from '@shared/components/datepicker-input/datepicker-input.component'; +import { + TimepickerInputComponent, + TimeInputObject, +} from '@shared/components/timepicker-input/timepicker-input.component'; +import { + TimezoneDropdownComponent, + TimezoneOption, +} from '@shared/components/timezone-dropdown/timezone-dropdown.component'; +import { + Meridian, + EditDateModel, + DateObject, + TimeObject, + DateQualifierObject, +} from '../edit-date-time-modal/edit-date-time.model'; +import { EditDateTimeMappingService } from '../edit-date-time-modal/edit-date-time-mapping.service'; + +export interface SaveDateResult { + displayDT: string; + displayEndDT?: string | null; +} + +const EMPTY_DATE: DateObject = { year: '', month: '', day: '' }; + +const EMPTY_TIME: TimeObject = { + hours: '', + minutes: '', + seconds: '', + amPm: Meridian.AM, + timezoneOffset: '', + timezoneName: '', +}; + +const EMPTY_QUALIFIERS: DateQualifierObject = { + approximate: false, + uncertain: false, + unknown: false, +}; + +@Component({ + selector: 'pr-sidebar-date-picker', + standalone: true, + imports: [ + CommonModule, + DatepickerInputComponent, + TimepickerInputComponent, + TimezoneDropdownComponent, + ], + templateUrl: './sidebar-date-picker.component.html', + styleUrls: ['./sidebar-date-picker.component.scss'], +}) +export class SidebarDatePickerComponent implements OnInit, OnChanges { + @Input() displayDT: string | null = null; + @Input() displayEndDT: string | null = null; + @Input() disabled = false; + + @Output() saveClicked = new EventEmitter(); + @Output() moreOptionsClicked = new EventEmitter(); + + @ViewChild('sidebarDatePickerContainer') + container?: ElementRef; + + isDropdownOpen = signal(false); + + _date = signal({ ...EMPTY_DATE }); + _time = signal({ ...EMPTY_TIME }); + _endDate = signal({ ...EMPTY_DATE }); + _endTime = signal({ ...EMPTY_TIME }); + _qualifiers = signal({ ...EMPTY_QUALIFIERS }); + + activeQualifiers = computed(() => { + const q = this._qualifiers(); + const active: string[] = []; + if (q.approximate) active.push('Approximate'); + if (q.uncertain) active.push('Uncertain'); + if (q.unknown) active.push('Unknown'); + return active; + }); + + stringStartDate = computed(() => { + if (this._qualifiers().unknown) return 'xxxx-xx-xx'; + return this.computeDateString(this._date(), this._time()); + }); + + stringEndDate = computed(() => + this.computeDateString(this._endDate(), this._endTime()), + ); + + ngOnInit(): void { + this.updateDateValue(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.displayDT || changes.displayEndDT) { + this.updateDateValue(); + } + } + + @HostListener('document:click', ['$event']) + onDocumentClick(event: MouseEvent): void { + const target = event.target as Node; + if ( + this.isDropdownOpen() && + this.container && + target.isConnected && + !this.container.nativeElement.contains(target) + ) { + this.onCancel(); + } + } + + toggle(): void { + if (this.disabled) return; + if (this.isDropdownOpen()) { + this.onCancel(); + } else { + this.open(); + } + } + + open(): void { + if (this.disabled) return; + this.updateDateValue(); + this.isDropdownOpen.set(true); + } + + onDateChange(newDate: DateInputObject): void { + this._date.set(newDate); + } + + onTimeChange(newTime: TimeInputObject): void { + this._time.update((t) => ({ + ...t, + hours: newTime.hours, + minutes: newTime.minutes, + seconds: newTime.seconds, + amPm: newTime.amPm, + })); + } + + onTimezoneChange(tz: TimezoneOption): void { + this._time.update((t) => ({ + ...t, + timezoneOffset: tz.offset, + timezoneName: tz.name, + })); + } + + onMoreOptions(): void { + const currentValue = this.buildDateTimeObject(); + this.isDropdownOpen.set(false); + + const modalData: EditDateModel = { + qualifiers: { ...this._qualifiers() }, + date: { ...currentValue.date }, + time: { ...currentValue.time }, + }; + + const endDate = this._endDate(); + if (endDate.year || endDate.month || endDate.day) { + modalData.endDate = { ...endDate }; + modalData.endTime = { ...this._endTime() }; + } + + this.moreOptionsClicked.emit(modalData); + } + + onCancel(): void { + this.updateDateValue(); + this.isDropdownOpen.set(false); + } + + onSave(): void { + const newValue = this.buildDateTimeObject(); + const saveResult: SaveDateResult = { + displayDT: this.buildDisplayDT(newValue.date, newValue.time), + }; + + const endDate = this._endDate(); + if (endDate.year || endDate.month || endDate.day) { + saveResult.displayEndDT = this.buildDisplayDT(endDate, this._endTime()); + } + + this.saveClicked.emit(saveResult); + this.isDropdownOpen.set(false); + } + + private updateDateValue(): void { + const parsed = this.parseDate(this.displayDT); + this._date.set(parsed?.date ?? { ...EMPTY_DATE }); + this._time.set(parsed?.time ?? { ...EMPTY_TIME }); + + const parsedQualifiers = parsed?.qualifiers; + if ( + parsedQualifiers && + (parsedQualifiers.approximate || + parsedQualifiers.uncertain || + parsedQualifiers.unknown) + ) { + this._qualifiers.set(parsedQualifiers); + } + + const parsedEnd = this.parseDate(this.displayEndDT); + this._endDate.set(parsedEnd?.date ?? { ...EMPTY_DATE }); + this._endTime.set(parsedEnd?.time ?? { ...EMPTY_TIME }); + } + + private parseDate(input: string | null): { + date: DateObject; + time: TimeObject; + qualifiers: DateQualifierObject; + } | null { + try { + const parsed = parseEdtf(input); + const values = parsed.values; + + const year = values[0] == null ? '' : String(values[0]); + const month = + values[1] == null ? '' : String(values[1] + 1).padStart(2, '0'); + const day = values[2] == null ? '' : String(values[2]).padStart(2, '0'); + + let hours = ''; + let minutes = ''; + let seconds = ''; + let amPm = Meridian.AM; + let timezoneOffset = ''; + let timezoneName = ''; + + if (values.length > 3) { + const converted = EditDateTimeMappingService.to12Hour(values[3]); + hours = converted.hours; + amPm = converted.amPm; + minutes = values[4] == null ? '' : String(values[4]).padStart(2, '0'); + seconds = values[5] == null ? '' : String(values[5]).padStart(2, '0'); + } + + if (parsed.offset !== undefined && parsed.offset !== null) { + const totalMinutes = Math.abs(parsed.offset); + const hrs = Math.floor(totalMinutes / 60); + const mins = totalMinutes % 60; + const sign = parsed.offset >= 0 ? '+' : '-'; + timezoneOffset = `GMT${sign}${String(hrs).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; + timezoneName = + EditDateTimeMappingService.offsetToAbbreviation(timezoneOffset); + } + + return { + date: { year, month, day }, + time: { + hours, + minutes, + seconds, + amPm, + timezoneOffset, + timezoneName, + }, + qualifiers: { + approximate: !!parsed.approximate, + uncertain: !!parsed.uncertain, + unknown: false, + }, + }; + } catch { + return null; + } + } + + private buildDateTimeObject(): EditDateModel { + return { + date: { ...this._date() }, + time: { ...this._time() }, + }; + } + + private computeDateString(date: DateObject, time: TimeObject): string { + const hasYear = !!date.year; + const hasMonth = !!date.month; + const hasDay = !!date.day && parseInt(date.day, 10) > 0; + + if (!hasYear && !hasMonth && !hasDay) return ''; + + let dateStr = ''; + + if (hasYear) { + const parts = [date.year.padStart(4, '0')]; + if (hasMonth) parts.push(date.month.padStart(2, '0')); + if (hasDay) parts.push(date.day.padStart(2, '0')); + + dateStr = formatEdtfDate( + parts.join('-'), + 'en-US', + hasMonth ? { month: 'long' } : {}, + ); + } else { + const dateParts: string[] = []; + if (hasMonth) { + const monthIdx = parseInt(date.month, 10) - 1; + if (monthIdx >= 0 && monthIdx < 12) { + dateParts.push( + new Intl.DateTimeFormat('en-US', { + month: 'long', + }).format(new Date(2000, monthIdx)), + ); + } + } + if (hasDay) dateParts.push(date.day); + dateStr = dateParts.join(' '); + } + + if (time?.hours) { + const h = parseInt(time.hours, 10); + const m = (time.minutes || '00').padStart(2, '0'); + const s = (time.seconds || '00').padStart(2, '0'); + const timeStr = `${h}:${m}:${s} ${time.amPm}`; + const tz = time.timezoneName || ''; + const fullTime = tz ? `${timeStr} ${tz}` : timeStr; + return dateStr ? `${dateStr} \u00B7 ${fullTime}` : fullTime; + } + + return dateStr; + } + + private buildDisplayDT(date: DateObject, time: TimeObject): string { + return EditDateTimeMappingService.buildDisplayDT(date, time); + } +} diff --git a/src/app/file-browser/file-browser-components.module.ts b/src/app/file-browser/file-browser-components.module.ts index d0ce066d8..62d21601a 100644 --- a/src/app/file-browser/file-browser-components.module.ts +++ b/src/app/file-browser/file-browser-components.module.ts @@ -27,6 +27,7 @@ import { SidebarViewOptionComponent } from './components/sidebar-view-option/sid import { SharingDialogComponent } from './components/sharing-dialog/sharing-dialog.component'; import { DownloadButtonComponent } from './components/download-button/download-button.component'; +import { SidebarDatePickerComponent } from './components/sidebar-date-picker/sidebar-date-picker.component'; @NgModule({ imports: [ @@ -36,6 +37,7 @@ import { DownloadButtonComponent } from './components/download-button/download-b GoogleMapsModule, InViewportModule, FontAwesomeModule, + SidebarDatePickerComponent, ], exports: [ FileListComponent, diff --git a/src/app/shared/services/edtf-date/edtf-date.service.spec.ts b/src/app/shared/services/edtf-date/edtf-date.service.spec.ts new file mode 100644 index 000000000..ba1f5158b --- /dev/null +++ b/src/app/shared/services/edtf-date/edtf-date.service.spec.ts @@ -0,0 +1,194 @@ +import { + EdtfDateService, + EdtfDateModel, + Meridian, + DateQualifier, +} from './edtf-date.service'; + +function getEdtfObject(service: EdtfDateService): EdtfDateModel { + return service.edtfObject; +} + +describe('EdtfDateService', () => { + it('should be created', () => { + const service = new EdtfDateService('1985-05'); + + expect(service).toBeTruthy(); + }); + + describe('parseEdtfDate', () => { + it('should parse year only', () => { + const service = new EdtfDateService('1985'); + service.parseEdtfDate(); + const result = getEdtfObject(service); + + expect(result).toBeTruthy(); + expect(result.date).toEqual({ + year: '1985', + month: '', + day: '', + }); + + expect(result.time).toBeUndefined(); + }); + + it('should parse year-month', () => { + const service = new EdtfDateService('1985-05'); + service.parseEdtfDate(); + const result = getEdtfObject(service); + + expect(result.date).toEqual({ + year: '1985', + month: '05', + day: '', + }); + }); + + it('should parse full date', () => { + const service = new EdtfDateService('1985-05-20'); + service.parseEdtfDate(); + const result = getEdtfObject(service); + + expect(result.date).toEqual({ + year: '1985', + month: '05', + day: '20', + }); + }); + + it('should parse date with time and convert to 12h format', () => { + const service = new EdtfDateService('1985-05-20T14:30:45'); + service.parseEdtfDate(); + const result = getEdtfObject(service); + + expect(result.date).toEqual({ + year: '1985', + month: '05', + day: '20', + }); + + expect(result.time).toEqual({ + hours: '02', + minutes: '30', + seconds: '45', + amPm: Meridian.PM, + }); + }); + + it('should parse midnight as 12 AM', () => { + const service = new EdtfDateService('1985-05-20T00:00:00'); + service.parseEdtfDate(); + const result = getEdtfObject(service); + + expect(result.time!.hours).toBe('12'); + expect(result.time!.amPm).toBe(Meridian.AM); + }); + + it('should parse noon as 12 PM', () => { + const service = new EdtfDateService('1985-05-20T12:00:00'); + service.parseEdtfDate(); + const result = getEdtfObject(service); + + expect(result.time!.hours).toBe('12'); + expect(result.time!.amPm).toBe(Meridian.PM); + }); + + it('should extract timezone from offset', () => { + const service = new EdtfDateService('1985-05-20T14:30:00-05:00'); + service.parseEdtfDate(); + const result = getEdtfObject(service); + + expect(result.timezone!.timezoneOffset).toBe('GMT-05:00'); + expect(result.timezone!.timezoneName).toBe('EST'); + }); + + it('should extract positive timezone offset', () => { + const service = new EdtfDateService('1985-05-20T14:30:00+05:30'); + service.parseEdtfDate(); + const result = getEdtfObject(service); + + expect(result.timezone!.timezoneOffset).toBe('GMT+05:30'); + expect(result.timezone!.timezoneName).toBe('IST'); + }); + + it('should extract UTC timezone', () => { + const service = new EdtfDateService('1985-05-20T14:30:00Z'); + service.parseEdtfDate(); + const result = getEdtfObject(service); + + expect(result.timezone!.timezoneOffset).toBe('GMT+00:00'); + expect(result.timezone!.timezoneName).toBe('GMT'); + }); + + it('should detect approximate qualifier', () => { + const service = new EdtfDateService('1985-05~'); + service.parseEdtfDate(); + const result = getEdtfObject(service); + + expect(result.qualifiers).toBeTruthy(); + expect(result.qualifiers!.get(DateQualifier.Approximate)).toBeTrue(); + expect(result.qualifiers!.has(DateQualifier.Uncertain)).toBeFalse(); + }); + + it('should detect uncertain qualifier', () => { + const service = new EdtfDateService('1985-05?'); + service.parseEdtfDate(); + const result = getEdtfObject(service); + + expect(result.qualifiers).toBeTruthy(); + expect(result.qualifiers!.get(DateQualifier.Uncertain)).toBeTrue(); + }); + + it('should detect both approximate and uncertain from %', () => { + const service = new EdtfDateService('1985-05%'); + service.parseEdtfDate(); + const result = getEdtfObject(service); + + expect(result.qualifiers).toBeTruthy(); + expect(result.qualifiers!.get(DateQualifier.Approximate)).toBeTrue(); + expect(result.qualifiers!.get(DateQualifier.Uncertain)).toBeTrue(); + }); + + it('should not set qualifiers when none present', () => { + const service = new EdtfDateService('1985-05'); + service.parseEdtfDate(); + const result = getEdtfObject(service); + + expect(result.qualifiers).toBeUndefined(); + }); + + it('should keep empty values for empty string', () => { + const service = new EdtfDateService(''); + service.parseEdtfDate(); + const result = getEdtfObject(service); + + expect(result.date).toEqual({ year: '', month: '', day: '' }); + expect(result.time).toBeUndefined(); + expect(result.timezone).toBeUndefined(); + }); + + it('should keep empty values for invalid input', () => { + const service = new EdtfDateService('not-a-date'); + service.parseEdtfDate(); + const result = getEdtfObject(service); + + expect(result.date).toEqual({ year: '', month: '', day: '' }); + expect(result.time).toBeUndefined(); + expect(result.timezone).toBeUndefined(); + }); + }); + + describe('ngOnDestroy', () => { + it('should clear edtfString and edtfObject', () => { + const service = new EdtfDateService('1985-05'); + service.parseEdtfDate(); + service.ngOnDestroy(); + + const result = getEdtfObject(service); + + expect(result.date).toEqual({ year: '', month: '', day: '' }); + expect(result.time).toBeUndefined(); + expect(result.timezone).toBeUndefined(); + }); + }); +}); diff --git a/src/app/shared/services/edtf-date/edtf-date.service.ts b/src/app/shared/services/edtf-date/edtf-date.service.ts new file mode 100644 index 000000000..8a9976b63 --- /dev/null +++ b/src/app/shared/services/edtf-date/edtf-date.service.ts @@ -0,0 +1,316 @@ +import { Injectable, OnDestroy } from '@angular/core'; + +export interface EdtfParseResult { + values: number[]; + approximate?: boolean; + uncertain?: boolean; + offset?: number; +} + +export function parseEdtf(input: string): EdtfParseResult { + if (!input || typeof input !== 'string') { + throw new Error('Invalid EDTF input'); + } + + let str = input.trim(); + let approximate = false; + let uncertain = false; + + if (str.endsWith('%')) { + approximate = true; + uncertain = true; + str = str.slice(0, -1); + } else if (str.endsWith('~')) { + approximate = true; + str = str.slice(0, -1); + } else if (str.endsWith('?')) { + uncertain = true; + str = str.slice(0, -1); + } + + let offset: number | undefined; + let timePart: string | undefined; + + const tIndex = str.indexOf('T'); + let datePart = str; + + if (tIndex !== -1) { + datePart = str.slice(0, tIndex); + let timeStr = str.slice(tIndex + 1); + + const zIndex = timeStr.indexOf('Z'); + if (zIndex >= 0) { + offset = 0; + timeStr = timeStr.slice(0, zIndex); + } else { + const tzMatch = timeStr.match(/([+-])(\d{2}):(\d{2})$/); + if (tzMatch) { + const sign = tzMatch[1] === '+' ? 1 : -1; + const tzHrs = parseInt(tzMatch[2], 10); + const tzMins = parseInt(tzMatch[3], 10); + offset = sign * (tzHrs * 60 + tzMins); + timeStr = timeStr.slice(0, tzMatch.index); + } + } + + timePart = timeStr; + } + + const dateParts = datePart.split('-').filter((p) => p !== ''); + if (dateParts.length === 0) { + throw new Error('Invalid EDTF date'); + } + + const values: number[] = []; + + const isNegativeYear = datePart.startsWith('-'); + const yearValue = parseInt(dateParts[0], 10); + if (isNaN(yearValue)) { + throw new Error('Invalid EDTF date'); + } + values.push(isNegativeYear ? -yearValue : yearValue); + + if (dateParts.length > 1) { + const monthIdx = isNegativeYear ? 1 : 1; + values.push(parseInt(dateParts[monthIdx], 10) - 1); + } + + if (dateParts.length > 2) { + const dayIdx = isNegativeYear ? 2 : 2; + values.push(parseInt(dateParts[dayIdx], 10)); + } + + if (timePart) { + const timeParts = timePart.split(':'); + values.push(parseInt(timeParts[0], 10) || 0); + values.push(parseInt(timeParts[1], 10) || 0); + values.push(parseInt(timeParts[2], 10) || 0); + } + + const result: EdtfParseResult = { values }; + if (approximate) result.approximate = true; + if (uncertain) result.uncertain = true; + if (offset !== undefined) result.offset = offset; + + return result; +} + +export function formatEdtfDate( + dateParts: string, + locale: string = 'en-US', + options: Intl.DateTimeFormatOptions = {}, +): string { + const parts = dateParts.split('-').map((p) => parseInt(p, 10)); + const year = parts[0]; + const month = parts.length > 1 ? parts[1] - 1 : 0; + const day = parts.length > 2 ? parts[2] : 1; + + const formatOptions: Intl.DateTimeFormatOptions = { ...options }; + + if (parts.length >= 1) formatOptions.year = formatOptions.year ?? 'numeric'; + if (parts.length >= 2) formatOptions.month = formatOptions.month ?? 'long'; + if (parts.length >= 3) formatOptions.day = formatOptions.day ?? 'numeric'; + + const date = new Date(year, month, day); + if (year >= 0 && year < 100) { + date.setFullYear(year); + } + + return new Intl.DateTimeFormat(locale, formatOptions).format(date); +} + +export enum Meridian { + AM = 'AM', + PM = 'PM', +} + +export interface TimeModel { + hours: string; + minutes: string; + seconds: string; + amPm: Meridian; +} + +export enum DateQualifier { + Approximate = 'approximate', + Uncertain = 'uncertain', + Unknown = 'unknown', +} + +export interface DateModel { + year: string; + month: string; + day: string; +} + +export interface TimezoneModel { + timezoneOffset: string; + timezoneName: string; +} + +export interface EdtfDateModel { + qualifiers?: Map; + date: DateModel; + time?: TimeModel; + timezone?: TimezoneModel; +} + +const OFFSET_ABBREVIATIONS: Record = { + 'GMT-12:00': 'IDLW', + 'GMT-11:00': 'SST', + 'GMT-10:00': 'HST', + 'GMT-09:00': 'AKST', + 'GMT-08:00': 'PST', + 'GMT-07:00': 'MST', + 'GMT-06:00': 'CST', + 'GMT-05:00': 'EST', + 'GMT-04:00': 'AST', + 'GMT-03:30': 'NST', + 'GMT-03:00': 'ART', + 'GMT+00:00': 'GMT', + 'GMT+01:00': 'CET', + 'GMT+02:00': 'EET', + 'GMT+03:00': 'MSK', + 'GMT+03:30': 'IRST', + 'GMT+04:00': 'GST', + 'GMT+04:30': 'AFT', + 'GMT+05:00': 'PKT', + 'GMT+05:30': 'IST', + 'GMT+05:45': 'NPT', + 'GMT+06:00': 'BST', + 'GMT+07:00': 'ICT', + 'GMT+08:00': 'HKT', + 'GMT+09:00': 'JST', + 'GMT+09:30': 'ACST', + 'GMT+10:00': 'AEST', + 'GMT+11:00': 'SBT', + 'GMT+12:00': 'NZST', + 'GMT+13:00': 'TOT', +}; + +@Injectable({ providedIn: 'root' }) +export class EdtfDateService implements OnDestroy { + private edtfString: string; + private _edtfObject: EdtfDateModel; + + private static readonly EMPTY_OBJECT: EdtfDateModel = { + date: { year: '', month: '', day: '' }, + }; + + constructor(edtfString: string) { + this.edtfString = edtfString; + this._edtfObject = { + ...EdtfDateService.EMPTY_OBJECT, + date: { ...EdtfDateService.EMPTY_OBJECT.date }, + }; + } + + get edtfObject(): EdtfDateModel { + return this._edtfObject; + } + + ngOnDestroy(): void { + this.edtfString = ''; + this._edtfObject = { + ...EdtfDateService.EMPTY_OBJECT, + date: { ...EdtfDateService.EMPTY_OBJECT.date }, + }; + } + + parseEdtfDate(): void { + if (!this.edtfString) return; + + try { + const parsed = parseEdtf(this.edtfString); + const values = parsed.values; + + this._edtfObject.date = this.getDateFromEdtfValues(values); + + if (values.length > 3) { + this._edtfObject.time = this.getTimeFromEdtfValues(values); + } + + if (parsed.offset !== undefined && parsed.offset !== null) { + this._edtfObject.timezone = this.getTimezoneFromOffset(parsed.offset); + } + + const qualifiers = this.getQualifiersFromParsedEdtf(parsed); + if (qualifiers.size > 0) { + this._edtfObject.qualifiers = qualifiers; + } + } catch { + // invalid input; edtfObject remains null + } + } + + private getQualifiersFromParsedEdtf(parsed: { + approximate?: boolean; + uncertain?: boolean; + }): Map { + const qualifiers = new Map(); + if (parsed.approximate) qualifiers.set(DateQualifier.Approximate, true); + if (parsed.uncertain) qualifiers.set(DateQualifier.Uncertain, true); + return qualifiers; + } + + private getDateFromEdtfValues(values: number[]): DateModel { + return { + year: values[0] == null ? '' : String(values[0]), + month: values[1] == null ? '' : String(values[1] + 1).padStart(2, '0'), + day: values[2] == null ? '' : String(values[2]).padStart(2, '0'), + }; + } + + private getTimeFromEdtfValues(values: number[]): TimeModel { + const hour24 = values[3]; + let hours: string; + let amPm: Meridian; + + if (hour24 === 0) { + hours = '12'; + amPm = Meridian.AM; + } else if (hour24 < 12) { + hours = String(hour24).padStart(2, '0'); + amPm = Meridian.AM; + } else if (hour24 === 12) { + hours = '12'; + amPm = Meridian.PM; + } else { + hours = String(hour24 - 12).padStart(2, '0'); + amPm = Meridian.PM; + } + + return { + hours, + minutes: values[4] == null ? '' : String(values[4]).padStart(2, '0'), + seconds: values[5] == null ? '' : String(values[5]).padStart(2, '0'), + amPm, + }; + } + + private getTimezoneFromOffset(offset: number): TimezoneModel { + const totalMinutes = Math.abs(offset); + const hrs = Math.floor(totalMinutes / 60); + const mins = totalMinutes % 60; + const sign = offset >= 0 ? '+' : '-'; + const timezoneOffset = `GMT${sign}${String(hrs).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; + + let timezoneName: string; + if (OFFSET_ABBREVIATIONS[timezoneOffset]) { + timezoneName = OFFSET_ABBREVIATIONS[timezoneOffset]; + } else { + const match = timezoneOffset.match(/GMT([+-])(\d{2}):(\d{2})/); + if (match) { + const s = match[1]; + const h = parseInt(match[2], 10); + const m = parseInt(match[3], 10); + timezoneName = + m === 0 ? `UTC${s}${h}` : `UTC${s}${h}:${String(m).padStart(2, '0')}`; + } else { + timezoneName = timezoneOffset; + } + } + + return { timezoneOffset, timezoneName }; + } +} From 58513f577f0bcbec57c50f60dd65ee6a948c5605 Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Mon, 9 Mar 2026 14:52:54 +0200 Subject: [PATCH 09/13] Integrate edit date time into the sidebar Issue: PER-10416 --- .../components/sidebar/sidebar.component.html | 35 +---- .../sidebar/sidebar.component.spec.ts | 127 ++++++++++++++++-- .../components/sidebar/sidebar.component.ts | 47 ++++++- 3 files changed, 168 insertions(+), 41 deletions(-) diff --git a/src/app/file-browser/components/sidebar/sidebar.component.html b/src/app/file-browser/components/sidebar/sidebar.component.html index 5d5c26254..2aae0a62b 100644 --- a/src/app/file-browser/components/sidebar/sidebar.component.html +++ b/src/app/file-browser/components/sidebar/sidebar.component.html @@ -106,35 +106,14 @@ - @if (selectedItem.isFolder) { - - } diff --git a/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.scss b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.scss index dd16a9fc5..04e1c91fa 100644 --- a/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.scss +++ b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.scss @@ -1,9 +1,10 @@ @import 'colors'; +@import 'mixins'; .pr-edit-date-time-dialog { background: $white; border-radius: 12px; - min-width: 650px; + min-width: 658px; box-shadow: 0 8px 32px rgba($black, 0.12); font-family: 'Inter', sans-serif; @@ -52,28 +53,32 @@ display: flex; align-items: center; gap: 0; + width: 100%; .pr-qualifiers-label { font-size: 14px; color: $PR-blue-600; white-space: nowrap; - margin-right: 16px; + margin-right: 22px; } .pr-qualifier-option { display: flex; align-items: center; - gap: 8px; + gap: 24px; padding: 0 20px; border-right: 1px solid $PR-blue-100; + flex: 1; &:last-child { border-right: none; + padding-right: 0; } span { font-size: 14px; color: $PR-blue; + white-space: nowrap; } } } @@ -130,6 +135,11 @@ display: flex; gap: 16px; position: relative; + + pr-datepicker-input, + pr-timepicker-input { + flex: 1; + } } .pr-icon-button { @@ -153,10 +163,23 @@ align-items: center; gap: 12px; - span { + > span { font-size: 14px; color: $PR-blue; } + + .pr-clear-btn { + @include clear-btn; + margin-left: auto; + + i { + font-size: 18px; + } + + span { + font-size: 14px; + } + } } // Range label @@ -170,22 +193,26 @@ // Footer .pr-dialog-footer { - display: flex; - justify-content: space-between; - align-items: center; + @include panel-footer; padding: 24px; - border-top: 1px solid $PR-blue-100; - background: $PR-blue-25; - border-radius: 0 0 12px 12px; .pr-edtf-display { display: flex; align-items: center; gap: 10px; + min-width: 0; + max-width: 425px; + flex: 1; .pr-edtf-badge { - font-size: 12px; + font-family: 'Usual', sans-serif; font-weight: 400; + font-style: normal; + font-size: 10px; + line-height: 8px; + letter-spacing: 16%; + text-align: center; + text-transform: uppercase; color: $PR-blue; background: $white; border-radius: 4px; @@ -193,13 +220,27 @@ height: 20px; display: flex; align-items: center; - letter-spacing: 1px; } .pr-edtf-value { - font-size: 13px; + font-family: 'DM Mono', monospace; + font-weight: 400; + font-style: normal; + font-size: 12px; + line-height: 16px; + letter-spacing: -1%; + text-align: right; color: $PR-blue-600; - font-family: monospace; + } + + .pr-edtf-error { + font-size: 12px; + line-height: 16px; + color: $red; + word-wrap: break-word; + overflow-wrap: break-word; + white-space: normal; + min-width: 0; } } @@ -242,6 +283,11 @@ &:hover { background: $PR-blue-800; } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } } } diff --git a/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.spec.ts b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.spec.ts index ce61e1d8b..ce706613a 100644 --- a/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.spec.ts +++ b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.spec.ts @@ -1,14 +1,17 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; +import { + DateTimeModel, + DateQualifier, +} from '@shared/services/edtf-service/edtf.service'; import { EditDateTimeModalComponent } from './edit-date-time-modal.component'; -import { EditDateModel, DateQualifier, Meridian } from './edit-date-time.model'; describe('EditDateTimeModalComponent', () => { let component: EditDateTimeModalComponent; let fixture: ComponentFixture; let dialogRefSpy: jasmine.SpyObj; - const mockDialogData: EditDateModel = { + const mockDialogData: DateTimeModel = { qualifiers: { approximate: true, uncertain: false, @@ -19,7 +22,8 @@ describe('EditDateTimeModalComponent', () => { hours: '11', minutes: '', seconds: '', - amPm: Meridian.AM, + am: true, + pm: false, timezoneOffset: 'GMT+01:00', timezoneName: 'Central European Standard Time', }, @@ -54,7 +58,8 @@ describe('EditDateTimeModalComponent', () => { it('should initialize fields from dialog data', () => { expect(component.date().year).toBe('1930'); expect(component.time().hours).toBe('11'); - expect(component.time().amPm).toBe(Meridian.AM); + expect(component.time().am).toBe(true); + expect(component.time().pm).toBe(false); expect(component.qualifiers().approximate).toBeTrue(); expect(component.qualifiers().uncertain).toBeFalse(); expect(component.qualifiers().unknown).toBeFalse(); @@ -77,23 +82,46 @@ describe('EditDateTimeModalComponent', () => { expect(component.endDate().year).toBe('2026'); }); + it('should save form state with unknown false when data has unknown true', () => { + const unknownData: DateTimeModel = { + qualifiers: { approximate: false, uncertain: false, unknown: true }, + date: { year: '', month: '', day: '' }, + time: { + hours: '', + minutes: '', + seconds: '', + am: true, + pm: false, + timezoneOffset: '', + timezoneName: '', + }, + }; + + component.data = unknownData; + component.ngOnInit(); + + expect(component.qualifiers().unknown).toBeTrue(); + expect(component.savedFormState()).not.toBeNull(); + expect(component.savedFormState().qualifiers.unknown).toBeFalse(); + }); + // --- Time updates via onTimeChange --- it('should update time fields via onTimeChange', () => { component.onTimeChange( - { hours: '02', minutes: '30', seconds: '15', amPm: Meridian.PM }, + { hours: '02', minutes: '30', seconds: '15', am: false, pm: true }, component.time, ); expect(component.time().hours).toBe('02'); expect(component.time().minutes).toBe('30'); expect(component.time().seconds).toBe('15'); - expect(component.time().amPm).toBe(Meridian.PM); + expect(component.time().pm).toBe(true); }); it('should update end time fields via onTimeChange', () => { component.onTimeChange( - { hours: '06', minutes: '45', seconds: '00', amPm: Meridian.AM }, + { hours: '06', minutes: '45', seconds: '00', am: true, pm: false }, component.endTime, ); @@ -106,12 +134,13 @@ describe('EditDateTimeModalComponent', () => { hours: '10', minutes: '00', seconds: '00', - amPm: Meridian.AM, + am: true, + pm: false, timezoneOffset: 'GMT-05:00', timezoneName: 'Eastern Standard Time', }); component.onTimeChange( - { hours: '11', minutes: '30', seconds: '00', amPm: Meridian.AM }, + { hours: '11', minutes: '30', seconds: '00', am: true, pm: false }, component.time, ); @@ -225,19 +254,6 @@ describe('EditDateTimeModalComponent', () => { expect(component.time().hours).toBe(''); }); - it('should save form state before setting unknown', () => { - component.date.set({ year: '2026', month: '02', day: '18' }); - component.time.update((t) => ({ ...t, hours: '10' })); - - component.onQualifierChange(DateQualifier.Unknown); - - const saved = component.savedFormState(); - - expect(saved).not.toBeNull(); - expect(saved!.date.year).toBe('2026'); - expect(saved!.time.hours).toBe('10'); - }); - it('should restore form state including qualifiers when unknown is toggled off', () => { component.qualifiers.set({ approximate: true, @@ -250,8 +266,6 @@ describe('EditDateTimeModalComponent', () => { component.onQualifierChange(DateQualifier.Unknown); expect(component.qualifiers().unknown).toBeTrue(); - expect(component.qualifiers().approximate).toBeFalse(); - expect(component.qualifiers().uncertain).toBeFalse(); expect(component.date().year).toBe(''); component.onQualifierChange(DateQualifier.Unknown); @@ -266,36 +280,56 @@ describe('EditDateTimeModalComponent', () => { expect(component.savedFormState()).toBeNull(); }); + it('should enable form when toggling unknown off from initial unknown state', () => { + const unknownData: DateTimeModel = { + qualifiers: { approximate: false, uncertain: false, unknown: true }, + date: { year: '', month: '', day: '' }, + time: { + hours: '', + minutes: '', + seconds: '', + am: true, + pm: false, + timezoneOffset: '', + timezoneName: '', + }, + }; + + component.data = unknownData; + component.ngOnInit(); + + expect(component.fieldsDisabled()).toBeTrue(); + + component.onQualifierChange(DateQualifier.Unknown); + + expect(component.qualifiers().unknown).toBeFalse(); + expect(component.fieldsDisabled()).toBeFalse(); + }); + it('should show xxxx-xx-xx as EDTF value when unknown is on', () => { component.date.set({ year: '2026', month: '02', day: '18' }); component.onQualifierChange(DateQualifier.Unknown); - expect(component.edtfValue()).toBe('xxxx-xx-xx'); + expect(component.edtfValue()).toBe('XXXX-XX-XX'); }); - it('should restore EDTF value when unknown is toggled off', () => { + // --- clearAll --- + + it('should clear all fields', () => { component.date.set({ year: '2026', month: '02', day: '18' }); - component.time.set({ - hours: '', - minutes: '', - seconds: '', - amPm: Meridian.AM, - timezoneOffset: 'GMT+01:00', - timezoneName: 'Central European Standard Time', - }); + component.time.update((t) => ({ ...t, hours: '10' })); component.qualifiers.set({ - approximate: false, + approximate: true, uncertain: false, unknown: false, }); - component.onQualifierChange(DateQualifier.Unknown); - - expect(component.edtfValue()).toBe('xxxx-xx-xx'); - - component.onQualifierChange(DateQualifier.Unknown); + component.clearAll(); - expect(component.edtfValue()).toBe('2026-02-18'); + expect(component.date().year).toBe(''); + expect(component.time().hours).toBe(''); + expect(component.qualifiers().approximate).toBeFalse(); + expect(component.savedFormState()).toBeNull(); }); // --- Dialog actions --- @@ -323,7 +357,7 @@ describe('EditDateTimeModalComponent', () => { component.onSave(); const result = dialogRefSpy.close.calls.mostRecent() - .args[0] as EditDateModel; + .args[0] as DateTimeModel; expect(result.endDate).toEqual({ year: '2026', month: '12', day: '31' }); }); @@ -333,7 +367,7 @@ describe('EditDateTimeModalComponent', () => { component.onSave(); const result = dialogRefSpy.close.calls.mostRecent() - .args[0] as EditDateModel; + .args[0] as DateTimeModel; expect(result.endDate).toBeUndefined(); }); @@ -345,8 +379,9 @@ describe('EditDateTimeModalComponent', () => { component.time.set({ hours: '10', minutes: '30', - seconds: '', - amPm: Meridian.AM, + seconds: '00', + am: true, + pm: false, timezoneOffset: 'GMT+01:00', timezoneName: 'Central European Standard Time', }); @@ -356,7 +391,7 @@ describe('EditDateTimeModalComponent', () => { unknown: false, }); - expect(component.edtfValue()).toBe('2026-02-18T10:30'); + expect(component.edtfValue()).toBe('2026-02-18T10:30:00+01:00'); }); it('should compute EDTF with date only', () => { @@ -365,7 +400,8 @@ describe('EditDateTimeModalComponent', () => { hours: '', minutes: '', seconds: '', - amPm: Meridian.AM, + am: true, + pm: false, timezoneOffset: 'GMT+01:00', timezoneName: 'Central European Standard Time', }); @@ -378,52 +414,45 @@ describe('EditDateTimeModalComponent', () => { expect(component.edtfValue()).toBe('2026'); }); - it('should include end date in EDTF when useDateRange is on', () => { - component.date.set({ year: '2026', month: '01', day: '01' }); + it('should show error when time has invalid hours', () => { + component.date.set({ year: '2026', month: '02', day: '18' }); component.time.set({ - hours: '', - minutes: '', - seconds: '', - amPm: Meridian.AM, - timezoneOffset: 'GMT+01:00', - timezoneName: 'Central European Standard Time', + hours: '13', + minutes: '30', + seconds: '00', + am: false, + pm: true, + timezoneOffset: '', + timezoneName: '', }); component.qualifiers.set({ approximate: false, uncertain: false, unknown: false, }); - component.useDateRange.set(true); - component.endDate.set({ year: '2026', month: '12', day: '31' }); - component.endTime.set({ - hours: '11', - minutes: '59', - seconds: '', - amPm: Meridian.PM, - timezoneOffset: 'GMT+01:00', - timezoneName: 'Central European Standard Time', - }); - expect(component.edtfValue()).toBe('2026-01-01/2026-12-31T23:59'); + expect(component.isEdtfValid()).toBeFalse(); + expect(component.edtfErrorMessage()).toBeTruthy(); + expect(component.edtfValue()).toBe(''); }); - it('should not include end date in EDTF when useDateRange is off', () => { - component.date.set({ year: '2026', month: '01', day: '01' }); + it('should not include day without month in EDTF', () => { + component.date.set({ year: '2026', month: '', day: '18' }); component.time.set({ hours: '', minutes: '', seconds: '', - amPm: Meridian.AM, - timezoneOffset: 'GMT+01:00', - timezoneName: 'Central European Standard Time', + am: true, + pm: false, + timezoneOffset: '', + timezoneName: '', }); component.qualifiers.set({ approximate: false, uncertain: false, unknown: false, }); - component.useDateRange.set(false); - expect(component.edtfValue()).toBe('2026-01-01'); + expect(component.edtfValue()).toBe('2026'); }); }); 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 15addaf1e..cf52b1713 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 @@ -7,49 +7,29 @@ import { WritableSignal, } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; import { DatepickerInputComponent } from '@shared/components/datepicker-input/datepicker-input.component'; +import { TimepickerInputComponent } from '@shared/components/timepicker-input/timepicker-input.component'; +import { TimezoneDropdownComponent } from '@shared/components/timezone-dropdown/timezone-dropdown.component'; import { - TimepickerInputComponent, - TimeInputObject, -} from '@shared/components/timepicker-input/timepicker-input.component'; -import { - TimezoneDropdownComponent, - TimezoneOption, -} from '@shared/components/timezone-dropdown/timezone-dropdown.component'; -import { - EditDateModel, - DateQualifierObject, - DateObject, - TimeObject, + EdtfService, DateQualifier, - Meridian, -} from './edit-date-time.model'; -import { EditDateTimeMappingService } from './edit-date-time-mapping.service'; - -interface SavedFormState { - qualifiers: DateQualifierObject; - date: DateObject; - time: TimeObject; - endDate: DateObject; - endTime: TimeObject; - useDateRange: boolean; -} - -const DEFAULT_TIME: TimeObject = { - hours: '', - minutes: '', - seconds: '', - amPm: Meridian.AM, - timezoneOffset: '', - timezoneName: '', -}; + DateQualifierFlags, + DateModel, + TimeModel, + DateTimeModel, + DEFAULT_TIME, + UNKNOWN_VALUE, + TimezoneOption, +} from '@shared/services/edtf-service/edtf.service'; @Component({ selector: 'pr-edit-date-time-modal', standalone: true, imports: [ CommonModule, + NgbTooltipModule, DatepickerInputComponent, TimepickerInputComponent, TimezoneDropdownComponent, @@ -60,53 +40,75 @@ const DEFAULT_TIME: TimeObject = { export class EditDateTimeModalComponent implements OnInit { readonly DateQualifier = DateQualifier; - qualifiers = signal({ + qualifiers = signal({ approximate: false, uncertain: false, unknown: false, }); - savedFormState = signal(null); + savedFormState = signal<{ + qualifiers: DateQualifierFlags; + date: DateModel; + time: TimeModel; + endDate: DateModel; + endTime: TimeModel; + useDateRange: boolean; + } | null>(null); fieldsDisabled = computed(() => this.qualifiers().unknown); - date = signal({ year: '', month: '', day: '' }); + date = signal({ year: '', month: '', day: '' }); - time = signal({ ...DEFAULT_TIME }); + time = signal({ ...DEFAULT_TIME }); useDateRange = signal(false); - endDate = signal({ year: '', month: '', day: '' }); + endDate = signal({ year: '', month: '', day: '' }); - endTime = signal({ ...DEFAULT_TIME }); + endTime = signal({ ...DEFAULT_TIME }); - edtfValue = computed(() => { + private edtfResult = computed<{ + value: string; + valid: boolean; + errorMessage: string; + }>(() => { if (this.qualifiers().unknown) { - return 'xxxx-xx-xx'; + return { value: UNKNOWN_VALUE, valid: true, errorMessage: '' }; } - let computedEdtfValue = EditDateTimeMappingService.buildEdtf( - this.date(), - this.time(), - this.qualifiers(), - ); + const dateTimeModel: DateTimeModel = { + date: this.date(), + time: this.time(), + qualifiers: { + approximate: this.qualifiers().approximate, + uncertain: this.qualifiers().uncertain, + unknown: this.qualifiers().unknown, + }, + }; if (this.useDateRange()) { - const endEdtf = EditDateTimeMappingService.buildEdtf( - this.endDate(), - this.endTime(), - ); - - if (endEdtf) { - computedEdtfValue += `/${endEdtf}`; - } + dateTimeModel.endDate = this.endDate(); + dateTimeModel.endTime = this.endTime(); + } + try { + const edtfDate = this.edtfService.toEdtfDate(dateTimeModel); + return { value: edtfDate, valid: true, errorMessage: '' }; + } catch (error) { + return { + value: '', + valid: false, + errorMessage: error instanceof Error ? error.message : 'Invalid date', + }; } - - return computedEdtfValue; }); + edtfValue = computed(() => this.edtfResult().value); + isEdtfValid = computed(() => this.edtfResult().valid); + edtfErrorMessage = computed(() => this.edtfResult().errorMessage); + constructor( - public dialogRef: DialogRef, - @Inject(DIALOG_DATA) public data: EditDateModel, + public dialogRef: DialogRef, + @Inject(DIALOG_DATA) public data: DateTimeModel, + private edtfService: EdtfService, ) {} ngOnInit(): void { @@ -126,25 +128,37 @@ export class EditDateTimeModalComponent implements OnInit { this.endDate.set(this.data.endDate); this.endTime.set(this.data.endTime ?? { ...DEFAULT_TIME }); } + + if (this.data.qualifiers?.unknown) { + this.savedFormState.set({ + qualifiers: { approximate: false, uncertain: false, unknown: false }, + date: { ...this.date() }, + time: { ...this.time() }, + endDate: { ...this.endDate() }, + endTime: { ...this.endTime() }, + useDateRange: this.useDateRange(), + }); + } } } onTimeChange( - timeInputValue: TimeInputObject, - currentTime: WritableSignal, + timeInputValue: TimeModel, + currentTime: WritableSignal, ): void { currentTime.update((t) => ({ ...t, hours: timeInputValue.hours, minutes: timeInputValue.minutes, seconds: timeInputValue.seconds, - amPm: timeInputValue.amPm, + am: timeInputValue.am, + pm: timeInputValue.pm, })); } onTimezoneChange( timezoneOption: TimezoneOption, - currentTime: WritableSignal, + currentTime: WritableSignal, ): void { currentTime.update((t) => ({ ...t, @@ -224,12 +238,22 @@ export class EditDateTimeModalComponent implements OnInit { this.useDateRange.update((v) => !v); } + clearAll(): void { + this.qualifiers.set({ + approximate: false, + uncertain: false, + unknown: false, + }); + this.resetForm(); + this.savedFormState.set(null); + } + onCancel(): void { this.dialogRef.close(); } onSave(): void { - const newDateModel: EditDateModel = { + const newDateModel: DateTimeModel = { qualifiers: { ...this.qualifiers() }, date: { ...this.date() }, time: { ...this.time() }, diff --git a/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.service.spec.ts b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.service.spec.ts index 05ab497ed..3ac46ed06 100644 --- a/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.service.spec.ts +++ b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.service.spec.ts @@ -1,25 +1,26 @@ import { TestBed } from '@angular/core/testing'; import { DialogRef } from '@angular/cdk/dialog'; import { DialogCdkService } from '@root/app/dialog-cdk/dialog-cdk.service'; +import { DateTimeModel } from '@shared/services/edtf-service/edtf.service'; import { EditDateTimeModalService } from './edit-date-time-modal.service'; import { EditDateTimeModalComponent } from './edit-date-time-modal.component'; -import { EditDateModel, Meridian } from './edit-date-time.model'; describe('EditDateTimeModalService', () => { let service: EditDateTimeModalService; let dialogCdkServiceSpy: jasmine.SpyObj; let mockDialogRef: jasmine.SpyObj< - DialogRef + DialogRef >; - const mockData: EditDateModel = { + const mockData: DateTimeModel = { qualifiers: { approximate: false, uncertain: false, unknown: false }, date: { year: '2026', month: '02', day: '18' }, time: { hours: '10', minutes: '30', seconds: '', - amPm: Meridian.AM, + am: true, + pm: false, timezoneOffset: 'GMT+01:00', timezoneName: 'Central European Standard Time', }, diff --git a/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.service.ts b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.service.ts index e44693882..42839ce0c 100644 --- a/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.service.ts +++ b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.service.ts @@ -1,8 +1,8 @@ import { Injectable } from '@angular/core'; import { DialogRef } from '@angular/cdk/dialog'; import { DialogCdkService } from '@root/app/dialog-cdk/dialog-cdk.service'; +import { DateTimeModel } from '@shared/services/edtf-service/edtf.service'; import { EditDateTimeModalComponent } from './edit-date-time-modal.component'; -import { EditDateModel } from './edit-date-time.model'; @Injectable({ providedIn: 'root', @@ -11,12 +11,12 @@ export class EditDateTimeModalService { constructor(private dialogCdkService: DialogCdkService) {} open( - data: EditDateModel, - ): DialogRef { + data: DateTimeModel, + ): DialogRef { return this.dialogCdkService.open< EditDateTimeModalComponent, - EditDateModel, - EditDateModel + DateTimeModel, + DateTimeModel >(EditDateTimeModalComponent, { data, hasBackdrop: true, diff --git a/src/app/file-browser/components/edit-date-time-modal/edit-date-time.model.ts b/src/app/file-browser/components/edit-date-time-modal/edit-date-time.model.ts deleted file mode 100644 index adab5a9db..000000000 --- a/src/app/file-browser/components/edit-date-time-modal/edit-date-time.model.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Meridian } from '@shared/components/timepicker-input/timepicker-input.component'; - -export enum DateQualifier { - Approximate = 'approximate', - Uncertain = 'uncertain', - Unknown = 'unknown', -} - -export interface DateQualifierObject { - approximate: boolean; - uncertain: boolean; - unknown: boolean; -} - -export interface DateObject { - year: string; - month: string; - day: string; -} - -export { Meridian }; - -export interface TimeObject { - hours: string; - minutes: string; - seconds: string; - amPm: Meridian; - timezoneOffset: string; - timezoneName: string; -} - -export interface EditDateModel { - qualifiers?: DateQualifierObject; - date: DateObject; - time: TimeObject; - endDate?: DateObject; - endTime?: TimeObject; -} diff --git a/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.html b/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.html index f2be29f50..257ba997d 100644 --- a/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.html +++ b/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.html @@ -2,8 +2,9 @@
Date @if (activeQualifiers().length) { + fiber_manual_record - · {{ activeQualifiers().join(', ') }}: + {{ activeQualifiers().join(', ') }}: }
@@ -14,10 +15,26 @@ (click)="toggle()" >
- From - @if (stringStartDate()) { + @if (hasEndDate()) { + From + } + @if (hasStartDate()) { - {{ stringStartDate() }} + {{ formattedStartDate() }} + @if (formattedStartTime()) { + fiber_manual_record + } + + @if (formattedStartTime()) { + + {{ formattedStartTime() }} + {{ startMeridian() }} + @if (startTimezone()) { + {{ startTimezone() }} + } + + } } @else { @@ -31,11 +48,25 @@ }
- @if (stringEndDate()) { + @if (hasEndDate()) {
To - {{ stringEndDate() }} + {{ formattedEndDate() }} + @if (formattedEndTime()) { + fiber_manual_record + } + + @if (formattedEndTime()) { + + {{ formattedEndTime() }} + {{ endMeridian() }} + @if (endTimezone()) { + {{ endTimezone() }} + } + + }
} @@ -56,9 +87,14 @@ + +