Skip to content

Commit 840cffd

Browse files
committed
fix(file attachments): avoid errors while offline
by disabling UI and offering better error messages see #2450
1 parent a70251f commit 840cffd

13 files changed

+237
-64
lines changed
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* Custom error indicating that some functionality (like file access) is not at the moment because the app is offline
3+
* and that feature is not supported in offline mode.
4+
*/
5+
export class NotAvailableOfflineError extends Error {
6+
/**
7+
* @param feature The functionality that was attempted but is not available offline.
8+
*/
9+
constructor(feature: string) {
10+
super("Functionality not available offline: " + feature);
11+
}
12+
}

src/app/features/file/couchdb-file.service.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ import { map } from "rxjs/operators";
3333
import { DomSanitizer, SafeUrl } from "@angular/platform-browser";
3434
import { SyncService } from "../../core/database/sync.service";
3535
import { environment } from "../../../environments/environment";
36+
import { NAVIGATOR_TOKEN } from "../../utils/di-tokens";
37+
import { NotAvailableOfflineError } from "../../core/session/not-available-offline.error";
3638

3739
describe("CouchdbFileService", () => {
3840
let service: CouchdbFileService;
@@ -43,6 +45,7 @@ describe("CouchdbFileService", () => {
4345
let dismiss: jasmine.Spy;
4446
let updates: Subject<UpdatedEntity<Entity>>;
4547
const attachmentUrlPrefix = `${environment.DB_PROXY_PREFIX}/${environment.DB_NAME}-attachments`;
48+
let mockNavigator;
4649

4750
beforeEach(() => {
4851
mockHttp = jasmine.createSpyObj(["get", "put", "delete"]);
@@ -57,6 +60,8 @@ describe("CouchdbFileService", () => {
5760
dataType: FileDatatype.dataType,
5861
});
5962

63+
mockNavigator = { onLine: true };
64+
6065
TestBed.configureTestingModule({
6166
providers: [
6267
CouchdbFileService,
@@ -79,6 +84,7 @@ describe("CouchdbFileService", () => {
7984
},
8085
},
8186
{ provide: SyncService, useValue: mockSyncService },
87+
{ provide: NAVIGATOR_TOKEN, useValue: mockNavigator },
8288
],
8389
});
8490
service = TestBed.inject(CouchdbFileService);
@@ -156,6 +162,29 @@ describe("CouchdbFileService", () => {
156162
});
157163
});
158164

165+
it("should throw NotAvailableOffline error for uploadFile if offline (and not make requests)", (done) => {
166+
mockNavigator.onLine = false;
167+
168+
service.uploadFile(null, new Entity("testId"), "testProp").subscribe({
169+
error: (err) => {
170+
expect(err).toBeInstanceOf(NotAvailableOfflineError);
171+
expect(mockHttp.put).not.toHaveBeenCalled();
172+
done();
173+
},
174+
});
175+
});
176+
it("should throw NotAvailableOffline error for removeFile if offline (and not make requests)", (done) => {
177+
mockNavigator.onLine = false;
178+
179+
service.removeFile(new Entity("testId"), "testProp").subscribe({
180+
error: (err) => {
181+
expect(err).toBeInstanceOf(NotAvailableOfflineError);
182+
expect(mockHttp.delete).not.toHaveBeenCalled();
183+
done();
184+
},
185+
});
186+
});
187+
159188
it("should remove a file using the latest rev", () => {
160189
mockHttp.get.and.returnValue(of({ _rev: "test_rev" }));
161190
mockHttp.delete.and.returnValue(of({ ok: true }));

src/app/features/file/couchdb-file.service.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Injectable } from "@angular/core";
1+
import { Inject, Injectable } from "@angular/core";
22
import {
33
HttpClient,
44
HttpEvent,
@@ -16,7 +16,7 @@ import {
1616
shareReplay,
1717
tap,
1818
} from "rxjs/operators";
19-
import { from, Observable, of } from "rxjs";
19+
import { from, Observable, of, throwError } from "rxjs";
2020
import { MatDialog } from "@angular/material/dialog";
2121
import { ShowFileComponent } from "./show-file/show-file.component";
2222
import { Entity } from "../../core/entity/model/entity";
@@ -32,6 +32,8 @@ import { SyncStateSubject } from "../../core/session/session-type";
3232
import { SyncService } from "../../core/database/sync.service";
3333
import { SyncState } from "../../core/session/session-states/sync-state.enum";
3434
import { environment } from "../../../environments/environment";
35+
import { NAVIGATOR_TOKEN } from "../../utils/di-tokens";
36+
import { NotAvailableOfflineError } from "../../core/session/not-available-offline.error";
3537

3638
/**
3739
* Stores the files in the CouchDB.
@@ -54,11 +56,16 @@ export class CouchdbFileService extends FileService {
5456
entityMapper: EntityMapperService,
5557
entities: EntityRegistry,
5658
syncState: SyncStateSubject,
59+
@Inject(NAVIGATOR_TOKEN) private navigator: Navigator,
5760
) {
5861
super(entityMapper, entities, syncState);
5962
}
6063

6164
uploadFile(file: File, entity: Entity, property: string): Observable<any> {
65+
if (!this.navigator.onLine) {
66+
return throwError(() => new NotAvailableOfflineError("File Attachments"));
67+
}
68+
6269
const obs = this.requestQueue.add(
6370
this.runFileUpload(file, entity, property),
6471
);
@@ -112,6 +119,10 @@ export class CouchdbFileService extends FileService {
112119
}
113120

114121
removeFile(entity: Entity, property: string) {
122+
if (!this.navigator.onLine) {
123+
return throwError(() => new NotAvailableOfflineError("File Attachments"));
124+
}
125+
115126
return this.requestQueue.add(this.runFileRemoval(entity, property));
116127
}
117128

@@ -185,9 +196,7 @@ export class CouchdbFileService extends FileService {
185196
.pipe(
186197
map((blob) => URL.createObjectURL(blob)),
187198
catchError((err) => {
188-
Logging.warn(
189-
`Could not load file (${entity?.getId()} . ${property}): ${err}`,
190-
);
199+
Logging.warn("Could not load file", entity?.getId(), property, err);
191200

192201
if (throwErrors) {
193202
throw err;

src/app/features/file/edit-file/edit-file.component.html

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
<mat-form-field
99
[ngClass]="{ clickable: formControl.value }"
10-
(click)="formClicked()"
10+
(click)="navigator.onLine ? formClicked() : null"
1111
>
1212
<mat-label>{{ label }}</mat-label>
1313
<input
@@ -21,6 +21,7 @@
2121
matTooltip="Show file"
2222
[matTooltipDisabled]="!(initialValue && formControl.value === initialValue)"
2323
/>
24+
2425
<button
2526
*ngIf="formControl.value && formControl.enabled"
2627
type="button"
@@ -29,6 +30,7 @@
2930
(click)="delete(); $event.stopPropagation()"
3031
i18n-mattooltip="Tooltip remove file"
3132
matTooltip="Remove file"
33+
[disabled]="!navigator.onLine"
3234
>
3335
<fa-icon icon="xmark"></fa-icon>
3436
</button>
@@ -40,9 +42,15 @@
4042
(click)="fileUpload.click(); $event.stopPropagation()"
4143
i18n-matTooltip="Tooltip upload file button"
4244
matTooltip="Upload file"
45+
[disabled]="!navigator.onLine"
4346
>
4447
<fa-icon icon="upload"></fa-icon>
4548
</button>
49+
50+
@if (!navigator.onLine && formControl.enabled) {
51+
<mat-hint i18n>Changes to files are not possible offline.</mat-hint>
52+
}
53+
4654
<mat-error>
4755
<app-error-hint [form]="formControl"></app-error-hint>
4856
</mat-error>

src/app/features/file/edit-file/edit-file.component.spec.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { ComponentFixture, TestBed } from "@angular/core/testing";
1+
import {
2+
ComponentFixture,
3+
fakeAsync,
4+
TestBed,
5+
tick,
6+
} from "@angular/core/testing";
27

38
import { EditFileComponent } from "./edit-file.component";
49
import { AlertService } from "../../../core/alerts/alert.service";
@@ -10,6 +15,7 @@ import { NoopAnimationsModule } from "@angular/platform-browser/animations";
1015
import { FileService } from "../file.service";
1116
import { EntitySchemaService } from "../../../core/entity/schema/entity-schema.service";
1217
import { EntityMapperService } from "../../../core/entity/entity-mapper/entity-mapper.service";
18+
import { NAVIGATOR_TOKEN } from "../../../utils/di-tokens";
1319

1420
describe("EditFileComponent", () => {
1521
let component: EditFileComponent;
@@ -27,7 +33,7 @@ describe("EditFileComponent", () => {
2733
"removeFile",
2834
]);
2935
mockAlertService = jasmine.createSpyObj(["addDanger", "addInfo"]);
30-
mockEntityMapper = jasmine.createSpyObj(["save"]);
36+
mockEntityMapper = jasmine.createSpyObj(["save", "load"]);
3137
await TestBed.configureTestingModule({
3238
imports: [
3339
EditFileComponent,
@@ -39,6 +45,7 @@ describe("EditFileComponent", () => {
3945
{ provide: AlertService, useValue: mockAlertService },
4046
{ provide: FileService, useValue: mockFileService },
4147
{ provide: EntityMapperService, useValue: mockEntityMapper },
48+
{ provide: NAVIGATOR_TOKEN, useValue: { onLine: true } },
4249
],
4350
}).compileComponents();
4451

@@ -218,8 +225,14 @@ describe("EditFileComponent", () => {
218225
);
219226
});
220227

221-
it("should show upload errors as an alert and reset entity", () => {
228+
it("should show upload errors as an alert and reset entity", fakeAsync(() => {
222229
setupComponent("old.file");
230+
mockEntityMapper.load.and.resolveTo(
231+
Object.assign(new Entity(component.entity.getId()), {
232+
_rev: "2",
233+
testProp: "new.file",
234+
}),
235+
);
223236
const subject = new Subject();
224237
mockFileService.uploadFile.and.returnValue(subject);
225238
component.formControl.enable();
@@ -233,12 +246,19 @@ describe("EditFileComponent", () => {
233246
expect(component.entity[component.formControlName]).toBe(file.name);
234247

235248
subject.error(new Error());
249+
tick();
236250

237251
expect(mockAlertService.addDanger).toHaveBeenCalled();
238252
expect(component.formControl).toHaveValue("old.file");
239253
expect(component.entity[component.formControlName]).toBe("old.file");
240-
expect(mockEntityMapper.save).toHaveBeenCalledWith(component.entity);
241-
});
254+
expect(mockEntityMapper.save).toHaveBeenCalledWith(
255+
jasmine.objectContaining({
256+
_id: component.entity["_id"],
257+
_rev: "2",
258+
testProp: "old.file",
259+
}),
260+
);
261+
}));
242262

243263
it("should show a file when clicking on the form element", () => {
244264
setupComponent("existing.file");

src/app/features/file/edit-file/edit-file.component.ts

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { Component, ElementRef, OnInit, ViewChild } from "@angular/core";
1+
import {
2+
Component,
3+
ElementRef,
4+
Inject,
5+
OnInit,
6+
ViewChild,
7+
} from "@angular/core";
28
import { EditComponent } from "../../../core/entity/default-datatype/edit-component";
39
import { DynamicComponent } from "../../../core/config/dynamic-components/dynamic-component.decorator";
410
import { AlertService } from "../../../core/alerts/alert.service";
@@ -14,6 +20,8 @@ import { MatTooltipModule } from "@angular/material/tooltip";
1420
import { MatButtonModule } from "@angular/material/button";
1521
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
1622
import { ErrorHintComponent } from "../../../core/common-components/error-hint/error-hint.component";
23+
import { NotAvailableOfflineError } from "../../../core/session/not-available-offline.error";
24+
import { NAVIGATOR_TOKEN } from "../../../utils/di-tokens";
1725

1826
/**
1927
* This component should be used as a `editComponent` when a property should store files.
@@ -47,6 +55,7 @@ export class EditFileComponent extends EditComponent<string> implements OnInit {
4755
protected fileService: FileService,
4856
private alertService: AlertService,
4957
private entityMapper: EntityMapperService,
58+
@Inject(NAVIGATOR_TOKEN) protected navigator: Navigator,
5059
) {
5160
super();
5261
}
@@ -99,18 +108,34 @@ export class EditFileComponent extends EditComponent<string> implements OnInit {
99108
}
100109

101110
private handleError(err) {
102-
Logging.error("Failed uploading file: " + JSON.stringify(err));
103-
104-
let errorMessage = $localize`:File Upload Error Message:Failed uploading file. Please try again.`;
111+
let errorMessage: string;
105112
if (err?.status === 413) {
106113
errorMessage = $localize`:File Upload Error Message:File too large. Usually files up to 5 MB are supported.`;
114+
} else if (err instanceof NotAvailableOfflineError) {
115+
errorMessage = $localize`:File Upload Error Message:Changes to file attachments are not available offline.`;
116+
} else {
117+
Logging.error("Failed to update file: " + JSON.stringify(err));
118+
errorMessage = $localize`:File Upload Error Message:Failed to update file attachment. Please try again.`;
107119
}
108120
this.alertService.addDanger(errorMessage);
109121

122+
return this.revertEntityChanges();
123+
}
124+
125+
private async revertEntityChanges() {
126+
// ensure we have latest _rev of entity
127+
this.entity = await this.entityMapper.load(
128+
this.entity.getConstructor(),
129+
this.entity.getId(),
130+
);
131+
110132
// Reset entity to how it was before
111133
this.entity[this.formControlName] = this.initialValue;
112134
this.formControl.setValue(this.initialValue);
113-
return this.entityMapper.save(this.entity);
135+
136+
await this.entityMapper.save(this.entity);
137+
138+
this.resetFile();
114139
}
115140

116141
formClicked() {
@@ -136,15 +161,16 @@ export class EditFileComponent extends EditComponent<string> implements OnInit {
136161
}
137162

138163
protected deleteExistingFile() {
139-
this.fileService
140-
.removeFile(this.entity, this.formControlName)
141-
.subscribe(() => {
164+
this.fileService.removeFile(this.entity, this.formControlName).subscribe({
165+
error: (err) => this.handleError(err),
166+
complete: () => {
142167
this.alertService.addInfo(
143168
$localize`:Message for user:File "${this.initialValue}" deleted`,
144169
);
145170
this.initialValue = undefined;
146171
this.removeClicked = false;
147-
});
172+
},
173+
});
148174
}
149175

150176
protected resetFile() {

src/app/features/file/edit-photo/edit-photo.component.html

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
<img [src]="imgPath" alt="Image" class="image" (click)="openPopup()" />
22

3-
<div class="img-controls">
3+
<div
4+
class="img-controls"
5+
[matTooltipDisabled]="!(formControl.enabled && !navigator.onLine)"
6+
matTooltip="Changes to files are not possible offline."
7+
i18n-matTooltip
8+
>
49
<label class="img-label">{{ label }}</label>
510

611
<button
@@ -10,6 +15,7 @@
1015
(click)="delete()"
1116
i18n-mattooltip="Tooltip remove file"
1217
matTooltip="Remove file"
18+
[disabled]="!navigator.onLine"
1319
>
1420
<fa-icon icon="xmark"></fa-icon>
1521
</button>
@@ -20,9 +26,11 @@
2026
(click)="fileUpload.click()"
2127
i18n-matTooltip="Tooltip upload file button"
2228
matTooltip="Upload file"
29+
[disabled]="!navigator.onLine"
2330
>
2431
<fa-icon icon="upload"></fa-icon>
2532
</button>
33+
2634
<input
2735
type="file"
2836
style="display: none"

src/app/features/file/edit-photo/edit-photo.component.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { EntitySchemaService } from "../../../core/entity/schema/entity-schema.s
1111
import { FormControl } from "@angular/forms";
1212
import { Entity } from "../../../core/entity/model/entity";
1313
import { MatDialog } from "@angular/material/dialog";
14+
import { NAVIGATOR_TOKEN } from "../../../utils/di-tokens";
1415

1516
describe("EditPhotoComponent", () => {
1617
let component: EditPhotoComponent;
@@ -42,6 +43,7 @@ describe("EditPhotoComponent", () => {
4243
{ provide: FileService, useValue: mockFileService },
4344
{ provide: EntityMapperService, useValue: mockEntityMapper },
4445
{ provide: MatDialog, useValue: mockDialog },
46+
{ provide: NAVIGATOR_TOKEN, useValue: { onLine: true } },
4547
],
4648
}).compileComponents();
4749

0 commit comments

Comments
 (0)