diff --git a/src/app/core/services/edit/edit.service.spec.ts b/src/app/core/services/edit/edit.service.spec.ts index cfde96814..cec31facd 100644 --- a/src/app/core/services/edit/edit.service.spec.ts +++ b/src/app/core/services/edit/edit.service.spec.ts @@ -445,6 +445,77 @@ describe('EditService', () => { expect(apiService.folder.getStelaFolderVOs).not.toHaveBeenCalled(); }); + it('should revert property and show error when updateStelaRecord fails', async () => { + const messageService = TestBed.inject(MessageService); + spyOn(messageService, 'showError'); + + const record = new RecordVO({ recordId: 1, displayTime: 'original-value' }); + const httpError = { + error: { message: 'Invalid date format' }, + message: 'Http failure', + }; + + (apiService.record.updateStelaRecord as jasmine.Spy).and.returnValue( + Promise.reject(httpError), + ); + (apiService.record.update as jasmine.Spy).and.returnValue( + Promise.resolve([]), + ); + + await service.saveItemVoProperty(record, 'displayTime', 'new-value'); + + expect(record.displayTime).toBe('original-value'); + expect(messageService.showError).toHaveBeenCalledWith({ + message: 'Invalid date format', + }); + }); + + it('should show fallback error when updateStelaRecord fails without error body', async () => { + const messageService = TestBed.inject(MessageService); + spyOn(messageService, 'showError'); + + const record = new RecordVO({ recordId: 1, displayTime: 'original-value' }); + + (apiService.record.updateStelaRecord as jasmine.Spy).and.returnValue( + Promise.reject({}), + ); + (apiService.record.update as jasmine.Spy).and.returnValue( + Promise.resolve([]), + ); + + await service.saveItemVoProperty(record, 'displayTime', 'new-value'); + + expect(record.displayTime).toBe('original-value'); + expect(messageService.showError).toHaveBeenCalledWith({ + message: 'Failed to save changes', + }); + }); + + it('should revert property and show error when updateStelaFolder fails', async () => { + const messageService = TestBed.inject(MessageService); + spyOn(messageService, 'showError'); + + const folder = new FolderVO({ folderId: 1, displayTime: 'original-value' }); + const httpError = { error: { error: 'Invalid EDTF string' } }; + + (apiService.folder.updateStelaFolder as jasmine.Spy).and.returnValue( + Promise.reject(httpError), + ); + (apiService.folder.update as jasmine.Spy).and.returnValue( + Promise.resolve({}), + ); + (apiService.folder.getStelaFolderVOs as jasmine.Spy).and.returnValue( + Promise.resolve({ getFolderVOs: () => [] }), + ); + + await service.saveItemVoProperty(folder, 'displayTime', 'new-value'); + + expect(folder.displayTime).toBe('original-value'); + expect(messageService.showError).toHaveBeenCalledWith({ + message: 'Invalid EDTF string', + }); + }); + describe('openShareDialog', () => { const mockShareLink: ShareLink = { id: 'link1', diff --git a/src/app/core/services/edit/edit.service.ts b/src/app/core/services/edit/edit.service.ts index 229e72f8b..0adfc4a28 100644 --- a/src/app/core/services/edit/edit.service.ts +++ b/src/app/core/services/edit/edit.service.ts @@ -356,10 +356,23 @@ export class EditService { item.update(newData); await this.updateItems([item], [property]); } catch (err) { + const revertData: Partial = {}; + revertData[property] = originalValue; + item.update(revertData); + if (err instanceof FolderResponse || err instanceof RecordResponse) { - const revertData: Partial = {}; - revertData[property] = originalValue; - item.update(revertData); + this.message.showError({ + message: err.getMessage(), + translate: true, + }); + } else { + this.message.showError({ + message: + err?.error?.message || + err?.error?.error || + err?.message || + 'Failed to save changes', + }); } } } 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..cfc898e7e --- /dev/null +++ b/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.html @@ -0,0 +1,109 @@ +
+
+ Date + @if (activeQualifiers().length) { + fiber_manual_record + + {{ activeQualifiers().join(', ') }}: + + } +
+ +
+
+ @if (hasEndDate()) { + From + } + @if (hasStartDate()) { + + {{ formattedStartDate() }} + @if (formattedStartTime()) { + fiber_manual_record + } + + @if (formattedStartTime()) { + + {{ formattedStartTime() }} + {{ startMeridian() }} + @if (startTimezone()) { + {{ startTimezone() }} + } + + } + + } @else { + + {{ disabled ? 'No date' : 'Click to add date' }} + + } + @if (!disabled) { + + edit + + } +
+ + @if (hasEndDate()) { +
+ To + + {{ formattedEndDate() }} + @if (formattedEndTime()) { + fiber_manual_record + } + + @if (formattedEndTime()) { + + {{ formattedEndTime() }} + {{ endMeridian() }} + @if (endTimezone()) { + {{ endTimezone() }} + } + + } + +
+ } +
+ + @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..72ea5fae3 --- /dev/null +++ b/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.scss @@ -0,0 +1,217 @@ +@import 'colors'; +@import 'mixins'; +@import 'variables'; + +.pr-sidebar-date-picker { + position: relative; + + .pr-sidebar-date-picker-header { + display: flex; + align-items: center; + gap: 2px; + font-size: 13px; + color: $PR-blue-600; + margin-bottom: 4px; + + .pr-header-dot { + font-size: 6px; + color: $PR-blue-400; + margin: 0 2px; + } + } + + .pr-sidebar-date-picker-label { + font-weight: 600; + font-size: $font-size-base; + color: $body-color; + } + + .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: baseline; + } + + .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; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + font-size: 14px; + color: $PR-blue; + font-weight: 500; + + .pr-time-group { + display: flex; + align-items: center; + gap: 4px; + white-space: nowrap; + } + + .pr-dot { + font-size: 6px; + color: $PR-blue; + vertical-align: middle; + margin-left: 2px; + } + + .pr-muted { + color: $PR-blue-400; + } + } + + .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-clear-btn { + @include clear-btn; + margin-top: 4px; + width: 151px; + + i { + color: $PR-blue; + font-size: 16px; + } + + span { + color: $PR-blue; + font-size: 14px; + } + } + + .pr-sidebar-date-picker-footer { + @include panel-footer; + padding: 12px 16px; + } + + .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..c5f73f673 --- /dev/null +++ b/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.spec.ts @@ -0,0 +1,632 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CUSTOM_ELEMENTS_SCHEMA, Component } from '@angular/core'; +import { DateTimeModel } from '@shared/services/edtf-service/edtf.service'; +import { SidebarDatePickerComponent } from './sidebar-date-picker.component'; + +@Component({ + template: ` + + `, + standalone: false, +}) +class TestHostComponent { + displayTime: DateTimeModel | null = null; + disabled = false; + savedValue: DateTimeModel | null = null; + moreOptionsData: DateTimeModel | null = null; + + onSaveClicked(value: DateTimeModel) { + this.savedValue = value; + } + onMoreOptionsClicked(data: DateTimeModel) { + 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 date when displayTime is set', () => { + host.displayTime = { + date: { year: '1985', month: '05', day: '' }, + time: { + hours: '', + minutes: '', + seconds: '', + format: 'am', + }, + }; + 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.displayTime = { + date: { year: '1985', month: '', day: '' }, + time: { + hours: '', + minutes: '', + seconds: '', + format: 'am', + }, + }; + 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.displayTime = { + date: { year: '1985', month: '05', day: '20' }, + time: { + hours: '', + minutes: '', + seconds: '', + format: 'am', + }, + }; + 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.displayTime = { + date: { year: '1985', month: '05', day: '20' }, + time: { + hours: '02', + minutes: '30', + seconds: '00', + format: 'pm', + }, + }; + fixture.detectChanges(); + + const value = fixture.nativeElement.querySelector( + '.pr-sidebar-date-picker-row-value', + ); + + expect(value.textContent).toContain('May 20, 1985'); + expect(value.textContent).toContain('02:30:00'); + expect(value.textContent).toContain('PM'); + }); + + it('should display "Unknown date and time" when unknown qualifier is set', () => { + host.displayTime = { + qualifiers: { approximate: false, uncertain: false, unknown: true }, + date: { year: '', month: '', day: '' }, + time: { + hours: '', + minutes: '', + seconds: '', + format: 'am', + }, + }; + fixture.detectChanges(); + + const value = fixture.nativeElement.querySelector( + '.pr-sidebar-date-picker-row-value', + ); + + expect(value.textContent).toContain('Unknown date and time'); + }); + + it('should not show To row when no end date', () => { + host.displayTime = { + date: { year: '1985', month: '05', day: '' }, + time: { + hours: '', + minutes: '', + seconds: '', + format: 'am', + }, + }; + 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.displayTime = { + date: { year: '1985', month: '05', day: '' }, + time: { + hours: '', + minutes: '', + seconds: '', + format: 'am', + }, + endDate: { year: '1990', month: '06', day: '' }, + endTime: { + hours: '', + minutes: '', + seconds: '', + format: 'am', + }, + }; + 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'); + }); + + it('should show a browser-default timezone label when time is entered', () => { + host.displayTime = { + date: { year: '1985', month: '05', day: '20' }, + time: { + hours: '02', + minutes: '30', + seconds: '00', + format: 'pm', + }, + }; + fixture.detectChanges(); + + expect(component.startTimezone()).not.toBe(''); + }); + + it('should not show a timezone label when no time is entered', () => { + host.displayTime = { + date: { year: '1985', month: '05', day: '20' }, + time: { + hours: '', + minutes: '', + seconds: '', + format: 'am', + }, + }; + fixture.detectChanges(); + + expect(component.startTimezone()).toBe(''); + }); + }); + + describe('formattedStartDate with X-padding', () => { + const setDate = (year: string, month: string, day: string): void => { + host.displayTime = { + date: { year, month, day }, + time: { hours: '', minutes: '', seconds: '', format: 'am' }, + }; + fixture.detectChanges(); + }; + + it('should pad partial year with X (year only)', () => { + setDate('198', '', ''); + + expect(component.formattedStartDate()).toBe('198X'); + }); + + it('should display "May XXXX" when only month is set', () => { + setDate('', '05', ''); + + expect(component.formattedStartDate()).toBe('May XXXX'); + }); + + it('should display "May 20, 198X" with partial year and full month/day', () => { + setDate('198', '05', '20'); + + expect(component.formattedStartDate()).toBe('May 20, 198X'); + }); + + it('should display "May 2X, 1985" with partial day', () => { + setDate('1985', '05', '2'); + + expect(component.formattedStartDate()).toBe('May 2X, 1985'); + }); + + it('should fall back to ISO style "1985-XX-20" when month is missing', () => { + setDate('1985', '', '20'); + + expect(component.formattedStartDate()).toBe('1985-XX-20'); + }); + + it('should fall back to ISO style "1985-1X-20" when month is partial', () => { + setDate('1985', '1', '20'); + + expect(component.formattedStartDate()).toBe('1985-1X-20'); + }); + + it('should fall back to ISO style "XXXX-XX-20" when only day is set', () => { + setDate('', '', '20'); + + expect(component.formattedStartDate()).toBe('XXXX-XX-20'); + }); + + it('should still display fully-specified date with month name (regression)', () => { + setDate('1985', '05', '20'); + + expect(component.formattedStartDate()).toBe('May 20, 1985'); + }); + }); + + describe('qualifiers', () => { + it('should not show qualifiers when none are active', () => { + host.displayTime = { + date: { year: '1985', month: '05', day: '' }, + time: { + hours: '', + minutes: '', + seconds: '', + format: 'am', + }, + }; + fixture.detectChanges(); + + const qualifiers = fixture.nativeElement.querySelector( + '.pr-sidebar-date-picker-qualifiers', + ); + + expect(qualifiers).toBeFalsy(); + }); + + it('should display Approximate qualifier', () => { + host.displayTime = { + qualifiers: { approximate: true, uncertain: false, unknown: false }, + date: { year: '1985', month: '05', day: '' }, + time: { + hours: '', + minutes: '', + seconds: '', + format: 'am', + }, + }; + fixture.detectChanges(); + + const qualifiers = fixture.nativeElement.querySelector( + '.pr-sidebar-date-picker-qualifiers', + ); + + expect(qualifiers).toBeTruthy(); + expect(qualifiers.textContent).toContain('Approximate'); + }); + + it('should display Uncertain qualifier', () => { + host.displayTime = { + qualifiers: { approximate: false, uncertain: true, unknown: false }, + date: { year: '1985', month: '05', day: '' }, + time: { + hours: '', + minutes: '', + seconds: '', + format: 'am', + }, + }; + fixture.detectChanges(); + + const qualifiers = fixture.nativeElement.querySelector( + '.pr-sidebar-date-picker-qualifiers', + ); + + expect(qualifiers).toBeTruthy(); + 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.displayTime = { + date: { year: '1985', month: '05', day: '' }, + time: { + hours: '', + minutes: '', + seconds: '', + format: 'am', + }, + }; + fixture.detectChanges(); + + component.toggle(); + + expect(component.isDropdownOpen()).toBeTrue(); + }); + + it('should close dropdown on second toggle', () => { + host.displayTime = { + date: { year: '1985', month: '05', day: '' }, + time: { + hours: '', + minutes: '', + seconds: '', + format: 'am', + }, + }; + fixture.detectChanges(); + + component.toggle(); + + expect(component.isDropdownOpen()).toBeTrue(); + + component.toggle(); + + expect(component.isDropdownOpen()).toBeFalse(); + }); + + it('should open modal instead of dropdown when end date is present', () => { + host.displayTime = { + date: { year: '1985', month: '05', day: '' }, + time: { + hours: '', + minutes: '', + seconds: '', + format: 'am', + }, + endDate: { year: '1990', month: '06', day: '' }, + endTime: { + hours: '', + minutes: '', + seconds: '', + format: 'am', + }, + }; + fixture.detectChanges(); + + component.toggle(); + + expect(component.isDropdownOpen()).toBeFalse(); + expect(host.moreOptionsData).toBeTruthy(); + }); + + it('should open modal instead of dropdown when unknown qualifier is set', () => { + host.displayTime = { + qualifiers: { approximate: false, uncertain: false, unknown: true }, + date: { year: '', month: '', day: '' }, + time: { + hours: '', + minutes: '', + seconds: '', + format: 'am', + }, + }; + fixture.detectChanges(); + + component.toggle(); + + expect(component.isDropdownOpen()).toBeFalse(); + expect(host.moreOptionsData).toBeTruthy(); + }); + }); + + describe('clearAll', () => { + it('should clear all fields but keep dropdown open', () => { + host.displayTime = { + date: { year: '1985', month: '05', day: '20' }, + time: { + hours: '10', + minutes: '30', + seconds: '00', + format: 'am', + }, + }; + fixture.detectChanges(); + + component.open(); + component.clearAll(); + + expect(component._date().year).toBe(''); + expect(component._time().hours).toBe(''); + expect(component._qualifiers().unknown).toBeFalse(); + expect(component.isDropdownOpen()).toBeTrue(); + }); + }); + + describe('onSave', () => { + it('should emit saveClicked and close dropdown', () => { + host.displayTime = { + date: { year: '1985', month: '05', day: '20' }, + time: { + hours: '02', + minutes: '30', + seconds: '00', + format: 'pm', + }, + }; + 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.displayTime = { + date: { year: '1985', month: '05', day: '' }, + time: { + hours: '', + minutes: '', + seconds: '', + format: 'am', + }, + }; + 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.displayTime = { + date: { year: '1985', month: '05', day: '' }, + time: { + hours: '', + minutes: '', + seconds: '', + format: 'am', + }, + }; + 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 close dropdown when emitting', () => { + host.displayTime = { + date: { year: '1985', month: '05', day: '' }, + time: { + hours: '', + minutes: '', + seconds: '', + format: 'am', + }, + }; + fixture.detectChanges(); + + component.toggle(); + fixture.detectChanges(); + component.onMoreOptions(); + + expect(component.isDropdownOpen()).toBeFalse(); + }); + }); + + describe('outside click', () => { + it('should close dropdown on outside click', () => { + host.displayTime = { + date: { year: '1985', month: '05', day: '' }, + time: { + hours: '', + minutes: '', + seconds: '', + format: 'am', + }, + }; + 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(); + }); + }); + + 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', + format: 'pm', + }); + + expect(component._time().hours).toBe('10'); + expect(component._time().minutes).toBe('30'); + expect(component._time().format).toBe('pm'); + }); + }); +}); 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..4c12c9978 --- /dev/null +++ b/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.ts @@ -0,0 +1,317 @@ +import { + Component, + Input, + Output, + EventEmitter, + signal, + computed, + OnInit, + OnChanges, + SimpleChanges, + HostListener, + ViewChild, + ElementRef, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { format } from 'date-fns'; +import { + EdtfService, + TIME_FORMAT_LABEL, + DateTimeModel, + DateModel, + TimeModel, + DateQualifierFlags, +} from '@shared/services/edtf-service/edtf.service'; +import { DatepickerInputComponent } from '@shared/components/datepicker-input/datepicker-input.component'; +import { TimepickerInputComponent } from '@shared/components/timepicker-input/timepicker-input.component'; + +const EMPTY_DATE: DateModel = { year: '', month: '', day: '' }; + +const EMPTY_TIME: TimeModel = { + hours: '', + minutes: '', + seconds: '', + format: 'am', +}; + +const EMPTY_QUALIFIERS: DateQualifierFlags = { + approximate: false, + uncertain: false, + unknown: false, +}; + +@Component({ + selector: 'pr-sidebar-date-picker', + standalone: true, + imports: [CommonModule, DatepickerInputComponent, TimepickerInputComponent], + templateUrl: './sidebar-date-picker.component.html', + styleUrls: ['./sidebar-date-picker.component.scss'], +}) +export class SidebarDatePickerComponent implements OnInit, OnChanges { + @Input() displayTime: DateTimeModel | null; + @Input() disabled = false; + + @Output() saveClicked = new EventEmitter(); + @Output() moreOptionsClicked = new EventEmitter(); + + @ViewChild('sidebarDatePickerContainer') + container?: ElementRef; + + constructor(private readonly edtfService: EdtfService) {} + + isDropdownOpen = signal(false); + + _date = signal({ ...EMPTY_DATE }); + _time = signal({ ...EMPTY_TIME }); + _endDate = signal({ ...EMPTY_DATE }); + _endTime = signal({ ...EMPTY_TIME }); + _qualifiers = signal({ ...EMPTY_QUALIFIERS }); + _isOpenStart = signal(false); + _isOpenEnd = signal(false); + + 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; + }); + + // Start date/time computed properties + hasStartDate = computed(() => { + if (this._qualifiers().unknown) return true; + if (this._isOpenStart()) return true; + const date = this._date(); + return !!(date.year || date.month || date.day); + }); + + formattedStartDate = computed(() => { + if (this._qualifiers().unknown) return 'Unknown date and time'; + if (this._isOpenStart()) return '..'; + return this.formatDate(this._date()); + }); + + formattedStartTime = computed(() => this.formatTime(this._time())); + startMeridian = computed(() => { + const time = this._time(); + if (!time.hours || time.format === 'h24') return ''; + return TIME_FORMAT_LABEL[time.format]; + }); + + startTimezone = computed(() => + this.edtfService.browserTimezoneAbbreviation(this._date(), this._time()), + ); + + // End date/time computed properties + hasEndDate = computed(() => { + if (this._isOpenEnd()) return true; + const date = this._endDate(); + return !!(date.year || date.month || date.day); + }); + + formattedEndDate = computed(() => { + if (this._isOpenEnd()) return '..'; + return this.formatDate(this._endDate()); + }); + + formattedEndTime = computed(() => this.formatTime(this._endTime())); + endMeridian = computed(() => { + const time = this._endTime(); + if (!time.hours || time.format === 'h24') return ''; + return TIME_FORMAT_LABEL[time.format]; + }); + + endTimezone = computed(() => + this.edtfService.browserTimezoneAbbreviation( + this._endDate(), + this._endTime(), + ), + ); + + ngOnInit(): void { + this.updateFromDisplayTime(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.displayTime && !this.isDropdownOpen()) { + this.updateFromDisplayTime(); + } + } + + @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; + const q = this._qualifiers(); + if (this.hasEndDate() || q.unknown || q.approximate || q.uncertain) { + this.onMoreOptions(); + return; + } + if (this.isDropdownOpen()) { + this.onCancel(); + } else { + this.open(); + } + } + + open(): void { + if (this.disabled) return; + this.updateFromDisplayTime(); + this.isDropdownOpen.set(true); + } + + onDateChange(newDate: DateModel): void { + this._date.set(newDate); + } + + onTimeChange(newTime: TimeModel): void { + this._time.update((t) => ({ + ...t, + ...newTime, + })); + } + + clearAll(): void { + this._date.set({ ...EMPTY_DATE }); + this._time.set({ ...EMPTY_TIME }); + this._endDate.set({ ...EMPTY_DATE }); + this._endTime.set({ ...EMPTY_TIME }); + this._qualifiers.set({ ...EMPTY_QUALIFIERS }); + this._isOpenStart.set(false); + this._isOpenEnd.set(false); + } + + onMoreOptions(): void { + this.isDropdownOpen.set(false); + + const modalData: DateTimeModel = { + qualifiers: { ...this._qualifiers() }, + date: { ...this._date() }, + time: { ...this._time() }, + }; + + const endDate = this._endDate(); + if (endDate.year || endDate.month || endDate.day || this._isOpenEnd()) { + modalData.endDate = { ...endDate }; + modalData.endTime = { ...this._endTime() }; + } + + this.moreOptionsClicked.emit(modalData); + } + + onCancel(): void { + this.updateFromDisplayTime(); + this.isDropdownOpen.set(false); + } + + onSave(): void { + const dateTimeModel: DateTimeModel = { + qualifiers: { ...this._qualifiers() }, + date: { ...this._date() }, + time: { ...this._time() }, + }; + + const endDate = this._endDate(); + if (endDate.year || endDate.month || endDate.day || this._isOpenEnd()) { + dateTimeModel.endDate = { ...endDate }; + dateTimeModel.endTime = { ...this._endTime() }; + } + + this.saveClicked.emit(dateTimeModel); + this.isDropdownOpen.set(false); + } + + private updateFromDisplayTime(): void { + if (!this.displayTime) { + this._date.set({ ...EMPTY_DATE }); + this._time.set({ ...EMPTY_TIME }); + this._endDate.set({ ...EMPTY_DATE }); + this._endTime.set({ ...EMPTY_TIME }); + this._qualifiers.set({ ...EMPTY_QUALIFIERS }); + this._isOpenStart.set(false); + this._isOpenEnd.set(false); + return; + } + + const startDate = this.displayTime.date ?? { ...EMPTY_DATE }; + const endDate = this.displayTime.endDate; + const isInterval = !!endDate; + const isStartEmpty = !startDate.year && !startDate.month && !startDate.day; + const isEndEmpty = + isInterval && !endDate.year && !endDate.month && !endDate.day; + + this._isOpenStart.set(isInterval && isStartEmpty); + this._isOpenEnd.set(isEndEmpty); + + this._date.set({ ...startDate }); + this._time.set( + this.displayTime.time ? { ...this.displayTime.time } : { ...EMPTY_TIME }, + ); + this._qualifiers.set( + this.displayTime.qualifiers + ? { ...this.displayTime.qualifiers } + : { ...EMPTY_QUALIFIERS }, + ); + this._endDate.set(endDate ? { ...endDate } : { ...EMPTY_DATE }); + this._endTime.set( + this.displayTime.endTime + ? { ...this.displayTime.endTime } + : { ...EMPTY_TIME }, + ); + } + + private padDigitsWithX(value: string, width: number): string { + const v = value ?? ''; + return v.length >= width ? v : v + 'X'.repeat(width - v.length); + } + + private formatDate(date: DateModel): string { + const yearRaw = date.year ?? ''; + const monthRaw = date.month ?? ''; + const dayRaw = date.day ?? ''; + + const hasYear = !!yearRaw; + const hasMonth = !!monthRaw; + const hasDay = !!dayRaw && parseInt(dayRaw, 10) !== 0; + + if (!hasYear && !hasMonth && !hasDay) return ''; + + const yearDisplay = this.padDigitsWithX(yearRaw, 4); + const monthComplete = /^\d{2}$/.test(monthRaw); + const monthName = monthComplete + ? format(new Date(2000, parseInt(monthRaw, 10) - 1), 'MMMM') + : null; + const dayDisplay = hasDay ? this.padDigitsWithX(dayRaw, 2) : ''; + + if (monthName && hasDay) + return `${monthName} ${dayDisplay}, ${yearDisplay}`; + if (monthName) return `${monthName} ${yearDisplay}`; + if (!hasMonth && !hasDay) return yearDisplay; + + const monthDisplay = hasMonth ? this.padDigitsWithX(monthRaw, 2) : 'XX'; + const parts: string[] = [yearDisplay, monthDisplay]; + if (hasDay) parts.push(dayDisplay); + return parts.join('-'); + } + + private formatTime(time: TimeModel): string { + if (!time?.hours) return ''; + + const h = time.hours.padStart(2, '0'); + const m = (time.minutes || '00').padStart(2, '0'); + const s = (time.seconds || '00').padStart(2, '0'); + return `${h}:${m}:${s}`; + } +} diff --git a/src/app/file-browser/components/sidebar/sidebar.component.html b/src/app/file-browser/components/sidebar/sidebar.component.html index a81773bcf..14ce93477 100644 --- a/src/app/file-browser/components/sidebar/sidebar.component.html +++ b/src/app/file-browser/components/sidebar/sidebar.component.html @@ -105,35 +105,46 @@ > - - @if (selectedItem.isFolder) { + @if (showEdtfDatePicker) { + + } @else { + @if (selectedItem.isFolder) { + + } }