mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-03-26 10:52:46 +00:00
Frontend edit version label
This commit is contained in:
@@ -59,37 +59,76 @@
|
||||
<div class="dropdown-divider"></div>
|
||||
@for (version of versions; track version.id) {
|
||||
<div class="dropdown-item">
|
||||
<div class="d-flex align-items-center w-100 version-item">
|
||||
<button type="button" class="btn btn-link link-underline link-underline-opacity-0 d-flex align-items-center flex-grow-1 small ps-0 text-start" (click)="selectVersion(version.id)">
|
||||
<div class="d-flex align-items-start w-100 version-item">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-link link-underline link-underline-opacity-0 d-flex align-items-center small ps-0 text-start"
|
||||
(click)="selectVersion(version.id)"
|
||||
>
|
||||
<div class="badge bg-light text-lowercase text-muted">
|
||||
{{ version.checksum | slice:0:8 }}
|
||||
</div>
|
||||
<div class="d-flex flex-column small ms-2">
|
||||
<div>
|
||||
</button>
|
||||
<div class="d-flex flex-column small ms-2 flex-grow-1 min-w-0">
|
||||
@if (isEditingVersion(version.id)) {
|
||||
<input
|
||||
class="form-control form-control-sm"
|
||||
type="text"
|
||||
[(ngModel)]="versionLabelDraft"
|
||||
i18n-placeholder
|
||||
placeholder="Version label"
|
||||
[disabled]="savingVersionLabelId !== null"
|
||||
(keydown.enter)="submitEditedVersionLabel(version, $event)"
|
||||
(keydown.escape)="cancelEditingVersion($event)"
|
||||
(click)="$event.stopPropagation()"
|
||||
/>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-link link-underline link-underline-opacity-0 p-0 small text-start"
|
||||
(click)="selectVersion(version.id)"
|
||||
>
|
||||
@if (version.version_label) {
|
||||
{{ version.version_label }}
|
||||
} @else {
|
||||
<span i18n>ID</span> #{{version.id}}
|
||||
<span i18n>ID</span> #{{ version.id }}
|
||||
}
|
||||
</div>
|
||||
<div class="text-muted">
|
||||
{{ version.added | customDate:'short' }}
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
<div class="text-muted">
|
||||
{{ version.added | customDate:'short' }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="d-flex align-items-center ms-2">
|
||||
@if (canEditLabels) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-link btn-sm text-secondary p-0"
|
||||
[disabled]="savingVersionLabelId !== null"
|
||||
(click)="isEditingVersion(version.id) ? submitEditedVersionLabel(version, $event) : beginEditingVersion(version, $event)"
|
||||
[attr.aria-label]="isEditingVersion(version.id) ? 'Save version label' : 'Edit version label'"
|
||||
>
|
||||
@if (isEditingVersion(version.id)) {
|
||||
<i-bs name="check-lg"></i-bs>
|
||||
} @else {
|
||||
<i-bs name="pencil"></i-bs>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (!version.is_root) {
|
||||
<pngx-confirm-button
|
||||
buttonClasses="btn-link btn-sm text-danger ms-2"
|
||||
iconName="trash"
|
||||
confirmMessage="Delete this version?"
|
||||
i18n-confirmMessage
|
||||
[disabled]="!userIsOwner || !userCanEdit"
|
||||
(confirm)="deleteVersion(version.id)"
|
||||
>
|
||||
<span class="visually-hidden" i18n>Delete version</span>
|
||||
</pngx-confirm-button>
|
||||
}
|
||||
</div>
|
||||
@if (selectedVersionId === version.id) { <span class="ms-2">✓</span> }
|
||||
@if (!version.is_root) {
|
||||
<pngx-confirm-button
|
||||
buttonClasses="btn-link btn-sm text-danger ms-2"
|
||||
iconName="trash"
|
||||
confirmMessage="Delete this version?"
|
||||
i18n-confirmMessage
|
||||
[disabled]="!userIsOwner || !userCanEdit"
|
||||
(confirm)="deleteVersion(version.id)"
|
||||
>
|
||||
<span class="visually-hidden" i18n>Delete version</span>
|
||||
</pngx-confirm-button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
let component: DocumentVersionDropdownComponent
|
||||
let fixture: ComponentFixture<DocumentVersionDropdownComponent>
|
||||
let documentService: jest.Mocked<
|
||||
Pick<DocumentService, 'deleteVersion' | 'getVersions' | 'uploadVersion'>
|
||||
Pick<
|
||||
DocumentService,
|
||||
'deleteVersion' | 'getVersions' | 'uploadVersion' | 'updateVersionLabel'
|
||||
>
|
||||
>
|
||||
let toastService: jest.Mocked<Pick<ToastService, 'showError' | 'showInfo'>>
|
||||
let finished$: Subject<{ taskId: string }>
|
||||
@@ -30,6 +33,7 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
deleteVersion: jest.fn(),
|
||||
getVersions: jest.fn(),
|
||||
uploadVersion: jest.fn(),
|
||||
updateVersionLabel: jest.fn(),
|
||||
}
|
||||
toastService = {
|
||||
showError: jest.fn(),
|
||||
@@ -127,6 +131,96 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('beginEditingVersion should set active row and draft label', () => {
|
||||
component.userCanEdit = true
|
||||
component.userIsOwner = true
|
||||
const version = {
|
||||
id: 10,
|
||||
is_root: false,
|
||||
checksum: 'bbbb',
|
||||
version_label: 'Current',
|
||||
} as DocumentVersionInfo
|
||||
|
||||
component.beginEditingVersion(version)
|
||||
|
||||
expect(component.editingVersionId).toEqual(10)
|
||||
expect(component.versionLabelDraft).toEqual('Current')
|
||||
})
|
||||
|
||||
it('submitEditedVersionLabel should close editor without save if unchanged', () => {
|
||||
const version = {
|
||||
id: 10,
|
||||
is_root: false,
|
||||
checksum: 'bbbb',
|
||||
version_label: 'Current',
|
||||
} as DocumentVersionInfo
|
||||
const saveSpy = jest.spyOn(component, 'saveVersionLabel')
|
||||
component.editingVersionId = 10
|
||||
component.versionLabelDraft = ' Current '
|
||||
|
||||
component.submitEditedVersionLabel(version)
|
||||
|
||||
expect(saveSpy).not.toHaveBeenCalled()
|
||||
expect(component.editingVersionId).toBeNull()
|
||||
expect(component.versionLabelDraft).toEqual('')
|
||||
})
|
||||
|
||||
it('submitEditedVersionLabel should call saveVersionLabel when changed', () => {
|
||||
const version = {
|
||||
id: 10,
|
||||
is_root: false,
|
||||
checksum: 'bbbb',
|
||||
version_label: 'Current',
|
||||
} as DocumentVersionInfo
|
||||
const saveSpy = jest
|
||||
.spyOn(component, 'saveVersionLabel')
|
||||
.mockImplementation(() => {})
|
||||
component.editingVersionId = 10
|
||||
component.versionLabelDraft = ' Updated '
|
||||
|
||||
component.submitEditedVersionLabel(version)
|
||||
|
||||
expect(saveSpy).toHaveBeenCalledWith(10, 'Updated')
|
||||
expect(component.editingVersionId).toBeNull()
|
||||
})
|
||||
|
||||
it('saveVersionLabel should update the version and emit versionsUpdated', () => {
|
||||
documentService.updateVersionLabel.mockReturnValue(
|
||||
of({
|
||||
id: 10,
|
||||
version_label: 'Updated',
|
||||
is_root: false,
|
||||
} as any)
|
||||
)
|
||||
const emitSpy = jest.spyOn(component.versionsUpdated, 'emit')
|
||||
|
||||
component.saveVersionLabel(10, 'Updated')
|
||||
|
||||
expect(documentService.updateVersionLabel).toHaveBeenCalledWith(
|
||||
3,
|
||||
10,
|
||||
'Updated'
|
||||
)
|
||||
expect(emitSpy).toHaveBeenCalledWith([
|
||||
{ id: 3, is_root: true, checksum: 'aaaa' },
|
||||
{ id: 10, is_root: false, checksum: 'bbbb', version_label: 'Updated' },
|
||||
])
|
||||
expect(component.savingVersionLabelId).toBeNull()
|
||||
})
|
||||
|
||||
it('saveVersionLabel should show error toast on failure', () => {
|
||||
const error = new Error('save failed')
|
||||
documentService.updateVersionLabel.mockReturnValue(throwError(() => error))
|
||||
|
||||
component.saveVersionLabel(10, 'Updated')
|
||||
|
||||
expect(toastService.showError).toHaveBeenCalledWith(
|
||||
'Error updating version label',
|
||||
error
|
||||
)
|
||||
expect(component.savingVersionLabelId).toBeNull()
|
||||
})
|
||||
|
||||
it('onVersionFileSelected should upload and update versions after websocket success', () => {
|
||||
const versions: DocumentVersionInfo[] = [
|
||||
{ id: 3, is_root: true, checksum: 'aaaa' },
|
||||
@@ -215,6 +309,8 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
it('ngOnChanges should clear upload status on document switch', () => {
|
||||
component.versionUploadState = UploadState.Failed
|
||||
component.versionUploadError = 'something failed'
|
||||
component.editingVersionId = 10
|
||||
component.versionLabelDraft = 'draft'
|
||||
|
||||
component.ngOnChanges({
|
||||
documentId: new SimpleChange(3, 4, false),
|
||||
@@ -222,5 +318,7 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
|
||||
expect(component.versionUploadState).toEqual(UploadState.Idle)
|
||||
expect(component.versionUploadError).toBeNull()
|
||||
expect(component.editingVersionId).toBeNull()
|
||||
expect(component.versionLabelDraft).toEqual('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { merge, of, Subject } from 'rxjs'
|
||||
import {
|
||||
filter,
|
||||
finalize,
|
||||
first,
|
||||
map,
|
||||
switchMap,
|
||||
@@ -59,6 +60,9 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
||||
newVersionLabel: string = ''
|
||||
versionUploadState: UploadState = UploadState.Idle
|
||||
versionUploadError: string | null = null
|
||||
savingVersionLabelId: number | null = null
|
||||
editingVersionId: number | null = null
|
||||
versionLabelDraft: string = ''
|
||||
|
||||
private readonly documentsService = inject(DocumentService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
@@ -70,6 +74,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
||||
if (changes.documentId && !changes.documentId.firstChange) {
|
||||
this.documentChange$.next()
|
||||
this.clearVersionUploadStatus()
|
||||
this.cancelEditingVersion()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +89,43 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
||||
this.versionSelected.emit(versionId)
|
||||
}
|
||||
|
||||
get canEditLabels(): boolean {
|
||||
return this.userIsOwner && this.userCanEdit
|
||||
}
|
||||
|
||||
isEditingVersion(versionId: number): boolean {
|
||||
return this.editingVersionId === versionId
|
||||
}
|
||||
|
||||
beginEditingVersion(version: DocumentVersionInfo, event?: Event): void {
|
||||
event?.preventDefault()
|
||||
event?.stopPropagation()
|
||||
if (!this.canEditLabels || this.savingVersionLabelId !== null) return
|
||||
this.editingVersionId = version.id
|
||||
this.versionLabelDraft = version.version_label ?? ''
|
||||
}
|
||||
|
||||
cancelEditingVersion(event?: Event): void {
|
||||
event?.preventDefault()
|
||||
event?.stopPropagation()
|
||||
this.editingVersionId = null
|
||||
this.versionLabelDraft = ''
|
||||
}
|
||||
|
||||
submitEditedVersionLabel(version: DocumentVersionInfo, event?: Event): void {
|
||||
event?.preventDefault()
|
||||
event?.stopPropagation()
|
||||
if (this.savingVersionLabelId !== null) return
|
||||
const nextLabel = this.versionLabelDraft?.trim() || null
|
||||
const currentLabel = version.version_label?.trim() || null
|
||||
if (nextLabel === currentLabel) {
|
||||
this.cancelEditingVersion()
|
||||
return
|
||||
}
|
||||
this.saveVersionLabel(version.id, nextLabel)
|
||||
this.cancelEditingVersion()
|
||||
}
|
||||
|
||||
deleteVersion(versionId: number): void {
|
||||
const wasSelected = this.selectedVersionId === versionId
|
||||
this.documentsService
|
||||
@@ -114,6 +156,41 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
||||
})
|
||||
}
|
||||
|
||||
saveVersionLabel(versionId: number, versionLabel: string | null): void {
|
||||
if (this.savingVersionLabelId !== null) return
|
||||
this.savingVersionLabelId = versionId
|
||||
this.documentsService
|
||||
.updateVersionLabel(this.documentId, versionId, versionLabel)
|
||||
.pipe(
|
||||
first(),
|
||||
finalize(() => {
|
||||
if (this.savingVersionLabelId === versionId) {
|
||||
this.savingVersionLabelId = null
|
||||
}
|
||||
}),
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
.subscribe({
|
||||
next: (updatedVersion) => {
|
||||
const updatedVersions = this.versions.map((version) =>
|
||||
version.id === versionId
|
||||
? {
|
||||
...version,
|
||||
version_label: updatedVersion.version_label,
|
||||
}
|
||||
: version
|
||||
)
|
||||
this.versionsUpdated.emit(updatedVersions)
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error updating version label`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onVersionFileSelected(event: Event): void {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (!input?.files || input.files.length === 0) return
|
||||
|
||||
@@ -359,6 +359,18 @@ describe(`DocumentService`, () => {
|
||||
req.flush({ result: 'OK', current_version_id: documents[0].id })
|
||||
})
|
||||
|
||||
it('should call appropriate api endpoint for updating a document version label', () => {
|
||||
subscription = service
|
||||
.updateVersionLabel(documents[0].id, 10, 'Updated label')
|
||||
.subscribe()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}${endpoint}/${documents[0].id}/versions/10/`
|
||||
)
|
||||
expect(req.request.method).toEqual('PATCH')
|
||||
expect(req.request.body).toEqual({ version_label: 'Updated label' })
|
||||
req.flush({ id: 10, version_label: 'Updated label', is_root: false })
|
||||
})
|
||||
|
||||
it('should call appropriate api endpoint for uploading a new version', () => {
|
||||
const file = new File(['hello'], 'test.pdf', { type: 'application/pdf' })
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
DOCUMENT_SORT_FIELDS,
|
||||
DOCUMENT_SORT_FIELDS_FULLTEXT,
|
||||
Document,
|
||||
DocumentVersionInfo,
|
||||
} from 'src/app/data/document'
|
||||
import { DocumentMetadata } from 'src/app/data/document-metadata'
|
||||
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
|
||||
@@ -245,6 +246,17 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
||||
)
|
||||
}
|
||||
|
||||
updateVersionLabel(
|
||||
rootDocumentId: number,
|
||||
versionId: number,
|
||||
versionLabel: string | null
|
||||
): Observable<DocumentVersionInfo> {
|
||||
return this.http.patch<DocumentVersionInfo>(
|
||||
this.getResourceUrl(rootDocumentId, `versions/${versionId}`),
|
||||
{ version_label: versionLabel }
|
||||
)
|
||||
}
|
||||
|
||||
getNextAsn(): Observable<number> {
|
||||
return this.http.get<number>(this.getResourceUrl(null, 'next_asn'))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user