... button for choosing which type

This commit is contained in:
shamoon
2026-09-24 14:53:11 -07:00
parent 5e30ab5e7e
commit a72c6c6a92
6 changed files with 195 additions and 57 deletions
@@ -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>
@@ -1,3 +1,7 @@
.suggestions-dropdown {
min-width: 250px;
}
.btn-link.dropdown-toggle::after {
display: none;
}
@@ -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')
})
})
@@ -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 {