diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts
index 4f299e26a..80e2c6cf0 100644
--- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts
+++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts
@@ -1205,17 +1205,19 @@ describe('DocumentDetailComponent', () => {
expect(errorSpy).toHaveBeenCalled()
})
- it('should warn when open document does not match doc retrieved from backend on init', () => {
- let openModal: NgbModalRef
- modalService.activeInstances.subscribe((modals) => (openModal = modals[0]))
+ it('should show remote update warning when open local draft is older than backend on init', () => {
const modalSpy = jest.spyOn(modalService, 'open')
- const openDoc = Object.assign({}, doc)
+ const openDoc = Object.assign({}, doc, {
+ __changedFields: ['title'],
+ })
// simulate a document being modified elsewhere and db updated
- doc.modified = new Date()
+ const remoteDoc = Object.assign({}, doc, {
+ modified: new Date(new Date(doc.modified).getTime() + 1000),
+ })
jest
.spyOn(activatedRoute, 'paramMap', 'get')
.mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' })))
- jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc))
+ jest.spyOn(documentService, 'get').mockReturnValueOnce(of(remoteDoc))
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
of({
@@ -1225,11 +1227,11 @@ describe('DocumentDetailComponent', () => {
})
)
fixture.detectChanges() // calls ngOnInit
- expect(modalSpy).toHaveBeenCalledWith(ConfirmDialogComponent)
- const closeSpy = jest.spyOn(openModal, 'close')
- const confirmDialog = openModal.componentInstance as ConfirmDialogComponent
- confirmDialog.confirmClicked.next(confirmDialog)
- expect(closeSpy).toHaveBeenCalled()
+ expect(component.remoteUpdateDetected).toBeTruthy()
+ expect(component.remoteUpdateModified).toEqual(
+ remoteDoc.modified.toISOString()
+ )
+ expect(modalSpy).not.toHaveBeenCalledWith(ConfirmDialogComponent)
})
it('should change preview element by render type', () => {
diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts
index 710bea91d..2d93ab15a 100644
--- a/src-ui/src/app/components/document-detail/document-detail.component.ts
+++ b/src-ui/src/app/components/document-detail/document-detail.component.ts
@@ -1,4 +1,4 @@
-import { AsyncPipe, NgTemplateOutlet } from '@angular/common'
+import { AsyncPipe, DatePipe, NgTemplateOutlet } from '@angular/common'
import { HttpClient, HttpResponse } from '@angular/common/http'
import { Component, inject, OnDestroy, OnInit, ViewChild } from '@angular/core'
import {
@@ -80,6 +80,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
+import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { getFilenameFromContentDisposition } from 'src/app/utils/http'
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
import * as UTIF from 'utif'
@@ -163,6 +164,7 @@ enum ContentRenderType {
MonetaryComponent,
UrlComponent,
SuggestionsDropdownComponent,
+ DatePipe,
CustomDatePipe,
FileSizePipe,
IfPermissionsDirective,
@@ -205,6 +207,7 @@ export class DocumentDetailComponent
private componentRouterService = inject(ComponentRouterService)
private deviceDetectorService = inject(DeviceDetectorService)
private savedViewService = inject(SavedViewService)
+ private websocketStatusService = inject(WebsocketStatusService)
@ViewChild('inputTitle')
titleInput: TextComponent
@@ -270,6 +273,8 @@ export class DocumentDetailComponent
customFields: CustomField[]
public downloading: boolean = false
+ remoteUpdateDetected: boolean = false
+ remoteUpdateModified: string | null = null
public readonly CustomFieldDataType = CustomFieldDataType
@@ -432,7 +437,14 @@ export class DocumentDetailComponent
)
}
- private loadDocument(documentId: number): void {
+ private hasLocalEdits(doc: Document): boolean {
+ return (
+ this.openDocumentService.isDirty(doc) || !!doc.__changedFields?.length
+ )
+ }
+
+ private loadDocument(documentId: number, forceRemote: boolean = false): void {
+ this.dismissRemoteUpdateWarning()
this.previewUrl = this.documentsService.getPreviewUrl(documentId)
this.updatePdfSource()
this.http
@@ -477,21 +489,28 @@ export class DocumentDetailComponent
openDocument.duplicate_documents = doc.duplicate_documents
this.openDocumentService.save()
}
- const useDoc = openDocument || doc
- if (openDocument) {
- if (
- new Date(doc.modified) > new Date(openDocument.modified) &&
- !this.modalService.hasOpenModals()
- ) {
- const modal = this.modalService.open(ConfirmDialogComponent)
- modal.componentInstance.title = $localize`Document changes detected`
- modal.componentInstance.messageBold = $localize`The version of this document in your browser session appears older than the existing version.`
- modal.componentInstance.message = $localize`Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.`
- modal.componentInstance.cancelBtnClass = 'visually-hidden'
- modal.componentInstance.btnCaption = $localize`Ok`
- modal.componentInstance.confirmClicked.subscribe(() =>
- modal.close()
- )
+ let useDoc = openDocument || doc
+ if (openDocument && forceRemote) {
+ Object.assign(openDocument, doc)
+ openDocument.__changedFields = []
+ this.openDocumentService.setDirty(openDocument, false)
+ this.openDocumentService.save()
+ useDoc = openDocument
+ } else if (openDocument) {
+ if (new Date(doc.modified) > new Date(openDocument.modified)) {
+ if (this.hasLocalEdits(openDocument)) {
+ this.remoteUpdateDetected = true
+ this.remoteUpdateModified = doc.modified
+ ? new Date(doc.modified).toISOString()
+ : null
+ } else {
+ // No local edits to preserve, so keep the tab in sync automatically.
+ Object.assign(openDocument, doc)
+ openDocument.__changedFields = []
+ this.openDocumentService.setDirty(openDocument, false)
+ this.openDocumentService.save()
+ useDoc = openDocument
+ }
}
} else {
this.openDocumentService
@@ -522,6 +541,38 @@ export class DocumentDetailComponent
})
}
+ private handleIncomingDocumentUpdated(data: {
+ document_id: number
+ modified?: string
+ }): void {
+ if (!this.documentId || data.document_id !== this.documentId) return
+ if (!this.document || this.networkActive) return
+
+ if (this.openDocumentService.isDirty(this.document)) {
+ this.remoteUpdateDetected = true
+ this.remoteUpdateModified = data.modified ?? null
+ } else {
+ this.docChangeNotifier.next(this.documentId)
+ this.loadDocument(this.documentId, true)
+ this.toastService.showInfo(
+ $localize`Document reloaded with latest changes.`
+ )
+ }
+ }
+
+ dismissRemoteUpdateWarning() {
+ this.remoteUpdateDetected = false
+ this.remoteUpdateModified = null
+ }
+
+ reloadRemoteVersion() {
+ if (!this.documentId) return
+
+ this.docChangeNotifier.next(this.documentId)
+ this.loadDocument(this.documentId, true)
+ this.toastService.showInfo($localize`Document reloaded.`)
+ }
+
ngOnInit(): void {
this.setZoom(
this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING) as PdfZoomScale
@@ -580,6 +631,11 @@ export class DocumentDetailComponent
this.getCustomFields()
+ this.websocketStatusService
+ .onDocumentUpdated()
+ .pipe(takeUntil(this.unsubscribeNotifier))
+ .subscribe((data) => this.handleIncomingDocumentUpdated(data))
+
this.route.paramMap
.pipe(
filter(
@@ -914,6 +970,7 @@ export class DocumentDetailComponent
)
.subscribe({
next: (doc) => {
+ this.dismissRemoteUpdateWarning()
Object.assign(this.document, doc)
doc['permissions_form'] = {
owner: doc.owner,
@@ -960,6 +1017,7 @@ export class DocumentDetailComponent
.pipe(first())
.subscribe({
next: (docValues) => {
+ this.dismissRemoteUpdateWarning()
// in case data changed while saving eg removing inbox_tags
this.documentForm.patchValue(docValues)
const newValues = Object.assign({}, this.documentForm.value)
@@ -1039,6 +1097,7 @@ export class DocumentDetailComponent
.pipe(first())
.subscribe({
next: ({ updateResult, nextDocId, closeResult }) => {
+ this.dismissRemoteUpdateWarning()
this.error = null
this.networkActive = false
if (closeResult && updateResult && nextDocId) {
@@ -1135,7 +1194,7 @@ export class DocumentDetailComponent
.subscribe({
next: () => {
this.toastService.showInfo(
- $localize`Reprocess operation for "${this.document.title}" will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.`
+ $localize`Reprocess operation for "${this.document.title}" will begin in the background.`
)
if (modal) {
modal.close()