From 2e542602ed24086c4866418557a7c9ebb9cadc0d Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Thu, 4 Jun 2026 14:02:16 +0300 Subject: [PATCH 1/4] Add support for unspecified digits In the edtf date dropdown and modal, when year, month or day have undefined digits, replace them with X values for display and leave them as empty in the inputs. PER-10641 --- .../edtf-service/edtf.service.spec.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/app/shared/services/edtf-service/edtf.service.spec.ts b/src/app/shared/services/edtf-service/edtf.service.spec.ts index 0559f6167..5449e3669 100644 --- a/src/app/shared/services/edtf-service/edtf.service.spec.ts +++ b/src/app/shared/services/edtf-service/edtf.service.spec.ts @@ -1227,5 +1227,45 @@ describe('EdtfService', () => { expect(result).toBe(edtfString); }); + + it('should roundtrip partial year (198X)', () => { + const edtfString = '198X'; + const model = service.toDateTimeModel(edtfString); + const result = service.toEdtfDate(model); + + expect(result).toBe(edtfString); + }); + + it('should roundtrip month-only with unknown year (XXXX-05)', () => { + const edtfString = 'XXXX-05'; + const model = service.toDateTimeModel(edtfString); + const result = service.toEdtfDate(model); + + expect(result).toBe(edtfString); + }); + + it('should roundtrip known year and day with unknown month (1985-XX-20)', () => { + const edtfString = '1985-XX-20'; + const model = service.toDateTimeModel(edtfString); + const result = service.toEdtfDate(model); + + expect(result).toBe(edtfString); + }); + + it('should roundtrip partial day (1985-05-2X)', () => { + const edtfString = '1985-05-2X'; + const model = service.toDateTimeModel(edtfString); + const result = service.toEdtfDate(model); + + expect(result).toBe(edtfString); + }); + + it('should roundtrip partial year with full month and day (198X-05-20)', () => { + const edtfString = '198X-05-20'; + const model = service.toDateTimeModel(edtfString); + const result = service.toEdtfDate(model); + + expect(result).toBe(edtfString); + }); }); }); From faeec56674c462c0ed747737e88711855020e372 Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Mon, 8 Jun 2026 16:42:42 +0300 Subject: [PATCH 2/4] Add the edit date time modal and service for opening it In order to be able to input an edtf date, the modal includes components that will help with qualifiers and intervals. Issue: PER-10642 --- .../edit-date-time-modal.component.html | 179 ++++++ .../edit-date-time-modal.component.scss | 317 +++++++++++ .../edit-date-time-modal.component.spec.ts | 526 ++++++++++++++++++ .../edit-date-time-modal.component.ts | 298 ++++++++++ .../edit-date-time-modal.service.spec.ts | 63 +++ .../edit-date-time-modal.service.ts | 26 + .../edtf-service/edtf.service.spec.ts | 77 ++- .../services/edtf-service/edtf.service.ts | 82 ++- 8 files changed, 1534 insertions(+), 34 deletions(-) create mode 100644 src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.html create mode 100644 src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.scss create mode 100644 src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.spec.ts create mode 100644 src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.ts create mode 100644 src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.service.spec.ts create mode 100644 src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.service.ts 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..ebc8c850d --- /dev/null +++ b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.html @@ -0,0 +1,179 @@ +
+
+

Edit date and time

+ +
+ +
+
+
+ Start date: + +
+
+ Approximate + +
+ +
+ Uncertain + +
+ +
+ Unknown + +
+
+
+ +
+ + + +
+ + +
+ + @if (useDateRange()) { +
+
+ End date: + +
+
+ Approximate + +
+ +
+ Uncertain + +
+ +
+ Unknown + +
+
+
+ +
+ + + +
+ + +
+ } + +
+ 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..228f72fda --- /dev/null +++ b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.scss @@ -0,0 +1,317 @@ +@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; + background: $PR-blue-25; + } + + // Date card (one per side: start / end) + .pr-date-card { + display: flex; + flex-direction: column; + gap: 16px; + padding: 20px; + border: 1px solid $PR-blue-100; + border-radius: 12px; + background: $white; + } + + .pr-card-header { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + } + + .pr-card-label { + font-size: 14px; + color: $PR-blue-600; + white-space: nowrap; + } + + // Qualifiers row + .pr-qualifiers-row { + display: flex; + align-items: center; + gap: 0; + flex: 1; + + .pr-qualifier-option { + display: flex; + align-items: center; + gap: 16px; + padding: 0 16px; + border-right: 1px solid $PR-blue-100; + flex: 1; + + &:first-child { + padding-left: 0; + } + + &:last-child { + border-right: none; + padding-right: 0; + } + + span { + font-size: 14px; + color: $PR-blue; + white-space: nowrap; + } + } + } + + // Per-card clear link + .pr-clear-link { + @include clear-btn; + margin-left: auto; + + i { + font-size: 18px; + color: $PR-blue-900; + } + + span { + font-size: 14px; + color: $PR-blue-900; + } + } + + // 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; + } + } + + // Footer + .pr-dialog-footer { + @include panel-footer; + border-top: none; + 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..951dded87 --- /dev/null +++ b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.spec.ts @@ -0,0 +1,526 @@ +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: '', + format: 'am', + }, + }; + + 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().format).toBe('am'); + expect(component.qualifiers().approximate).toBeTrue(); + expect(component.qualifiers().uncertain).toBeFalse(); + expect(component.qualifiers().unknown).toBeFalse(); + + 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 initialize endQualifiers from dialog data', () => { + component.data = { + ...mockDialogData, + endDate: { year: '2026', month: '01', day: '15' }, + endTime: { format: 'am' }, + endQualifiers: { approximate: false, uncertain: true, unknown: false }, + }; + component.ngOnInit(); + + expect(component.endQualifiers().uncertain).toBeTrue(); + expect(component.endQualifiers().approximate).toBeFalse(); + }); + + it('should save start 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: '', + format: 'am', + }, + }; + + component.data = unknownData; + component.ngOnInit(); + + expect(component.qualifiers().unknown).toBeTrue(); + expect(component.savedStartState()).not.toBeNull(); + expect(component.savedStartState().qualifiers.unknown).toBeFalse(); + }); + + it('should save end form state when data has end unknown true', () => { + component.data = { + ...mockDialogData, + endDate: { year: '', month: '', day: '' }, + endTime: { format: 'am' }, + endQualifiers: { approximate: false, uncertain: false, unknown: true }, + }; + component.ngOnInit(); + + expect(component.endQualifiers().unknown).toBeTrue(); + expect(component.savedEndState()).not.toBeNull(); + }); + + // --- Time updates via onTimeChange --- + + it('should update time fields via onTimeChange', () => { + component.onTimeChange( + { hours: '02', minutes: '30', seconds: '15', format: 'pm' }, + component.time, + ); + + expect(component.time().hours).toBe('02'); + expect(component.time().minutes).toBe('30'); + expect(component.time().seconds).toBe('15'); + expect(component.time().format).toBe('pm'); + }); + + it('should update end time fields via onTimeChange', () => { + component.onTimeChange( + { hours: '06', minutes: '45', seconds: '00', format: 'am' }, + component.endTime, + ); + + expect(component.endTime().hours).toBe('06'); + expect(component.endTime().minutes).toBe('45'); + }); + + // --- Date range toggle --- + + it('should toggle date range', () => { + expect(component.useDateRange()).toBeFalse(); + component.toggleDateRange(); + + expect(component.useDateRange()).toBeTrue(); + component.toggleDateRange(); + + expect(component.useDateRange()).toBeFalse(); + }); + + // --- Start-side qualifiers --- + + it('should toggle start approximate on and off', () => { + component.qualifiers.set({ + approximate: false, + uncertain: false, + unknown: false, + }); + component.onQualifierChange(DateQualifier.Approximate, false); + + expect(component.qualifiers().approximate).toBeTrue(); + + component.onQualifierChange(DateQualifier.Approximate, false); + + expect(component.qualifiers().approximate).toBeFalse(); + }); + + it('should allow start approximate and uncertain to be active at the same time', () => { + component.qualifiers.set({ + approximate: false, + uncertain: false, + unknown: false, + }); + + component.onQualifierChange(DateQualifier.Approximate, false); + component.onQualifierChange(DateQualifier.Uncertain, false); + + expect(component.qualifiers().approximate).toBeTrue(); + expect(component.qualifiers().uncertain).toBeTrue(); + }); + + it('should turn off start approximate and uncertain when start unknown is toggled on', () => { + component.qualifiers.set({ + approximate: true, + uncertain: true, + unknown: false, + }); + + component.onQualifierChange(DateQualifier.Unknown, false); + + expect(component.qualifiers().unknown).toBeTrue(); + expect(component.qualifiers().approximate).toBeFalse(); + expect(component.qualifiers().uncertain).toBeFalse(); + }); + + it('should turn off start unknown when start approximate is toggled on', () => { + component.qualifiers.set({ + approximate: false, + uncertain: false, + unknown: true, + }); + + component.onQualifierChange(DateQualifier.Approximate, false); + + expect(component.qualifiers().approximate).toBeTrue(); + expect(component.qualifiers().unknown).toBeFalse(); + }); + + it('should reset start fields and disable them when start unknown is toggled on', () => { + component.date.set({ year: '2026', month: '02', day: '18' }); + component.time.update((t) => ({ ...t, hours: '10' })); + + component.onQualifierChange(DateQualifier.Unknown, false); + + expect(component.qualifiers().unknown).toBeTrue(); + expect(component.startFieldsDisabled()).toBeTrue(); + expect(component.date().year).toBe(''); + expect(component.date().month).toBe(''); + expect(component.time().hours).toBe(''); + }); + + it('should restore start form state when start 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, false); + + expect(component.qualifiers().unknown).toBeTrue(); + expect(component.date().year).toBe(''); + + component.onQualifierChange(DateQualifier.Unknown, false); + + expect(component.qualifiers().unknown).toBeFalse(); + expect(component.qualifiers().approximate).toBeTrue(); + expect(component.qualifiers().uncertain).toBeTrue(); + expect(component.startFieldsDisabled()).toBeFalse(); + expect(component.date().year).toBe('2026'); + expect(component.date().month).toBe('02'); + expect(component.time().hours).toBe('10'); + expect(component.savedStartState()).toBeNull(); + }); + + it('should enable start fields when toggling start unknown off from initial unknown state', () => { + const unknownData: DateTimeModel = { + qualifiers: { approximate: false, uncertain: false, unknown: true }, + date: { year: '', month: '', day: '' }, + time: { + hours: '', + minutes: '', + seconds: '', + format: 'am', + }, + }; + + component.data = unknownData; + component.ngOnInit(); + + expect(component.startFieldsDisabled()).toBeTrue(); + + component.onQualifierChange(DateQualifier.Unknown, false); + + expect(component.qualifiers().unknown).toBeFalse(); + expect(component.startFieldsDisabled()).toBeFalse(); + }); + + it('should show XXXX-XX-XX as EDTF value when unknown is on (no range)', () => { + component.date.set({ year: '2026', month: '02', day: '18' }); + component.onQualifierChange(DateQualifier.Unknown, false); + + expect(component.edtfValue()).toBe('XXXX-XX-XX'); + }); + + // --- End-side qualifiers --- + + it('should toggle end qualifiers independently of start qualifiers', () => { + component.useDateRange.set(true); + component.qualifiers.set({ + approximate: true, + uncertain: false, + unknown: false, + }); + component.endQualifiers.set({ + approximate: false, + uncertain: false, + unknown: false, + }); + + component.onQualifierChange(DateQualifier.Uncertain, true); + + expect(component.qualifiers().approximate).toBeTrue(); + expect(component.qualifiers().uncertain).toBeFalse(); + expect(component.endQualifiers().uncertain).toBeTrue(); + expect(component.endQualifiers().approximate).toBeFalse(); + }); + + it('should reset end fields when end unknown is toggled on', () => { + component.useDateRange.set(true); + component.endDate.set({ year: '2026', month: '02', day: '18' }); + component.endTime.update((t) => ({ ...t, hours: '10' })); + + component.onQualifierChange(DateQualifier.Unknown, true); + + expect(component.endQualifiers().unknown).toBeTrue(); + expect(component.endFieldsDisabled()).toBeTrue(); + expect(component.endDate().year).toBe(''); + expect(component.endTime().hours).toBe(''); + }); + + it('should restore end form state when end unknown is toggled off', () => { + component.useDateRange.set(true); + component.endQualifiers.set({ + approximate: true, + uncertain: false, + unknown: false, + }); + component.endDate.set({ year: '2026', month: '02', day: '18' }); + component.endTime.update((t) => ({ ...t, hours: '10' })); + + component.onQualifierChange(DateQualifier.Unknown, true); + + expect(component.endQualifiers().unknown).toBeTrue(); + expect(component.endDate().year).toBe(''); + + component.onQualifierChange(DateQualifier.Unknown, true); + + expect(component.endQualifiers().unknown).toBeFalse(); + expect(component.endQualifiers().approximate).toBeTrue(); + expect(component.endDate().year).toBe('2026'); + expect(component.endTime().hours).toBe('10'); + expect(component.savedEndState()).toBeNull(); + }); + + it('should produce empty-slot EDTF when end unknown is on in a range', () => { + component.useDateRange.set(true); + component.date.set({ year: '1985', month: '04', day: '12' }); + component.time.set({ hours: '', minutes: '', seconds: '', format: 'am' }); + component.qualifiers.set({ + approximate: false, + uncertain: false, + unknown: false, + }); + component.endQualifiers.set({ + approximate: false, + uncertain: false, + unknown: true, + }); + + expect(component.edtfValue()).toBe('1985-04-12/'); + }); + + // --- clearStart / clearEnd --- + + it('should clear start fields only', () => { + 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.endDate.set({ year: '2030', month: '06', day: '01' }); + + component.clearStart(); + + expect(component.date().year).toBe(''); + expect(component.time().hours).toBe(''); + expect(component.qualifiers().approximate).toBeFalse(); + expect(component.savedStartState()).toBeNull(); + expect(component.endDate().year).toBe('2030'); + }); + + it('should clear end fields only', () => { + component.endDate.set({ year: '2026', month: '02', day: '18' }); + component.endTime.update((t) => ({ ...t, hours: '10' })); + component.endQualifiers.set({ + approximate: false, + uncertain: true, + unknown: false, + }); + component.date.set({ year: '1985' }); + + component.clearEnd(); + + expect(component.endDate().year).toBe(''); + expect(component.endTime().hours).toBe(''); + expect(component.endQualifiers().uncertain).toBeFalse(); + expect(component.savedEndState()).toBeNull(); + expect(component.date().year).toBe('1985'); + }); + + // --- 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, endTime and endQualifiers when useDateRange is on', () => { + component.useDateRange.set(true); + component.endDate.set({ year: '2026', month: '12', day: '31' }); + component.endQualifiers.set({ + approximate: false, + uncertain: true, + unknown: false, + }); + component.onSave(); + + const result = dialogRefSpy.close.calls.mostRecent() + .args[0] as DateTimeModel; + + expect(result.endDate).toEqual({ year: '2026', month: '12', day: '31' }); + expect(result.endQualifiers).toEqual({ + approximate: false, + uncertain: true, + unknown: false, + }); + }); + + it('should not include endDate or endQualifiers when useDateRange is off', () => { + component.useDateRange.set(false); + component.onSave(); + + const result = dialogRefSpy.close.calls.mostRecent() + .args[0] as DateTimeModel; + + expect(result.endDate).toBeUndefined(); + expect(result.endQualifiers).toBeUndefined(); + }); + + // --- EDTF computed --- + + it('should compute EDTF value from date and time without timezone suffix', () => { + component.date.set({ year: '2026', month: '02', day: '18' }); + component.time.set({ + hours: '10', + minutes: '30', + seconds: '00', + format: 'am', + }); + component.qualifiers.set({ + approximate: false, + uncertain: false, + unknown: false, + }); + + expect(component.edtfValue()).toBe('2026-02-18T10:30:00'); + }); + + it('should compute EDTF with date only', () => { + component.date.set({ year: '2026', month: '', day: '' }); + component.time.set({ + hours: '', + minutes: '', + seconds: '', + format: 'am', + }); + 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', + format: 'pm', + }); + component.qualifiers.set({ + approximate: false, + uncertain: false, + unknown: false, + }); + + expect(component.isEdtfValid()).toBeFalse(); + expect(component.edtfErrorMessage()).toBeTruthy(); + expect(component.edtfValue()).toBe(''); + }); + + it('should X-pad missing month when day is provided', () => { + component.date.set({ year: '2026', month: '', day: '18' }); + component.time.set({ + hours: '', + minutes: '', + seconds: '', + format: 'am', + }); + component.qualifiers.set({ + approximate: false, + uncertain: false, + unknown: false, + }); + + expect(component.edtfValue()).toBe('2026-XX-18'); + }); +}); 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..2ac136f29 --- /dev/null +++ b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.ts @@ -0,0 +1,298 @@ +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 { + EdtfService, + DateQualifier, + DateQualifierFlags, + DateModel, + TimeModel, + DateTimeModel, + DEFAULT_TIME, + UNKNOWN_VALUE, +} from '@shared/services/edtf-service/edtf.service'; + +interface SavedSideState { + qualifiers: DateQualifierFlags; + date: DateModel; + time: TimeModel; +} + +@Component({ + selector: 'pr-edit-date-time-modal', + standalone: true, + imports: [ + CommonModule, + NgbTooltipModule, + DatepickerInputComponent, + TimepickerInputComponent, + ], + 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, + }); + + endQualifiers = signal({ + approximate: false, + uncertain: false, + unknown: false, + }); + + savedStartState = signal(null); + savedEndState = signal(null); + + startFieldsDisabled = computed(() => this.qualifiers().unknown); + endFieldsDisabled = computed(() => this.endQualifiers().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.useDateRange() && this.qualifiers().unknown) { + return { value: UNKNOWN_VALUE, valid: true, errorMessage: '' }; + } + + const dateTimeModel: DateTimeModel = { + date: this.date(), + time: this.time(), + qualifiers: { ...this.qualifiers() }, + }; + + if (this.useDateRange()) { + dateTimeModel.endDate = this.endDate(); + dateTimeModel.endTime = this.endTime(); + dateTimeModel.endQualifiers = { ...this.endQualifiers() }; + } + 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); + + 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 }); + this.endQualifiers.set( + this.data.endQualifiers ?? { + approximate: false, + uncertain: false, + unknown: false, + }, + ); + } + + if (this.data.qualifiers?.unknown) { + this.savedStartState.set({ + qualifiers: { approximate: false, uncertain: false, unknown: false }, + date: { ...this.date() }, + time: { ...this.time() }, + }); + } + + if (this.data.endQualifiers?.unknown) { + this.savedEndState.set({ + qualifiers: { approximate: false, uncertain: false, unknown: false }, + date: { ...this.endDate() }, + time: { ...this.endTime() }, + }); + } + } + } + + onTimeChange( + timeInputValue: TimeModel, + currentTime: WritableSignal, + ): void { + currentTime.update((t) => ({ + ...t, + hours: timeInputValue.hours, + minutes: timeInputValue.minutes, + seconds: timeInputValue.seconds, + format: timeInputValue.format, + })); + } + + onQualifierChange(newDateQualifier: DateQualifier, isEnd: boolean): void { + const qualifierSignal = isEnd ? this.endQualifiers : this.qualifiers; + const dateSignal = isEnd ? this.endDate : this.date; + const timeSignal = isEnd ? this.endTime : this.time; + const savedStateSignal = isEnd ? this.savedEndState : this.savedStartState; + + const currentQualifiers = qualifierSignal(); + + if (newDateQualifier === DateQualifier.Unknown) { + if (currentQualifiers.unknown) { + this.restoreSide( + qualifierSignal, + dateSignal, + timeSignal, + savedStateSignal, + ); + } else { + this.saveSide( + qualifierSignal, + dateSignal, + timeSignal, + savedStateSignal, + ); + qualifierSignal.set({ + approximate: false, + uncertain: false, + unknown: true, + }); + dateSignal.set({ year: '', month: '', day: '' }); + timeSignal.set({ ...DEFAULT_TIME }); + } + return; + } + + if (newDateQualifier === DateQualifier.Approximate) { + qualifierSignal.update((a) => ({ + ...a, + approximate: !a.approximate, + unknown: !a.approximate || a.uncertain ? false : a.unknown, + })); + } + + if (newDateQualifier === DateQualifier.Uncertain) { + qualifierSignal.update((a) => ({ + ...a, + uncertain: !a.uncertain, + unknown: a.approximate || !a.uncertain ? false : a.unknown, + })); + } + } + + private saveSide( + qualifierSignal: WritableSignal, + dateSignal: WritableSignal, + timeSignal: WritableSignal, + savedStateSignal: WritableSignal, + ): void { + savedStateSignal.set({ + qualifiers: { ...qualifierSignal() }, + date: { ...dateSignal() }, + time: { ...timeSignal() }, + }); + } + + private restoreSide( + qualifierSignal: WritableSignal, + dateSignal: WritableSignal, + timeSignal: WritableSignal, + savedStateSignal: WritableSignal, + ): void { + const saved = savedStateSignal(); + if (saved) { + qualifierSignal.set(saved.qualifiers); + dateSignal.set(saved.date); + timeSignal.set(saved.time); + savedStateSignal.set(null); + } else { + qualifierSignal.update((a) => ({ ...a, unknown: false })); + } + } + + toggleDateRange(): void { + this.useDateRange.update((v) => !v); + } + + clearStart(): void { + this.qualifiers.set({ + approximate: false, + uncertain: false, + unknown: false, + }); + this.date.set({ year: '', month: '', day: '' }); + this.time.set({ ...DEFAULT_TIME }); + this.savedStartState.set(null); + } + + clearEnd(): void { + this.endQualifiers.set({ + approximate: false, + uncertain: false, + unknown: false, + }); + this.endDate.set({ year: '', month: '', day: '' }); + this.endTime.set({ ...DEFAULT_TIME }); + this.savedEndState.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.endQualifiers = { ...this.endQualifiers() }; + 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..32252ed76 --- /dev/null +++ b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.service.spec.ts @@ -0,0 +1,63 @@ +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: '', + format: 'am', + }, + }; + + 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/shared/services/edtf-service/edtf.service.spec.ts b/src/app/shared/services/edtf-service/edtf.service.spec.ts index 5449e3669..e18f8d0f9 100644 --- a/src/app/shared/services/edtf-service/edtf.service.spec.ts +++ b/src/app/shared/services/edtf-service/edtf.service.spec.ts @@ -632,7 +632,7 @@ describe('EdtfService', () => { expect(service.toEdtfDate(model)).toBe('1985/1990'); }); - it('should apply approximate qualifier to both dates in a range', () => { + it('should apply approximate qualifier only to the start when end has no qualifiers', () => { const model: DateTimeModel = { date: { year: '1985', month: '05' }, time: { format: 'am' }, @@ -641,28 +641,51 @@ describe('EdtfService', () => { qualifiers: { approximate: true, uncertain: false, unknown: false }, }; - expect(service.toEdtfDate(model)).toBe('1985-05~/1990-06~'); + expect(service.toEdtfDate(model)).toBe('1985-05~/1990-06'); }); - it('should apply uncertain qualifier to both dates in a range', () => { + it('should apply uncertain qualifier only to the end', () => { const model: DateTimeModel = { date: { year: '1985', month: '05' }, time: { format: 'am' }, endDate: { year: '1990', month: '06' }, endTime: { format: 'am' }, - qualifiers: { approximate: false, uncertain: true, unknown: false }, + qualifiers: { approximate: false, uncertain: false, unknown: false }, + endQualifiers: { + approximate: false, + uncertain: true, + unknown: false, + }, }; - expect(service.toEdtfDate(model)).toBe('1985-05?/1990-06?'); + expect(service.toEdtfDate(model)).toBe('1985-05/1990-06?'); }); - it('should apply combined qualifier to both dates in a range', () => { + it('should apply different qualifiers to each side independently', () => { + const model: DateTimeModel = { + date: { year: '1985', month: '05' }, + time: { format: 'am' }, + endDate: { year: '1990', month: '06' }, + endTime: { format: 'am' }, + qualifiers: { approximate: true, uncertain: false, unknown: false }, + endQualifiers: { + approximate: false, + uncertain: true, + unknown: false, + }, + }; + + expect(service.toEdtfDate(model)).toBe('1985-05~/1990-06?'); + }); + + it('should apply combined qualifier per side', () => { const model: DateTimeModel = { date: { year: '1985', month: '05' }, time: { format: 'am' }, endDate: { year: '1990', month: '06' }, endTime: { format: 'am' }, qualifiers: { approximate: true, uncertain: true, unknown: false }, + endQualifiers: { approximate: true, uncertain: true, unknown: false }, }; expect(service.toEdtfDate(model)).toBe('1985-05%/1990-06%'); @@ -680,16 +703,48 @@ describe('EdtfService', () => { expect(service.toEdtfDate(model)).toBe('1985-05~/..'); }); - it('should apply qualifier to both dates with mixed precision', () => { + it('should emit empty-slot form when end qualifier is unknown', () => { const model: DateTimeModel = { - date: { year: '1985' }, + date: { year: '1985', month: '04', day: '12' }, time: { format: 'am' }, - endDate: { year: '1990', month: '06' }, + endDate: { year: '', month: '', day: '' }, endTime: { format: 'am' }, - qualifiers: { approximate: true, uncertain: false, unknown: false }, + qualifiers: { approximate: false, uncertain: false, unknown: false }, + endQualifiers: { + approximate: false, + uncertain: false, + unknown: true, + }, + }; + + expect(service.toEdtfDate(model)).toBe('1985-04-12/'); + }); + + it('should emit empty-slot form when start qualifier is unknown', () => { + const model: DateTimeModel = { + date: { year: '', month: '', day: '' }, + time: { format: 'am' }, + endDate: { year: '1990', month: '02', day: '04' }, + endTime: { format: 'am' }, + qualifiers: { approximate: false, uncertain: false, unknown: true }, + endQualifiers: { + approximate: false, + uncertain: false, + unknown: false, + }, }; - expect(service.toEdtfDate(model)).toBe('1985~/1990-06~'); + expect(service.toEdtfDate(model)).toBe('/1990-02-04'); + }); + + it('should still return XXXX-XX-XX for standalone unknown without a range', () => { + const model: DateTimeModel = { + date: { year: '', month: '', day: '' }, + time: { format: 'am' }, + qualifiers: { approximate: false, uncertain: false, unknown: true }, + }; + + expect(service.toEdtfDate(model)).toBe('XXXX-XX-XX'); }); }); }); diff --git a/src/app/shared/services/edtf-service/edtf.service.ts b/src/app/shared/services/edtf-service/edtf.service.ts index 77ccab0d2..c29fe554a 100644 --- a/src/app/shared/services/edtf-service/edtf.service.ts +++ b/src/app/shared/services/edtf-service/edtf.service.ts @@ -56,6 +56,7 @@ export interface DateTimeModel { qualifiers?: DateQualifierFlags; date: DateModel; time: TimeModel; + endQualifiers?: DateQualifierFlags; endDate?: DateModel; endTime?: TimeModel; } @@ -99,9 +100,13 @@ export class EdtfService { const [startPart, endPart] = edtfString.split('/'); const normalizedStart = - startPart === '..' ? '..' : this.normalizeForParsing(startPart); + startPart === '..' || startPart === '' + ? startPart + : this.normalizeForParsing(startPart); const normalizedEnd = - endPart === '..' ? '..' : this.normalizeForParsing(endPart); + endPart === '..' || endPart === '' + ? endPart + : this.normalizeForParsing(endPart); try { const edtfObject = edtf(`${normalizedStart}/${normalizedEnd}`); @@ -130,30 +135,37 @@ export class EdtfService { toEdtfDate(model: DateTimeModel): string { try { - const { date, time, qualifiers, endDate, endTime } = model; + const { date, time, qualifiers, endQualifiers, endDate, endTime } = model; + const hasRange = !!endDate; - if (qualifiers?.unknown) { + if (!hasRange && qualifiers?.unknown) { return UNKNOWN_VALUE; } const isStartEmpty = this.isEmptyDateTime(date, time); - const isOpenEnd = endDate && this.isEmptyDateTime(endDate, endTime); - const hasEndDate = endDate && !this.isEmptyDateTime(endDate, endTime); + const isEndEmpty = hasRange && this.isEmptyDateTime(endDate, endTime); - if (isStartEmpty && !hasEndDate) { + if (isStartEmpty && !hasRange) { return ''; } - const startPart = isStartEmpty - ? '..' - : this.normalizeEdtfString(date, time, qualifiers); + const startPart = qualifiers?.unknown + ? '' + : isStartEmpty + ? '..' + : this.normalizeEdtfString(date, time, qualifiers); + + let endPart: string | null = null; + if (hasRange) { + endPart = endQualifiers?.unknown + ? '' + : isEndEmpty + ? '..' + : this.normalizeEdtfString(endDate, endTime, endQualifiers); + } - const endPart = isOpenEnd - ? '..' - : hasEndDate - ? this.normalizeEdtfString(endDate, endTime, qualifiers) - : null; - const stringDate = endPart ? `${startPart}/${endPart}` : startPart; + const stringDate = + endPart === null ? startPart : `${startPart}/${endPart}`; edtf(stringDate); return stringDate; } catch (error) { @@ -342,18 +354,42 @@ export class EdtfService { const lower = interval.lower; const upper = interval.upper; - const openStart = typeof lower === 'number' || lower === null; - const openEnd = typeof upper === 'number' || upper === null; - - const model: DateTimeModel = openStart - ? { date: { year: '' } as DateModel, time: { format: 'am' } } - : this.extDateToDateTimeModel(lower, startRaw); + const isStartUnknownSlot = startRaw === ''; + const isEndUnknownSlot = endRaw === ''; + const isStartOpenBound = startRaw === '..'; + const isEndOpenBound = endRaw === '..'; + + let model: DateTimeModel; + if (isStartUnknownSlot) { + model = { + qualifiers: { approximate: false, uncertain: false, unknown: true }, + date: { year: '', month: '', day: '' }, + time: { ...DEFAULT_TIME }, + }; + } else if ( + isStartOpenBound || + lower === null || + typeof lower === 'number' + ) { + model = { date: { year: '' } as DateModel, time: { format: 'am' } }; + } else { + model = this.extDateToDateTimeModel(lower, startRaw); + } - if (openEnd) { + if (isEndUnknownSlot) { + model.endQualifiers = { + approximate: false, + uncertain: false, + unknown: true, + }; + model.endDate = { year: '', month: '', day: '' }; + model.endTime = { ...DEFAULT_TIME }; + } else if (isEndOpenBound || upper === null || typeof upper === 'number') { model.endDate = { year: '' } as DateModel; model.endTime = { format: 'am' }; } else if (upper) { const upperModel = this.extDateToDateTimeModel(upper, endRaw); + model.endQualifiers = upperModel.qualifiers; model.endDate = upperModel.date; model.endTime = upperModel.time; } From da984ea693058e2c263d9097db6ac06f5e2c871b Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Tue, 9 Jun 2026 14:50:54 +0300 Subject: [PATCH 3/4] Add per-side qualifiers to the EDTF date editor Each date in an interval now has its own Approximate / Uncertain / Unknown toggles. The sidebar reads "Sometime before/after" for open intervals and "Unknown" for unknown sides. Issue: PER-10642 --- .../edit-date-time-modal.component.spec.ts | 17 -- .../sidebar-date-picker.component.html | 84 ++++----- .../sidebar-date-picker.component.spec.ts | 165 +++++++++++++++++- .../sidebar-date-picker.component.ts | 143 ++++++++++++--- .../sidebar/sidebar.component.spec.ts | 95 +++++++++- .../components/sidebar/sidebar.component.ts | 17 +- .../edtf-service/edtf.service.spec.ts | 40 ----- 7 files changed, 423 insertions(+), 138 deletions(-) diff --git a/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.spec.ts b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.spec.ts index 951dded87..3584fb010 100644 --- a/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.spec.ts +++ b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.spec.ts @@ -454,23 +454,6 @@ describe('EditDateTimeModalComponent', () => { // --- EDTF computed --- - it('should compute EDTF value from date and time without timezone suffix', () => { - component.date.set({ year: '2026', month: '02', day: '18' }); - component.time.set({ - hours: '10', - minutes: '30', - seconds: '00', - format: 'am', - }); - component.qualifiers.set({ - approximate: false, - uncertain: false, - unknown: false, - }); - - expect(component.edtfValue()).toBe('2026-02-18T10:30:00'); - }); - it('should compute EDTF with date only', () => { component.date.set({ year: '2026', month: '', day: '' }); component.time.set({ diff --git a/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.html b/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.html index cfc898e7e..9587f223d 100644 --- a/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.html +++ b/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.html @@ -14,60 +14,46 @@ [class.can-edit]="!disabled" (click)="toggle()" > -
- @if (hasEndDate()) { - From - } - @if (hasStartDate()) { - - {{ formattedStartDate() }} - @if (formattedStartTime()) { - fiber_manual_record - } + @for (row of rows(); track $index) { +
+ @if (row.prefixLabel) { + + {{ row.prefixLabel }} - @if (formattedStartTime()) { - - {{ formattedStartTime() }} - {{ startMeridian() }} - @if (startTimezone()) { - {{ startTimezone() }} + } + @if (row.text) { + {{ row.text }} + } @else if (row.date) { + + {{ row.date }} + @if (row.time) { + fiber_manual_record } - } - - } @else { - - {{ disabled ? 'No date' : 'Click to add date' }} - - } - @if (!disabled) { - - edit - - } -
- - @if (hasEndDate()) { -
- To - - {{ formattedEndDate() }} - @if (formattedEndTime()) { - fiber_manual_record + @if (row.time) { + + {{ row.time }} + {{ row.meridian }} + @if (row.timezone) { + {{ row.timezone }} + } + } - @if (formattedEndTime()) { - - {{ formattedEndTime() }} - {{ endMeridian() }} - @if (endTimezone()) { - {{ endTimezone() }} - } - - } - + } @else if (row.isEmpty) { + + {{ disabled ? 'No date' : 'Click to add date' }} + + } + @if ($index === 0 && !disabled) { + + edit + + }
}
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 index a2d26864f..ace6c2130 100644 --- 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 @@ -1,6 +1,9 @@ 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 { + DateTimeModel, + EdtfService, +} from '@shared/services/edtf-service/edtf.service'; import { SidebarDatePickerComponent } from './sidebar-date-picker.component'; @Component({ @@ -150,7 +153,7 @@ describe('SidebarDatePickerComponent', () => { expect(value.textContent).toContain('PM'); }); - it('should display "Unknown date and time" when unknown qualifier is set', () => { + it('should display "Unknown" when unknown qualifier is set', () => { host.displayTime = { qualifiers: { approximate: false, uncertain: false, unknown: true }, date: { year: '', month: '', day: '' }, @@ -167,7 +170,8 @@ describe('SidebarDatePickerComponent', () => { '.pr-sidebar-date-picker-row-value', ); - expect(value.textContent).toContain('Unknown date and time'); + expect(value.textContent.trim()).toBe('Unknown'); + expect(component.activeQualifiers()).not.toContain('Unknown'); }); it('should not show To row when no end date', () => { @@ -368,6 +372,54 @@ describe('SidebarDatePickerComponent', () => { expect(qualifiers).toBeTruthy(); expect(qualifiers.textContent).toContain('Uncertain'); }); + + it('should union qualifiers across start and end sides', () => { + host.displayTime = { + qualifiers: { approximate: true, uncertain: false, unknown: false }, + date: { year: '1985', month: '05', day: '' }, + time: { hours: '', minutes: '', seconds: '', format: 'am' }, + endQualifiers: { approximate: false, uncertain: true, unknown: false }, + endDate: { year: '1990', month: '06', day: '' }, + endTime: { 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'); + expect(qualifiers.textContent).toContain('Uncertain'); + }); + + it('should show "Unknown" for end side when endQualifiers.unknown is true', () => { + host.displayTime = { + date: { year: '1985', month: '04', day: '12' }, + time: { hours: '', minutes: '', seconds: '', format: 'am' }, + endQualifiers: { approximate: false, uncertain: false, unknown: true }, + endDate: { year: '', month: '', day: '' }, + endTime: { hours: '', minutes: '', seconds: '', format: 'am' }, + }; + fixture.detectChanges(); + + expect(component.formattedEndDate()).toBe('Unknown'); + expect(component.activeQualifiers()).not.toContain('Unknown'); + }); + + it('should still surface Approximate / Uncertain when set alongside Unknown', () => { + host.displayTime = { + qualifiers: { approximate: true, uncertain: false, unknown: false }, + date: { year: '1985', month: '04', day: '12' }, + time: { hours: '', minutes: '', seconds: '', format: 'am' }, + endQualifiers: { approximate: false, uncertain: false, unknown: true }, + endDate: { year: '', month: '', day: '' }, + endTime: { hours: '', minutes: '', seconds: '', format: 'am' }, + }; + fixture.detectChanges(); + + expect(component.activeQualifiers()).toEqual(['Approximate']); + }); }); describe('toggle and dropdown', () => { @@ -461,6 +513,22 @@ describe('SidebarDatePickerComponent', () => { expect(component.isDropdownOpen()).toBeFalse(); expect(host.moreOptionsData).toBeTruthy(); }); + + it('should open modal when end qualifier is active', () => { + host.displayTime = { + date: { year: '1985', month: '05', day: '' }, + time: { hours: '', minutes: '', seconds: '', format: 'am' }, + endQualifiers: { approximate: false, uncertain: true, unknown: false }, + endDate: { year: '1990', month: '06', day: '' }, + endTime: { hours: '', minutes: '', seconds: '', format: 'am' }, + }; + fixture.detectChanges(); + + component.toggle(); + + expect(component.isDropdownOpen()).toBeFalse(); + expect(host.moreOptionsData).toBeTruthy(); + }); }); describe('clearAll', () => { @@ -560,6 +628,25 @@ describe('SidebarDatePickerComponent', () => { expect(host.moreOptionsData.date.month).toBe('05'); }); + it('should include endQualifiers when an end side has qualifiers', () => { + host.displayTime = { + date: { year: '1985', month: '05', day: '' }, + time: { hours: '', minutes: '', seconds: '', format: 'am' }, + endQualifiers: { approximate: false, uncertain: true, unknown: false }, + endDate: { year: '1990', month: '06', day: '' }, + endTime: { hours: '', minutes: '', seconds: '', format: 'am' }, + }; + fixture.detectChanges(); + + component.onMoreOptions(); + + expect(host.moreOptionsData.endQualifiers).toEqual({ + approximate: false, + uncertain: true, + unknown: false, + }); + }); + it('should close dropdown when emitting', () => { host.displayTime = { date: { year: '1985', month: '05', day: '' }, @@ -629,4 +716,76 @@ describe('SidebarDatePickerComponent', () => { expect(component._time().format).toBe('pm'); }); }); + + describe('interval display', () => { + let edtfService: EdtfService; + + beforeEach(() => { + edtfService = TestBed.inject(EdtfService); + }); + + it('should show "Sometime before" and the end date for an open-start interval', () => { + host.displayTime = edtfService.toDateTimeModel('../1990'); + fixture.detectChanges(); + + expect(component.intervalLabel()).toBe('Sometime before'); + expect(component.intervalValueDate()).toBe('1990'); + + const rows = fixture.nativeElement.querySelectorAll( + '.pr-sidebar-date-picker-row', + ); + + expect(rows.length).toBe(2); + expect(rows[0].textContent).toContain('Sometime before'); + expect(rows[1].textContent).toContain('1990'); + }); + + it('should show "Sometime after" and the start date for an open-end interval', () => { + host.displayTime = edtfService.toDateTimeModel('1985/..'); + fixture.detectChanges(); + + expect(component.intervalLabel()).toBe('Sometime after'); + expect(component.intervalValueDate()).toBe('1985'); + + const rows = fixture.nativeElement.querySelectorAll( + '.pr-sidebar-date-picker-row', + ); + + expect(rows.length).toBe(2); + expect(rows[0].textContent).toContain('Sometime after'); + expect(rows[1].textContent).toContain('1985'); + }); + + it('should keep From/To layout with Unknown on the unknown start side', () => { + host.displayTime = edtfService.toDateTimeModel('/1990-02-04'); + fixture.detectChanges(); + + expect(component.intervalLabel()).toBeNull(); + + const rows = fixture.nativeElement.querySelectorAll( + '.pr-sidebar-date-picker-row', + ); + + expect(rows[0].textContent).toContain('From'); + expect(rows[0].textContent).toContain('Unknown'); + expect(rows[1].textContent).toContain('To'); + expect(rows[1].textContent).toContain('February 04, 1990'); + }); + + it('should keep From/To layout with Unknown on the unknown end side', () => { + host.displayTime = edtfService.toDateTimeModel('1985-04-12/'); + fixture.detectChanges(); + + expect(component.intervalLabel()).toBeNull(); + + const rows = fixture.nativeElement.querySelectorAll( + '.pr-sidebar-date-picker-row', + ); + + expect(rows[0].textContent).toContain('From'); + expect(rows[0].textContent).toContain('April 12, 1985'); + expect(rows[1].textContent).toContain('To'); + expect(rows[1].textContent).toContain('Unknown'); + }); + }); }); diff --git a/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.ts b/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.ts index 5d4248f0b..c950082e1 100644 --- a/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.ts +++ b/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.ts @@ -40,6 +40,16 @@ const EMPTY_QUALIFIERS: DateQualifierFlags = { unknown: false, }; +interface SidebarDateRow { + prefixLabel?: string; + text?: string; + date?: string; + time?: string; + meridian?: string; + timezone?: string; + isEmpty?: boolean; +} + @Component({ selector: 'pr-sidebar-date-picker', standalone: true, @@ -66,15 +76,16 @@ export class SidebarDatePickerComponent implements OnInit, OnChanges { _endDate = signal({ ...EMPTY_DATE }); _endTime = signal({ ...EMPTY_TIME }); _qualifiers = signal({ ...EMPTY_QUALIFIERS }); + _endQualifiers = signal({ ...EMPTY_QUALIFIERS }); _isOpenStart = signal(false); _isOpenEnd = signal(false); activeQualifiers = computed(() => { - const q = this._qualifiers(); + const start = this._qualifiers(); + const end = this._endQualifiers(); const active: string[] = []; - if (q.approximate) active.push('Approximate'); - if (q.uncertain) active.push('Uncertain'); - if (q.unknown) active.push('Unknown'); + if (start.approximate || end.approximate) active.push('Approximate'); + if (start.uncertain || end.uncertain) active.push('Uncertain'); return active; }); @@ -87,7 +98,7 @@ export class SidebarDatePickerComponent implements OnInit, OnChanges { }); formattedStartDate = computed(() => { - if (this._qualifiers().unknown) return 'Unknown date and time'; + if (this._qualifiers().unknown) return 'Unknown'; if (this._isOpenStart()) return '..'; return this.formatDate(this._date()); }); @@ -105,12 +116,14 @@ export class SidebarDatePickerComponent implements OnInit, OnChanges { // End date/time computed properties hasEndDate = computed(() => { + if (this._endQualifiers().unknown) return true; if (this._isOpenEnd()) return true; const date = this._endDate(); return !!(date.year || date.month || date.day); }); formattedEndDate = computed(() => { + if (this._endQualifiers().unknown) return 'Unknown'; if (this._isOpenEnd()) return '..'; return this.formatDate(this._endDate()); }); @@ -129,6 +142,66 @@ export class SidebarDatePickerComponent implements OnInit, OnChanges { ), ); + intervalLabel = computed(() => { + if (this._isOpenStart()) return 'Sometime before'; + if (this._isOpenEnd()) return 'Sometime after'; + return null; + }); + + intervalValueDate = computed(() => + this._isOpenStart() ? this.formattedEndDate() : this.formattedStartDate(), + ); + intervalValueTime = computed(() => + this._isOpenStart() ? this.formattedEndTime() : this.formattedStartTime(), + ); + intervalValueMeridian = computed(() => + this._isOpenStart() ? this.endMeridian() : this.startMeridian(), + ); + intervalValueTimezone = computed(() => + this._isOpenStart() ? this.endTimezone() : this.startTimezone(), + ); + + rows = computed(() => { + const intervalLabel = this.intervalLabel(); + if (intervalLabel) { + return [ + { text: intervalLabel }, + { + date: this.intervalValueDate(), + time: this.intervalValueTime(), + meridian: this.intervalValueMeridian(), + timezone: this.intervalValueTimezone(), + }, + ]; + } + + const isInterval = this.hasEndDate(); + const startRow: SidebarDateRow = { + prefixLabel: isInterval ? 'From' : undefined, + }; + if (this.hasStartDate()) { + startRow.date = this.formattedStartDate(); + startRow.time = this.formattedStartTime(); + startRow.meridian = this.startMeridian(); + startRow.timezone = this.startTimezone(); + } else { + startRow.isEmpty = true; + } + + if (!isInterval) return [startRow]; + + return [ + startRow, + { + prefixLabel: 'To', + date: this.formattedEndDate(), + time: this.formattedEndTime(), + meridian: this.endMeridian(), + timezone: this.endTimezone(), + }, + ]; + }); + ngOnInit(): void { this.updateFromDisplayTime(); } @@ -154,8 +227,11 @@ export class SidebarDatePickerComponent implements OnInit, OnChanges { toggle(): void { if (this.disabled) return; - const q = this._qualifiers(); - if (this.hasEndDate() || q.unknown || q.approximate || q.uncertain) { + if ( + this.hasEndDate() || + this.hasAnyQualifier(this._qualifiers()) || + this.hasAnyQualifier(this._endQualifiers()) + ) { this.onMoreOptions(); return; } @@ -189,6 +265,7 @@ export class SidebarDatePickerComponent implements OnInit, OnChanges { this._endDate.set({ ...EMPTY_DATE }); this._endTime.set({ ...EMPTY_TIME }); this._qualifiers.set({ ...EMPTY_QUALIFIERS }); + this._endQualifiers.set({ ...EMPTY_QUALIFIERS }); this._isOpenStart.set(false); this._isOpenEnd.set(false); } @@ -200,14 +277,9 @@ export class SidebarDatePickerComponent implements OnInit, OnChanges { qualifiers: { ...this._qualifiers() }, date: { ...this._date() }, time: { ...this._time() }, + ...(this.buildEndSide() ?? {}), }; - const endDate = this._endDate(); - if (endDate.year || endDate.month || endDate.day || this._isOpenEnd()) { - modalData.endDate = { ...endDate }; - modalData.endTime = { ...this._endTime() }; - } - this.moreOptionsClicked.emit(modalData); } @@ -221,18 +293,35 @@ export class SidebarDatePickerComponent implements OnInit, OnChanges { qualifiers: { ...this._qualifiers() }, date: { ...this._date() }, time: { ...this._time() }, + ...(this.buildEndSide() ?? {}), }; - 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 hasAnyQualifier(flags: DateQualifierFlags): boolean { + return flags.approximate || flags.uncertain || flags.unknown; + } + + private buildEndSide(): Pick< + DateTimeModel, + 'endQualifiers' | 'endDate' | 'endTime' + > | null { + const endDate = this._endDate(); + const endQualifiers = this._endQualifiers(); + const hasEnd = + !!(endDate.year || endDate.month || endDate.day) || + this._isOpenEnd() || + this.hasAnyQualifier(endQualifiers); + if (!hasEnd) return null; + return { + endQualifiers: { ...endQualifiers }, + endDate: { ...endDate }, + endTime: { ...this._endTime() }, + }; + } + private updateFromDisplayTime(): void { if (!this.displayTime) { this._date.set({ ...EMPTY_DATE }); @@ -240,6 +329,7 @@ export class SidebarDatePickerComponent implements OnInit, OnChanges { this._endDate.set({ ...EMPTY_DATE }); this._endTime.set({ ...EMPTY_TIME }); this._qualifiers.set({ ...EMPTY_QUALIFIERS }); + this._endQualifiers.set({ ...EMPTY_QUALIFIERS }); this._isOpenStart.set(false); this._isOpenEnd.set(false); return; @@ -247,23 +337,24 @@ export class SidebarDatePickerComponent implements OnInit, OnChanges { const startDate = this.displayTime.date ?? { ...EMPTY_DATE }; const endDate = this.displayTime.endDate; + const startQualifiers = this.displayTime.qualifiers ?? EMPTY_QUALIFIERS; + const endQualifiers = this.displayTime.endQualifiers ?? EMPTY_QUALIFIERS; const 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._isOpenStart.set( + isInterval && isStartEmpty && !startQualifiers.unknown, + ); + this._isOpenEnd.set(isEndEmpty && !endQualifiers.unknown); 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._qualifiers.set({ ...startQualifiers }); + this._endQualifiers.set({ ...endQualifiers }); this._endDate.set(endDate ? { ...endDate } : { ...EMPTY_DATE }); this._endTime.set( this.displayTime.endTime diff --git a/src/app/file-browser/components/sidebar/sidebar.component.spec.ts b/src/app/file-browser/components/sidebar/sidebar.component.spec.ts index 28e9e1100..eefc4a473 100644 --- a/src/app/file-browser/components/sidebar/sidebar.component.spec.ts +++ b/src/app/file-browser/components/sidebar/sidebar.component.spec.ts @@ -5,10 +5,12 @@ import { EditService } from '@core/services/edit/edit.service'; import { AccountService } from '@shared/services/account/account.service'; import { ArchiveVO, RecordVO } from '@models/index'; import { GetThumbnailPipe } from '@shared/pipes/get-thumbnail.pipe'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, Subject } from 'rxjs'; +import { DateTimeModel } from '@shared/services/edtf-service/edtf.service'; import { MessageService } from '@shared/services/message/message.service'; import { FeatureFlagService } from '@root/app/feature-flag/services/feature-flag.service'; import { EdtfService } from '@shared/services/edtf-service/edtf.service'; +import { EditDateTimeModalService } from '../edit-date-time-modal/edit-date-time-modal.service'; import { SidebarComponent } from './sidebar.component'; @Pipe({ name: 'prTooltip', standalone: false }) @@ -110,6 +112,14 @@ const mockEditService = { saveItemVoProperty: (_item: any, _prop: any, _value: any) => {}, }; +let closedSubject: Subject; + +const mockModalService = { + open: (_data: DateTimeModel) => ({ + closed: closedSubject.asObservable(), + }), +}; + class MockAccountService { getArchive() { return new ArchiveVO({}); @@ -131,6 +141,8 @@ describe('SidebarComponent', () => { let fixture: ComponentFixture; beforeEach(async () => { + closedSubject = new Subject(); + selectedItemsSubject = new BehaviorSubject>( new Set([ new RecordVO({ @@ -169,6 +181,10 @@ describe('SidebarComponent', () => { provide: AccountService, useClass: MockAccountService, }, + { + provide: EditDateTimeModalService, + useValue: mockModalService, + }, { provide: MessageService, useValue: { @@ -510,4 +526,81 @@ describe('SidebarComponent', () => { expect(component.displayTimeObject?.date.year).toBe('1985'); }); }); + + describe('onDateMoreOptions', () => { + it('should open the edit date time modal with provided data', () => { + const openSpy = spyOn(mockModalService, 'open').and.callThrough(); + + const modalData: DateTimeModel = { + date: { year: '1985', month: '05', day: '' }, + time: { + hours: '', + minutes: '', + seconds: '', + format: 'am', + }, + }; + + component.onDateMoreOptions(modalData); + + expect(openSpy).toHaveBeenCalledWith(modalData); + }); + + it('should save displayTime when modal returns a result', () => { + const saveSpy = spyOn( + mockEditService, + 'saveItemVoProperty', + ).and.callThrough(); + + const modalData: DateTimeModel = { + date: { year: '1985', month: '05', day: '' }, + time: { + hours: '', + minutes: '', + seconds: '', + format: 'am', + }, + }; + + component.onDateMoreOptions(modalData); + + closedSubject.next({ + date: { year: '2000', month: '03', day: '15' }, + time: { + hours: '10', + minutes: '30', + seconds: '00', + format: 'am', + }, + }); + + expect(saveSpy).toHaveBeenCalledWith( + component.selectedItem, + 'displayTime', + jasmine.any(String), + ); + }); + + it('should not save when modal is dismissed', () => { + const saveSpy = spyOn( + mockEditService, + 'saveItemVoProperty', + ).and.callThrough(); + + const modalData: DateTimeModel = { + date: { year: '1985', month: '05', day: '' }, + time: { + hours: '', + minutes: '', + seconds: '', + format: 'am', + }, + }; + + component.onDateMoreOptions(modalData); + closedSubject.next(undefined); + + expect(saveSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/app/file-browser/components/sidebar/sidebar.component.ts b/src/app/file-browser/components/sidebar/sidebar.component.ts index c8d06e1d8..392902fa9 100644 --- a/src/app/file-browser/components/sidebar/sidebar.component.ts +++ b/src/app/file-browser/components/sidebar/sidebar.component.ts @@ -19,6 +19,7 @@ import { } from '@shared/services/edtf-service/edtf.service'; import { MessageService } from '@shared/services/message/message.service'; import { FeatureFlagService } from '@root/app/feature-flag/services/feature-flag.service'; +import { EditDateTimeModalService } from '../edit-date-time-modal/edit-date-time-modal.service'; type SidebarTab = 'info' | 'details' | 'sharing' | 'views'; @Component({ @@ -97,6 +98,7 @@ export class SidebarComponent implements OnDestroy, HasSubscriptions { private edtfService: EdtfService, private message: MessageService, private accountService: AccountService, + private editDateTimeModalService: EditDateTimeModalService, private cdr: ChangeDetectorRef, private feature: FeatureFlagService, ) { @@ -258,8 +260,19 @@ export class SidebarComponent implements OnDestroy, HasSubscriptions { } } - async onDateMoreOptions(): Promise { - //TODO: add edit date time modal PER-10642 + async onDateMoreOptions(modalData: DateTimeModel): Promise { + const dialogRef = this.editDateTimeModalService.open(modalData); + + dialogRef.closed.subscribe(async (result) => { + if (result) { + try { + const newDisplayTime = this.edtfService.toEdtfDate(result); + await this.onFinishEditing('displayTime', newDisplayTime); + } catch (err) { + this.message.showError({ message: err?.message }); + } + } + }); } onLocationClick() { diff --git a/src/app/shared/services/edtf-service/edtf.service.spec.ts b/src/app/shared/services/edtf-service/edtf.service.spec.ts index e18f8d0f9..2f86e5f4a 100644 --- a/src/app/shared/services/edtf-service/edtf.service.spec.ts +++ b/src/app/shared/services/edtf-service/edtf.service.spec.ts @@ -1282,45 +1282,5 @@ describe('EdtfService', () => { expect(result).toBe(edtfString); }); - - it('should roundtrip partial year (198X)', () => { - const edtfString = '198X'; - const model = service.toDateTimeModel(edtfString); - const result = service.toEdtfDate(model); - - expect(result).toBe(edtfString); - }); - - it('should roundtrip month-only with unknown year (XXXX-05)', () => { - const edtfString = 'XXXX-05'; - const model = service.toDateTimeModel(edtfString); - const result = service.toEdtfDate(model); - - expect(result).toBe(edtfString); - }); - - it('should roundtrip known year and day with unknown month (1985-XX-20)', () => { - const edtfString = '1985-XX-20'; - const model = service.toDateTimeModel(edtfString); - const result = service.toEdtfDate(model); - - expect(result).toBe(edtfString); - }); - - it('should roundtrip partial day (1985-05-2X)', () => { - const edtfString = '1985-05-2X'; - const model = service.toDateTimeModel(edtfString); - const result = service.toEdtfDate(model); - - expect(result).toBe(edtfString); - }); - - it('should roundtrip partial year with full month and day (198X-05-20)', () => { - const edtfString = '198X-05-20'; - const model = service.toDateTimeModel(edtfString); - const result = service.toEdtfDate(model); - - expect(result).toBe(edtfString); - }); }); }); From b6b99b906a05c561262b74e1325d24cc6ad5204a Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Tue, 16 Jun 2026 16:37:40 +0300 Subject: [PATCH 4/4] Use a default state for qualifiers and update sidebar with modal date Extract the detault qualifiers into a shared constant. Add a display update in the sidebar when the modal saves a new date. Issue: PER-10642 --- .../edit-date-time-modal.component.ts | 41 ++++----------- .../sidebar-date-picker.component.ts | 25 ++++----- .../sidebar/sidebar.component.spec.ts | 52 +++++++++++++++++++ .../components/sidebar/sidebar.component.ts | 33 ++++++------ .../services/edtf-service/edtf.service.ts | 16 +++--- 5 files changed, 99 insertions(+), 68 deletions(-) diff --git a/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.ts b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.ts index 2ac136f29..6fa8227ab 100644 --- a/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.ts +++ b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.ts @@ -19,6 +19,7 @@ import { TimeModel, DateTimeModel, DEFAULT_TIME, + DEFAULT_DATE_QUALIFIERS, UNKNOWN_VALUE, } from '@shared/services/edtf-service/edtf.service'; @@ -43,17 +44,9 @@ interface SavedSideState { export class EditDateTimeModalComponent implements OnInit { readonly DateQualifier = DateQualifier; - qualifiers = signal({ - approximate: false, - uncertain: false, - unknown: false, - }); + qualifiers = signal({ ...DEFAULT_DATE_QUALIFIERS }); - endQualifiers = signal({ - approximate: false, - uncertain: false, - unknown: false, - }); + endQualifiers = signal({ ...DEFAULT_DATE_QUALIFIERS }); savedStartState = signal(null); savedEndState = signal(null); @@ -116,11 +109,7 @@ export class EditDateTimeModalComponent implements OnInit { ngOnInit(): void { if (this.data) { this.qualifiers.set( - this.data.qualifiers ?? { - approximate: false, - uncertain: false, - unknown: false, - }, + this.data.qualifiers ?? { ...DEFAULT_DATE_QUALIFIERS }, ); this.date.set(this.data.date ?? { year: '', month: '', day: '' }); this.time.set(this.data.time ?? { ...DEFAULT_TIME }); @@ -130,17 +119,13 @@ export class EditDateTimeModalComponent implements OnInit { this.endDate.set(this.data.endDate); this.endTime.set(this.data.endTime ?? { ...DEFAULT_TIME }); this.endQualifiers.set( - this.data.endQualifiers ?? { - approximate: false, - uncertain: false, - unknown: false, - }, + this.data.endQualifiers ?? { ...DEFAULT_DATE_QUALIFIERS }, ); } if (this.data.qualifiers?.unknown) { this.savedStartState.set({ - qualifiers: { approximate: false, uncertain: false, unknown: false }, + qualifiers: { ...DEFAULT_DATE_QUALIFIERS }, date: { ...this.date() }, time: { ...this.time() }, }); @@ -148,7 +133,7 @@ export class EditDateTimeModalComponent implements OnInit { if (this.data.endQualifiers?.unknown) { this.savedEndState.set({ - qualifiers: { approximate: false, uncertain: false, unknown: false }, + qualifiers: { ...DEFAULT_DATE_QUALIFIERS }, date: { ...this.endDate() }, time: { ...this.endTime() }, }); @@ -255,22 +240,14 @@ export class EditDateTimeModalComponent implements OnInit { } clearStart(): void { - this.qualifiers.set({ - approximate: false, - uncertain: false, - unknown: false, - }); + this.qualifiers.set({ ...DEFAULT_DATE_QUALIFIERS }); this.date.set({ year: '', month: '', day: '' }); this.time.set({ ...DEFAULT_TIME }); this.savedStartState.set(null); } clearEnd(): void { - this.endQualifiers.set({ - approximate: false, - uncertain: false, - unknown: false, - }); + this.endQualifiers.set({ ...DEFAULT_DATE_QUALIFIERS }); this.endDate.set({ year: '', month: '', day: '' }); this.endTime.set({ ...DEFAULT_TIME }); this.savedEndState.set(null); diff --git a/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.ts b/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.ts index c950082e1..6274fbcb1 100644 --- a/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.ts +++ b/src/app/file-browser/components/sidebar-date-picker/sidebar-date-picker.component.ts @@ -21,6 +21,7 @@ import { DateModel, TimeModel, DateQualifierFlags, + DEFAULT_DATE_QUALIFIERS, } from '@shared/services/edtf-service/edtf.service'; import { DatepickerInputComponent } from '@shared/components/datepicker-input/datepicker-input.component'; import { TimepickerInputComponent } from '@shared/components/timepicker-input/timepicker-input.component'; @@ -34,12 +35,6 @@ const EMPTY_TIME: TimeModel = { format: 'am', }; -const EMPTY_QUALIFIERS: DateQualifierFlags = { - approximate: false, - uncertain: false, - unknown: false, -}; - interface SidebarDateRow { prefixLabel?: string; text?: string; @@ -75,8 +70,8 @@ export class SidebarDatePickerComponent implements OnInit, OnChanges { _time = signal({ ...EMPTY_TIME }); _endDate = signal({ ...EMPTY_DATE }); _endTime = signal({ ...EMPTY_TIME }); - _qualifiers = signal({ ...EMPTY_QUALIFIERS }); - _endQualifiers = signal({ ...EMPTY_QUALIFIERS }); + _qualifiers = signal({ ...DEFAULT_DATE_QUALIFIERS }); + _endQualifiers = signal({ ...DEFAULT_DATE_QUALIFIERS }); _isOpenStart = signal(false); _isOpenEnd = signal(false); @@ -264,8 +259,8 @@ export class SidebarDatePickerComponent implements OnInit, OnChanges { this._time.set({ ...EMPTY_TIME }); this._endDate.set({ ...EMPTY_DATE }); this._endTime.set({ ...EMPTY_TIME }); - this._qualifiers.set({ ...EMPTY_QUALIFIERS }); - this._endQualifiers.set({ ...EMPTY_QUALIFIERS }); + this._qualifiers.set({ ...DEFAULT_DATE_QUALIFIERS }); + this._endQualifiers.set({ ...DEFAULT_DATE_QUALIFIERS }); this._isOpenStart.set(false); this._isOpenEnd.set(false); } @@ -328,8 +323,8 @@ export class SidebarDatePickerComponent implements OnInit, OnChanges { this._time.set({ ...EMPTY_TIME }); this._endDate.set({ ...EMPTY_DATE }); this._endTime.set({ ...EMPTY_TIME }); - this._qualifiers.set({ ...EMPTY_QUALIFIERS }); - this._endQualifiers.set({ ...EMPTY_QUALIFIERS }); + this._qualifiers.set({ ...DEFAULT_DATE_QUALIFIERS }); + this._endQualifiers.set({ ...DEFAULT_DATE_QUALIFIERS }); this._isOpenStart.set(false); this._isOpenEnd.set(false); return; @@ -337,8 +332,10 @@ export class SidebarDatePickerComponent implements OnInit, OnChanges { const startDate = this.displayTime.date ?? { ...EMPTY_DATE }; const endDate = this.displayTime.endDate; - const startQualifiers = this.displayTime.qualifiers ?? EMPTY_QUALIFIERS; - const endQualifiers = this.displayTime.endQualifiers ?? EMPTY_QUALIFIERS; + const startQualifiers = + this.displayTime.qualifiers ?? DEFAULT_DATE_QUALIFIERS; + const endQualifiers = + this.displayTime.endQualifiers ?? DEFAULT_DATE_QUALIFIERS; const isInterval = !!endDate; const isStartEmpty = !startDate.year && !startDate.month && !startDate.day; const isEndEmpty = diff --git a/src/app/file-browser/components/sidebar/sidebar.component.spec.ts b/src/app/file-browser/components/sidebar/sidebar.component.spec.ts index eefc4a473..a3364b71f 100644 --- a/src/app/file-browser/components/sidebar/sidebar.component.spec.ts +++ b/src/app/file-browser/components/sidebar/sidebar.component.spec.ts @@ -581,6 +581,58 @@ describe('SidebarComponent', () => { ); }); + it('should refresh the cached display time after saving from the modal', async () => { + spyOn(mockEditService, 'saveItemVoProperty').and.callFake( + async (item: any, prop: any, value: any) => { + item[prop] = value; + }, + ); + + component.selectedItem = new RecordVO({ displayTime: '1985-05-20' }); + + component.onDateMoreOptions({ + date: { year: '1985', month: '05', day: '20' }, + time: { format: 'am' }, + } as DateTimeModel); + + closedSubject.next({ + date: { year: '2000', month: '03', day: '15' }, + time: { format: 'am' }, + } as DateTimeModel); + await fixture.whenStable(); + + expect(component.displayTimeObject?.date.year).toBe('2000'); + }); + + it('should not save when the modal closes after the sidebar is destroyed', () => { + const saveSpy = spyOn(mockEditService, 'saveItemVoProperty'); + + const modalData: DateTimeModel = { + date: { year: '1985', month: '05', day: '' }, + time: { + hours: '', + minutes: '', + seconds: '', + format: 'am', + }, + }; + + component.onDateMoreOptions(modalData); + component.ngOnDestroy(); + + closedSubject.next({ + date: { year: '2000', month: '03', day: '15' }, + time: { + hours: '', + minutes: '', + seconds: '', + format: 'am', + }, + }); + + expect(saveSpy).not.toHaveBeenCalled(); + }); + it('should not save when modal is dismissed', () => { const saveSpy = spyOn( mockEditService, diff --git a/src/app/file-browser/components/sidebar/sidebar.component.ts b/src/app/file-browser/components/sidebar/sidebar.component.ts index 392902fa9..70e731109 100644 --- a/src/app/file-browser/components/sidebar/sidebar.component.ts +++ b/src/app/file-browser/components/sidebar/sidebar.component.ts @@ -250,31 +250,34 @@ export class SidebarComponent implements OnDestroy, HasSubscriptions { } async onDateSaved(result: DateTimeModel) { + await this.saveDisplayTime(result); + } + + async onDateMoreOptions(modalData: DateTimeModel): Promise { + const dialogRef = this.editDateTimeModalService.open(modalData); + + this.subscriptions.push( + dialogRef.closed.subscribe(async (result) => { + if (result) { + await this.saveDisplayTime(result); + } + }), + ); + } + + private async saveDisplayTime(result: DateTimeModel): Promise { try { const newDisplayTime = this.edtfService.toEdtfDate(result); await this.onFinishEditing('displayTime', newDisplayTime); } catch (err) { this.message.showError({ message: err?.message }); } finally { + // Recompute so the picker re-syncs to the stored value, whether the + // save came from the inline picker or the modal, and on failure too. this.updateDisplayTimeObject(); } } - async onDateMoreOptions(modalData: DateTimeModel): Promise { - const dialogRef = this.editDateTimeModalService.open(modalData); - - dialogRef.closed.subscribe(async (result) => { - if (result) { - try { - const newDisplayTime = this.edtfService.toEdtfDate(result); - await this.onFinishEditing('displayTime', newDisplayTime); - } catch (err) { - this.message.showError({ message: err?.message }); - } - } - }); - } - onLocationClick() { if (this.canEdit) { this.editService.openLocationDialog(this.selectedItem); diff --git a/src/app/shared/services/edtf-service/edtf.service.ts b/src/app/shared/services/edtf-service/edtf.service.ts index c29fe554a..8cc7dd34d 100644 --- a/src/app/shared/services/edtf-service/edtf.service.ts +++ b/src/app/shared/services/edtf-service/edtf.service.ts @@ -38,6 +38,12 @@ export interface DateQualifierFlags { unknown: boolean; } +export const DEFAULT_DATE_QUALIFIERS: DateQualifierFlags = { + approximate: false, + uncertain: false, + unknown: false, +}; + export interface DateModel { year: string; month?: string; @@ -73,7 +79,7 @@ export class EdtfService { if (/^X{4}-X{2}-X{2}$/i.test(edtfString)) { return { - qualifiers: { approximate: false, uncertain: false, unknown: true }, + qualifiers: { ...DEFAULT_DATE_QUALIFIERS, unknown: true }, date: { year: '', month: '', day: '' }, time: { ...DEFAULT_TIME }, }; @@ -362,7 +368,7 @@ export class EdtfService { let model: DateTimeModel; if (isStartUnknownSlot) { model = { - qualifiers: { approximate: false, uncertain: false, unknown: true }, + qualifiers: { ...DEFAULT_DATE_QUALIFIERS, unknown: true }, date: { year: '', month: '', day: '' }, time: { ...DEFAULT_TIME }, }; @@ -377,11 +383,7 @@ export class EdtfService { } if (isEndUnknownSlot) { - model.endQualifiers = { - approximate: false, - uncertain: false, - unknown: true, - }; + model.endQualifiers = { ...DEFAULT_DATE_QUALIFIERS, unknown: true }; model.endDate = { year: '', month: '', day: '' }; model.endTime = { ...DEFAULT_TIME }; } else if (isEndOpenBound || upper === null || typeof upper === 'number') {