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
58 changes: 58 additions & 0 deletions src/app/core/services/edit/edit.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,64 @@ describe('EditService', () => {
).toBeResolved();
});

it('should send several properties in one update and revert them together on failure', async () => {
const messageService = TestBed.inject(MessageService);
spyOn(messageService, 'showError');
spyOn(console, 'error');

const record = new RecordVO({
recordId: 1,
displayTime: 'original-value',
timezone: 'America/Chicago',
});

(apiService.record.updateStelaRecord as jasmine.Spy).and.returnValue(
Promise.reject({}),
);
(apiService.record.update as jasmine.Spy).and.returnValue(
Promise.resolve([]),
);

await service.saveItemVoProperties(
record,
{ displayTime: 'new-value', timezone: 'Europe/Bucharest' },
['displayTime'],
);

expect(record.displayTime).toBe('original-value');
expect(record.timezone).toBe('America/Chicago');
});

it('should apply several properties before the update call', async () => {
const record = new RecordVO({ recordId: 1 });
let timezoneWhenSent: string;

(apiService.record.updateStelaRecord as jasmine.Spy).and.callFake(
async (sentRecord: RecordVO) => {
timezoneWhenSent = sentRecord.timezone;
return {};
},
);
(apiService.record.get as jasmine.Spy).and.returnValue(
Promise.resolve({ getRecordVOs: () => [] }),
);

await service.saveItemVoProperties(
record,
{ displayTime: '1985-05-20', timezone: 'Europe/Bucharest' },
['displayTime'],
);

expect(timezoneWhenSent).toBe('Europe/Bucharest');
expect(record.displayTime).toBe('1985-05-20');
});

it('should do nothing when there is no item to save', async () => {
await expectAsync(
service.saveItemVoProperties(null, { displayTime: 'x' }, ['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
73 changes: 49 additions & 24 deletions src/app/core/services/edit/edit.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,30 +352,47 @@ export class EditService {
property: KeysOfType<ItemVO, string>,
value: string,
) {
if (item) {
const originalValue = item[property];
const newData: Partial<ItemVO> = {};
newData[property] = value;
try {
item.update(newData);
await this.updateItems([item], [property]);
} catch (err) {
const revertData: Partial<ItemVO> = {};
revertData[property] = originalValue;
item.update(revertData);

if (err instanceof FolderResponse || err instanceof RecordResponse) {
this.message.showError({
message: err.getMessage(),
translate: true,
});
} else {
console.error('Failed to save item property', err);
this.message.showError({
message: 'error.generic.update_fail',
translate: true,
});
}
const newData: Partial<ItemVO> = {};
newData[property] = value;
await this.saveItemVoProperties(item, newData, [property]);
}

/**
* Applies several properties in one optimistic update so fields that belong
* together — a date and the timezone it was recorded in — reach the backend
* in a single request and revert together when it fails.
*/
public async saveItemVoProperties(
item: ItemVO,
changes: Partial<ItemVO>,
whitelist: (keyof ItemVO)[],
) {
if (!item) {
return;
}

const originalValues: Record<string, unknown> = {};
Object.keys(changes).forEach((key) => {
originalValues[key] = item[key];
});

try {
item.update(changes);
await this.updateItems([item], whitelist);
} catch (err) {
item.update(originalValues as Partial<ItemVO>);

if (err instanceof FolderResponse || err instanceof RecordResponse) {
this.message.showError({
message: err.getMessage(),
translate: true,
});
} else {
console.error('Failed to save item property', err);
this.message.showError({
message: 'error.generic.update_fail',
translate: true,
});
}
}
}
Expand Down Expand Up @@ -432,6 +449,10 @@ export class EditService {
newData.displayTime = updatedItem.displayTime;
}

if (updatedItem.timezone) {
newData.timezone = updatedItem.timezone;
}

if (updatedItem.displayDT) {
newData.displayDT = updatedItem.displayDT;
}
Expand Down Expand Up @@ -464,6 +485,10 @@ export class EditService {
newData.displayTime = res.displayTime;
}

if (res.timezone) {
newData.timezone = res.timezone;
}

if (res.displayDT) {
newData.displayDT = res.displayDT;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ <h2>Edit date and time</h2>
/>
</div>

<pr-timezone-dropdown
class="pr-timezone-field"
fieldLabel="Start date timezone"
[selectedTimezone]="selectedTimezoneId()"
[disabled]="startFieldsDisabled()"
[showSearch]="true"
(timezoneChange)="onTimezoneChange($event)"
/>

<button class="pr-clear-link" (click)="clearStart()">
<i class="material-icons">backspace</i>
<span>Clear start date and time</span>
Expand Down Expand Up @@ -132,6 +141,15 @@ <h2>Edit date and time</h2>
/>
</div>

<pr-timezone-dropdown
class="pr-timezone-field"
fieldLabel="End date timezone"
[selectedTimezone]="selectedTimezoneId()"
[disabled]="endFieldsDisabled()"
[showSearch]="true"
(timezoneChange)="onTimezoneChange($event)"
/>

<button class="pr-clear-link" (click)="clearEnd()">
<i class="material-icons">backspace</i>
<span>Clear end date and time</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,10 @@
}
}

.pr-timezone-field {
display: block;
}

.pr-icon-button {
background: none;
border: none;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -506,4 +506,153 @@ describe('EditDateTimeModalComponent', () => {

expect(component.edtfValue()).toBe('2026-XX-18');
});

describe('timezone', () => {
beforeEach(() => {
// The shared dialog data is flagged approximate, and the EDTF grammar
// rejects a qualifier on a datetime, so clear it before serializing.
component.qualifiers.set({
approximate: false,
uncertain: false,
unknown: false,
});
});

it('should start empty when the data carries no zone', () => {
expect(component.selectedTimezoneId()).toBeNull();
});

it('should start empty for an unusable stored zone', () => {
component.data.time.timezoneId = 'Not/AZone';
component.ngOnInit();

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

it('should clear the zone along with the start date and time', () => {
component.onTimezoneChange('Europe/Bucharest');
component.clearStart();

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

it('should clear the zone along with the end date and time', () => {
component.onTimezoneChange('Europe/Bucharest');
component.clearEnd();

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

it('should accept being emptied again', () => {
component.onTimezoneChange('Europe/Bucharest');
component.onTimezoneChange(null);

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

it('should drive the offset written into the EDTF value', () => {
component.date.set({ year: '1985', month: '05', day: '12' });
component.time.set({
hours: '12',
minutes: '45',
seconds: '00',
format: 'pm',
});
component.onTimezoneChange('Europe/Bucharest');

expect(component.edtfValue()).toBe('1985-05-12T12:45:00+03:00');
});

it('should apply one zone to both sides of a range', () => {
component.date.set({ year: '1985', month: '05', day: '12' });
component.time.set({
hours: '12',
minutes: '45',
seconds: '00',
format: 'pm',
});
component.toggleDateRange();
component.endDate.set({ year: '2026', month: '01', day: '15' });
component.endTime.set({
hours: '10',
minutes: '00',
seconds: '00',
format: 'am',
});
component.onTimezoneChange('Europe/Bucharest');

expect(component.edtfValue()).toBe(
'1985-05-12T12:45:00+03:00/2026-01-15T10:00:00+02:00',
);
});

it('should hand the chosen zone back to the caller on save', () => {
component.onTimezoneChange('Europe/Bucharest');
component.toggleDateRange();
component.onSave();
const saved = dialogRefSpy.close.calls.mostRecent()
.args[0] as DateTimeModel;

expect(saved.time.timezoneId).toBe('Europe/Bucharest');
expect(saved.endTime.timezoneId).toBe('Europe/Bucharest');
});

it('should write no offset when there is no usable zone', () => {
component.selectedTimezoneId.set(null);
component.date.set({ year: '1985', month: '05', day: '12' });
component.time.set({
hours: '12',
minutes: '45',
seconds: '00',
format: 'pm',
});

expect(component.edtfValue()).toBe('1985-05-12T12:45:00');
});

it('should drop the parsed offset when the zone is cleared', () => {
component.date.set({ year: '1985', month: '05', day: '12' });
component.time.set({
hours: '12',
minutes: '45',
seconds: '00',
format: 'pm',
timezoneOffset: '+03:00',
});
component.selectedTimezoneId.set('Europe/Bucharest');

component.onTimezoneChange(null);

expect(component.time().timezoneOffset).toBeUndefined();
expect(component.edtfValue()).toBe('1985-05-12T12:45:00');
});

it('should drop the offset on both sides of a range when cleared', () => {
component.useDateRange.set(true);
component.time.update((time) => ({
...time,
timezoneOffset: '+03:00',
}));
component.endTime.update((time) => ({
...time,
timezoneOffset: '+03:00',
}));

component.onTimezoneChange(null);

expect(component.time().timezoneOffset).toBeUndefined();
expect(component.endTime().timezoneOffset).toBeUndefined();
});

it('should keep the parsed offset when a real zone is chosen', () => {
component.time.update((time) => ({
...time,
timezoneOffset: '+03:00',
}));

component.onTimezoneChange('Europe/Bucharest');

expect(component.time().timezoneOffset).toBe('+03:00');
});
});
});
Loading
Loading