mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-29 12:50:32 +00:00
... button for choosing which type
This commit is contained in:
+77
-51
@@ -1,58 +1,84 @@
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())" [aria-label]="noSuggestions ? 'No suggestions' : 'Suggest'" i18n-aria-label>
|
||||
@if (loading()) {
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
} @else if (noSuggestions) {
|
||||
<i-bs width="1.2em" height="1.2em" name="check-circle"></i-bs>
|
||||
} @else {
|
||||
<i-bs width="1.2em" height="1.2em" name="stars"></i-bs>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())" [aria-label]="noSuggestions ? 'No suggestions' : 'Suggest'" i18n-aria-label>
|
||||
@if (loading()) {
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
} @else if (noSuggestions) {
|
||||
<i-bs width="1.2em" height="1.2em" name="check-circle"></i-bs>
|
||||
} @else {
|
||||
<i-bs width="1.2em" height="1.2em" name="stars"></i-bs>
|
||||
}
|
||||
@if (noSuggestions) {
|
||||
<span class="d-none d-lg-inline ps-1" i18n>No suggestions</span>
|
||||
} @else {
|
||||
<span class="d-none d-lg-inline ps-1" i18n>Suggest</span>
|
||||
}
|
||||
@if (totalSuggestions > 0) {
|
||||
<span class="badge bg-primary ms-2">{{ totalSuggestions }}</span>
|
||||
}
|
||||
</button>
|
||||
|
||||
@if (aiEnabled()) {
|
||||
<div class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="disabled() || loading() || !suggestions()" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
|
||||
<span class="visually-hidden" i18n>Show suggestions</span>
|
||||
</button>
|
||||
|
||||
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
||||
<div class="list-group list-group-flush small pb-0">
|
||||
@if (novelSuggestions === 0 && reusableSuggestions === 0) {
|
||||
<div class="list-group-item text-muted fst-italic">
|
||||
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
||||
</div>
|
||||
}
|
||||
@if (suggestions()?.suggested_tags?.length > 0) {
|
||||
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
|
||||
@for (tag of suggestions().suggested_tags; track tag) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
|
||||
}
|
||||
}
|
||||
@if (suggestions()?.suggested_document_types?.length > 0) {
|
||||
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
|
||||
@for (type of suggestions().suggested_document_types; track type) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
|
||||
}
|
||||
}
|
||||
@if (suggestions()?.suggested_correspondents?.length > 0) {
|
||||
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
|
||||
@for (correspondent of suggestions().suggested_correspondents; track correspondent) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
|
||||
}
|
||||
}
|
||||
@if (reusableSuggestions > 0) {
|
||||
<div class="list-group-item text-muted fst-italic">
|
||||
<small class="text-muted small fst-italic" i18n>{reusableSuggestions, plural, =1 {1 existing value suggested below} other {{{reusableSuggestions}} existing values suggested below}}</small>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (noSuggestions) {
|
||||
<span class="d-none d-lg-inline ps-1" i18n>No suggestions</span>
|
||||
} @else {
|
||||
<span class="d-none d-lg-inline ps-1" i18n>Suggest</span>
|
||||
}
|
||||
@if (totalSuggestions > 0) {
|
||||
<span class="badge bg-primary ms-2">{{ totalSuggestions }}</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (aiEnabled()) {
|
||||
<div class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="disabled() || loading() || !suggestions()" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
|
||||
<span class="visually-hidden" i18n>Show suggestions</span>
|
||||
<div ngbDropdown autoClose="outside" placement="bottom-end" [popperOptions]="popperOptions">
|
||||
<button type="button" class="btn btn-sm btn-link position-relative" ngbDropdownToggle [disabled]="disabled() || loading()" i18n-title title="Suggestion options">
|
||||
<i-bs name="three-dots"></i-bs>
|
||||
@if (source() !== defaultSource()) {
|
||||
<span class="position-absolute top-0 start-100 translate-middle p-1 bg-primary border border-light rounded-circle">
|
||||
<span class="visually-hidden" i18n>Not using default</span>
|
||||
</span>
|
||||
}
|
||||
</button>
|
||||
|
||||
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
||||
<div class="list-group list-group-flush small pb-0">
|
||||
@if (novelSuggestions === 0 && reusableSuggestions === 0) {
|
||||
<div class="list-group-item text-muted fst-italic">
|
||||
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
||||
</div>
|
||||
}
|
||||
@if (suggestions()?.suggested_tags?.length > 0) {
|
||||
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
|
||||
@for (tag of suggestions().suggested_tags; track tag) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
|
||||
}
|
||||
}
|
||||
@if (suggestions()?.suggested_document_types?.length > 0) {
|
||||
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
|
||||
@for (type of suggestions().suggested_document_types; track type) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
|
||||
}
|
||||
}
|
||||
@if (suggestions()?.suggested_correspondents?.length > 0) {
|
||||
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
|
||||
@for (correspondent of suggestions().suggested_correspondents; track correspondent) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
|
||||
}
|
||||
}
|
||||
@if (reusableSuggestions > 0) {
|
||||
<div class="list-group-item text-muted fst-italic">
|
||||
<small class="text-muted small fst-italic" i18n>{reusableSuggestions, plural, =1 {1 existing value suggested below} other {{{reusableSuggestions}} existing values suggested below}}</small>
|
||||
</div>
|
||||
}
|
||||
<div ngbDropdownMenu class="shadow p-3">
|
||||
<div class="small text-muted mb-2" i18n>Suggest using:</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="suggestionSourceML" [checked]="useML" [disabled]="useML && !useAI" (change)="toggleSource(SuggestionSource.ML)">
|
||||
<label class="form-check-label" for="suggestionSourceML" i18n>ML</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="suggestionSourceAI" [checked]="useAI" [disabled]="useAI && !useML" (change)="toggleSource(SuggestionSource.AI)">
|
||||
<label class="form-check-label" for="suggestionSourceAI" i18n>AI</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+4
@@ -1,3 +1,7 @@
|
||||
.suggestions-dropdown {
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.btn-link.dropdown-toggle::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
+61
-1
@@ -1,6 +1,7 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { SuggestionSource } from 'src/app/data/ui-settings'
|
||||
import { SuggestionsDropdownComponent } from './suggestions-dropdown.component'
|
||||
|
||||
describe('SuggestionsDropdownComponent', () => {
|
||||
@@ -179,14 +180,73 @@ describe('SuggestionsDropdownComponent', () => {
|
||||
|
||||
it('should toggle dropdown when clickSuggest is called and suggestions are not null', () => {
|
||||
fixture.componentRef.setInput('aiEnabled', true)
|
||||
fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML])
|
||||
fixture.detectChanges()
|
||||
fixture.componentRef.setInput('suggestions', {
|
||||
suggested_correspondents: [],
|
||||
suggested_tags: [],
|
||||
suggested_document_types: [],
|
||||
})
|
||||
fixture.detectChanges()
|
||||
component.clickSuggest()
|
||||
expect(component.dropdown.open).toBeTruthy()
|
||||
expect(component.dropdown.isOpen()).toBeTruthy()
|
||||
expect(fixture.nativeElement.textContent).toContain('No novel suggestions')
|
||||
})
|
||||
|
||||
it('should fetch unfetched sources and show existing suggestions', () => {
|
||||
jest.spyOn(component.getSuggestions, 'emit')
|
||||
fixture.componentRef.setInput('aiEnabled', true)
|
||||
fixture.componentRef.setInput('source', SuggestionSource.Both)
|
||||
fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML])
|
||||
fixture.componentRef.setInput('suggestions', { tags: [1] })
|
||||
fixture.detectChanges()
|
||||
component.clickSuggest()
|
||||
expect(component.getSuggestions.emit).toHaveBeenCalledWith(
|
||||
SuggestionSource.Both
|
||||
)
|
||||
expect(component.dropdown.isOpen()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should only show source options when AI is enabled', () => {
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('#suggestionSourceML')
|
||||
).toBeNull()
|
||||
fixture.componentRef.setInput('aiEnabled', true)
|
||||
fixture.detectChanges()
|
||||
fixture.nativeElement
|
||||
.querySelector('button[title="Suggestion options"]')
|
||||
.click()
|
||||
fixture.detectChanges()
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('#suggestionSourceML')
|
||||
).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should emit source changes and never allow no source', () => {
|
||||
const emitSpy = jest.spyOn(component.sourceChange, 'emit')
|
||||
fixture.componentRef.setInput('source', SuggestionSource.AI)
|
||||
component.toggleSource(SuggestionSource.ML)
|
||||
expect(emitSpy).toHaveBeenCalledWith(SuggestionSource.Both)
|
||||
|
||||
fixture.componentRef.setInput('source', SuggestionSource.Both)
|
||||
component.toggleSource(SuggestionSource.AI)
|
||||
expect(emitSpy).toHaveBeenCalledWith(SuggestionSource.ML)
|
||||
|
||||
emitSpy.mockClear()
|
||||
fixture.componentRef.setInput('source', SuggestionSource.ML)
|
||||
component.toggleSource(SuggestionSource.ML)
|
||||
expect(emitSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should indicate a non-default source', () => {
|
||||
fixture.componentRef.setInput('aiEnabled', true)
|
||||
fixture.componentRef.setInput('source', SuggestionSource.AI)
|
||||
fixture.componentRef.setInput('defaultSource', SuggestionSource.AI)
|
||||
fixture.detectChanges()
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Not using default')
|
||||
|
||||
fixture.componentRef.setInput('source', SuggestionSource.Both)
|
||||
fixture.detectChanges()
|
||||
expect(fixture.nativeElement.textContent).toContain('Not using default')
|
||||
})
|
||||
})
|
||||
|
||||
+43
-3
@@ -8,6 +8,7 @@ import {
|
||||
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
|
||||
import { SuggestionSource } from 'src/app/data/ui-settings'
|
||||
import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
||||
|
||||
@Component({
|
||||
@@ -18,12 +19,16 @@ import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
||||
})
|
||||
export class SuggestionsDropdownComponent {
|
||||
public popperOptions = pngxPopperOptions
|
||||
public readonly SuggestionSource = SuggestionSource
|
||||
|
||||
@ViewChild('dropdown') dropdown: NgbDropdown
|
||||
readonly suggestions = input<DocumentSuggestions>(null)
|
||||
readonly aiEnabled = input(false)
|
||||
readonly loading = input(false)
|
||||
readonly disabled = input(false)
|
||||
readonly source = input<SuggestionSource>(SuggestionSource.ML)
|
||||
readonly defaultSource = input<SuggestionSource>(SuggestionSource.ML)
|
||||
readonly fetchedSources = input<SuggestionSource[]>([])
|
||||
|
||||
readonly appliedTags = input<number[]>([])
|
||||
readonly appliedCorrespondent = input<number>(null)
|
||||
@@ -31,8 +36,10 @@ export class SuggestionsDropdownComponent {
|
||||
readonly appliedStoragePath = input<number>(null)
|
||||
|
||||
@Output()
|
||||
getSuggestions: EventEmitter<SuggestionsDropdownComponent> =
|
||||
new EventEmitter()
|
||||
getSuggestions: EventEmitter<SuggestionSource> = new EventEmitter()
|
||||
|
||||
@Output()
|
||||
sourceChange: EventEmitter<SuggestionSource> = new EventEmitter()
|
||||
|
||||
@Output()
|
||||
addTag: EventEmitter<string> = new EventEmitter()
|
||||
@@ -53,12 +60,45 @@ export class SuggestionsDropdownComponent {
|
||||
}
|
||||
|
||||
if (!this.suggestions()) {
|
||||
this.getSuggestions.emit(this)
|
||||
this.getSuggestions.emit(this.source())
|
||||
} else if (this.hasUnfetchedSources) {
|
||||
// sources changed, fetch the rest and show what we have meanwhile
|
||||
this.getSuggestions.emit(this.source())
|
||||
this.dropdown?.open()
|
||||
} else {
|
||||
this.dropdown?.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
get useML(): boolean {
|
||||
return this.source() !== SuggestionSource.AI
|
||||
}
|
||||
|
||||
get useAI(): boolean {
|
||||
return this.source() !== SuggestionSource.ML
|
||||
}
|
||||
|
||||
get hasUnfetchedSources(): boolean {
|
||||
const fetched = this.fetchedSources()
|
||||
return (
|
||||
(this.useML && !fetched.includes(SuggestionSource.ML)) ||
|
||||
(this.useAI && !fetched.includes(SuggestionSource.AI))
|
||||
)
|
||||
}
|
||||
|
||||
public toggleSource(source: SuggestionSource.ML | SuggestionSource.AI) {
|
||||
const ml = source === SuggestionSource.ML ? !this.useML : this.useML
|
||||
const ai = source === SuggestionSource.AI ? !this.useAI : this.useAI
|
||||
if (!ml && !ai) return
|
||||
this.sourceChange.emit(
|
||||
ml && ai
|
||||
? SuggestionSource.Both
|
||||
: ml
|
||||
? SuggestionSource.ML
|
||||
: SuggestionSource.AI
|
||||
)
|
||||
}
|
||||
|
||||
get novelSuggestions(): number {
|
||||
return (
|
||||
(this.suggestions()?.suggested_correspondents?.length ?? 0) +
|
||||
|
||||
@@ -134,11 +134,15 @@
|
||||
[loading]="suggestionsLoading()"
|
||||
[suggestions]="suggestions()"
|
||||
[aiEnabled]="aiEnabled"
|
||||
[source]="suggestionSource"
|
||||
[defaultSource]="defaultSuggestionSource"
|
||||
[fetchedSources]="fetchedSuggestionSources()"
|
||||
[appliedTags]="documentForm.value.tags"
|
||||
[appliedCorrespondent]="documentForm.value.correspondent"
|
||||
[appliedDocumentType]="documentForm.value.document_type"
|
||||
[appliedStoragePath]="documentForm.value.storage_path"
|
||||
(getSuggestions)="getSuggestions()"
|
||||
(getSuggestions)="getSuggestions($event)"
|
||||
(sourceChange)="suggestionSourceOverride.set($event)"
|
||||
(addTag)="createTag($event)"
|
||||
(addDocumentType)="createDocumentType($event)"
|
||||
(addCorrespondent)="createCorrespondent($event)">
|
||||
|
||||
@@ -375,9 +375,13 @@ export class DocumentDetailComponent
|
||||
return this.autoSuggestSetting()
|
||||
}
|
||||
|
||||
get defaultSuggestionSource(): SuggestionSource {
|
||||
return this.aiEnabled ? this.suggestionSourceSetting() : SuggestionSource.ML
|
||||
}
|
||||
|
||||
get suggestionSource(): SuggestionSource {
|
||||
if (!this.aiEnabled) return SuggestionSource.ML
|
||||
return this.suggestionSourceOverride() ?? this.suggestionSourceSetting()
|
||||
return this.suggestionSourceOverride() ?? this.defaultSuggestionSource
|
||||
}
|
||||
|
||||
get archiveContentRenderType(): ContentRenderType {
|
||||
|
||||
Reference in New Issue
Block a user