Compare commits

..

7 Commits

Author SHA1 Message Date
shamoon
6a0311bd45 Unify 404 logic 2025-08-22 20:03:51 -07:00
shamoon
297e9558cc Refactor document edit permission check 2025-08-22 20:03:50 -07:00
shamoon
3f84d581b1 Refactor document form handling and loading logic 2025-08-22 20:03:50 -07:00
shamoon
22064ed004 Chore: add test for navigation on document load error 2025-08-22 20:03:06 -07:00
shamoon
23daa0b974 Chore: add tests for previewText handling in DocumentDetail 2025-08-22 19:31:41 -07:00
shamoon
7b63f5a98c Merge branch 'main' into dev 2025-08-22 18:59:33 -07:00
shamoon
7c76377477 Fix: center document close button in app frame (#10661) 2025-08-22 18:58:09 -07:00
5 changed files with 186 additions and 176 deletions

View File

@@ -471,7 +471,7 @@ Failing to invalidate the cache after such modifications can lead to stale data
Use the following management command to clear the cache:
```
python3 manage.py invalidate_cachalot
invalidate_cachalot
```
!!! info

View File

@@ -147,7 +147,7 @@
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
popoverClass="popover-slim">
<i-bs class="me-1" name="file-text"></i-bs><span>&nbsp;{{d.title | documentTitle}}</span>
<span class="close" (click)="closeDocument(d); $event.preventDefault()">
<span class="close flex-column justify-content-center" (click)="closeDocument(d); $event.preventDefault()">
<i-bs name="x"></i-bs>
</span>
</a>

View File

@@ -191,7 +191,7 @@ main {
list-style-type: none;
&:hover .close {
display: block;
display: flex;
}
.close {

View File

@@ -452,6 +452,18 @@ describe('DocumentDetailComponent', () => {
expect(navigateSpy).toHaveBeenCalledWith(['404'], { replaceUrl: true })
})
it('should navigate to 404 if error on load', () => {
jest
.spyOn(activatedRoute, 'paramMap', 'get')
.mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' })))
const navigateSpy = jest.spyOn(router, 'navigate')
jest
.spyOn(documentService, 'get')
.mockReturnValue(throwError(() => new Error('not found')))
fixture.detectChanges()
expect(navigateSpy).toHaveBeenCalledWith(['404'], { replaceUrl: true })
})
it('should support save, close and show success toast', () => {
initNormally()
component.title = 'Foo Bar'
@@ -1388,4 +1400,19 @@ describe('DocumentDetailComponent', () => {
component.openEmailDocument()
expect(modalSpy).toHaveBeenCalled()
})
it('should set previewText', () => {
initNormally()
const previewText = 'Hello world, this is a test'
httpTestingController.expectOne(component.previewUrl).flush(previewText)
expect(component.previewText).toEqual(previewText)
})
it('should set previewText to error message if preview fails', () => {
initNormally()
httpTestingController
.expectOne(component.previewUrl)
.flush('fail', { status: 500, statusText: 'Server Error' })
expect(component.previewText).toContain('An error occurred loading content')
})
})

View File

@@ -21,8 +21,9 @@ import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { DeviceDetectorService } from 'ngx-device-detector'
import { BehaviorSubject, Observable, Subject } from 'rxjs'
import { BehaviorSubject, Observable, of, Subject } from 'rxjs'
import {
catchError,
debounceTime,
distinctUntilChanged,
filter,
@@ -327,19 +328,147 @@ export class DocumentDetailComponent
}
}
private mapDocToForm(doc: Document): any {
return {
...doc,
permissions_form: { owner: doc.owner, set_permissions: doc.permissions },
}
}
private mapFormToDoc(value: any): any {
const docValues = { ...value }
docValues['owner'] = value['permissions_form']?.owner
docValues['set_permissions'] = value['permissions_form']?.set_permissions
delete docValues['permissions_form']
return docValues
}
private prepareForm(doc: Document): void {
this.documentForm.reset(this.mapDocToForm(doc), { emitEvent: false })
if (!this.userCanEditDoc(doc)) {
this.documentForm.disable({ emitEvent: false })
} else {
this.documentForm.enable({ emitEvent: false })
}
if (doc.__changedFields) {
doc.__changedFields.forEach((field) => {
if (field === 'owner' || field === 'set_permissions') {
this.documentForm.get('permissions_form')?.markAsDirty()
} else {
this.documentForm.get(field)?.markAsDirty()
}
})
}
}
private setupDirtyTracking(
currentDocument: Document,
originalDocument: Document
): void {
this.store = new BehaviorSubject({
title: originalDocument.title,
content: originalDocument.content,
created: originalDocument.created,
correspondent: originalDocument.correspondent,
document_type: originalDocument.document_type,
storage_path: originalDocument.storage_path,
archive_serial_number: originalDocument.archive_serial_number,
tags: [...originalDocument.tags],
permissions_form: {
owner: originalDocument.owner,
set_permissions: originalDocument.permissions,
},
custom_fields: [...originalDocument.custom_fields],
})
this.isDirty$ = dirtyCheck(this.documentForm, this.store.asObservable())
this.isDirty$
.pipe(
takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier)
)
.subscribe((dirty) =>
this.openDocumentService.setDirty(
currentDocument,
dirty,
this.getChangedFields()
)
)
}
private loadDocument(documentId: number): void {
this.previewUrl = this.documentsService.getPreviewUrl(documentId)
this.http.get(this.previewUrl, { responseType: 'text' }).subscribe({
next: (res) => (this.previewText = res.toString()),
error: (err) =>
(this.previewText = $localize`An error occurred loading content: ${
err.message ?? err.toString()
}`),
})
this.thumbUrl = this.documentsService.getThumbUrl(documentId)
this.documentsService
.get(documentId)
.pipe(
catchError(() => {
// 404 is handled in the subscribe below
return of(null)
}),
first()
)
.subscribe({
next: (doc) => {
if (!doc) {
this.router.navigate(['404'], { replaceUrl: true })
return
}
this.documentId = doc.id
this.suggestions = null
const openDocument = this.openDocumentService.getOpenDocument(
this.documentId
)
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()
)
}
} else {
this.openDocumentService.openDocument(doc).pipe(first()).subscribe()
}
this.updateComponent(useDoc)
this.titleSubject
.pipe(
debounceTime(1000),
distinctUntilChanged(),
takeUntil(this.docChangeNotifier),
takeUntil(this.unsubscribeNotifier)
)
.subscribe((titleValue) => {
if (titleValue !== this.titleInput.value) return
this.title = titleValue
this.documentForm.patchValue({ title: titleValue })
})
this.setupDirtyTracking(useDoc, doc)
},
})
}
ngOnInit(): void {
this.setZoom(this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING))
this.documentForm.valueChanges
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
.subscribe((values) => {
this.error = null
const docValues = Object.assign({}, this.documentForm.value)
docValues['owner'] =
this.documentForm.get('permissions_form').value['owner']
docValues['set_permissions'] =
this.documentForm.get('permissions_form').value['set_permissions']
delete docValues['permissions_form']
Object.assign(this.document, docValues)
Object.assign(this.document, this.mapFormToDoc(values))
})
if (
@@ -391,154 +520,17 @@ export class DocumentDetailComponent
this.route.paramMap
.pipe(
filter((paramMap) => {
// only init when changing docs & section is set
return (
filter(
(paramMap) =>
+paramMap.get('id') !== this.documentId &&
paramMap.get('section')?.length > 0
)
}),
takeUntil(this.unsubscribeNotifier),
switchMap((paramMap) => {
const documentId = +paramMap.get('id')
this.docChangeNotifier.next(documentId)
// Dont wait to get the preview
this.previewUrl = this.documentsService.getPreviewUrl(documentId)
this.http.get(this.previewUrl, { responseType: 'text' }).subscribe({
next: (res) => {
this.previewText = res.toString()
},
error: (err) => {
this.previewText = $localize`An error occurred loading content: ${
err.message ?? err.toString()
}`
},
})
this.thumbUrl = this.documentsService.getThumbUrl(documentId)
return this.documentsService.get(documentId)
})
),
takeUntil(this.unsubscribeNotifier)
)
.pipe(
switchMap((doc) => {
this.documentId = doc.id
this.suggestions = null
const openDocument = this.openDocumentService.getOpenDocument(
this.documentId
)
if (openDocument) {
if (
new Date(doc.modified) > new Date(openDocument.modified) &&
!this.modalService.hasOpenModals()
) {
let 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()
)
}
// Prevent mutating stale form values into the next document: only sync if it still matches the active document.
if (
this.documentForm.dirty &&
(this.document?.id === openDocument.id || !this.document)
) {
Object.assign(openDocument, this.documentForm.value)
openDocument['owner'] =
this.documentForm.get('permissions_form').value['owner']
openDocument['permissions'] =
this.documentForm.get('permissions_form').value[
'set_permissions'
]
delete openDocument['permissions_form']
}
if (openDocument.__changedFields) {
openDocument.__changedFields.forEach((field) => {
if (field === 'owner' || field === 'set_permissions') {
this.documentForm.get('permissions_form').markAsDirty()
} else {
this.documentForm.get(field)?.markAsDirty()
}
})
}
this.updateComponent(openDocument)
} else {
this.openDocumentService.openDocument(doc)
this.updateComponent(doc)
}
this.titleSubject
.pipe(
debounceTime(1000),
distinctUntilChanged(),
takeUntil(this.docChangeNotifier),
takeUntil(this.unsubscribeNotifier)
)
.subscribe({
next: (titleValue) => {
// In the rare case when the field changed just after debounced event was fired.
// We dont want to overwrite what's actually in the text field, so just return
if (titleValue !== this.titleInput.value) return
this.title = titleValue
this.documentForm.patchValue({ title: titleValue })
},
complete: () => {
// doc changed so we manually check dirty in case title was changed
if (
this.store.getValue().title !==
this.documentForm.get('title').value
) {
this.openDocumentService.setDirty(
doc,
true,
this.getChangedFields()
)
}
},
})
// Initialize dirtyCheck
this.store = new BehaviorSubject({
title: doc.title,
content: doc.content,
created: doc.created,
correspondent: doc.correspondent,
document_type: doc.document_type,
storage_path: doc.storage_path,
archive_serial_number: doc.archive_serial_number,
tags: [...doc.tags],
permissions_form: {
owner: doc.owner,
set_permissions: doc.permissions,
},
custom_fields: [...doc.custom_fields],
})
this.isDirty$ = dirtyCheck(
this.documentForm,
this.store.asObservable()
)
return this.isDirty$.pipe(
takeUntil(this.unsubscribeNotifier),
map((dirty) => ({ doc, dirty }))
)
})
)
.subscribe({
next: ({ doc, dirty }) => {
this.openDocumentService.setDirty(doc, dirty, this.getChangedFields())
},
error: (error) => {
this.router.navigate(['404'], {
replaceUrl: true,
})
},
.subscribe((paramMap) => {
const documentId = +paramMap.get('id')
this.docChangeNotifier.next(documentId)
this.loadDocument(documentId)
})
this.route.paramMap.subscribe((paramMap) => {
@@ -682,19 +674,7 @@ export class DocumentDetailComponent
})
}
this.title = this.documentTitlePipe.transform(doc.title)
const docFormValues = Object.assign({}, doc)
docFormValues['permissions_form'] = {
owner: doc.owner,
set_permissions: doc.permissions,
}
this.documentForm.patchValue(docFormValues, { emitEvent: false })
if (!this.userCanEdit) this.documentForm.disable()
setTimeout(() => {
// check again after a tick in case form was dirty
if (!this.userCanEdit) this.documentForm.disable()
else this.documentForm.enable()
}, 10)
this.prepareForm(doc)
}
get customFieldFormFields(): FormArray {
@@ -1238,16 +1218,19 @@ export class DocumentDetailComponent
) {
doc.owner = this.store.value.permissions_form.owner
}
return !this.document || this.userCanEditDoc(doc)
}
private userCanEditDoc(doc: Document): boolean {
return (
!this.document ||
(this.permissionsService.currentUserCan(
this.permissionsService.currentUserCan(
PermissionAction.Change,
PermissionType.Document
) &&
this.permissionsService.currentUserHasObjectPermissions(
PermissionAction.Change,
doc
))
this.permissionsService.currentUserHasObjectPermissions(
PermissionAction.Change,
doc
)
)
}