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/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..43f1537ad --- /dev/null +++ b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.html @@ -0,0 +1,147 @@ +
+
+

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-modal/edit-date-time-modal.component.scss b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.scss new file mode 100644 index 000000000..c000446d0 --- /dev/null +++ b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.scss @@ -0,0 +1,300 @@ +@import 'colors'; +@import 'mixins'; + +.pr-edit-date-time-dialog { + background: $white; + border-radius: 12px; + min-width: 658px; + 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; + width: 100%; + + .pr-qualifiers-label { + font-size: 14px; + color: $PR-blue-600; + white-space: nowrap; + margin-right: 22px; + } + + .pr-qualifier-option { + display: flex; + align-items: center; + 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; + } + } + } + + // 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-datepicker-input, + pr-timepicker-input { + flex: 1; + } + } + + .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; + } + } + + // Date range row + .pr-date-range-row { + display: flex; + align-items: center; + gap: 12px; + + > 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 + .pr-range-label { + font-size: 12px; + font-weight: 600; + color: $PR-blue-600; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + // Footer + .pr-dialog-footer { + @include panel-footer; + padding: 24px; + + .pr-edtf-display { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + max-width: 425px; + flex: 1; + + .pr-edtf-badge { + font-family: 'Usual', sans-serif; + font-weight: 400; + font-style: normal; + font-size: 10px; + line-height: 8px; + letter-spacing: 0.16em; + text-align: center; + text-transform: uppercase; + color: $PR-blue; + background: $white; + border-radius: 4px; + padding: 0 8px; + height: 20px; + display: flex; + align-items: center; + } + + .pr-edtf-value { + font-family: 'DM Mono', monospace; + font-weight: 400; + font-style: normal; + font-size: 12px; + line-height: 16px; + letter-spacing: -0.01em; + text-align: right; + color: $PR-blue-600; + } + + .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; + } + } + + .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 { + opacity: 0.5; + cursor: not-allowed; + } + } + } + + // Disabled state + .disabled, + .pr-datetime-row.disabled { + opacity: 0.5; + pointer-events: none; + } +} 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 new file mode 100644 index 000000000..1db58f9f8 --- /dev/null +++ b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.spec.ts @@ -0,0 +1,467 @@ +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'; + +describe('EditDateTimeModalComponent', () => { + let component: EditDateTimeModalComponent; + let fixture: ComponentFixture; + let dialogRefSpy: jasmine.SpyObj; + + const mockDialogData: DateTimeModel = { + qualifiers: { + approximate: true, + uncertain: false, + unknown: false, + }, + date: { year: '1930', month: '', day: '' }, + time: { + hours: '11', + minutes: '', + seconds: '', + am: true, + pm: false, + timezoneOffset: 'GMT+01:00', + timezoneName: 'Europe/Berlin', + }, + }; + + beforeEach(async () => { + dialogRefSpy = jasmine.createSpyObj('DialogRef', ['close']); + + await TestBed.configureTestingModule({ + imports: [EditDateTimeModalComponent], + providers: [ + { provide: DialogRef, useValue: dialogRefSpy }, + { provide: DIALOG_DATA, useValue: mockDialogData }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(EditDateTimeModalComponent); + 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().am).toBe(true); + expect(component.time().pm).toBe(false); + 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('Europe/Berlin'); + + 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'); + }); + + 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', 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().pm).toBe(true); + }); + + it('should update end time fields via onTimeChange', () => { + component.onTimeChange( + { hours: '06', minutes: '45', seconds: '00', am: true, pm: false }, + 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', + am: true, + pm: false, + timezoneOffset: 'GMT-05:00', + timezoneName: 'America/New_York', + }); + component.onTimeChange( + { hours: '11', minutes: '30', seconds: '00', am: true, pm: false }, + component.time, + ); + + expect(component.time().hours).toBe('11'); + expect(component.time().timezoneOffset).toBe('GMT-05:00'); + expect(component.time().timezoneName).toBe('America/New_York'); + }); + + // --- Timezone updates via onTimezoneChange --- + + it('should update timezone on time signal via onTimezoneChange', () => { + component.onTimezoneChange( + { + ianaZone: 'America/New_York', + offset: 'GMT-05:00', + label: 'America / New York', + abbreviation: 'EST', + }, + component.time, + ); + + expect(component.time().timezoneOffset).toBe('GMT-05:00'); + expect(component.time().timezoneName).toBe('America/New_York'); + }); + + it('should update timezone on endTime signal via onTimezoneChange', () => { + component.onTimezoneChange( + { + ianaZone: 'Asia/Tokyo', + offset: 'GMT+09:00', + label: 'Asia / Tokyo', + abbreviation: 'JST', + }, + component.endTime, + ); + + expect(component.endTime().timezoneOffset).toBe('GMT+09:00'); + expect(component.endTime().timezoneName).toBe('Asia/Tokyo'); + }); + + // --- 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 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.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 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'); + }); + + // --- clearAll --- + + it('should clear all fields', () => { + component.date.set({ year: '2026', month: '02', day: '18' }); + component.time.update((t) => ({ ...t, hours: '10' })); + component.qualifiers.set({ + approximate: true, + uncertain: false, + unknown: false, + }); + + component.clearAll(); + + expect(component.date().year).toBe(''); + expect(component.time().hours).toBe(''); + expect(component.qualifiers().approximate).toBeFalse(); + expect(component.savedFormState()).toBeNull(); + }); + + // --- 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 DateTimeModel; + + 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 DateTimeModel; + + 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: '00', + am: true, + pm: false, + timezoneOffset: 'GMT+01:00', + // Empty IANA zone keeps the stored offset untouched (no DST re-derivation). + timezoneName: '', + }); + component.qualifiers.set({ + approximate: false, + uncertain: false, + unknown: false, + }); + + expect(component.edtfValue()).toBe('2026-02-18T10:30:00+01:00'); + }); + + it('should compute EDTF with date only', () => { + component.date.set({ year: '2026', month: '', day: '' }); + component.time.set({ + hours: '', + minutes: '', + seconds: '', + am: true, + pm: false, + timezoneOffset: 'GMT+01:00', + timezoneName: '', + }); + component.qualifiers.set({ + approximate: false, + uncertain: false, + unknown: false, + }); + + expect(component.edtfValue()).toBe('2026'); + }); + + it('should show error when time has invalid hours', () => { + component.date.set({ year: '2026', month: '02', day: '18' }); + component.time.set({ + hours: '13', + minutes: '30', + seconds: '00', + am: false, + pm: true, + timezoneOffset: '', + timezoneName: '', + }); + component.qualifiers.set({ + approximate: false, + uncertain: false, + unknown: false, + }); + + expect(component.isEdtfValid()).toBeFalse(); + expect(component.edtfErrorMessage()).toBeTruthy(); + expect(component.edtfValue()).toBe(''); + }); + + it('should not include day without month in EDTF', () => { + component.date.set({ year: '2026', month: '', day: '18' }); + component.time.set({ + hours: '', + minutes: '', + seconds: '', + am: true, + pm: false, + timezoneOffset: '', + timezoneName: '', + }); + component.qualifiers.set({ + approximate: false, + uncertain: false, + unknown: false, + }); + + 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 new file mode 100644 index 000000000..395ab6760 --- /dev/null +++ b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.ts @@ -0,0 +1,296 @@ +import { + Component, + Inject, + OnInit, + signal, + computed, + 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 { + EdtfService, + DateQualifier, + DateQualifierFlags, + DateModel, + TimeModel, + DateTimeModel, + DEFAULT_TIME, + UNKNOWN_VALUE, +} from '@shared/services/edtf-service/edtf.service'; +import { TimezoneOption } from '@shared/services/timezone-service/timezone.service'; + +@Component({ + selector: 'pr-edit-date-time-modal', + standalone: true, + imports: [ + CommonModule, + NgbTooltipModule, + DatepickerInputComponent, + TimepickerInputComponent, + TimezoneDropdownComponent, + ], + templateUrl: './edit-date-time-modal.component.html', + styleUrls: ['./edit-date-time-modal.component.scss'], +}) +export class EditDateTimeModalComponent implements OnInit { + readonly DateQualifier = DateQualifier; + + qualifiers = signal({ + approximate: false, + uncertain: false, + unknown: false, + }); + + 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: '' }); + + time = signal({ ...DEFAULT_TIME }); + + useDateRange = signal(false); + + endDate = signal({ year: '', month: '', day: '' }); + + endTime = signal({ ...DEFAULT_TIME }); + + private edtfResult = computed<{ + value: string; + valid: boolean; + errorMessage: string; + }>(() => { + if (this.qualifiers().unknown) { + return { value: UNKNOWN_VALUE, valid: true, errorMessage: '' }; + } + + 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()) { + 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', + }; + } + }); + + edtfValue = computed(() => this.edtfResult().value); + isEdtfValid = computed(() => this.edtfResult().valid); + edtfErrorMessage = computed(() => this.edtfResult().errorMessage); + + startReferenceDate = computed(() => + this.buildReferenceDate(this.date(), this.time()), + ); + endReferenceDate = computed(() => + this.buildReferenceDate(this.endDate(), this.endTime()), + ); + + constructor( + public dialogRef: DialogRef, + @Inject(DIALOG_DATA) public data: DateTimeModel, + private edtfService: EdtfService, + ) {} + + 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 }); + } + + 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: TimeModel, + currentTime: WritableSignal, + ): void { + currentTime.update((t) => ({ + ...t, + hours: timeInputValue.hours, + minutes: timeInputValue.minutes, + seconds: timeInputValue.seconds, + am: timeInputValue.am, + pm: timeInputValue.pm, + })); + } + + onTimezoneChange( + timezoneOption: TimezoneOption, + currentTime: WritableSignal, + ): void { + currentTime.update((t) => ({ + ...t, + timezoneOffset: timezoneOption.offset, + timezoneName: timezoneOption.ianaZone, + })); + } + + private buildReferenceDate(date: DateModel, time: TimeModel): Date { + const year = parseInt(date?.year ?? '', 10); + if (Number.isNaN(year)) return new Date(); + const month = date.month ? parseInt(date.month, 10) - 1 : 0; + const day = date.day ? parseInt(date.day, 10) : 1; + const time24 = time?.hours + ? this.edtfService.parseTimeAs24Hour(time) + : null; + return new Date( + Date.UTC( + year, + month, + day, + time24?.hour ?? 0, + time24?.minute ?? 0, + time24?.second ?? 0, + ), + ); + } + + 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); + } + + 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: DateTimeModel = { + 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-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 new file mode 100644 index 000000000..3ac46ed06 --- /dev/null +++ b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.service.spec.ts @@ -0,0 +1,66 @@ +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'; + +describe('EditDateTimeModalService', () => { + let service: EditDateTimeModalService; + let dialogCdkServiceSpy: jasmine.SpyObj; + let mockDialogRef: jasmine.SpyObj< + DialogRef + >; + + const mockData: DateTimeModel = { + qualifiers: { approximate: false, uncertain: false, unknown: false }, + date: { year: '2026', month: '02', day: '18' }, + time: { + hours: '10', + minutes: '30', + seconds: '', + am: true, + pm: false, + 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: [ + EditDateTimeModalService, + { provide: DialogCdkService, useValue: dialogCdkServiceSpy }, + ], + }); + + service = TestBed.inject(EditDateTimeModalService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should open EditDateTimeModalComponent via DialogCdkService', () => { + service.open(mockData); + + expect(dialogCdkServiceSpy.open).toHaveBeenCalledWith( + EditDateTimeModalComponent, + jasmine.objectContaining({ + data: mockData, + hasBackdrop: true, + panelClass: 'edit-date-time-modal-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-modal/edit-date-time-modal.service.ts b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.service.ts new file mode 100644 index 000000000..42839ce0c --- /dev/null +++ b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.service.ts @@ -0,0 +1,26 @@ +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'; + +@Injectable({ + providedIn: 'root', +}) +export class EditDateTimeModalService { + constructor(private dialogCdkService: DialogCdkService) {} + + open( + data: DateTimeModel, + ): DialogRef { + return this.dialogCdkService.open< + EditDateTimeModalComponent, + DateTimeModel, + DateTimeModel + >(EditDateTimeModalComponent, { + data, + hasBackdrop: true, + panelClass: 'edit-date-time-modal-dialog-panel', + }); + } +} 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..686231cf0 --- /dev/null +++ b/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.html @@ -0,0 +1,116 @@ +
+
+ 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..fa22b3c8f --- /dev/null +++ b/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.scss @@ -0,0 +1,214 @@ +@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; + + i { + font-size: 16px; + } + + span { + font-size: 13px; + } + } + + .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..55a7c2040 --- /dev/null +++ b/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.spec.ts @@ -0,0 +1,661 @@ +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: '', + am: true, + pm: false, + timezoneOffset: '', + timezoneName: '', + }, + }; + 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: '', + am: true, + pm: false, + timezoneOffset: '', + timezoneName: '', + }, + }; + 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: '', + am: true, + pm: false, + timezoneOffset: '', + timezoneName: '', + }, + }; + 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', + am: false, + pm: true, + timezoneOffset: '', + timezoneName: '', + }, + }; + 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: '', + am: true, + pm: false, + timezoneOffset: '', + timezoneName: '', + }, + }; + 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: '', + am: true, + pm: false, + timezoneOffset: '', + timezoneName: '', + }, + }; + 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: '', + am: true, + pm: false, + timezoneOffset: '', + timezoneName: '', + }, + endDate: { year: '1990', month: '06', day: '' }, + endTime: { + hours: '', + minutes: '', + seconds: '', + am: true, + pm: false, + timezoneOffset: '', + timezoneName: '', + }, + }; + 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.displayTime = { + date: { year: '1985', month: '05', day: '' }, + time: { + hours: '', + minutes: '', + seconds: '', + am: true, + pm: false, + timezoneOffset: '', + timezoneName: '', + }, + }; + 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: '', + am: true, + pm: false, + timezoneOffset: '', + timezoneName: '', + }, + }; + 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: '', + am: true, + pm: false, + timezoneOffset: '', + timezoneName: '', + }, + }; + 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: '', + am: true, + pm: false, + timezoneOffset: '', + timezoneName: '', + }, + }; + 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: '', + am: true, + pm: false, + timezoneOffset: '', + timezoneName: '', + }, + }; + 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: '', + am: true, + pm: false, + timezoneOffset: '', + timezoneName: '', + }, + endDate: { year: '1990', month: '06', day: '' }, + endTime: { + hours: '', + minutes: '', + seconds: '', + am: true, + pm: false, + timezoneOffset: '', + timezoneName: '', + }, + }; + 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: '', + am: true, + pm: false, + timezoneOffset: '', + timezoneName: '', + }, + }; + 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', + am: true, + pm: false, + timezoneOffset: 'GMT+01:00', + timezoneName: 'Europe/Berlin', + }, + }; + 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', + am: false, + pm: true, + timezoneOffset: '', + timezoneName: '', + }, + }; + 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: '', + am: true, + pm: false, + timezoneOffset: '', + timezoneName: '', + }, + }; + 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: '', + am: true, + pm: false, + timezoneOffset: '', + timezoneName: '', + }, + }; + 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: '', + am: true, + pm: false, + timezoneOffset: '', + timezoneName: '', + }, + }; + 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: '', + am: true, + pm: false, + timezoneOffset: '', + timezoneName: '', + }, + }; + 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', + am: false, + pm: true, + }); + + expect(component._time().hours).toBe('10'); + expect(component._time().minutes).toBe('30'); + expect(component._time().pm).toBe(true); + }); + + it('should update timezone on onTimezoneChange', () => { + component.onTimezoneChange({ + ianaZone: 'Europe/Berlin', + offset: 'GMT+01:00', + label: 'Europe / Berlin', + abbreviation: 'CET', + }); + + expect(component._time().timezoneOffset).toBe('GMT+01:00'); + expect(component._time().timezoneName).toBe('Europe/Berlin'); + }); + }); + + describe('timezone abbreviation', () => { + it('should derive a DST-aware abbreviation from the IANA zone', () => { + host.displayTime = { + date: { year: '2025', month: '07', day: '15' }, + time: { + hours: '10', + minutes: '00', + seconds: '00', + am: true, + pm: false, + timezoneOffset: 'GMT-04:00', + timezoneName: 'America/New_York', + }, + }; + fixture.detectChanges(); + + expect(component.startTimezone()).toBe('EDT'); + }); + + it('should fall back to displaying the offset when no IANA zone is set', () => { + host.displayTime = { + date: { year: '2025', month: '07', day: '15' }, + time: { + hours: '10', + minutes: '00', + seconds: '00', + am: true, + pm: false, + timezoneOffset: 'GMT+03:00', + timezoneName: '', + }, + }; + fixture.detectChanges(); + + expect(component.startTimezone()).toBe('GMT+03:00'); + }); + }); +}); 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..8698cc61a --- /dev/null +++ b/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.ts @@ -0,0 +1,377 @@ +import { + Component, + Input, + Output, + EventEmitter, + signal, + computed, + OnInit, + OnChanges, + SimpleChanges, + HostListener, + ViewChild, + ElementRef, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { format, setYear } from 'date-fns'; +import { + EdtfService, + Meridian, + DateTimeModel, + DateModel, + TimeModel, + DateQualifierFlags, +} from '@shared/services/edtf-service/edtf.service'; +import { + TimezoneOption, + TimezoneService, +} from '@shared/services/timezone-service/timezone.service'; +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'; + +const EMPTY_DATE: DateModel = { year: '', month: '', day: '' }; + +const EMPTY_TIME: TimeModel = { + hours: '', + minutes: '', + seconds: '', + am: true, + pm: false, + timezoneOffset: '', + timezoneName: '', +}; + +const EMPTY_QUALIFIERS: DateQualifierFlags = { + 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() displayTime: DateTimeModel | null; + @Input() disabled = false; + + @Output() saveClicked = new EventEmitter(); + @Output() moreOptionsClicked = new EventEmitter(); + + @ViewChild('sidebarDatePickerContainer') + container?: ElementRef; + + constructor( + private readonly edtfService: EdtfService, + private readonly timezoneService: TimezoneService, + ) {} + + 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(() => + this._time().hours ? (this._time().pm ? Meridian.PM : Meridian.AM) : '', + ); + + startReferenceDate = computed(() => + this.buildReferenceDate(this._date(), this._time()), + ); + + endReferenceDate = computed(() => + this.buildReferenceDate(this._endDate(), this._endTime()), + ); + + startTimezone = computed(() => { + const time = this._time(); + if (time.timezoneName) { + return this.timezoneService.getAbbreviationForZone( + time.timezoneName, + this.startReferenceDate(), + ); + } + return time.timezoneOffset ?? ''; + }); + + // 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(() => + this._endTime().hours + ? this._endTime().pm + ? Meridian.PM + : Meridian.AM + : '', + ); + + endTimezone = computed(() => { + const time = this._endTime(); + if (time.timezoneName) { + return this.timezoneService.getAbbreviationForZone( + time.timezoneName, + this.endReferenceDate(), + ); + } + return time.timezoneOffset ?? ''; + }); + + 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, + })); + } + + onTimezoneChange(tz: TimezoneOption): void { + this._time.update((t) => ({ + ...t, + timezoneOffset: tz.offset, + timezoneName: tz.ianaZone, + })); + } + + private buildReferenceDate(date: DateModel, time: TimeModel): Date { + const year = parseInt(date?.year ?? '', 10); + if (Number.isNaN(year)) return new Date(); + const month = date.month ? parseInt(date.month, 10) - 1 : 0; + const day = date.day ? parseInt(date.day, 10) : 1; + const time24 = time?.hours + ? this.edtfService.parseTimeAs24Hour(time) + : null; + return new Date( + Date.UTC( + year, + month, + day, + time24?.hour ?? 0, + time24?.minute ?? 0, + time24?.second ?? 0, + ), + ); + } + + 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 formatDate(date: DateModel): string { + const hasYear = !!date.year; + const hasMonth = !!date.month; + const hasDay = !!date.day && parseInt(date.day, 10) > 0; + + if (!hasYear && !hasMonth && !hasDay) return ''; + + if (hasYear) { + const yearNum = parseInt(date.year, 10); + const monthNum = hasMonth ? parseInt(date.month, 10) - 1 : 0; + const dayNum = hasDay ? parseInt(date.day, 10) : 1; + + // setYear avoids the JS Date quirk where years 0-99 are interpreted as 1900-1999 + const dateObj = setYear(new Date(2000, monthNum, dayNum), yearNum); + + if (hasMonth && hasDay) return format(dateObj, 'MMMM d, yyyy'); + if (hasMonth) return format(dateObj, 'MMMM yyyy'); + return format(dateObj, 'yyyy'); + } + + const dateParts: string[] = []; + if (hasMonth) { + const monthIdx = parseInt(date.month, 10) - 1; + if (monthIdx >= 0 && monthIdx < 12) { + dateParts.push(format(new Date(2000, monthIdx), 'MMMM')); + } + } + if (hasDay) dateParts.push(date.day); + return dateParts.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 5d5c26254..8fa96a0a6 100644 --- a/src/app/file-browser/components/sidebar/sidebar.component.html +++ b/src/app/file-browser/components/sidebar/sidebar.component.html @@ -106,35 +106,13 @@ - @if (selectedItem.isFolder) { - - }