Compare commits

...
13 changed files with 349 additions and 123 deletions
+28 -28
View File
@@ -343,7 +343,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
<context context-type="linenumber">58</context>
<context context-type="linenumber">59</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/management-list/management-list.component.html</context>
@@ -2655,11 +2655,11 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
<context context-type="linenumber">31,32</context>
<context context-type="linenumber">32,33</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
<context context-type="linenumber">49</context>
<context context-type="linenumber">50</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.html</context>
@@ -2962,11 +2962,11 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
<context context-type="linenumber">30,31</context>
<context context-type="linenumber">31,32</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
<context context-type="linenumber">46</context>
<context context-type="linenumber">47</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/management-list/management-list.component.html</context>
@@ -3098,15 +3098,15 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">910</context>
<context context-type="linenumber">915</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">946</context>
<context context-type="linenumber">951</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">969</context>
<context context-type="linenumber">974</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.ts</context>
@@ -3723,7 +3723,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">858</context>
<context context-type="linenumber">863</context>
</context-group>
</trans-unit>
<trans-unit id="994016933065248559" datatype="html">
@@ -5743,7 +5743,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">862</context>
<context context-type="linenumber">867</context>
</context-group>
</trans-unit>
<trans-unit id="4522609911791833187" datatype="html">
@@ -5965,7 +5965,7 @@
<source>Not assigned</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts</context>
<context context-type="linenumber">100</context>
<context context-type="linenumber">104</context>
</context-group>
<note priority="1" from="description">Filter drop down element to filter for documents with no correspondent/type/tag assigned</note>
</trans-unit>
@@ -5973,7 +5973,7 @@
<source>Open <x id="PH" equiv-text="this.title"/> filter</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts</context>
<context context-type="linenumber">831</context>
<context context-type="linenumber">835</context>
</context-group>
</trans-unit>
<trans-unit id="7005745151564974365" datatype="html">
@@ -8197,7 +8197,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">860</context>
<context context-type="linenumber">865</context>
</context-group>
</trans-unit>
<trans-unit id="7295637485862454066" datatype="html">
@@ -8215,7 +8215,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">906</context>
<context context-type="linenumber">911</context>
</context-group>
</trans-unit>
<trans-unit id="2951161989614003846" datatype="html">
@@ -8762,84 +8762,84 @@
<source>Move <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s) to the trash?</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">859</context>
<context context-type="linenumber">864</context>
</context-group>
</trans-unit>
<trans-unit id="8585195717323764335" datatype="html">
<source>This operation will permanently recreate the archive files for <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">907</context>
<context context-type="linenumber">912</context>
</context-group>
</trans-unit>
<trans-unit id="7366623494074776040" datatype="html">
<source>The archive files will be re-generated with the current settings.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">908</context>
<context context-type="linenumber">913</context>
</context-group>
</trans-unit>
<trans-unit id="6555329262222566158" datatype="html">
<source>Rotate confirm</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">943</context>
<context context-type="linenumber">948</context>
</context-group>
</trans-unit>
<trans-unit id="5203024009814367559" datatype="html">
<source>This operation will add rotated versions of the <x id="PH" equiv-text="this.getSelectionSize()"/> document(s).</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">944</context>
<context context-type="linenumber">949</context>
</context-group>
</trans-unit>
<trans-unit id="7910756456450124185" datatype="html">
<source>Merge confirm</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">967</context>
<context context-type="linenumber">972</context>
</context-group>
</trans-unit>
<trans-unit id="7643543647233874431" datatype="html">
<source>This operation will merge <x id="PH" equiv-text="this.getSelectionSize()"/> selected documents into a new document.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">968</context>
<context context-type="linenumber">973</context>
</context-group>
</trans-unit>
<trans-unit id="7869008840945899895" datatype="html">
<source>Merged document will be queued for consumption.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">991</context>
<context context-type="linenumber">996</context>
</context-group>
</trans-unit>
<trans-unit id="476913782630693351" datatype="html">
<source>Custom fields updated.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">1016</context>
<context context-type="linenumber">1021</context>
</context-group>
</trans-unit>
<trans-unit id="3873496751167944011" datatype="html">
<source>Error updating custom fields.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">1025</context>
<context context-type="linenumber">1030</context>
</context-group>
</trans-unit>
<trans-unit id="6144801143088984138" datatype="html">
<source>Share link bundle creation requested.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">1073</context>
<context context-type="linenumber">1078</context>
</context-group>
</trans-unit>
<trans-unit id="46019676931295023" datatype="html">
<source>Share link bundle creation is not available yet.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">1080</context>
<context context-type="linenumber">1085</context>
</context-group>
</trans-unit>
<trans-unit id="6307402210351946694" datatype="html">
@@ -9677,7 +9677,7 @@
<source>Filter Documents (<x id="INTERPOLATION" equiv-text="{{ field.document_count }}"/>)</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
<context context-type="linenumber">38,39</context>
<context context-type="linenumber">39,40</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/management-list/management-list.component.html</context>
@@ -9700,7 +9700,7 @@
<source>No fields defined.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
<context context-type="linenumber">68,70</context>
<context context-type="linenumber">70,72</context>
</context-group>
</trans-unit>
<trans-unit id="3032792139967609806" datatype="html">
@@ -2,3 +2,8 @@
.d-block.d-sm-none .dropdown-toggle::after {
display: none;
}
tbody tr:last-child td,
table:not(:has(tbody tr)) thead th {
border-bottom: none;
}
@@ -19,6 +19,11 @@
height: 0.8em;
}
.sidebar-heading,
.text-uppercase {
letter-spacing: 0.06em;
}
.view-name {
max-width: calc(100% - 50px)
}
@@ -81,7 +86,7 @@ main {
.sidebar li.nav-item span,
.sidebar .sidebar-heading span {
transition: all .1s ease;
transition: opacity .1s ease;
}
@media(min-width: 768px) {
@@ -149,7 +154,7 @@ main {
z-index: 996;
--bs-btn-padding-x: 0.35rem;
--bs-btn-padding-y: 0.125rem;
transition: all .2s ease;
transition: left .2s ease;
}
.sidebar.slim .sidebar-slim-toggler {
@@ -181,6 +186,8 @@ main {
.sidebar .nav-link {
font-weight: 500;
white-space: nowrap;
border-left: 2px solid transparent;
transition: color .15s ease-in-out;
&:hover, &.active, &:focus {
color: var(--bs-primary);
@@ -193,6 +200,7 @@ main {
&.active {
font-weight: bold;
border-left-color: var(--bs-primary);
}
i-bs {
@@ -201,6 +209,17 @@ main {
}
}
// sub-page gets marker only
.nav-item:has(.attributes-submenu.show .nav-link.active) > .attributes-row > .nav-link.active {
border-left-color: transparent;
}
// bring sub-menu markers back out to L edge
.attributes-submenu .nav-link {
margin-left: -0.5rem;
padding-left: calc(var(--bs-nav-link-padding-x) + 0.5rem);
}
.attributes-row .attributes-expand-btn {
opacity: 0.2;
transition: opacity 0.15s ease-in-out;
@@ -1,4 +1,4 @@
<div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown" (keydown)="listKeyDown($event)" [popperOptions]="popperOptions">
<div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown" (keydown)="listKeyDown($event)" [popperOptions]="popperOptions" [autoClose]="!creating()">
<button class="btn btn-sm" id="dropdown_{{name}}" ngbDropdownToggle [ngClass]="!editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
<i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
@if (!editing && selectionModel.totalCount > 0) {
@@ -3,6 +3,7 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { NEVER, Subject } from 'rxjs'
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
import {
DEFAULT_MATCHING_ALGORITHM,
@@ -48,6 +49,7 @@ const negativeNullItem = {
let selectionModel: FilterableDropdownSelectionModel
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
const createModalRef = () => ({ closed: NEVER, dismissed: NEVER }) as any
describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => {
let component: FilterableDropdownComponent
@@ -868,7 +870,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
})
it('should set support create, keep open model and call createRef method', async () => {
it('should keep the dropdown open while the create modal is active', async () => {
component.selectionModel.items = items
component.icon = 'tag-fill'
component.selectionModel = selectionModel
@@ -882,20 +884,44 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
fixture.detectChanges()
component.filterText = 'Test Filter Text'
component.createRef = jest.fn()
const modalClosed = new Subject<void>()
component.createRef = jest.fn(
() =>
({
closed: modalClosed,
dismissed: NEVER,
}) as any
)
component.createClicked()
expect(component.creating).toBeTruthy()
expect(component.creating()).toBeTruthy()
expect(component.createRef).toHaveBeenCalledWith('Test Filter Text')
fixture.detectChanges()
expect(component.dropdown.autoClose).toBeFalsy()
document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
document.body.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
await wait(10)
expect(component.dropdown.isOpen()).toBeTruthy()
// Also cover a close that was already scheduled before autoClose changed.
const openSpy = jest.spyOn(component.dropdown, 'open')
component.dropdownOpenChange(false)
expect(openSpy).toHaveBeenCalled() // should keep open
component.dropdownOpenChange(false)
expect(openSpy).toHaveBeenCalledTimes(2) // modal interactions keep it open
modalClosed.next()
fixture.detectChanges()
expect(component.creating()).toBeFalsy()
expect(component.dropdown.autoClose).toBeTruthy()
expect(component.dropdown.isOpen()).toBeTruthy()
})
it('should call create on enter inside filter field if 0 items remain while editing', async () => {
component.selectionModel.items = items
component.icon = 'tag-fill'
component.editing = true
component.createRef = jest.fn()
component.createRef = jest.fn(createModalRef)
const createSpy = jest.spyOn(component, 'createClicked')
expect(component.selectionModel.getSelectedItems()).toEqual([])
fixture.nativeElement
@@ -915,7 +941,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
component.selectionModel.items = []
component.icon = 'tag-fill'
component.editing = true
component.createRef = jest.fn()
component.createRef = jest.fn(createModalRef)
fixture.detectChanges()
expect(fixture.nativeElement.textContent).not.toContain('Create')
@@ -15,9 +15,13 @@ import {
signal,
} from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import {
NgbDropdown,
NgbDropdownModule,
NgbModalRef,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subject, filter, takeUntil } from 'rxjs'
import { Subject, filter, first, merge, takeUntil } from 'rxjs'
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
import { MatchingModel } from 'src/app/data/matching-model'
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
@@ -759,7 +763,7 @@ export class FilterableDropdownComponent
disabled = false
@Input()
createRef: (name) => void
createRef: (name: string) => NgbModalRef
@Input()
set documentCounts(counts: SelectionDataItem[]) {
@@ -777,7 +781,7 @@ export class FilterableDropdownComponent
@Input()
showExtraButtonIfEmpty: boolean = false
creating: boolean = false
readonly creating = signal(false)
@Output()
apply = new EventEmitter<ChangedItems>()
@@ -854,12 +858,18 @@ export class FilterableDropdownComponent
}
createClicked() {
this.creating = true
this.createRef(this.filterText)
this.creating.set(true)
const modal = this.createRef(this.filterText)
merge(modal.closed, modal.dismissed)
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe(() => this.creating.set(false))
}
dropdownOpenChange(open: boolean): void {
if (open) {
// Dont let a create modal close this
if (this.creating()) return
setTimeout(() => {
this.listFilterTextInput?.nativeElement.focus()
this.buttonsViewport?.checkViewportSize()
@@ -872,9 +882,8 @@ export class FilterableDropdownComponent
this.editing && !this.selectionModel.manyToOne
this.opened.next(this)
} else {
if (this.creating) {
if (this.creating()) {
this.dropdown?.open()
this.creating = false
} else {
this.filterText = ''
if (this.applyOnClose && this.selectionModel.isDirty()) {
@@ -762,6 +762,7 @@ export class BulkEditorComponent
this.tagSelectionModel.items = flattenTags(tags.results)
this.tagSelectionModel.toggle(newTag.id)
})
return modal
}
createCorrespondent(name: string) {
@@ -785,6 +786,7 @@ export class BulkEditorComponent
this.correspondentSelectionModel.items = correspondents.results
this.correspondentSelectionModel.toggle(newCorrespondent.id)
})
return modal
}
createDocumentType(name: string) {
@@ -806,6 +808,7 @@ export class BulkEditorComponent
this.documentTypeSelectionModel.items = documentTypes.results
this.documentTypeSelectionModel.toggle(newDocumentType.id)
})
return modal
}
createStoragePath(name: string) {
@@ -827,6 +830,7 @@ export class BulkEditorComponent
this.storagePathsSelectionModel.items = storagePaths.results
this.storagePathsSelectionModel.toggle(newStoragePath.id)
})
return modal
}
createCustomField(name: string) {
@@ -848,6 +852,7 @@ export class BulkEditorComponent
this.customFieldsSelectionModel.items = customFields.results
this.customFieldsSelectionModel.toggle(newCustomField.id)
})
return modal
}
applyDelete() {
@@ -21,45 +21,47 @@
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editField(field)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.CustomField)">{{field.name}}</button></div>
<div class="col d-flex align-items-center">{{getDataType(field)}}</div>
<div class="col">
<div class="btn-group d-block d-sm-none">
<div ngbDropdown container="body" class="d-inline-block">
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
<i-bs name="three-dots-vertical"></i-bs>
</button>
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
<button (click)="editField(field)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" ngbDropdownItem i18n>Edit</button>
<button class="text-danger" (click)="deleteField(field)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" ngbDropdownItem i18n>Delete</button>
@if (field.document_count > 0) {
<a
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
ngbDropdownItem
[routerLink]="getDocumentFilterUrl(field)"
i18n
>Filter Documents ({{ field.document_count }})</a
>
}
<div class="btn-toolbar gap-2">
<div class="btn-group d-block d-sm-none">
<div ngbDropdown container="body" class="d-inline-block">
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
<i-bs name="three-dots-vertical"></i-bs>
</button>
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
<button (click)="editField(field)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" ngbDropdownItem i18n>Edit</button>
<button class="text-danger" (click)="deleteField(field)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" ngbDropdownItem i18n>Delete</button>
@if (field.document_count > 0) {
<a
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
ngbDropdownItem
[routerLink]="getDocumentFilterUrl(field)"
i18n
>Filter Documents ({{ field.document_count }})</a
>
}
</div>
</div>
</div>
</div>
<div class="btn-group d-none d-sm-inline-block">
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editField(field)">
<i-bs width="1em" height="1em" name="pencil" class="me-1"></i-bs><ng-container i18n>Edit</ng-container>
</button>
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteField(field)">
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
</button>
</div>
@if (field.document_count > 0) {
<div class="btn-group d-none d-sm-inline-block ms-2">
<a
class="btn btn-sm btn-outline-secondary"
[routerLink]="getDocumentFilterUrl(field)"
>
<i-bs width="1em" height="1em" name="filter" class="me-1"></i-bs><ng-container i18n>Documents</ng-container
><span class="badge bg-light text-secondary ms-2">{{ field.document_count }}</span>
</a>
<div class="btn-group d-none d-sm-inline-block">
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editField(field)">
<i-bs width="1em" height="1em" name="pencil" class="me-1"></i-bs><ng-container i18n>Edit</ng-container>
</button>
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteField(field)">
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
</button>
</div>
}
@if (field.document_count > 0) {
<div class="btn-group d-none d-sm-inline-block">
<a
class="btn btn-sm btn-outline-secondary"
[routerLink]="getDocumentFilterUrl(field)"
>
<i-bs width="1em" height="1em" name="filter" class="me-1"></i-bs><ng-container i18n>Documents</ng-container
><span class="badge bg-light text-secondary ms-2">{{ field.document_count }}</span>
</a>
</div>
}
</div>
</div>
</div>
</li>
@@ -69,7 +69,7 @@
}
</ul>
<div class="my-3 shadow-sm">
<div class="my-3">
<ng-container
[ngComponentOutlet]="activeSection?.component"
#activeOutlet="ngComponentOutlet"
+65 -26
View File
@@ -127,6 +127,27 @@ table .btn-link {
background-color: var(--bs-body-bg);
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3, h4, h5, h6,
.h1, .h2, .h3, .h4, .h5, .h6 {
font-weight: 600;
}
pngx-page-header h3 {
font-size: 1.5rem;
}
table,
.badge,
.card-info,
.pagination {
font-variant-numeric: tabular-nums;
}
.bg-primary {
background-color: var(--bs-primary) !important;
color: var(--pngx-primary-text-contrast);
@@ -213,7 +234,8 @@ table .btn-link {
}
.form-switch .form-check-input:focus {
background-image: escape-svg(url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'><circle r='3' fill='#bbb'/></svg>"));
// neutral knob in place of bootstrap's blue, which clashes with the theme colour
--bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23bbb'/%3e%3c/svg%3e");
}
.nav-item a:focus-visible {
@@ -269,33 +291,44 @@ a.btn-link:focus-visible,
box-shadow: 0 0 0 3px rgba(255, 255, 255, .25);
}
.asc {
background-color: #f8f9fa!important;
}
// Sortable table headers
th[pngxSortable] {
cursor: pointer;
user-select: none;
white-space: nowrap;
.asc:after {
content: '';
transform: rotate(180deg);
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAmxJREFUeAHtmksrRVEUx72fH8CIGQNJkpGUUmakDEiZSJRIZsRQmCkTJRmZmJgQE0kpX0D5DJKJgff7v+ru2u3O3vvc67TOvsdatdrnnP1Y///v7HvvubdbUiIhBISAEBACQkAICAEhIAQ4CXSh2DnyDfmCPEG2Iv9F9MPlM/LHyAecdyMzHYNwR3fdNK/OH9HXl1UCozD24TCvILxizEDWIEzA0FcM8woCgRrJCoS5PIwrANQSMAJX1LEI9bqpQo4JYNFFKRSvIgsxHDVnqZgIkPnNBM0rIGtYk9YOOsqgbgepRCfdbmFtqhFkVEDVPjJp0+Z6e6hRHhqBKgg6ZDCvYBygVmUoEGoh5JTRvIJwhJo1aUOoh4CLPMyvxxi7EWOMgnCGsXXI1GIXlZUYX7ucU+kbR8NW8lh3O7cue0Pk32MKndfUxQFAwxdirk3fHappAnc0oqDPzDfGTBrCfHP04dM4oTV8cxr0SVzH9FF07xD3ib6xCDE+M+aUcVygtWzzbtGX2rPBrEUYfecfQkaFzYi6HjVnGBdtL7epqAlc1+jRdAap74RrnPc4BCijttY2tRcdN0g17w7HqZrXhdJTYAuS3hd8z+vKgK3V1zWPae0mZDMykadBn1hTQBLnZNwVrJpSe/NwEeDsEwCctEOsJTsgxLvCqUl2ACftEGvJDgjxrnBqkh3ASTvEWrIDQrwrnJpkB3DSDrGW7IAQ7wqnJtkBnLRztejXXVu4+mxz/nQ9jR1w5VB86ejLTFcnnDwhzV+F6T+CHZlx6THSjn76eyyBIOPHyDakhBAQAkJACAgBISAEhIAQYCLwC8JxpAmsEGt6AAAAAElFTkSuQmCC") no-repeat;
height: 1rem;
width: 1rem;
display: block;
background-size: 1rem;
float: right;
&::after {
content: '';
display: inline-block;
vertical-align: -0.15em;
width: 0.8rem;
height: 0.8rem;
margin-left: 0.25rem;
// chevron, matched to the bootstrap-icons set used elsewhere in the app
mask: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e") no-repeat center / 0.8rem;
background-color: currentColor;
opacity: 0;
transition: opacity 0.15s ease-in-out;
}
// hint that an unsorted column can be sorted
&:hover::after {
opacity: 0.35;
}
}
.asc,
.des {
background-color: #f8f9fa!important;
color: var(--bs-primary);
--bs-table-color-state: var(--bs-primary); // bootstrap sets cell color at higher specificity
&::after {
opacity: 1 !important;
}
}
.des:after {
content: '';
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAmxJREFUeAHtmksrRVEUx72fH8CIGQNJkpGUUmakDEiZSJRIZsRQmCkTJRmZmJgQE0kpX0D5DJKJgff7v+ru2u3O3vvc67TOvsdatdrnnP1Y///v7HvvubdbUiIhBISAEBACQkAICAEhIAQ4CXSh2DnyDfmCPEG2Iv9F9MPlM/LHyAecdyMzHYNwR3fdNK/OH9HXl1UCozD24TCvILxizEDWIEzA0FcM8woCgRrJCoS5PIwrANQSMAJX1LEI9bqpQo4JYNFFKRSvIgsxHDVnqZgIkPnNBM0rIGtYk9YOOsqgbgepRCfdbmFtqhFkVEDVPjJp0+Z6e6hRHhqBKgg6ZDCvYBygVmUoEGoh5JTRvIJwhJo1aUOoh4CLPMyvxxi7EWOMgnCGsXXI1GIXlZUYX7ucU+kbR8NW8lh3O7cue0Pk32MKndfUxQFAwxdirk3fHappAnc0oqDPzDfGTBrCfHP04dM4oTV8cxr0SVzH9FF07xD3ib6xCDE+M+aUcVygtWzzbtGX2rPBrEUYfecfQkaFzYi6HjVnGBdtL7epqAlc1+jRdAap74RrnPc4BCijttY2tRcdN0g17w7HqZrXhdJTYAuS3hd8z+vKgK3V1zWPae0mZDMykadBn1hTQBLnZNwVrJpSe/NwEeDsEwCctEOsJTsgxLvCqUl2ACftEGvJDgjxrnBqkh3ASTvEWrIDQrwrnJpkB3DSDrGW7IAQ7wqnJtkBnLRztejXXVu4+mxz/nQ9jR1w5VB86ejLTFcnnDwhzV+F6T+CHZlx6THSjn76eyyBIOPHyDakhBAQAkJACAgBISAEhIAQYCLwC8JxpAmsEGt6AAAAAElFTkSuQmCC") no-repeat;
height: 1rem;
width: 1rem;
display: block;
background-size: 1rem;
float: right;
.asc::after {
transform: rotate(180deg);
}
.paperless-input-select {
@@ -564,10 +597,6 @@ ul.pagination {
table.table {
--bs-table-color: var(--bs-body-color);
--bs-table-bg: var(--bs-light-rgb);
.des,.asc {
background-color: var(--bs-body-bg) !important;
}
}
.close {
@@ -776,6 +805,16 @@ canvas.hiddenCanvasElement {
.document-card {
overflow: hidden;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
&:hover:not(.card-selected) {
border-color: var(--pngx-card-hover-border);
box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.1);
}
.document-card-check {
border-color: var(--pngx-card-hover-border) !important;
}
.card-footer i-bs svg {
vertical-align: middle;
+9 -12
View File
@@ -23,6 +23,7 @@
--pngx-bg-darker: var(--bs-gray-100);
--pngx-bg-alt2: var(--bs-gray-200); // #e9ecef
--pngx-bg-disabled: #f7f7f7;
--pngx-card-hover-border: var(--bs-tertiary-color);
--pngx-focus-alpha: 0.3;
--pngx-toast-max-width: 340px;
--bs-info: var(--pngx-bg-alt2);
@@ -36,20 +37,22 @@
$text-color-light-bg: #212529;
$text-color-dark-bg: #abb2bf;
$text-color-dark-bg-accent: color.adjust($text-color-dark-bg, $lightness: 10%);
// Taken from bootstrap
$form-check-input-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'><path fill='none' stroke='#{$text-color-light-bg}' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/></svg>");
$form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'><circle r='2' fill='#{$text-color-light-bg}'/></svg>");
// url-encoded $text-color-light-bg
$text-color-light-bg-esc: "%23212529";
// Taken from bootstrap, pre-encoded
$form-check-input-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='#{$text-color-light-bg-esc}' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/%3e%3c/svg%3e");
$form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='#{$text-color-light-bg-esc}'/%3e%3c/svg%3e");
.primary-light {
--pngx-primary-text-contrast: #{$text-color-light-bg} !important;
.form-check:not(.form-switch) {
.form-check-input:checked[type=checkbox] {
background-image: escape-svg($form-check-input-checked-bg-image-dark);
--bs-form-check-bg-image: #{$form-check-input-checked-bg-image-dark};
}
.form-check-input:checked[type=radio] {
background-image: escape-svg($form-check-radio-checked-bg-image-dark);
--bs-form-check-bg-image: #{$form-check-radio-checked-bg-image-dark};
}
}
@@ -90,6 +93,7 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
--pngx-bg-alt2: #232323;
--pngx-bg-darker: #101216;
--pngx-bg-disabled: var(--pngx-bg-alt);
--pngx-card-hover-border: var(--bs-border-color);
--pngx-focus-alpha: 0.6;
--pngx-primary-faded: var(--pngx-primary-darken-15);
--pngx-primary-text-contrast: var(--bs-body-color);
@@ -249,13 +253,6 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
}
table {
.des,
.asc {
&::after {
filter: invert(0.8); /* arrow is a black inline png bkgd image (!) so use filter */
}
}
&.table-hover > tbody > tr:hover > * {
background-color: var(--bs-light);
color: var(--pngx-body-color-accent);
+44 -4
View File
@@ -1,7 +1,34 @@
from typing import Any
from typing import Final
from typing import TypedDict
from pydantic import BaseModel
from pydantic import Field
from pydantic import ValidationInfo
from pydantic import field_validator
from pydantic.fields import FieldInfo
# taxonomy.py MAX_TAG_CANDIDATES = 10, prompt is "up to 3 relevant dates"
MAX_EXISTING_IDS: Final = 10
MAX_NEW_NAMES: Final = 8
MAX_DATES: Final = 3
# Matches documents.models.Document.title's CharField(max_length=128).
MAX_TITLE_LENGTH: Final = 128
def _truncate_to_field_limit(value: Any, field: FieldInfo) -> Any:
"""
Clip down to its it's declared maximum. Run as a `mode="before"` validator.
"""
limit = next(
(m.max_length for m in field.metadata if hasattr(m, "max_length")),
None,
)
return (
value
if (limit is None or not isinstance(value, (list, str)))
else value[:limit]
)
class TaxonomyChoice(BaseModel):
@@ -14,19 +41,32 @@ class TaxonomyChoice(BaseModel):
TaxonomyChoiceDict below.
"""
existing_ids: list[int] = Field(default_factory=list)
new_names: list[str] = Field(default_factory=list)
existing_ids: list[int] = Field(
default_factory=list,
max_length=MAX_EXISTING_IDS,
)
new_names: list[str] = Field(default_factory=list, max_length=MAX_NEW_NAMES)
@field_validator("existing_ids", "new_names", mode="before")
@classmethod
def _truncate(cls, value: Any, info: ValidationInfo) -> Any:
return _truncate_to_field_limit(value, cls.model_fields[info.field_name])
class DocumentClassifierSchema(BaseModel):
"""Schema for document classification suggestions."""
title: str
title: str = Field(max_length=MAX_TITLE_LENGTH)
tags: TaxonomyChoice = Field(default_factory=TaxonomyChoice)
correspondents: TaxonomyChoice = Field(default_factory=TaxonomyChoice)
document_types: TaxonomyChoice = Field(default_factory=TaxonomyChoice)
storage_paths: TaxonomyChoice = Field(default_factory=TaxonomyChoice)
dates: list[str] = Field(default_factory=list)
dates: list[str] = Field(default_factory=list, max_length=MAX_DATES)
@field_validator("title", "dates", mode="before")
@classmethod
def _truncate(cls, value: Any, info: ValidationInfo) -> Any:
return _truncate_to_field_limit(value, cls.model_fields[info.field_name])
class TaxonomyChoiceDict(TypedDict):
+84
View File
@@ -1,3 +1,7 @@
from paperless_ai.base_model import MAX_DATES
from paperless_ai.base_model import MAX_EXISTING_IDS
from paperless_ai.base_model import MAX_NEW_NAMES
from paperless_ai.base_model import MAX_TITLE_LENGTH
from paperless_ai.base_model import ClassificationSuggestions
from paperless_ai.base_model import DocumentClassifierSchema
from paperless_ai.base_model import TaxonomyChoice
@@ -63,6 +67,86 @@ def test_document_classifier_schema_json_schema_is_self_contained():
assert set(taxonomy_choice_properties.keys()) == {"existing_ids", "new_names"}
def test_every_sequence_in_the_emitted_schema_is_bounded():
"""
GIVEN:
- The DocumentClassifierSchema pydantic model
WHEN:
- Its JSON schema is generated via model_json_schema()
THEN:
- Every array property in the schema, including those on the
referenced TaxonomyChoice definition, carries a maxItems
"""
schema = DocumentClassifierSchema.model_json_schema()
unbounded = [
f"{owner}.{name}"
for owner, definition in [
("DocumentClassifierSchema", schema),
*schema.get("$defs", {}).items(),
]
for name, prop in definition.get("properties", {}).items()
if prop.get("type") == "array" and "maxItems" not in prop
]
assert unbounded == []
def test_dates_bound_matches_what_the_prompt_asks_for():
"""
GIVEN:
- The DocumentClassifierSchema pydantic model
WHEN:
- The emitted maxItems for dates is inspected
THEN:
- It equals the 3 that build_prompt_without_rag asks the model for
"""
dates_schema = DocumentClassifierSchema.model_json_schema()["properties"]["dates"]
assert dates_schema["maxItems"] == MAX_DATES == 3
def test_over_long_response_is_truncated_rather_than_rejected():
"""
GIVEN:
- An LLM response overshooting every declared bound
WHEN:
- DocumentClassifierSchema is constructed from it
THEN:
- Each field is clipped to its maximum, with no ValidationError
"""
parsed = DocumentClassifierSchema(
title="T" * (MAX_TITLE_LENGTH + 50),
tags=TaxonomyChoice(
existing_ids=list(range(MAX_EXISTING_IDS + 20)),
new_names=["n"] * (MAX_NEW_NAMES + 20),
),
dates=[f"2016-{month:02d}-01" for month in range(1, 13)],
)
assert len(parsed.title) == MAX_TITLE_LENGTH
assert len(parsed.dates) == MAX_DATES
assert len(parsed.tags.existing_ids) == MAX_EXISTING_IDS
assert len(parsed.tags.new_names) == MAX_NEW_NAMES
def test_truncation_keeps_the_earliest_entries():
"""
GIVEN:
- An over-long dates list from an LLM response
WHEN:
- DocumentClassifierSchema is constructed from it
THEN:
- The kept entries are the first ones the model emitted
"""
parsed = DocumentClassifierSchema(
title="T",
dates=["2016-10-01", "2016-09-01", "2016-08-01", "2016-07-01", "2016-06-01"],
)
assert parsed.dates == ["2016-10-01", "2016-09-01", "2016-08-01"]
def test_model_dump_matches_typed_dict_keys():
"""
GIVEN: