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..3584fb010 --- /dev/null +++ b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.spec.ts @@ -0,0 +1,509 @@ +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 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..6fa8227ab --- /dev/null +++ b/src/app/file-browser/components/edit-date-time-modal/edit-date-time-modal.component.ts @@ -0,0 +1,275 @@ +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, + DEFAULT_DATE_QUALIFIERS, + 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({ ...DEFAULT_DATE_QUALIFIERS }); + + endQualifiers = signal({ ...DEFAULT_DATE_QUALIFIERS }); + + 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 ?? { ...DEFAULT_DATE_QUALIFIERS }, + ); + 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 ?? { ...DEFAULT_DATE_QUALIFIERS }, + ); + } + + if (this.data.qualifiers?.unknown) { + this.savedStartState.set({ + qualifiers: { ...DEFAULT_DATE_QUALIFIERS }, + date: { ...this.date() }, + time: { ...this.time() }, + }); + } + + if (this.data.endQualifiers?.unknown) { + this.savedEndState.set({ + qualifiers: { ...DEFAULT_DATE_QUALIFIERS }, + 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({ ...DEFAULT_DATE_QUALIFIERS }); + this.date.set({ year: '', month: '', day: '' }); + this.time.set({ ...DEFAULT_TIME }); + this.savedStartState.set(null); + } + + clearEnd(): void { + this.endQualifiers.set({ ...DEFAULT_DATE_QUALIFIERS }); + 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/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..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,11 +35,15 @@ const EMPTY_TIME: TimeModel = { format: 'am', }; -const EMPTY_QUALIFIERS: DateQualifierFlags = { - approximate: false, - uncertain: false, - unknown: false, -}; +interface SidebarDateRow { + prefixLabel?: string; + text?: string; + date?: string; + time?: string; + meridian?: string; + timezone?: string; + isEmpty?: boolean; +} @Component({ selector: 'pr-sidebar-date-picker', @@ -65,16 +70,17 @@ export class SidebarDatePickerComponent implements OnInit, OnChanges { _time = signal({ ...EMPTY_TIME }); _endDate = signal({ ...EMPTY_DATE }); _endTime = signal({ ...EMPTY_TIME }); - _qualifiers = signal({ ...EMPTY_QUALIFIERS }); + _qualifiers = signal({ ...DEFAULT_DATE_QUALIFIERS }); + _endQualifiers = signal({ ...DEFAULT_DATE_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 +93,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 +111,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 +137,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 +222,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; } @@ -188,7 +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._qualifiers.set({ ...DEFAULT_DATE_QUALIFIERS }); + this._endQualifiers.set({ ...DEFAULT_DATE_QUALIFIERS }); this._isOpenStart.set(false); this._isOpenEnd.set(false); } @@ -200,14 +272,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,25 +288,43 @@ 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 }); this._time.set({ ...EMPTY_TIME }); this._endDate.set({ ...EMPTY_DATE }); this._endTime.set({ ...EMPTY_TIME }); - this._qualifiers.set({ ...EMPTY_QUALIFIERS }); + this._qualifiers.set({ ...DEFAULT_DATE_QUALIFIERS }); + this._endQualifiers.set({ ...DEFAULT_DATE_QUALIFIERS }); this._isOpenStart.set(false); this._isOpenEnd.set(false); return; @@ -247,23 +332,26 @@ export class SidebarDatePickerComponent implements OnInit, OnChanges { const startDate = this.displayTime.date ?? { ...EMPTY_DATE }; const endDate = this.displayTime.endDate; + 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 = 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..a3364b71f 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,133 @@ 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 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, + '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..70e731109 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, ) { @@ -248,20 +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(): Promise { - //TODO: add edit date time modal PER-10642 - } - onLocationClick() { if (this.canEdit) { this.editService.openLocationDialog(this.selectedItem); 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..2f86e5f4a 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..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; @@ -56,6 +62,7 @@ export interface DateTimeModel { qualifiers?: DateQualifierFlags; date: DateModel; time: TimeModel; + endQualifiers?: DateQualifierFlags; endDate?: DateModel; endTime?: TimeModel; } @@ -72,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 }, }; @@ -99,9 +106,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 +141,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 +360,38 @@ 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: { ...DEFAULT_DATE_QUALIFIERS, 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 = { ...DEFAULT_DATE_QUALIFIERS, 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; }