Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions src/app/core/services/edit/edit.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,72 @@ describe('EditService', () => {
expect(apiService.folder.getStelaFolderVOs).not.toHaveBeenCalled();
});

it('should reconcile a folder when the Stela response has a string folderId and no folder_linkId', async () => {
// The in-memory folder holds a numeric folderId and a folder_linkId, while
// the Stela response VO has a string folderId and no folder_linkId — the
// mismatch that previously left the folder unmatched and threw on update.
const folder = new FolderVO({
folderId: 1,
folder_linkId: 100,
displayName: 'Test Folder',
});
const updatedFolderVO = new FolderVO({
folderId: '1' as unknown as number,
updatedDT: '2024-03-03',
displayTime: '1990-07',
});
const mockFolderResponse = {
getFolderVOs: jasmine
.createSpy('getFolderVOs')
.and.returnValue([updatedFolderVO]),
};

(apiService.folder.updateStelaFolder as jasmine.Spy).and.returnValue(
Promise.resolve(mockFolderResponse),
);
(apiService.folder.getStelaFolderVOs as jasmine.Spy).and.returnValue(
Promise.resolve(mockFolderResponse),
);
folder.update = jasmine.createSpy('update');

await service.updateItems([folder], ['displayTime']);

expect(folder.update).toHaveBeenCalledWith(
jasmine.objectContaining({
updatedDT: '2024-03-03',
displayTime: '1990-07',
}),
);
});

it('should not throw when a response folder matches no in-memory folder', async () => {
const folder = new FolderVO({
folderId: 1,
folder_linkId: 100,
displayName: 'Test Folder',
});
const unmatchedVO = new FolderVO({
folderId: '999' as unknown as number,
updatedDT: '2024-03-03',
});
const mockFolderResponse = {
getFolderVOs: jasmine
.createSpy('getFolderVOs')
.and.returnValue([unmatchedVO]),
};

(apiService.folder.updateStelaFolder as jasmine.Spy).and.returnValue(
Promise.resolve(mockFolderResponse),
);
(apiService.folder.getStelaFolderVOs as jasmine.Spy).and.returnValue(
Promise.resolve(mockFolderResponse),
);

await expectAsync(
service.updateItems([folder], ['displayTime']),
).toBeResolved();
});

it('should revert property and show a translatable generic error when updateStelaRecord fails', async () => {
const messageService = TestBed.inject(MessageService);
spyOn(messageService, 'showError');
Expand Down
13 changes: 9 additions & 4 deletions src/app/core/services/edit/edit.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,10 @@ export class EditService {
const itemsByLinkId: { [key: number]: ItemVO } = {};

const recordsByRecordId: Map<number, RecordVO> = new Map();
const foldersByFolderId: Map<number, FolderVO> = new Map();
// Keyed by a stringified folderId: the Stela update response returns
// folderId as a string while the in-memory folder holds a number, so we
// normalize both sides to match them reliably.
const foldersByFolderId: Map<string, FolderVO> = new Map();

items.forEach((item) => {
item.isFolder ? folders.push(item) : records.push(item);
Expand All @@ -396,7 +399,7 @@ export class EditService {
recordsByRecordId.set(item.recordId, item);
}
} else if (item.folderId) {
foldersByFolderId.set(item.folderId, item);
foldersByFolderId.set(String(item.folderId), item);
}
});

Expand Down Expand Up @@ -435,8 +438,10 @@ export class EditService {

const folder =
(itemsByLinkId[updatedItem.folder_linkId] as FolderVO) ||
foldersByFolderId.get(updatedItem.folderId);
folder.update(newData);
foldersByFolderId.get(String(updatedItem.folderId));
// Guard against a response VO that maps to no in-memory folder so a
// lookup miss can never crash an otherwise-successful save.
folder?.update(newData);
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { MessageService } from '@shared/services/message/message.service';
import { AccountService } from '@shared/services/account/account.service';
import { DragService } from '@shared/services/drag/drag.service';
import { ShareLinksService } from '@root/app/share-links/services/share-links.service';
import { FeatureFlagService } from '@root/app/feature-flag/services/feature-flag.service';
import { EditService } from '@core/services/edit/edit.service';
import { DeviceService } from '@shared/services/device/device.service';
import { provideNoopAnimations } from '@angular/platform-browser/animations';
Expand Down Expand Up @@ -66,7 +67,13 @@ describe('FileListItemComponent', () => {
isMobileWidth: jasmine.createSpy().and.returnValue(false),
};

const mockFeatureFlagService = {
isEnabled: jasmine.createSpy().and.returnValue(false),
};

beforeEach(async () => {
mockFeatureFlagService.isEnabled.and.returnValue(false);

await TestBed.configureTestingModule({
imports: [MockItemTypeIconPipe, MockPrDatePipe, MockPrConstantsPipe],
declarations: [FileListItemComponent, GetThumbnailPipe],
Expand Down Expand Up @@ -131,6 +138,7 @@ describe('FileListItemComponent', () => {
},
{ provide: EditService, useValue: mockEditService },
{ provide: DeviceService, useValue: mockDeviceService },
{ provide: FeatureFlagService, useValue: mockFeatureFlagService },
],
}).compileComponents();

Expand Down Expand Up @@ -419,4 +427,36 @@ describe('FileListItemComponent', () => {
expect(component.date).not.toContain('2023');
expect(component.date).not.toContain('2026');
});

describe('with the edtf-date feature flag enabled', () => {
beforeEach(() => {
mockFeatureFlagService.isEnabled.and.callFake(
(flag: string) => flag === 'edtf-date',
);
});

it('should not fall back to displayDT when displayTime is missing', () => {
component.item.displayTime = undefined;
component.item.displayDT = '2023-01-01T00:00:00.000Z';
fixture.detectChanges();

expect(component.startDisplayTime).toBe('');
});

it('should show nothing when displayTime was explicitly cleared', () => {
component.item.displayTime = null;
component.item.displayDT = '2023-01-01T00:00:00.000Z';
fixture.detectChanges();

expect(component.startDisplayTime).toBe('');
});

it('should still show the displayTime start date', () => {
component.item.displayTime = '2020-06-10/2026-06-15';
component.item.displayDT = '2023-01-01T00:00:00.000Z';
fixture.detectChanges();

expect(component.startDisplayTime).toBe('2020-06-10');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
import { DataStatus } from '@models/data-status.enum';
import { EditService } from '@core/services/edit/edit.service';
import { EdtfService } from '@shared/services/edtf-service/edtf.service';
import { FeatureFlagService } from '@root/app/feature-flag/services/feature-flag.service';
import {
RecordResponse,
FolderResponse,
Expand Down Expand Up @@ -249,13 +250,22 @@ export class FileListItemComponent
@Inject(DOCUMENT) private document: Document,
private shareLinksService: ShareLinksService,
private edtfService: EdtfService,
private featureFlagService: FeatureFlagService,
) {}

get startDisplayTime(): string {
return (
this.edtfService.getEdtfIntervalStartDate(this.item.displayTime) ||
this.item.displayDT
const edtfStartDate = this.edtfService.getEdtfIntervalStartDate(
this.item.displayTime,
);

// Once the edtf-date UI ships, displayTime is authoritative (a null
// value means the user cleared the date, so nothing is shown). Until
// then, items may only have displayDT populated, so keep the fallback.
if (this.featureFlagService.isEnabled('edtf-date')) {
return edtfStartDate;
}

return edtfStartDate || this.item.displayDT;
}

async ngOnInit() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -292,10 +292,10 @@ describe('SidebarDatePickerComponent', () => {
expect(component.formattedStartDate()).toBe('1985-XX-20');
});

it('should fall back to ISO style "1985-1X-20" when month is partial', () => {
it('should render a single-digit month as the complete month, matching serialization ("1" -> January)', () => {
setDate('1985', '1', '20');

expect(component.formattedStartDate()).toBe('1985-1X-20');
expect(component.formattedStartDate()).toBe('January 20, 1985');
});

it('should fall back to ISO style "XXXX-XX-20" when only day is set', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
ElementRef,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { format } from 'date-fns';
import {
EdtfService,
TIME_FORMAT_LABEL,
Expand Down Expand Up @@ -360,40 +359,8 @@ export class SidebarDatePickerComponent implements OnInit, OnChanges {
);
}

private padDigitsWithX(value: string, width: number): string {
const v = value ?? '';
return v.length >= width ? v : v + 'X'.repeat(width - v.length);
}

private formatDate(date: DateModel): string {
const yearRaw = date.year ?? '';
const monthRaw = date.month ?? '';
const dayRaw = date.day ?? '';

const hasYear = !!yearRaw;
const hasMonth = !!monthRaw;
const hasDay = !!dayRaw && parseInt(dayRaw, 10) !== 0;

if (!hasYear && !hasMonth && !hasDay) return '';

const yearDisplay = this.padDigitsWithX(yearRaw, 4);
const monthComplete = /^\d{2}$/.test(monthRaw);
const monthName = monthComplete
? format(new Date(2000, parseInt(monthRaw, 10) - 1), 'MMMM')
: null;
// A day is a discrete value, not a range, so a single digit is
// zero-padded on the left ("2" -> "02") rather than X-padded.
const dayDisplay = hasDay ? dayRaw.padStart(2, '0') : '';

if (monthName && hasDay)
return `${monthName} ${dayDisplay}, ${yearDisplay}`;
if (monthName) return `${monthName} ${yearDisplay}`;
if (!hasMonth && !hasDay) return yearDisplay;

const monthDisplay = hasMonth ? this.padDigitsWithX(monthRaw, 2) : 'XX';
const parts: string[] = [yearDisplay, monthDisplay];
if (hasDay) parts.push(dayDisplay);
return parts.join('-');
return this.edtfService.formatDateForDisplay(date);
}

private formatTime(time: TimeModel): string {
Expand Down
66 changes: 66 additions & 0 deletions src/app/file-browser/components/sidebar/sidebar.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,72 @@ describe('SidebarComponent', () => {
});
});

describe('saveDisplayTime null handling', () => {
let saveItemVoPropertySpy: jasmine.Spy;

beforeEach(() => {
saveItemVoPropertySpy = spyOn(
mockEditService,
'saveItemVoProperty',
).and.callFake(async (item: any, prop: any, value: any) => {
item[prop] = value;
});
});

it('should save null when the date is cleared to empty', async () => {
component.selectedItem = new RecordVO({ displayTime: '1985-05-20' });

await component.onDateSaved({
date: { year: '', month: '', day: '' },
time: { format: 'am' },
});

expect(saveItemVoPropertySpy).toHaveBeenCalledWith(
component.selectedItem,
'displayTime',
null,
);
});

it('should save the EDTF string unchanged for a non-empty date', async () => {
component.selectedItem = new RecordVO({ displayTime: '1985-05-20' });

await component.onDateSaved({
date: { year: '1990', month: '06', day: '15' },
time: { format: 'am' },
});

expect(saveItemVoPropertySpy).toHaveBeenCalledWith(
component.selectedItem,
'displayTime',
'1990-06-15',
);
});
});

describe('updateDisplayTimeObject', () => {
it('should show an empty date when displayTime is explicitly null, ignoring displayDT', () => {
component.selectedItem = new RecordVO({
displayTime: null,
displayDT: '1985-05-20T00:00:00Z',
});

(component as any).updateDisplayTimeObject();

expect(component.displayTimeObject).toBeNull();
});

it('should not fall back to displayDT when displayTime is undefined', () => {
component.selectedItem = new RecordVO({
displayDT: '1985-05-20T00:00:00Z',
});

(component as any).updateDisplayTimeObject();

expect(component.displayTimeObject).toBeNull();
});
});

describe('onDateMoreOptions', () => {
it('should open the edit date time modal with provided data', () => {
const openSpy = spyOn(mockModalService, 'open').and.callThrough();
Expand Down
8 changes: 5 additions & 3 deletions src/app/file-browser/components/sidebar/sidebar.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,10 @@ export class SidebarComponent implements OnDestroy, HasSubscriptions {

displayTimeObject: DateTimeModel | null = null;

// No displayDT fallback needed here because this feeds the edtf-date picker only,
// which is already hidden behind a flag
private updateDisplayTimeObject(): void {
const timeSource =
this.selectedItem?.displayTime || this.selectedItem?.displayDT;
const timeSource = this.selectedItem?.displayTime;
try {
this.displayTimeObject = timeSource
? this.edtfService.toDateTimeModel(timeSource)
Expand Down Expand Up @@ -267,7 +268,8 @@ export class SidebarComponent implements OnDestroy, HasSubscriptions {

private async saveDisplayTime(result: DateTimeModel): Promise<void> {
try {
const newDisplayTime = this.edtfService.toEdtfDate(result);
const edtfDate = this.edtfService.toEdtfDate(result);
const newDisplayTime = edtfDate === '' ? null : edtfDate;
await this.onFinishEditing('displayTime', newDisplayTime);
} catch (err) {
this.message.showError({ message: err?.message });
Expand Down
4 changes: 2 additions & 2 deletions src/app/models/folder-vo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export class FolderVO
public displayName;
public displayDT;
public displayEndDT;
public displayTime?: string;
public displayTime?: string | null;
public derivedDT;
public derivedEndDT;
public altText;
Expand Down Expand Up @@ -182,7 +182,7 @@ export interface FolderVOData extends BaseVOData {
displayName?: any;
displayDT?: any;
displayEndDT?: any;
displayTime?: string;
displayTime?: string | null;
derivedDT?: any;
derivedEndDT?: any;
note?: any;
Expand Down
Loading