Frontend edit version label

This commit is contained in:
shamoon
2026-02-25 19:31:26 -08:00
parent c598275d4e
commit ea9838f134
5 changed files with 261 additions and 23 deletions

View File

@@ -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>
}

View File

@@ -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('')
})
})

View File

@@ -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

View File

@@ -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' })

View File

@@ -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'))
}