Feature: live preview of storage path (#7870)

This commit is contained in:
shamoon 2024-10-09 16:35:36 -07:00 committed by GitHub
parent 8dd355f6bf
commit 024b60638a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 426 additions and 54 deletions

View File

@ -568,7 +568,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
<context context-type="linenumber">29</context> <context context-type="linenumber">79</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context>
@ -700,6 +700,10 @@
<context context-type="sourcefile">src/app/components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component.html</context>
<context context-type="linenumber">35</context> <context context-type="linenumber">35</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
<context context-type="linenumber">50</context>
</context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.html</context> <context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.html</context>
<context context-type="linenumber">51</context> <context context-type="linenumber">51</context>
@ -1680,7 +1684,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
<context context-type="linenumber">28</context> <context context-type="linenumber">78</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context>
@ -3500,7 +3504,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
<context context-type="linenumber">14</context> <context context-type="linenumber">64</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context>
@ -3519,7 +3523,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
<context context-type="linenumber">16</context> <context context-type="linenumber">66</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context>
@ -3538,7 +3542,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
<context context-type="linenumber">19</context> <context context-type="linenumber">69</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html</context>
@ -4126,39 +4130,72 @@
<context context-type="linenumber">42</context> <context context-type="linenumber">42</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6625768491622252297" datatype="html"> <trans-unit id="2816147949408898105" datatype="html">
<source>e.g.</source> <source>See &lt;a target=&apos;_blank&apos; href=&apos;https://docs.paperless-ngx.com/advanced_usage/#file-name-handling&apos;&gt;the documentation&lt;/a&gt;.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
<context context-type="linenumber">28</context> <context context-type="linenumber">13</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1918584360573970155" datatype="html"> <trans-unit id="1295614462098694869" datatype="html">
<source>or use slashes to add directories e.g.</source> <source>Preview</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
<context context-type="linenumber">18</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">282</context>
</context-group>
</trans-unit>
<trans-unit id="8057014866157903311" datatype="html">
<source>Path test failed</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
<context context-type="linenumber">30</context> <context context-type="linenumber">30</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7871464228487558644" datatype="html"> <trans-unit id="9116034231465034307" datatype="html">
<source>See &lt;a target=&quot;_blank&quot; href=&quot;https://docs.paperless-ngx.com/advanced_usage/#file-name-handling&quot;&gt;documentation&lt;/a&gt; for full list.</source> <source>No document selected</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
<context context-type="linenumber">32</context> <context context-type="linenumber">32</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5676637575587497817" datatype="html">
<source>Search for documents</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
<context context-type="linenumber">38</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.ts</context>
<context context-type="linenumber">53</context>
</context-group>
</trans-unit>
<trans-unit id="6423278459497515329" datatype="html">
<source>No documents found</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.html</context>
<context context-type="linenumber">39</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.ts</context>
<context context-type="linenumber">44</context>
</context-group>
</trans-unit>
<trans-unit id="6898961890896270754" datatype="html"> <trans-unit id="6898961890896270754" datatype="html">
<source>Create new storage path</source> <source>Create new storage path</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.ts</context>
<context context-type="linenumber">37</context> <context context-type="linenumber">63</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3754859110054016570" datatype="html"> <trans-unit id="3754859110054016570" datatype="html">
<source>Edit storage path</source> <source>Edit storage path</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component.ts</context>
<context context-type="linenumber">41</context> <context context-type="linenumber">67</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="9011959596901584887" datatype="html"> <trans-unit id="9011959596901584887" datatype="html">
@ -4836,20 +4873,6 @@
<context context-type="linenumber">14</context> <context context-type="linenumber">14</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6423278459497515329" datatype="html">
<source>No documents found</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.ts</context>
<context context-type="linenumber">44</context>
</context-group>
</trans-unit>
<trans-unit id="5676637575587497817" datatype="html">
<source>Search for documents</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/document-link/document-link.component.ts</context>
<context context-type="linenumber">53</context>
</context-group>
</trans-unit>
<trans-unit id="8627133593113147800" datatype="html"> <trans-unit id="8627133593113147800" datatype="html">
<source>Selected items</source> <source>Selected items</source>
<context-group purpose="location"> <context-group purpose="location">
@ -6120,13 +6143,6 @@
<context context-type="linenumber">275</context> <context context-type="linenumber">275</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1295614462098694869" datatype="html">
<source>Preview</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">282</context>
</context-group>
</trans-unit>
<trans-unit id="7206723502037428235" datatype="html"> <trans-unit id="7206723502037428235" datatype="html">
<source>Notes <x id="START_BLOCK_IF" equiv-text="@if (document?.notes.length) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge text-bg-secondary ms-1&quot;&gt;"/><x id="INTERPOLATION" equiv-text="ngth}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source> <source>Notes <x id="START_BLOCK_IF" equiv-text="@if (document?.notes.length) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;badge text-bg-secondary ms-1&quot;&gt;"/><x id="INTERPOLATION" equiv-text="ngth}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source>
<context-group purpose="location"> <context-group purpose="location">

View File

@ -10,7 +10,57 @@
<div class="modal-body"> <div class="modal-body">
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text> <pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text>
<pngx-input-textarea i18n-title title="Path" formControlName="path" [error]="error?.path" [hint]="pathHint" [monospace]="true"></pngx-input-textarea> <pngx-input-textarea i18n-title title="Path" formControlName="path" [error]="error?.path" hint="See <a target='_blank' href='https://docs.paperless-ngx.com/advanced_usage/#file-name-handling'>the documentation</a>." i18n-hint [monospace]="true"></pngx-input-textarea>
<div ngbAccordion>
<div ngbAccordionItem>
<h2 ngbAccordionHeader>
<button ngbAccordionButton i18n>Preview</button>
</h2>
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<ng-template>
<div class="card mb-2">
<div class="card-body p-2">
@if (testLoading) {
<ng-container [ngTemplateOutlet]="loadingTemplate"></ng-container>
} @else if (testResult) {
<code>{{testResult}}</code>
} @else if (testFailed) {
<div class="text-danger" i18n>Path test failed</div>
} @else {
<div class="text-muted small" i18n>No document selected</div>
}
</div>
</div>
<ng-select name="testDocument"
[items]="foundDocuments$ | async"
placeholder="Search for a document" i18n-placeholder
notFoundText="No documents found" i18n-notFoundText
bindValue="id"
bindLabel="title"
[compareWith]="compareDocuments"
[trackByFn]="trackByFn"
[minTermLength]="2"
[loading]="loading"
[typeahead]="documentsInput$"
(change)="testPath($event)">
<ng-template #loadingTemplate ng-loadingspinner-tmp>
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
</ng-template>
<ng-template ng-option-tmp let-document="item" let-index="index" let-search="searchTerm">
<div>{{document.title}} <small class="text-muted">({{document.created | customDate:'shortDate'}})</small></div>
</ng-template>
</ng-select>
</ng-template>
</div>
</div>
</div>
</div>
<hr/>
<pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select> <pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
@if (patternRequired) { @if (patternRequired) {
<pngx-input-text i18n-title title="Matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text> <pngx-input-text i18n-title title="Matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text>

View File

@ -0,0 +1,4 @@
.accordion {
--bs-accordion-btn-padding-x: 0.75rem;
--bs-accordion-btn-padding-y: 0.375rem;
}

View File

@ -1,7 +1,11 @@
import { provideHttpClientTesting } from '@angular/common/http/testing' import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap' import {
NgbAccordionButton,
NgbActiveModal,
NgbModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select' import { NgSelectModule } from '@ng-select/ng-select'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive' import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
@ -14,10 +18,16 @@ import { TextAreaComponent } from '../../input/textarea/textarea.component'
import { EditDialogMode } from '../edit-dialog.component' import { EditDialogMode } from '../edit-dialog.component'
import { StoragePathEditDialogComponent } from './storage-path-edit-dialog.component' import { StoragePathEditDialogComponent } from './storage-path-edit-dialog.component'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { of, throwError } from 'rxjs'
import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
import { By } from '@angular/platform-browser'
describe('StoragePathEditDialogComponent', () => { describe('StoragePathEditDialogComponent', () => {
let component: StoragePathEditDialogComponent let component: StoragePathEditDialogComponent
let settingsService: SettingsService let settingsService: SettingsService
let documentService: DocumentService
let fixture: ComponentFixture<StoragePathEditDialogComponent> let fixture: ComponentFixture<StoragePathEditDialogComponent>
beforeEach(async () => { beforeEach(async () => {
@ -40,6 +50,7 @@ describe('StoragePathEditDialogComponent', () => {
], ],
}).compileComponents() }).compileComponents()
documentService = TestBed.inject(DocumentService)
fixture = TestBed.createComponent(StoragePathEditDialogComponent) fixture = TestBed.createComponent(StoragePathEditDialogComponent)
settingsService = TestBed.inject(SettingsService) settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = { id: 99, username: 'user99' } settingsService.currentUser = { id: 99, username: 'user99' }
@ -59,4 +70,87 @@ describe('StoragePathEditDialogComponent', () => {
fixture.detectChanges() fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled() expect(editTitleSpy).toHaveBeenCalled()
}) })
it('should support test path', () => {
const testSpy = jest.spyOn(
component['service'] as StoragePathService,
'testPath'
)
testSpy.mockReturnValueOnce(of('test/abc123'))
component.objectForm.patchValue({ path: 'test/{{title}}' })
fixture.detectChanges()
component.testPath({ id: 1 })
expect(testSpy).toHaveBeenCalledWith('test/{{title}}', 1)
expect(component.testResult).toBe('test/abc123')
expect(component.testFailed).toBeFalsy()
// test failed
testSpy.mockReturnValueOnce(of(''))
component.testPath({ id: 1 })
expect(component.testResult).toBeNull()
expect(component.testFailed).toBeTruthy()
component.testPath(null)
expect(component.testResult).toBeNull()
})
it('should compare two documents by id', () => {
const doc1 = { id: 1 }
const doc2 = { id: 2 }
expect(component.compareDocuments(doc1, doc1)).toBeTruthy()
expect(component.compareDocuments(doc1, doc2)).toBeFalsy()
})
it('should use id as trackBy', () => {
expect(component.trackByFn({ id: 1 })).toBe(1)
})
it('should search on select text input', () => {
fixture.debugElement
.query(By.directive(NgbAccordionButton))
.triggerEventHandler('click', null)
fixture.detectChanges()
const documents = [
{ id: 1, title: 'foo' },
{ id: 2, title: 'bar' },
]
const listSpy = jest.spyOn(documentService, 'listFiltered')
listSpy.mockReturnValueOnce(
of({
count: 1,
results: documents[0],
all: [1],
} as any)
)
component.documentsInput$.next('bar')
expect(listSpy).toHaveBeenCalledWith(
1,
null,
'created',
true,
[{ rule_type: FILTER_TITLE, value: 'bar' }],
{ truncate_content: true }
)
listSpy.mockReturnValueOnce(
of({
count: 2,
results: [...documents],
all: [1, 2],
} as any)
)
component.documentsInput$.next('ba')
listSpy.mockReturnValueOnce(throwError(() => new Error()))
component.documentsInput$.next('foo')
})
it('should run path test on path change', () => {
const testSpy = jest.spyOn(component, 'testPath')
component['testDocument'] = { id: 1 } as any
component.objectForm.patchValue(
{ path: 'test/{{title}}' },
{ emitEvent: true }
)
fixture.detectChanges()
expect(testSpy).toHaveBeenCalled()
})
}) })

View File

@ -1,9 +1,25 @@
import { Component } from '@angular/core' import { Component, OnDestroy } from '@angular/core'
import { FormControl, FormGroup } from '@angular/forms' import { FormControl, FormGroup } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import {
Subject,
Observable,
concat,
of,
distinctUntilChanged,
takeUntil,
tap,
switchMap,
map,
catchError,
filter,
} from 'rxjs'
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component' import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { Document } from 'src/app/data/document'
import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model' import { DEFAULT_MATCHING_ALGORITHM } from 'src/app/data/matching-model'
import { StoragePath } from 'src/app/data/storage-path' import { StoragePath } from 'src/app/data/storage-path'
import { DocumentService } from 'src/app/services/rest/document.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
@ -13,24 +29,34 @@ import { SettingsService } from 'src/app/services/settings.service'
templateUrl: './storage-path-edit-dialog.component.html', templateUrl: './storage-path-edit-dialog.component.html',
styleUrls: ['./storage-path-edit-dialog.component.scss'], styleUrls: ['./storage-path-edit-dialog.component.scss'],
}) })
export class StoragePathEditDialogComponent extends EditDialogComponent<StoragePath> { export class StoragePathEditDialogComponent
extends EditDialogComponent<StoragePath>
implements OnDestroy
{
public documentsInput$ = new Subject<string>()
public foundDocuments$: Observable<Document[]>
private testDocument: Document
public testResult: string
public testFailed: boolean = false
public loading = false
public testLoading = false
private unsubscribeNotifier: Subject<any> = new Subject()
constructor( constructor(
service: StoragePathService, service: StoragePathService,
activeModal: NgbActiveModal, activeModal: NgbActiveModal,
userService: UserService, userService: UserService,
settingsService: SettingsService settingsService: SettingsService,
private documentsService: DocumentService
) { ) {
super(service, activeModal, userService, settingsService) super(service, activeModal, userService, settingsService)
this.initPathObservables()
} }
get pathHint() { ngOnDestroy(): void {
return ( this.unsubscribeNotifier.next(this)
$localize`e.g.` + this.unsubscribeNotifier.complete()
' <code class="text-nowrap">{{ created_year }}-{{ title }}</code> ' +
$localize`or use slashes to add directories e.g.` +
' <code class="text-nowrap">{{ created_year }}/{{ title }}</code>. ' +
$localize`See <a target="_blank" href="https://docs.paperless-ngx.com/advanced_usage/#file-name-handling">documentation</a> for full list.`
)
} }
getCreateTitle() { getCreateTitle() {
@ -51,4 +77,71 @@ export class StoragePathEditDialogComponent extends EditDialogComponent<StorageP
permissions_form: new FormControl(null), permissions_form: new FormControl(null),
}) })
} }
public testPath(document: Document) {
if (!document) {
this.testResult = null
return
}
this.testDocument = document
this.testLoading = true
;(this.service as StoragePathService)
.testPath(this.objectForm.get('path').value, document.id)
.subscribe((result) => {
if (result?.length) {
this.testResult = result
this.testFailed = false
} else {
this.testResult = null
this.testFailed = true
}
this.testLoading = false
})
}
compareDocuments(document: Document, selectedDocument: Document) {
return document.id === selectedDocument.id
}
private initPathObservables() {
this.objectForm
.get('path')
.valueChanges.pipe(
takeUntil(this.unsubscribeNotifier),
filter((path) => path && !!this.testDocument)
)
.subscribe(() => {
this.testPath(this.testDocument)
})
this.foundDocuments$ = concat(
of([]), // default items
this.documentsInput$.pipe(
tap(() => console.log('searching')),
distinctUntilChanged(),
takeUntil(this.unsubscribeNotifier),
tap(() => (this.loading = true)),
switchMap((title) =>
this.documentsService
.listFiltered(
1,
null,
'created',
true,
[{ rule_type: FILTER_TITLE, value: title }],
{ truncate_content: true }
)
.pipe(
map((result) => result.results),
catchError(() => of([])), // empty on error
tap(() => (this.loading = false))
)
)
)
)
}
trackByFn(item: Document) {
return item.id
}
} }

View File

@ -3,3 +3,7 @@
color: rgba(var(--bs-danger-rgb), var(--bs-text-opacity)) !important; color: rgba(var(--bs-danger-rgb), var(--bs-text-opacity)) !important;
} }
} }
.accordion-button {
font-size: 1rem;
}

View File

@ -20,7 +20,7 @@
(change)="onChange(value)" (change)="onChange(value)"
[disabled]="disabled" [disabled]="disabled"
[placeholder]="placeholder" [placeholder]="placeholder"
rows="6"> rows="4">
</textarea> </textarea>
@if (hint) { @if (hint) {
<small class="form-text text-muted" [innerHTML]="hint | safeHtml"></small> <small class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>

View File

@ -1,7 +1,35 @@
import { StoragePathService } from './storage-path.service' import { StoragePathService } from './storage-path.service'
import { commonAbstractNameFilterPaperlessServiceTests } from './abstract-name-filter-service.spec' import { commonAbstractNameFilterPaperlessServiceTests } from './abstract-name-filter-service.spec'
import { Subscription } from 'rxjs'
import { HttpTestingController } from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing'
import { environment } from 'src/environments/environment'
let httpTestingController: HttpTestingController
let service: StoragePathService
let subscription: Subscription
const endpoint = 'storage_paths'
commonAbstractNameFilterPaperlessServiceTests( commonAbstractNameFilterPaperlessServiceTests(
'storage_paths', 'storage_paths',
StoragePathService StoragePathService
) )
describe(`Additional service tests for StoragePathservice`, () => {
beforeEach(() => {
httpTestingController = TestBed.inject(HttpTestingController)
service = TestBed.inject(StoragePathService)
})
afterEach(() => {
subscription?.unsubscribe()
httpTestingController.verify()
})
it('should support testing path', () => {
subscription = service.testPath('path', 11).subscribe()
httpTestingController
.expectOne(`${environment.apiBaseUrl}${endpoint}/test/`)
.flush('ok')
})
})

View File

@ -2,6 +2,7 @@ import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { StoragePath } from 'src/app/data/storage-path' import { StoragePath } from 'src/app/data/storage-path'
import { AbstractNameFilterService } from './abstract-name-filter-service' import { AbstractNameFilterService } from './abstract-name-filter-service'
import { Observable } from 'rxjs'
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@ -10,4 +11,11 @@ export class StoragePathService extends AbstractNameFilterService<StoragePath> {
constructor(http: HttpClient) { constructor(http: HttpClient) {
super(http, 'storage_paths') super(http, 'storage_paths')
} }
public testPath(path: string, documentID: number): Observable<any> {
return this.http.post<string>(`${this.getResourceUrl()}test/`, {
path,
document: documentID,
})
}
} }

View File

@ -21,7 +21,8 @@ $form-file-button-hover-bg: var(--pngx-bg-alt);
// Paperless-ngx styles // Paperless-ngx styles
body { body {
font-size: 0.875rem; --pngx-body-font-size: 0.875rem;
font-size: var(--pngx-body-font-size);
height: 100vh; height: 100vh;
} }
@ -653,6 +654,10 @@ code {
filter: invert(0.5) saturate(0); filter: invert(0.5) saturate(0);
} }
.accordion-button {
font-size: var(--pngx-body-font-size);
}
.me-1px { .me-1px {
margin-right: 1px !important; margin-right: 1px !important;
} }

View File

@ -2010,3 +2010,18 @@ class TrashSerializer(SerializerWithPerms):
"Some documents in the list have not yet been deleted.", "Some documents in the list have not yet been deleted.",
) )
return documents return documents
class StoragePathTestSerializer(SerializerWithPerms):
path = serializers.CharField(
required=True,
label="Path",
write_only=True,
)
document = serializers.PrimaryKeyRelatedField(
queryset=Document.objects.all(),
required=True,
label="Document",
write_only=True,
)

View File

@ -237,7 +237,6 @@ def get_custom_fields_context(
) )
# String types need to be sanitized # String types need to be sanitized
if field_instance.field.data_type in { if field_instance.field.data_type in {
CustomField.FieldDataType.DOCUMENTLINK,
CustomField.FieldDataType.MONETARY, CustomField.FieldDataType.MONETARY,
CustomField.FieldDataType.STRING, CustomField.FieldDataType.STRING,
CustomField.FieldDataType.URL, CustomField.FieldDataType.URL,

View File

@ -306,6 +306,35 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
# only called once # only called once
bulk_update_mock.assert_called_once_with([document.pk]) bulk_update_mock.assert_called_once_with([document.pk])
def test_test_storage_path(self):
"""
GIVEN:
- API request to test a storage path
WHEN:
- API is called
THEN:
- Correct HTTP response
- Correct response data
"""
document = Document.objects.create(
mime_type="application/pdf",
storage_path=self.sp1,
title="Something",
checksum="123",
)
response = self.client.post(
f"{self.ENDPOINT}test/",
json.dumps(
{
"document": document.id,
"path": "path/{{ title }}",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, "path/Something")
class TestBulkEditObjects(APITestCase): class TestBulkEditObjects(APITestCase):
# See test_api_permissions.py for bulk tests on permissions # See test_api_permissions.py for bulk tests on permissions

View File

@ -140,6 +140,7 @@ from documents.serialisers import SavedViewSerializer
from documents.serialisers import SearchResultSerializer from documents.serialisers import SearchResultSerializer
from documents.serialisers import ShareLinkSerializer from documents.serialisers import ShareLinkSerializer
from documents.serialisers import StoragePathSerializer from documents.serialisers import StoragePathSerializer
from documents.serialisers import StoragePathTestSerializer
from documents.serialisers import TagSerializer from documents.serialisers import TagSerializer
from documents.serialisers import TagSerializerVersion1 from documents.serialisers import TagSerializerVersion1
from documents.serialisers import TasksViewSerializer from documents.serialisers import TasksViewSerializer
@ -151,6 +152,7 @@ from documents.serialisers import WorkflowTriggerSerializer
from documents.signals import document_updated from documents.signals import document_updated
from documents.tasks import consume_file from documents.tasks import consume_file
from documents.tasks import empty_trash from documents.tasks import empty_trash
from documents.templating.filepath import validate_filepath_template_and_render
from paperless import version from paperless import version
from paperless.celery import app as celery_app from paperless.celery import app as celery_app
from paperless.config import GeneralConfig from paperless.config import GeneralConfig
@ -1549,6 +1551,25 @@ class StoragePathViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
return response return response
class StoragePathTestView(GenericAPIView):
"""
Test storage path against a document
"""
permission_classes = [IsAuthenticated]
serializer_class = StoragePathTestSerializer
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
document = serializer.validated_data.get("document")
path = serializer.validated_data.get("path")
result = validate_filepath_template_and_render(path, document)
return Response(result)
class UiSettingsView(GenericAPIView): class UiSettingsView(GenericAPIView):
queryset = UiSettings.objects.all() queryset = UiSettings.objects.all()
permission_classes = (IsAuthenticated, PaperlessObjectPermissions) permission_classes = (IsAuthenticated, PaperlessObjectPermissions)

View File

@ -32,6 +32,7 @@ from documents.views import SelectionDataView
from documents.views import SharedLinkView from documents.views import SharedLinkView
from documents.views import ShareLinkViewSet from documents.views import ShareLinkViewSet
from documents.views import StatisticsView from documents.views import StatisticsView
from documents.views import StoragePathTestView
from documents.views import StoragePathViewSet from documents.views import StoragePathViewSet
from documents.views import SystemStatusView from documents.views import SystemStatusView
from documents.views import TagViewSet from documents.views import TagViewSet
@ -165,6 +166,11 @@ urlpatterns = [
TrashView.as_view(), TrashView.as_view(),
name="trash", name="trash",
), ),
re_path(
"^storage_paths/test/",
StoragePathTestView.as_view(),
name="storage_paths_test",
),
*api_router.urls, *api_router.urls,
], ],
), ),