mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-02-14 00:09:35 -06:00
Compare commits
7 Commits
dependabot
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d313a33b7e | ||
|
|
56d1b5677a | ||
|
|
6622349b5f | ||
|
|
b050fab77f | ||
|
|
a467df0755 | ||
|
|
728c5ea07b | ||
|
|
4f2e16fdc7 |
@@ -52,11 +52,11 @@ test('dashboard saved view document links', async ({ page }) => {
|
||||
test('test slim sidebar', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await page.locator('#sidebarMenu').getByRole('button').click()
|
||||
await page.locator('.sidebar-slim-toggler').click()
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Dashboard' }).getByText('Dashboard')
|
||||
).toBeHidden()
|
||||
await page.locator('#sidebarMenu').getByRole('button').click()
|
||||
await page.locator('.sidebar-slim-toggler').click()
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Dashboard' }).getByText('Dashboard')
|
||||
).toBeVisible()
|
||||
|
||||
@@ -33,9 +33,9 @@ test('should not allow user to view correspondents', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Correspondents' })
|
||||
page.getByRole('link', { name: 'Attributes' })
|
||||
).not.toBeAttached()
|
||||
await page.goto('/correspondents')
|
||||
await page.goto('/attributes/correspondents')
|
||||
await expect(page.locator('body')).toHaveText(
|
||||
/You don't have permissions to do that/i
|
||||
)
|
||||
@@ -44,8 +44,10 @@ test('should not allow user to view correspondents', async ({ page }) => {
|
||||
test('should not allow user to view tags', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await expect(page.getByRole('link', { name: 'Tags' })).not.toBeAttached()
|
||||
await page.goto('/tags')
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Attributes' })
|
||||
).not.toBeAttached()
|
||||
await page.goto('/attributes/tags')
|
||||
await expect(page.locator('body')).toHaveText(
|
||||
/You don't have permissions to do that/i
|
||||
)
|
||||
@@ -55,9 +57,9 @@ test('should not allow user to view document types', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Document Types' })
|
||||
page.getByRole('link', { name: 'Attributes' })
|
||||
).not.toBeAttached()
|
||||
await page.goto('/documenttypes')
|
||||
await page.goto('/attributes/documenttypes')
|
||||
await expect(page.locator('body')).toHaveText(
|
||||
/You don't have permissions to do that/i
|
||||
)
|
||||
@@ -67,9 +69,9 @@ test('should not allow user to view storage paths', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Storage Paths' })
|
||||
page.getByRole('link', { name: 'Attributes' })
|
||||
).not.toBeAttached()
|
||||
await page.goto('/storagepaths')
|
||||
await page.goto('/attributes/storagepaths')
|
||||
await expect(page.locator('body')).toHaveText(
|
||||
/You don't have permissions to do that/i
|
||||
)
|
||||
|
||||
1569
src-ui/messages.xlf
1569
src-ui/messages.xlf
File diff suppressed because it is too large
Load Diff
@@ -11,13 +11,9 @@ import { DashboardComponent } from './components/dashboard/dashboard.component'
|
||||
import { DocumentAsnComponent } from './components/document-asn/document-asn.component'
|
||||
import { DocumentDetailComponent } from './components/document-detail/document-detail.component'
|
||||
import { DocumentListComponent } from './components/document-list/document-list.component'
|
||||
import { CorrespondentListComponent } from './components/manage/correspondent-list/correspondent-list.component'
|
||||
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
|
||||
import { DocumentTypeListComponent } from './components/manage/document-type-list/document-type-list.component'
|
||||
import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component'
|
||||
import { MailComponent } from './components/manage/mail/mail.component'
|
||||
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
|
||||
import { StoragePathListComponent } from './components/manage/storage-path-list/storage-path-list.component'
|
||||
import { TagListComponent } from './components/manage/tag-list/tag-list.component'
|
||||
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
|
||||
import { NotFoundComponent } from './components/not-found/not-found.component'
|
||||
import { DirtyDocGuard } from './guards/dirty-doc.guard'
|
||||
@@ -106,52 +102,76 @@ export const routes: Routes = [
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'tags',
|
||||
component: TagListComponent,
|
||||
path: 'attributes',
|
||||
component: DocumentAttributesComponent,
|
||||
canActivate: [PermissionsGuard],
|
||||
data: {
|
||||
requiredPermission: {
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.Tag,
|
||||
},
|
||||
componentName: 'TagListComponent',
|
||||
requiredPermissionAny: [
|
||||
{ action: PermissionAction.View, type: PermissionType.Tag },
|
||||
{
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.Correspondent,
|
||||
},
|
||||
{
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.DocumentType,
|
||||
},
|
||||
{ action: PermissionAction.View, type: PermissionType.StoragePath },
|
||||
{ action: PermissionAction.View, type: PermissionType.CustomField },
|
||||
],
|
||||
componentName: 'DocumentAttributesComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'documenttypes',
|
||||
component: DocumentTypeListComponent,
|
||||
path: 'attributes/:section',
|
||||
component: DocumentAttributesComponent,
|
||||
canActivate: [PermissionsGuard],
|
||||
data: {
|
||||
requiredPermission: {
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.DocumentType,
|
||||
},
|
||||
componentName: 'DocumentTypeListComponent',
|
||||
requiredPermissionAny: [
|
||||
{ action: PermissionAction.View, type: PermissionType.Tag },
|
||||
{
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.Correspondent,
|
||||
},
|
||||
{
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.DocumentType,
|
||||
},
|
||||
{ action: PermissionAction.View, type: PermissionType.StoragePath },
|
||||
{ action: PermissionAction.View, type: PermissionType.CustomField },
|
||||
],
|
||||
componentName: 'DocumentAttributesComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'documentproperties',
|
||||
redirectTo: '/attributes',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'documentproperties/:section',
|
||||
redirectTo: '/attributes/:section',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'tags',
|
||||
redirectTo: '/attributes/tags',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'correspondents',
|
||||
component: CorrespondentListComponent,
|
||||
canActivate: [PermissionsGuard],
|
||||
data: {
|
||||
requiredPermission: {
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.Correspondent,
|
||||
},
|
||||
componentName: 'CorrespondentListComponent',
|
||||
},
|
||||
redirectTo: '/attributes/correspondents',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'documenttypes',
|
||||
redirectTo: '/attributes/documenttypes',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'storagepaths',
|
||||
component: StoragePathListComponent,
|
||||
canActivate: [PermissionsGuard],
|
||||
data: {
|
||||
requiredPermission: {
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.StoragePath,
|
||||
},
|
||||
componentName: 'StoragePathListComponent',
|
||||
},
|
||||
redirectTo: '/attributes/storagepaths',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
@@ -239,15 +259,8 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'customfields',
|
||||
component: CustomFieldsComponent,
|
||||
canActivate: [PermissionsGuard],
|
||||
data: {
|
||||
requiredPermission: {
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.CustomField,
|
||||
},
|
||||
componentName: 'CustomFieldsComponent',
|
||||
},
|
||||
redirectTo: '/attributes/customfields',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'workflows',
|
||||
|
||||
@@ -195,8 +195,8 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
},
|
||||
{
|
||||
anchorId: 'tour.tags',
|
||||
content: $localize`Tags, correspondents, document types and storage paths can all be managed using these pages. They can also be created from the document edit view.`,
|
||||
route: '/tags',
|
||||
content: $localize`Attributes like tags, correspondents, document types, storage paths and custom fields can all be managed here. They can also be created from the document edit view.`,
|
||||
route: '/attributes/tags',
|
||||
backdropConfig: {
|
||||
offset: 0,
|
||||
},
|
||||
|
||||
@@ -175,44 +175,60 @@
|
||||
<span i18n>Manage</span>
|
||||
</h6>
|
||||
<ul class="nav flex-column mb-2">
|
||||
<li class="nav-item app-link"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
||||
<a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Correspondents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="person"></i-bs><span> <ng-container i18n>Correspondents</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"
|
||||
tourAnchor="tour.tags">
|
||||
<a class="nav-link" routerLink="tags" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Tags"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="tags"></i-bs><span> <ng-container i18n>Tags</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
||||
<a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Document Types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="hash"></i-bs><span> <ng-container i18n>Document Types</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
||||
<a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Storage Paths" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="folder"></i-bs><span> <ng-container i18n>Storage Paths</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
||||
<a class="nav-link" routerLink="customfields" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Custom Fields" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="ui-radios"></i-bs><span> <ng-container i18n>Custom Fields</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
@if (canManageAttributes) {
|
||||
<li class="nav-item app-link" tourAnchor="tour.tags">
|
||||
<div class="d-flex align-items-center attributes-row">
|
||||
<a class="nav-link flex-fill" routerLink="attributes" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="stack"></i-bs><span> <ng-container i18n>Attributes</ng-container></span>
|
||||
</a>
|
||||
@if (!slimSidebarEnabled) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-link btn-sm text-muted p-0 me-3 attributes-expand-btn"
|
||||
(click)="toggleAttributesSections($event)"
|
||||
[attr.aria-label]="attributesSectionsCollapsed ? 'Expand attributes sections' : 'Collapse attributes sections'"
|
||||
i18n-aria-label
|
||||
>
|
||||
<i-bs [name]="attributesSectionsCollapsed ? 'plus-circle' : 'dash-circle'"></i-bs>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
class="attributes-submenu ms-2"
|
||||
[ngbCollapse]="slimSidebarEnabled || attributesSectionsCollapsed"
|
||||
>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }">
|
||||
<a class="nav-link py-1" routerLink="attributes/tags" routerLinkActive="active" (click)="closeMenu()">
|
||||
<i-bs class="me-1" name="tags"></i-bs><span> <ng-container i18n>Tags</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
||||
<a class="nav-link py-1" routerLink="attributes/correspondents" routerLinkActive="active" (click)="closeMenu()">
|
||||
<i-bs class="me-1" name="person"></i-bs><span> <ng-container i18n>Correspondents</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
||||
<a class="nav-link py-1" routerLink="attributes/documenttypes" routerLinkActive="active" (click)="closeMenu()">
|
||||
<i-bs class="me-1" name="hash"></i-bs><span> <ng-container i18n>Document types</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
||||
<a class="nav-link py-1" routerLink="attributes/storagepaths" routerLinkActive="active" (click)="closeMenu()">
|
||||
<i-bs class="me-1" name="folder"></i-bs><span> <ng-container i18n>Storage paths</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
||||
<a class="nav-link py-1" routerLink="attributes/customfields" routerLinkActive="active" (click)="closeMenu()">
|
||||
<i-bs class="me-1" name="ui-radios"></i-bs><span> <ng-container i18n>Custom fields</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
|
||||
@@ -177,6 +177,15 @@ main {
|
||||
}
|
||||
}
|
||||
|
||||
.attributes-row .attributes-expand-btn {
|
||||
opacity: 0.2;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.attributes-row:hover .attributes-expand-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-heading {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
|
||||
@@ -28,7 +28,10 @@ import {
|
||||
DjangoMessagesService,
|
||||
} from 'src/app/services/django-messages.service'
|
||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import {
|
||||
PermissionType,
|
||||
PermissionsService,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { RemoteVersionService } from 'src/app/services/rest/remote-version.service'
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
import { SearchService } from 'src/app/services/rest/search.service'
|
||||
@@ -258,7 +261,7 @@ describe('AppFrameComponent', () => {
|
||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||
component.toggleSlimSidebar()
|
||||
httpTestingController
|
||||
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
||||
.match(`${environment.apiBaseUrl}ui_settings/`)[0]
|
||||
.flush('error', {
|
||||
status: 500,
|
||||
statusText: 'error',
|
||||
@@ -373,4 +376,103 @@ describe('AppFrameComponent', () => {
|
||||
it('should call maybeRefreshDocumentCounts after saved views reload', () => {
|
||||
expect(maybeRefreshSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should indicate attributes management availability when any permission is granted', () => {
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserCan')
|
||||
.mockImplementation((action, type) => {
|
||||
return type === PermissionType.Tag
|
||||
})
|
||||
|
||||
expect(component.canManageAttributes).toBe(true)
|
||||
})
|
||||
|
||||
it('should indicate attributes management availability for other permission types', () => {
|
||||
const canSpy = jest
|
||||
.spyOn(permissionsService, 'currentUserCan')
|
||||
.mockImplementation((action, type) => {
|
||||
return type === PermissionType.Correspondent
|
||||
})
|
||||
expect(component.canManageAttributes).toBe(true)
|
||||
|
||||
canSpy.mockImplementation((action, type) => {
|
||||
return type === PermissionType.DocumentType
|
||||
})
|
||||
expect(component.canManageAttributes).toBe(true)
|
||||
|
||||
canSpy.mockImplementation((action, type) => {
|
||||
return type === PermissionType.StoragePath
|
||||
})
|
||||
expect(component.canManageAttributes).toBe(true)
|
||||
|
||||
canSpy.mockImplementation((action, type) => {
|
||||
return type === PermissionType.CustomField
|
||||
})
|
||||
expect(component.canManageAttributes).toBe(true)
|
||||
})
|
||||
|
||||
it('should toggle attributes sections and stop event bubbling', () => {
|
||||
const preventDefault = jest.fn()
|
||||
const stopPropagation = jest.fn()
|
||||
const setSpy = jest.spyOn(settingsService, 'set')
|
||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
||||
|
||||
component.toggleAttributesSections({
|
||||
preventDefault,
|
||||
stopPropagation,
|
||||
} as any)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalled()
|
||||
expect(stopPropagation).toHaveBeenCalled()
|
||||
expect(setSpy).toHaveBeenCalledWith(
|
||||
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
|
||||
['attributes']
|
||||
)
|
||||
})
|
||||
|
||||
it('should show error when saving slim sidebar setting fails', () => {
|
||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
jest
|
||||
.spyOn(settingsService, 'storeSettings')
|
||||
.mockReturnValue(throwError(() => new Error('boom')))
|
||||
|
||||
component.slimSidebarEnabled = true
|
||||
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show error when saving attributes collapsed setting fails', () => {
|
||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
jest
|
||||
.spyOn(settingsService, 'storeSettings')
|
||||
.mockReturnValue(throwError(() => new Error('boom')))
|
||||
|
||||
component.attributesSectionsCollapsed = true
|
||||
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should persist attributes section collapse state', () => {
|
||||
const setSpy = jest.spyOn(settingsService, 'set')
|
||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
||||
|
||||
component.attributesSectionsCollapsed = true
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(
|
||||
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
|
||||
['attributes']
|
||||
)
|
||||
})
|
||||
|
||||
it('should collapse attributes sections when enabling slim sidebar', () => {
|
||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
||||
settingsService.set(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED, [])
|
||||
settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, false)
|
||||
|
||||
component.toggleSlimSidebar()
|
||||
|
||||
expect(component.attributesSectionsCollapsed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,7 +21,7 @@ import { Observable } from 'rxjs'
|
||||
import { first } from 'rxjs/operators'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import { SavedView } from 'src/app/data/saved-view'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { CollapsibleSection, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard'
|
||||
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||
@@ -141,11 +141,20 @@ export class AppFrameComponent
|
||||
toggleSlimSidebar(): void {
|
||||
this.slimSidebarAnimating = true
|
||||
this.slimSidebarEnabled = !this.slimSidebarEnabled
|
||||
if (this.slimSidebarEnabled) {
|
||||
this.attributesSectionsCollapsed = true
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.slimSidebarAnimating = false
|
||||
}, 200) // slightly longer than css animation for slim sidebar
|
||||
}
|
||||
|
||||
toggleAttributesSections(event?: Event): void {
|
||||
event?.preventDefault()
|
||||
event?.stopPropagation()
|
||||
this.attributesSectionsCollapsed = !this.attributesSectionsCollapsed
|
||||
}
|
||||
|
||||
get versionString(): string {
|
||||
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
|
||||
}
|
||||
@@ -167,6 +176,31 @@ export class AppFrameComponent
|
||||
)
|
||||
}
|
||||
|
||||
get canManageAttributes(): boolean {
|
||||
return (
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.Tag
|
||||
) ||
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.Correspondent
|
||||
) ||
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.DocumentType
|
||||
) ||
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.StoragePath
|
||||
) ||
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.CustomField
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
get slimSidebarEnabled(): boolean {
|
||||
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
|
||||
}
|
||||
@@ -186,6 +220,31 @@ export class AppFrameComponent
|
||||
})
|
||||
}
|
||||
|
||||
get attributesSectionsCollapsed(): boolean {
|
||||
return this.settingsService
|
||||
.get(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED)
|
||||
?.includes(CollapsibleSection.ATTRIBUTES)
|
||||
}
|
||||
|
||||
set attributesSectionsCollapsed(collapsed: boolean) {
|
||||
// TODO: refactor to be able to toggle individual sections, if implemented
|
||||
this.settingsService.set(
|
||||
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
|
||||
collapsed ? [CollapsibleSection.ATTRIBUTES] : []
|
||||
)
|
||||
this.settingsService
|
||||
.storeSettings()
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
error: (error) => {
|
||||
this.toastService.showError(
|
||||
$localize`An error occurred while saving settings.`
|
||||
)
|
||||
console.warn(error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
get aiEnabled(): boolean {
|
||||
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
|
||||
}
|
||||
|
||||
@@ -9,19 +9,24 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-6">
|
||||
<pngx-input-text [horizontal]="true" i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-4">
|
||||
<pngx-input-select [horizontal]="true" i18n-title title="Account" [items]="accounts" formControlName="account"></pngx-input-select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<pngx-input-number [horizontal]="true" i18n-title title="Order" formControlName="order" [showAdd]="false" [error]="error?.order"></pngx-input-number>
|
||||
</div>
|
||||
<div class="col-md-2 pt-2">
|
||||
<pngx-input-switch [horizontal]="true" i18n-title title="Enabled" formControlName="enabled"></pngx-input-switch>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<pngx-input-number [horizontal]="true" i18n-title title="Order" formControlName="order" [showAdd]="false" [error]="error?.order"></pngx-input-number>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<pngx-input-switch [horizontal]="true" i18n-title title="Stop further processing" formControlName="stop_processing" i18n-hint hint="Stop processing further rules if this rule queues any document(s)."></pngx-input-switch>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="mt-0"/>
|
||||
<div class="row">
|
||||
<p class="small" i18n>Paperless will only process mails that match <em>all</em> of the criteria specified below.</p>
|
||||
|
||||
@@ -222,6 +222,7 @@ export class MailRuleEditDialogComponent extends EditDialogComponent<MailRule> {
|
||||
),
|
||||
assign_correspondent: new FormControl(null),
|
||||
assign_owner_from_rule: new FormControl(true),
|
||||
stop_processing: new FormControl(false),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -150,4 +150,8 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
|
||||
& .annotationTextContent {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,13 @@ describe('PngxPdfViewerComponent', () => {
|
||||
const pageSpy = jest.fn()
|
||||
component.pageChange.subscribe(pageSpy)
|
||||
|
||||
// In real usage the viewer may have multiple pages; our pdfjs mock defaults
|
||||
// to a single page, so explicitly simulate a multi-page document here.
|
||||
const pdf = (component as any).pdf as { numPages: number }
|
||||
pdf.numPages = 3
|
||||
const viewer = (component as any).pdfViewer as PDFViewer
|
||||
viewer.setDocument(pdf)
|
||||
|
||||
component.zoomScale = PdfZoomScale.PageFit
|
||||
component.zoom = PdfZoomLevel.Two
|
||||
component.rotation = 90
|
||||
@@ -81,7 +88,6 @@ describe('PngxPdfViewerComponent', () => {
|
||||
page: new SimpleChange(undefined, 2, false),
|
||||
})
|
||||
|
||||
const viewer = (component as any).pdfViewer as PDFViewer
|
||||
expect(viewer.pagesRotation).toBe(90)
|
||||
expect(viewer.currentPageNumber).toBe(2)
|
||||
expect(pageSpy).toHaveBeenCalledWith(2)
|
||||
@@ -196,6 +202,8 @@ describe('PngxPdfViewerComponent', () => {
|
||||
const scaleSpy = jest.spyOn(component as any, 'applyViewerState')
|
||||
const resizeSpy = jest.spyOn(component as any, 'setupResizeObserver')
|
||||
|
||||
// Angular sets the input value before calling ngOnChanges; mirror that here.
|
||||
component.src = 'test.pdf'
|
||||
component.ngOnChanges({
|
||||
src: new SimpleChange(undefined, 'test.pdf', true),
|
||||
zoomScale: new SimpleChange(
|
||||
@@ -211,6 +219,25 @@ describe('PngxPdfViewerComponent', () => {
|
||||
expect(scaleSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resets viewer state on src change', () => {
|
||||
const mockViewer = {
|
||||
setDocument: jest.fn(),
|
||||
currentPageNumber: 7,
|
||||
cleanup: jest.fn(),
|
||||
}
|
||||
;(component as any).pdfViewer = mockViewer
|
||||
;(component as any).loadingTask = { destroy: jest.fn() }
|
||||
jest.spyOn(component as any, 'loadDocument').mockImplementation(() => {})
|
||||
|
||||
component.src = 'test.pdf'
|
||||
component.ngOnChanges({
|
||||
src: new SimpleChange(undefined, 'test.pdf', true),
|
||||
})
|
||||
|
||||
expect(mockViewer.setDocument).toHaveBeenCalledWith(null)
|
||||
expect(mockViewer.currentPageNumber).toBe(1)
|
||||
})
|
||||
|
||||
it('applies viewer state after view init when already loaded', () => {
|
||||
const applySpy = jest.spyOn(component as any, 'applyViewerState')
|
||||
;(component as any).hasLoaded = true
|
||||
|
||||
@@ -81,7 +81,7 @@ export class PngxPdfViewerComponent
|
||||
this.dispatchFindIfReady()
|
||||
this.rendered.emit()
|
||||
}
|
||||
private readonly onPagesInit = () => this.applyScale()
|
||||
private readonly onPagesInit = () => this.applyViewerState()
|
||||
private readonly onPageChanging = (evt: { pageNumber: number }) => {
|
||||
// Avoid [(page)] two-way binding re-triggers navigation
|
||||
this.lastViewerPage = evt.pageNumber
|
||||
@@ -90,8 +90,10 @@ export class PngxPdfViewerComponent
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['src']) {
|
||||
this.hasLoaded = false
|
||||
this.loadDocument()
|
||||
this.resetViewerState()
|
||||
if (this.src) {
|
||||
this.loadDocument()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -139,6 +141,21 @@ export class PngxPdfViewerComponent
|
||||
this.pdfViewer = undefined
|
||||
}
|
||||
|
||||
private resetViewerState(): void {
|
||||
this.hasLoaded = false
|
||||
this.hasRenderedPage = false
|
||||
this.lastFindQuery = ''
|
||||
this.lastViewerPage = undefined
|
||||
this.loadingTask?.destroy()
|
||||
this.loadingTask = undefined
|
||||
this.pdf = undefined
|
||||
this.linkService.setDocument(null)
|
||||
if (this.pdfViewer) {
|
||||
this.pdfViewer.setDocument(null)
|
||||
this.pdfViewer.currentPageNumber = 1
|
||||
}
|
||||
}
|
||||
|
||||
private async loadDocument(): Promise<void> {
|
||||
if (this.hasLoaded) {
|
||||
return
|
||||
@@ -222,7 +239,11 @@ export class PngxPdfViewerComponent
|
||||
hasPages &&
|
||||
this.page !== this.lastViewerPage
|
||||
) {
|
||||
this.pdfViewer.currentPageNumber = this.page
|
||||
const nextPage = Math.min(
|
||||
Math.max(Math.trunc(this.page), 1),
|
||||
this.pdfViewer.pagesCount
|
||||
)
|
||||
this.pdfViewer.currentPageNumber = nextPage
|
||||
}
|
||||
if (this.page === this.lastViewerPage) {
|
||||
this.lastViewerPage = undefined
|
||||
|
||||
@@ -457,7 +457,7 @@
|
||||
@if (!useNativePdfViewer) {
|
||||
<div class="preview-sticky pdf-viewer-container">
|
||||
<pngx-pdf-viewer
|
||||
[src]="{ url: previewUrl, password: password }"
|
||||
[src]="pdfSource"
|
||||
[renderMode]="PdfRenderMode.All"
|
||||
[(page)]="previewCurrentPage"
|
||||
[zoomScale]="previewZoomScale"
|
||||
|
||||
@@ -110,6 +110,7 @@ import { PDFEditorComponent } from '../common/pdf-editor/pdf-editor.component'
|
||||
import { PngxPdfViewerComponent } from '../common/pdf-viewer/pdf-viewer.component'
|
||||
import {
|
||||
PdfRenderMode,
|
||||
PdfSource,
|
||||
PdfZoomLevel,
|
||||
PdfZoomScale,
|
||||
PngxPdfDocumentProxy,
|
||||
@@ -227,6 +228,7 @@ export class DocumentDetailComponent
|
||||
title: string
|
||||
titleSubject: Subject<string> = new Subject()
|
||||
previewUrl: string
|
||||
pdfSource?: PdfSource
|
||||
thumbUrl: string
|
||||
previewText: string
|
||||
previewLoaded: boolean = false
|
||||
@@ -345,6 +347,17 @@ export class DocumentDetailComponent
|
||||
return ContentRenderType.Other
|
||||
}
|
||||
|
||||
private updatePdfSource() {
|
||||
if (!this.previewUrl) {
|
||||
this.pdfSource = undefined
|
||||
return
|
||||
}
|
||||
this.pdfSource = {
|
||||
url: this.previewUrl,
|
||||
password: this.password || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
get isRTL() {
|
||||
if (!this.metadata || !this.metadata.lang) return false
|
||||
else {
|
||||
@@ -421,6 +434,7 @@ export class DocumentDetailComponent
|
||||
|
||||
private loadDocument(documentId: number): void {
|
||||
this.previewUrl = this.documentsService.getPreviewUrl(documentId)
|
||||
this.updatePdfSource()
|
||||
this.http
|
||||
.get(this.previewUrl, { responseType: 'text' })
|
||||
.pipe(
|
||||
@@ -1230,6 +1244,7 @@ export class DocumentDetailComponent
|
||||
onPasswordKeyUp(event: KeyboardEvent) {
|
||||
if ('Enter' == event.key) {
|
||||
this.password = (event.target as HTMLInputElement).value
|
||||
this.updatePdfSource()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,3 @@
|
||||
<pngx-page-header
|
||||
title="Custom Fields"
|
||||
i18n-title
|
||||
info="Customize the data fields that can be attached to documents."
|
||||
i18n-info
|
||||
infoLink="usage/#custom-fields"
|
||||
>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="editField()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.CustomField }">
|
||||
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Add Field</ng-container>
|
||||
</button>
|
||||
</pngx-page-header>
|
||||
|
||||
<ul class="list-group">
|
||||
|
||||
<li class="list-group-item">
|
||||
@@ -26,9 +26,9 @@ import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { ConfirmDialogComponent } from '../../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { CustomFieldEditDialogComponent } from '../../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||
import { PageHeaderComponent } from '../../../common/page-header/page-header.component'
|
||||
import { CustomFieldsComponent } from './custom-fields.component'
|
||||
|
||||
const fields: CustomField[] = [
|
||||
@@ -110,10 +110,7 @@ describe('CustomFieldsComponent', () => {
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const reloadSpy = jest.spyOn(component, 'reload')
|
||||
|
||||
const createButton = fixture.debugElement
|
||||
.queryAll(By.css('button'))
|
||||
.find((btn) => btn.nativeElement.textContent.trim().includes('Add Field'))
|
||||
createButton.triggerEventHandler('click')
|
||||
component.editField()
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { delay, takeUntil, tap } from 'rxjs'
|
||||
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
|
||||
import { CustomFieldEditDialogComponent } from 'src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||
import { EditDialogMode } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
||||
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component'
|
||||
import { CustomField, DATA_TYPE_LABELS } from 'src/app/data/custom-field'
|
||||
import {
|
||||
CustomFieldQueryLogicalOperator,
|
||||
@@ -21,18 +25,12 @@ import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-custom-fields',
|
||||
templateUrl: './custom-fields.component.html',
|
||||
styleUrls: ['./custom-fields.component.scss'],
|
||||
imports: [
|
||||
PageHeaderComponent,
|
||||
IfPermissionsDirective,
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
@@ -44,14 +42,14 @@ export class CustomFieldsComponent
|
||||
extends LoadingComponentWithPermissions
|
||||
implements OnInit
|
||||
{
|
||||
private customFieldsService = inject(CustomFieldsService)
|
||||
permissionsService = inject(PermissionsService)
|
||||
private modalService = inject(NgbModal)
|
||||
private toastService = inject(ToastService)
|
||||
private documentListViewService = inject(DocumentListViewService)
|
||||
private settingsService = inject(SettingsService)
|
||||
private documentService = inject(DocumentService)
|
||||
private savedViewService = inject(SavedViewService)
|
||||
private readonly customFieldsService = inject(CustomFieldsService)
|
||||
public readonly permissionsService = inject(PermissionsService)
|
||||
private readonly modalService = inject(NgbModal)
|
||||
private readonly toastService = inject(ToastService)
|
||||
private readonly documentListViewService = inject(DocumentListViewService)
|
||||
private readonly settingsService = inject(SettingsService)
|
||||
private readonly documentService = inject(DocumentService)
|
||||
private readonly savedViewService = inject(SavedViewService)
|
||||
|
||||
public fields: CustomField[] = []
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<pngx-page-header
|
||||
[title]="activeTabLabel"
|
||||
info="Manage tags, correspondents, document types, storage paths, and custom fields."
|
||||
i18n-info
|
||||
[infoLink]="activeInfoLink"
|
||||
[loading]="activeHeaderLoading"
|
||||
>
|
||||
@if (activeManagementList) {
|
||||
<div ngbDropdown class="btn-group flex-fill d-sm-none">
|
||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
|
||||
<i-bs name="text-indent-left"></i-bs>
|
||||
<div class="d-none d-sm-inline"> <ng-container i18n>Select</ng-container></div>
|
||||
@if (activeManagementList.selectedObjects.size > 0) {
|
||||
<pngx-clearable-badge [selected]="activeManagementList.selectedObjects.size > 0" [number]="activeManagementList.selectedObjects.size" (cleared)="activeManagementList.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
||||
}
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
|
||||
<button ngbDropdownItem (click)="activeManagementList.selectNone()" i18n>Select none</button>
|
||||
<button ngbDropdownItem (click)="activeManagementList.selectPage(true)" i18n>Select page</button>
|
||||
<button ngbDropdownItem (click)="activeManagementList.selectAll()" i18n>Select all</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-none d-sm-flex flex-fill me-3">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text border-0" i18n>Select:</span>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm flex-nowrap">
|
||||
@if (activeManagementList.selectedObjects.size > 0) {
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="activeManagementList.selectNone()">
|
||||
<i-bs name="slash-circle"></i-bs> <ng-container i18n>None</ng-container>
|
||||
</button>
|
||||
}
|
||||
<button class="btn btn-sm btn-outline-primary" (click)="activeManagementList.selectPage(true)">
|
||||
<i-bs name="file-earmark-check"></i-bs> <ng-container i18n>Page</ng-container>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary" (click)="activeManagementList.selectAll()">
|
||||
<i-bs name="check-all"></i-bs> <ng-container i18n>All</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="activeManagementList.setPermissions()"
|
||||
[disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Change) || activeManagementList.selectedObjects.size === 0">
|
||||
<i-bs name="person-fill-lock"></i-bs> <ng-container i18n>Permissions</ng-container>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" (click)="activeManagementList.delete()"
|
||||
[disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Delete) || activeManagementList.selectedObjects.size === 0">
|
||||
<i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="activeManagementList.openCreateDialog()"
|
||||
*pngxIfPermissions="{ action: PermissionAction.Add, type: activeManagementList.permissionType }">
|
||||
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Create</ng-container>
|
||||
</button>
|
||||
} @else if (activeCustomFields) {
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="activeCustomFields.editField()"
|
||||
*pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.CustomField }">
|
||||
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Add Field</ng-container>
|
||||
</button>
|
||||
}
|
||||
</pngx-page-header>
|
||||
|
||||
<ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [(activeId)]="activeNavID" class="nav-underline">
|
||||
@for (section of visibleSections; track section.id) {
|
||||
<li [ngbNavItem]="section.id">
|
||||
<a ngbNavLink >
|
||||
<i-bs class="me-2" [name]="section.icon"></i-bs>{{ section.label }}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
<div class="my-3 shadow-sm">
|
||||
<ng-container
|
||||
[ngComponentOutlet]="activeSection?.component"
|
||||
#activeOutlet="ngComponentOutlet"
|
||||
></ng-container>
|
||||
</div>
|
||||
@@ -0,0 +1,189 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import {
|
||||
ActivatedRoute,
|
||||
convertToParamMap,
|
||||
ParamMap,
|
||||
Router,
|
||||
} from '@angular/router'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { Subject } from 'rxjs'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionsService,
|
||||
PermissionType,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import {
|
||||
DocumentAttributesComponent,
|
||||
DocumentAttributesSectionKind,
|
||||
} from './document-attributes.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-dummy-section',
|
||||
template: '',
|
||||
standalone: true,
|
||||
})
|
||||
class DummySectionComponent {}
|
||||
|
||||
describe('DocumentAttributesComponent', () => {
|
||||
let component: DocumentAttributesComponent
|
||||
let fixture: ComponentFixture<DocumentAttributesComponent>
|
||||
let router: Router
|
||||
let paramMapSubject: Subject<ParamMap>
|
||||
let permissionsService: PermissionsService
|
||||
|
||||
beforeEach(async () => {
|
||||
paramMapSubject = new Subject<ParamMap>()
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
RouterTestingModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
DocumentAttributesComponent,
|
||||
DummySectionComponent,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
paramMap: paramMapSubject.asObservable(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PermissionsService,
|
||||
useValue: {
|
||||
currentUserCan: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(DocumentAttributesComponent)
|
||||
component = fixture.componentInstance
|
||||
router = TestBed.inject(Router)
|
||||
permissionsService = TestBed.inject(PermissionsService)
|
||||
|
||||
jest.spyOn(router, 'navigate').mockResolvedValue(true)
|
||||
;(component as any).sections = [
|
||||
{
|
||||
id: 1,
|
||||
path: 'tags',
|
||||
label: 'Tags',
|
||||
icon: 'tags',
|
||||
permissionType: PermissionType.Tag,
|
||||
kind: DocumentAttributesSectionKind.ManagementList,
|
||||
component: DummySectionComponent,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
path: 'customfields',
|
||||
label: 'Custom fields',
|
||||
icon: 'ui-radios',
|
||||
permissionType: PermissionType.CustomField,
|
||||
kind: DocumentAttributesSectionKind.CustomFields,
|
||||
component: DummySectionComponent,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
it('should navigate to default section when no section is provided', () => {
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserCan')
|
||||
.mockImplementation((action, type) => {
|
||||
return action === PermissionAction.View && type === PermissionType.Tag
|
||||
})
|
||||
|
||||
fixture.detectChanges()
|
||||
paramMapSubject.next(convertToParamMap({}))
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(['attributes', 'tags'], {
|
||||
replaceUrl: true,
|
||||
})
|
||||
expect(component.activeNavID).toBe(1)
|
||||
})
|
||||
|
||||
it('should set active section from route param when valid', () => {
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserCan')
|
||||
.mockImplementation((action, type) => {
|
||||
return (
|
||||
action === PermissionAction.View &&
|
||||
type === PermissionType.CustomField
|
||||
)
|
||||
})
|
||||
|
||||
fixture.detectChanges()
|
||||
paramMapSubject.next(convertToParamMap({ section: 'customfields' }))
|
||||
|
||||
expect(component.activeNavID).toBe(2)
|
||||
expect(router.navigate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update active nav id when route section changes', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
|
||||
fixture.detectChanges()
|
||||
component.activeNavID = 1
|
||||
paramMapSubject.next(convertToParamMap({ section: 'customfields' }))
|
||||
|
||||
expect(component.activeNavID).toBe(2)
|
||||
})
|
||||
|
||||
it('should redirect to dashboard when no sections are visible', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false)
|
||||
|
||||
fixture.detectChanges()
|
||||
paramMapSubject.next(convertToParamMap({}))
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/dashboard'], {
|
||||
replaceUrl: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should navigate when a nav change occurs', () => {
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserCan')
|
||||
.mockImplementation(() => true)
|
||||
|
||||
fixture.detectChanges()
|
||||
paramMapSubject.next(convertToParamMap({ section: 'tags' }))
|
||||
|
||||
component.onNavChange({ nextId: 2 } as any)
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(['attributes', 'customfields'])
|
||||
})
|
||||
|
||||
it('should ignore nav changes for unknown sections', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
|
||||
fixture.detectChanges()
|
||||
paramMapSubject.next(convertToParamMap({ section: 'tags' }))
|
||||
|
||||
component.onNavChange({ nextId: 999 } as any)
|
||||
|
||||
expect(router.navigate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return activeManagementList correctly', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
expect(component.activeManagementList).toBeNull()
|
||||
|
||||
component.activeNavID = 1
|
||||
expect(component.activeSection.kind).toBe(
|
||||
DocumentAttributesSectionKind.ManagementList
|
||||
)
|
||||
expect(component.activeManagementList).toBeDefined()
|
||||
})
|
||||
|
||||
it('should return activeCustomFields correctly', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
expect(component.activeCustomFields).toBeNull()
|
||||
|
||||
component.activeNavID = 2
|
||||
expect(component.activeSection.kind).toBe(
|
||||
DocumentAttributesSectionKind.CustomFields
|
||||
)
|
||||
expect(component.activeCustomFields).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,256 @@
|
||||
import { NgComponentOutlet } from '@angular/common'
|
||||
import {
|
||||
AfterViewChecked,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
inject,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Type,
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import {
|
||||
NgbDropdownModule,
|
||||
NgbNavChangeEvent,
|
||||
NgbNavModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { Subject, takeUntil } from 'rxjs'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionsService,
|
||||
PermissionType,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { CustomFieldsComponent } from './custom-fields/custom-fields.component'
|
||||
import { CorrespondentListComponent } from './management-list/correspondent-list/correspondent-list.component'
|
||||
import { DocumentTypeListComponent } from './management-list/document-type-list/document-type-list.component'
|
||||
import { ManagementListComponent } from './management-list/management-list.component'
|
||||
import { StoragePathListComponent } from './management-list/storage-path-list/storage-path-list.component'
|
||||
import { TagListComponent } from './management-list/tag-list/tag-list.component'
|
||||
|
||||
enum DocumentAttributesNavIDs {
|
||||
Tags = 1,
|
||||
Correspondents = 2,
|
||||
DocumentTypes = 3,
|
||||
StoragePaths = 4,
|
||||
CustomFields = 5,
|
||||
}
|
||||
|
||||
export enum DocumentAttributesSectionKind {
|
||||
ManagementList = 'managementList',
|
||||
CustomFields = 'customFields',
|
||||
}
|
||||
|
||||
interface DocumentAttributesSection {
|
||||
id: DocumentAttributesNavIDs
|
||||
path: string
|
||||
label: string
|
||||
icon: string
|
||||
infoLink?: string
|
||||
permissionType: PermissionType
|
||||
kind: DocumentAttributesSectionKind
|
||||
component: Type<any>
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-document-attributes',
|
||||
templateUrl: './document-attributes.component.html',
|
||||
styleUrls: ['./document-attributes.component.scss'],
|
||||
imports: [
|
||||
PageHeaderComponent,
|
||||
NgbNavModule,
|
||||
NgbDropdownModule,
|
||||
NgComponentOutlet,
|
||||
NgxBootstrapIconsModule,
|
||||
IfPermissionsDirective,
|
||||
ClearableBadgeComponent,
|
||||
],
|
||||
})
|
||||
export class DocumentAttributesComponent
|
||||
implements OnInit, OnDestroy, AfterViewChecked
|
||||
{
|
||||
private readonly permissionsService = inject(PermissionsService)
|
||||
private readonly activatedRoute = inject(ActivatedRoute)
|
||||
private readonly router = inject(Router)
|
||||
private readonly cdr = inject(ChangeDetectorRef)
|
||||
private readonly unsubscribeNotifier = new Subject<void>()
|
||||
|
||||
protected readonly PermissionAction = PermissionAction
|
||||
protected readonly PermissionType = PermissionType
|
||||
|
||||
readonly sections: DocumentAttributesSection[] = [
|
||||
{
|
||||
id: DocumentAttributesNavIDs.Tags,
|
||||
path: 'tags',
|
||||
label: $localize`Tags`,
|
||||
icon: 'tags',
|
||||
infoLink: 'usage/#terms-and-definitions',
|
||||
permissionType: PermissionType.Tag,
|
||||
kind: DocumentAttributesSectionKind.ManagementList,
|
||||
component: TagListComponent,
|
||||
},
|
||||
{
|
||||
id: DocumentAttributesNavIDs.Correspondents,
|
||||
path: 'correspondents',
|
||||
label: $localize`Correspondents`,
|
||||
icon: 'person',
|
||||
infoLink: 'usage/#terms-and-definitions',
|
||||
permissionType: PermissionType.Correspondent,
|
||||
kind: DocumentAttributesSectionKind.ManagementList,
|
||||
component: CorrespondentListComponent,
|
||||
},
|
||||
{
|
||||
id: DocumentAttributesNavIDs.DocumentTypes,
|
||||
path: 'documenttypes',
|
||||
label: $localize`Document types`,
|
||||
icon: 'hash',
|
||||
infoLink: 'usage/#terms-and-definitions',
|
||||
permissionType: PermissionType.DocumentType,
|
||||
kind: DocumentAttributesSectionKind.ManagementList,
|
||||
component: DocumentTypeListComponent,
|
||||
},
|
||||
{
|
||||
id: DocumentAttributesNavIDs.StoragePaths,
|
||||
path: 'storagepaths',
|
||||
label: $localize`Storage paths`,
|
||||
icon: 'folder',
|
||||
infoLink: 'usage/#terms-and-definitions',
|
||||
permissionType: PermissionType.StoragePath,
|
||||
kind: DocumentAttributesSectionKind.ManagementList,
|
||||
component: StoragePathListComponent,
|
||||
},
|
||||
{
|
||||
id: DocumentAttributesNavIDs.CustomFields,
|
||||
path: 'customfields',
|
||||
label: $localize`Custom fields`,
|
||||
icon: 'ui-radios',
|
||||
infoLink: 'usage/#custom-fields',
|
||||
permissionType: PermissionType.CustomField,
|
||||
kind: DocumentAttributesSectionKind.CustomFields,
|
||||
component: CustomFieldsComponent,
|
||||
},
|
||||
]
|
||||
|
||||
@ViewChild('activeOutlet', { read: NgComponentOutlet })
|
||||
private readonly activeOutlet?: NgComponentOutlet
|
||||
|
||||
private lastHeaderLoading: boolean
|
||||
|
||||
activeNavID: number = null
|
||||
|
||||
get visibleSections(): DocumentAttributesSection[] {
|
||||
return this.sections.filter((section) =>
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
section.permissionType
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
get activeSection(): DocumentAttributesSection | null {
|
||||
return (
|
||||
this.visibleSections.find((section) => section.id === this.activeNavID) ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
get activeManagementList(): ManagementListComponent<any> | null {
|
||||
if (
|
||||
this.activeSection?.kind !== DocumentAttributesSectionKind.ManagementList
|
||||
)
|
||||
return null
|
||||
const instance = this.activeOutlet?.componentInstance
|
||||
return instance instanceof ManagementListComponent ? instance : null
|
||||
}
|
||||
|
||||
get activeCustomFields(): CustomFieldsComponent | null {
|
||||
if (this.activeSection?.kind !== DocumentAttributesSectionKind.CustomFields)
|
||||
return null
|
||||
const instance = this.activeOutlet?.componentInstance
|
||||
return instance instanceof CustomFieldsComponent ? instance : null
|
||||
}
|
||||
|
||||
get activeTabLabel(): string {
|
||||
return this.activeSection?.label ?? ''
|
||||
}
|
||||
|
||||
get activeInfoLink(): string {
|
||||
return this.activeSection?.infoLink ?? null
|
||||
}
|
||||
|
||||
get activeHeaderLoading(): boolean {
|
||||
return (
|
||||
this.activeManagementList?.loading ??
|
||||
this.activeCustomFields?.loading ??
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.activatedRoute.paramMap
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((paramMap) => {
|
||||
const section = paramMap.get('section')
|
||||
const navIDFromSection =
|
||||
this.getNavIDForSection(section) ?? this.getDefaultNavID()
|
||||
|
||||
if (navIDFromSection == null) {
|
||||
this.router.navigate(['/dashboard'], { replaceUrl: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (this.activeNavID !== navIDFromSection) {
|
||||
this.activeNavID = navIDFromSection
|
||||
}
|
||||
|
||||
if (!section || this.getNavIDForSection(section) == null) {
|
||||
this.router.navigate(
|
||||
['attributes', this.getSectionForNavID(this.activeNavID)],
|
||||
{ replaceUrl: true }
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.unsubscribeNotifier.next()
|
||||
this.unsubscribeNotifier.complete()
|
||||
}
|
||||
|
||||
ngAfterViewChecked(): void {
|
||||
const current = this.activeHeaderLoading
|
||||
if (this.lastHeaderLoading !== current) {
|
||||
this.lastHeaderLoading = current
|
||||
this.cdr.detectChanges()
|
||||
}
|
||||
}
|
||||
|
||||
onNavChange(navChangeEvent: NgbNavChangeEvent): void {
|
||||
const nextSection = this.getSectionForNavID(navChangeEvent.nextId)
|
||||
if (!nextSection) {
|
||||
return
|
||||
}
|
||||
this.router.navigate(['attributes', nextSection])
|
||||
}
|
||||
|
||||
private getDefaultNavID(): DocumentAttributesNavIDs | null {
|
||||
return this.visibleSections[0]?.id ?? null
|
||||
}
|
||||
|
||||
private getNavIDForSection(section: string): DocumentAttributesNavIDs | null {
|
||||
const path = section?.toLowerCase()
|
||||
if (!path) return null
|
||||
|
||||
const found = this.visibleSections.find((s) => s.path === path)
|
||||
return found?.id ?? null
|
||||
}
|
||||
|
||||
private getSectionForNavID(navID: number): string | null {
|
||||
const section = this.visibleSections.find((s) => s.id === navID)
|
||||
return section?.path ?? null
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { of } from 'rxjs'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { PageHeaderComponent } from '../../../../common/page-header/page-header.component'
|
||||
import { CorrespondentListComponent } from './correspondent-list.component'
|
||||
|
||||
describe('CorrespondentListComponent', () => {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
||||
import { NgClass, NgTemplateOutlet } from '@angular/common'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { RouterModule } from '@angular/router'
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
NgbPaginationModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { CorrespondentEditDialogComponent } from 'src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { FILTER_HAS_CORRESPONDENT_ANY } from 'src/app/data/filter-rule-type'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
@@ -14,21 +15,16 @@ import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { PermissionType } from 'src/app/services/permissions.service'
|
||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
|
||||
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { ManagementListComponent } from '../management-list/management-list.component'
|
||||
import { ManagementListComponent } from '../management-list.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-correspondent-list',
|
||||
templateUrl: './../management-list/management-list.component.html',
|
||||
styleUrls: ['./../management-list/management-list.component.scss'],
|
||||
templateUrl: './../management-list.component.html',
|
||||
styleUrls: ['./../management-list.component.scss'],
|
||||
providers: [{ provide: CustomDatePipe }],
|
||||
imports: [
|
||||
SortableDirective,
|
||||
IfPermissionsDirective,
|
||||
PageHeaderComponent,
|
||||
TitleCasePipe,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
@@ -37,11 +33,10 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
NgxBootstrapIconsModule,
|
||||
ClearableBadgeComponent,
|
||||
],
|
||||
})
|
||||
export class CorrespondentListComponent extends ManagementListComponent<Correspondent> {
|
||||
private datePipe = inject(CustomDatePipe)
|
||||
private readonly datePipe = inject(CustomDatePipe)
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
@@ -9,7 +9,7 @@ import { of } from 'rxjs'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { PageHeaderComponent } from '../../../../common/page-header/page-header.component'
|
||||
import { DocumentTypeListComponent } from './document-type-list.component'
|
||||
|
||||
describe('DocumentTypeListComponent', () => {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
||||
import { NgClass, NgTemplateOutlet } from '@angular/common'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { RouterModule } from '@angular/router'
|
||||
@@ -7,25 +7,21 @@ import {
|
||||
NgbPaginationModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { DocumentTypeEditDialogComponent } from 'src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
import { FILTER_HAS_DOCUMENT_TYPE_ANY } from 'src/app/data/filter-rule-type'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||
import { PermissionType } from 'src/app/services/permissions.service'
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
|
||||
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { ManagementListComponent } from '../management-list/management-list.component'
|
||||
import { ManagementListComponent } from '../management-list.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-document-type-list',
|
||||
templateUrl: './../management-list/management-list.component.html',
|
||||
styleUrls: ['./../management-list/management-list.component.scss'],
|
||||
templateUrl: './../management-list.component.html',
|
||||
styleUrls: ['./../management-list.component.scss'],
|
||||
imports: [
|
||||
SortableDirective,
|
||||
PageHeaderComponent,
|
||||
TitleCasePipe,
|
||||
IfPermissionsDirective,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
@@ -35,7 +31,6 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
NgxBootstrapIconsModule,
|
||||
ClearableBadgeComponent,
|
||||
],
|
||||
})
|
||||
export class DocumentTypeListComponent extends ManagementListComponent<DocumentType> {
|
||||
@@ -1,50 +1,3 @@
|
||||
<pngx-page-header title="{{ typeNamePlural | titlecase }}" info="View, add, edit and delete {{ typeNamePlural }}." infoLink="usage/#terms-and-definitions" [loading]="loading">
|
||||
|
||||
<div ngbDropdown class="btn-group flex-fill d-sm-none">
|
||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
|
||||
<i-bs name="text-indent-left"></i-bs>
|
||||
<div class="d-none d-sm-inline"> <ng-container i18n>Select</ng-container></div>
|
||||
@if (selectedObjects.size > 0) {
|
||||
<pngx-clearable-badge [selected]="selectedObjects.size > 0" [number]="selectedObjects.size" (cleared)="selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
||||
}
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
|
||||
<button ngbDropdownItem (click)="selectNone()" i18n>Select none</button>
|
||||
<button ngbDropdownItem (click)="selectPage(true)" i18n>Select page</button>
|
||||
<button ngbDropdownItem (click)="selectAll()" i18n>Select all</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-none d-sm-flex flex-fill me-3">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text border-0" i18n>Select:</span>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm flex-nowrap">
|
||||
@if (selectedObjects.size > 0) {
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="selectNone()">
|
||||
<i-bs name="slash-circle"></i-bs> <ng-container i18n>None</ng-container>
|
||||
</button>
|
||||
}
|
||||
<button class="btn btn-sm btn-outline-primary" (click)="selectPage(true)">
|
||||
<i-bs name="file-earmark-check"></i-bs> <ng-container i18n>Page</ng-container>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary" (click)="selectAll()">
|
||||
<i-bs name="check-all"></i-bs> <ng-container i18n>All</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="setPermissions()" [disabled]="!userCanBulkEdit(PermissionAction.Change) || selectedObjects.size === 0">
|
||||
<i-bs name="person-fill-lock"></i-bs> <ng-container i18n>Permissions</ng-container>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" (click)="delete()" [disabled]="!userCanBulkEdit(PermissionAction.Delete) || selectedObjects.size === 0">
|
||||
<i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="openCreateDialog()" *pngxIfPermissions="{ action: PermissionAction.Add, type: permissionType }">
|
||||
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Create</ng-container>
|
||||
</button>
|
||||
</pngx-page-header>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col mb-2 mb-xl-0">
|
||||
<div class="form-inline d-flex align-items-center">
|
||||
@@ -76,19 +29,19 @@
|
||||
<table class="table table-striped align-middle shadow-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">
|
||||
<th>
|
||||
<div class="form-check m-0 ms-2 me-n2">
|
||||
<input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="togggleAll" [disabled]="data.length === 0" (change)="selectPage($event.target.checked); $event.stopPropagation();">
|
||||
<input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="togggleAll" [disabled]="data.length === 0" (change)="$event.target.checked ? selectPage() : clearSelection(); $event.stopPropagation();">
|
||||
<label class="form-check-label" for="all-objects"></label>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="fw-normal" pngxSortable="name" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Name</th>
|
||||
<th scope="col" class="fw-normal d-none d-sm-table-cell" pngxSortable="matching_algorithm" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Matching</th>
|
||||
<th scope="col" class="fw-normal" pngxSortable="document_count" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Document count</th>
|
||||
<th class="fw-normal" pngxSortable="name" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Name</th>
|
||||
<th class="fw-normal d-none d-sm-table-cell" pngxSortable="matching_algorithm" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Matching</th>
|
||||
<th class="fw-normal" pngxSortable="document_count" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Document count</th>
|
||||
@for (column of extraColumns; track column) {
|
||||
<th scope="col" class="fw-normal" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }" pngxSortable="{{column.key}}" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)">{{column.name}}</th>
|
||||
<th class="fw-normal" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }" pngxSortable="{{column.key}}" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)">{{column.name}}</th>
|
||||
}
|
||||
<th scope="col" class="fw-normal" i18n>Actions</th>
|
||||
<th class="fw-normal" i18n>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -131,16 +84,16 @@
|
||||
<label class="form-check-label" for="{{typeName}}{{object.id}}"></label>
|
||||
</div>
|
||||
</td>
|
||||
<td scope="row" class="name-cell" style="--depth: {{depth}}">
|
||||
<td class="name-cell" style="--depth: {{depth}}">
|
||||
@if (depth > 0) {
|
||||
<div class="indicator"></div>
|
||||
}
|
||||
<button class="btn btn-link ms-0 ps-0 text-start" (click)="userCanEdit(object) ? openEditDialog(object) : null; $event.stopPropagation()">{{ object.name }}</button>
|
||||
</td>
|
||||
<td scope="row" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
|
||||
<td scope="row">{{ getDocumentCount(object) }}</td>
|
||||
<td class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
|
||||
<td>{{ getDocumentCount(object) }}</td>
|
||||
@for (column of extraColumns; track column) {
|
||||
<td scope="row" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }">
|
||||
<td [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }">
|
||||
@if (column.badgeFn) {
|
||||
<span
|
||||
class="badge"
|
||||
@@ -156,7 +109,7 @@
|
||||
}
|
||||
</td>
|
||||
}
|
||||
<td scope="row">
|
||||
<td>
|
||||
<div class="btn-toolbar gap-2">
|
||||
<div class="btn-group d-block d-sm-none">
|
||||
<div ngbDropdown container="body" class="d-inline-block">
|
||||
@@ -44,12 +44,12 @@ import { BulkEditObjectOperation } from 'src/app/services/rest/abstract-name-fil
|
||||
import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { EditDialogComponent } from '../../common/edit-dialog/edit-dialog.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
|
||||
import { TagListComponent } from '../tag-list/tag-list.component'
|
||||
import { ConfirmDialogComponent } from '../../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { EditDialogComponent } from '../../../common/edit-dialog/edit-dialog.component'
|
||||
import { PageHeaderComponent } from '../../../common/page-header/page-header.component'
|
||||
import { PermissionsDialogComponent } from '../../../common/permissions-dialog/permissions-dialog.component'
|
||||
import { ManagementListComponent } from './management-list.component'
|
||||
import { TagListComponent } from './tag-list/tag-list.component'
|
||||
|
||||
const tags: Tag[] = [
|
||||
{
|
||||
@@ -304,12 +304,12 @@ describe('ManagementListComponent', () => {
|
||||
})
|
||||
|
||||
it('selectPage should select current page items or clear selection', () => {
|
||||
component.selectPage(true)
|
||||
component.selectPage()
|
||||
expect(component.selectedObjects).toEqual(new Set(tags.map((t) => t.id)))
|
||||
expect(component.togggleAll).toBe(true)
|
||||
|
||||
component.togggleAll = true
|
||||
component.selectPage(false)
|
||||
component.clearSelection()
|
||||
expect(component.selectedObjects.size).toBe(0)
|
||||
expect(component.togggleAll).toBe(false)
|
||||
})
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
takeUntil,
|
||||
tap,
|
||||
} from 'rxjs/operators'
|
||||
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
|
||||
import { EditDialogMode } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
||||
import { PermissionsDialogComponent } from 'src/app/components/common/permissions-dialog/permissions-dialog.component'
|
||||
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component'
|
||||
import {
|
||||
MATCH_AUTO,
|
||||
MATCH_NONE,
|
||||
@@ -40,10 +44,6 @@ import {
|
||||
} from 'src/app/services/rest/abstract-name-filter-service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
||||
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
|
||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||
|
||||
export interface ManagementListColumn {
|
||||
key: string
|
||||
@@ -69,13 +69,14 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
implements OnInit, OnDestroy
|
||||
{
|
||||
protected service: AbstractNameFilterService<T>
|
||||
private modalService: NgbModal = inject(NgbModal)
|
||||
private readonly modalService: NgbModal = inject(NgbModal)
|
||||
protected editDialogComponent: any
|
||||
private toastService: ToastService = inject(ToastService)
|
||||
private documentListViewService: DocumentListViewService = inject(
|
||||
private readonly toastService: ToastService = inject(ToastService)
|
||||
private readonly documentListViewService: DocumentListViewService = inject(
|
||||
DocumentListViewService
|
||||
)
|
||||
private permissionsService: PermissionsService = inject(PermissionsService)
|
||||
private readonly permissionsService: PermissionsService =
|
||||
inject(PermissionsService)
|
||||
protected filterRuleType: number
|
||||
public typeName: string
|
||||
public typeNamePlural: string
|
||||
@@ -196,7 +197,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
}
|
||||
|
||||
openCreateDialog() {
|
||||
var activeModal = this.modalService.open(this.editDialogComponent, {
|
||||
const activeModal = this.modalService.open(this.editDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
activeModal.componentInstance.dialogMode = EditDialogMode.CREATE
|
||||
@@ -215,7 +216,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
}
|
||||
|
||||
openEditDialog(object: T) {
|
||||
var activeModal = this.modalService.open(this.editDialogComponent, {
|
||||
const activeModal = this.modalService.open(this.editDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
activeModal.componentInstance.object = object
|
||||
@@ -243,7 +244,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
}
|
||||
|
||||
openDeleteDialog(object: T) {
|
||||
var activeModal = this.modalService.open(ConfirmDialogComponent, {
|
||||
const activeModal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
activeModal.componentInstance.title = $localize`Confirm delete`
|
||||
@@ -343,13 +344,9 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
this.clearSelection()
|
||||
}
|
||||
|
||||
selectPage(select: boolean) {
|
||||
if (select) {
|
||||
this.selectedObjects = new Set(this.getSelectableIDs(this.data))
|
||||
this.togggleAll = this.areAllPageItemsSelected()
|
||||
} else {
|
||||
this.clearSelection()
|
||||
}
|
||||
selectPage() {
|
||||
this.selectedObjects = new Set(this.getSelectableIDs(this.data))
|
||||
this.togggleAll = this.areAllPageItemsSelected()
|
||||
}
|
||||
|
||||
selectAll() {
|
||||
@@ -10,7 +10,7 @@ import { StoragePath } from 'src/app/data/storage-path'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { PageHeaderComponent } from '../../../../common/page-header/page-header.component'
|
||||
import { StoragePathListComponent } from './storage-path-list.component'
|
||||
|
||||
describe('StoragePathListComponent', () => {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
||||
import { NgClass, NgTemplateOutlet } from '@angular/common'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { RouterModule } from '@angular/router'
|
||||
@@ -7,25 +7,21 @@ import {
|
||||
NgbPaginationModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { StoragePathEditDialogComponent } from 'src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
|
||||
import { FILTER_HAS_STORAGE_PATH_ANY } from 'src/app/data/filter-rule-type'
|
||||
import { StoragePath } from 'src/app/data/storage-path'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||
import { PermissionType } from 'src/app/services/permissions.service'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
|
||||
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { ManagementListComponent } from '../management-list/management-list.component'
|
||||
import { ManagementListComponent } from '../management-list.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-storage-path-list',
|
||||
templateUrl: './../management-list/management-list.component.html',
|
||||
styleUrls: ['./../management-list/management-list.component.scss'],
|
||||
templateUrl: './../management-list.component.html',
|
||||
styleUrls: ['./../management-list.component.scss'],
|
||||
imports: [
|
||||
SortableDirective,
|
||||
PageHeaderComponent,
|
||||
TitleCasePipe,
|
||||
IfPermissionsDirective,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
@@ -35,7 +31,6 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
NgxBootstrapIconsModule,
|
||||
ClearableBadgeComponent,
|
||||
],
|
||||
})
|
||||
export class StoragePathListComponent extends ManagementListComponent<StoragePath> {
|
||||
@@ -9,7 +9,7 @@ import { of } from 'rxjs'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||
import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { PageHeaderComponent } from '../../../../common/page-header/page-header.component'
|
||||
import { TagListComponent } from './tag-list.component'
|
||||
|
||||
describe('TagListComponent', () => {
|
||||
@@ -138,12 +138,12 @@ describe('TagListComponent', () => {
|
||||
}
|
||||
|
||||
component.data = [parent as any]
|
||||
component.selectPage(true)
|
||||
component.selectPage()
|
||||
|
||||
expect(component.selectedObjects.has(10)).toBe(true)
|
||||
expect(component.selectedObjects.has(11)).toBe(true)
|
||||
|
||||
component.selectPage(false)
|
||||
component.clearSelection()
|
||||
expect(component.selectedObjects.size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
||||
import { NgClass, NgTemplateOutlet } from '@angular/common'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { RouterModule } from '@angular/router'
|
||||
@@ -7,25 +7,21 @@ import {
|
||||
NgbPaginationModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { TagEditDialogComponent } from 'src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
||||
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||
import { PermissionType } from 'src/app/services/permissions.service'
|
||||
import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
|
||||
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { ManagementListComponent } from '../management-list/management-list.component'
|
||||
import { ManagementListComponent } from '../management-list.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-tag-list',
|
||||
templateUrl: './../management-list/management-list.component.html',
|
||||
styleUrls: ['./../management-list/management-list.component.scss'],
|
||||
templateUrl: './../management-list.component.html',
|
||||
styleUrls: ['./../management-list.component.scss'],
|
||||
imports: [
|
||||
SortableDirective,
|
||||
PageHeaderComponent,
|
||||
TitleCasePipe,
|
||||
IfPermissionsDirective,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
@@ -35,7 +31,6 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
NgxBootstrapIconsModule,
|
||||
ClearableBadgeComponent,
|
||||
],
|
||||
})
|
||||
export class TagListComponent extends ManagementListComponent<Tag> {
|
||||
@@ -84,4 +84,6 @@ export interface MailRule extends ObjectWithPermissions {
|
||||
assign_correspondent?: number // PaperlessCorrespondent.id
|
||||
|
||||
assign_owner_from_rule: boolean
|
||||
|
||||
stop_processing: boolean
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ export enum GlobalSearchType {
|
||||
TITLE_CONTENT = 'title-content',
|
||||
}
|
||||
|
||||
export enum CollapsibleSection {
|
||||
ATTRIBUTES = 'attributes',
|
||||
}
|
||||
|
||||
export const PAPERLESS_GREEN_HEX = '#17541f'
|
||||
|
||||
export const SETTINGS_KEYS = {
|
||||
@@ -51,6 +55,8 @@ export const SETTINGS_KEYS = {
|
||||
NOTES_ENABLED: 'general-settings:notes-enabled',
|
||||
AUDITLOG_ENABLED: 'general-settings:auditlog-enabled',
|
||||
SLIM_SIDEBAR: 'general-settings:slim-sidebar',
|
||||
ATTRIBUTES_SECTIONS_COLLAPSED:
|
||||
'general-settings:attributes-sections-collapsed',
|
||||
UPDATE_CHECKING_ENABLED: 'general-settings:update-checking:enabled',
|
||||
UPDATE_CHECKING_BACKEND_SETTING:
|
||||
'general-settings:update-checking:backend-setting',
|
||||
@@ -112,6 +118,11 @@ export const SETTINGS: UiSetting[] = [
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
|
||||
type: 'array',
|
||||
default: [],
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DOCUMENT_LIST_SIZE,
|
||||
type: 'number',
|
||||
|
||||
@@ -96,4 +96,52 @@ describe('PermissionsGuard', () => {
|
||||
expect(canActivate).toHaveProperty('root') // returns UrlTree
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should activate when any required permission is granted', () => {
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserCan')
|
||||
.mockImplementation((action, type) => {
|
||||
return type === PermissionType.Tag
|
||||
})
|
||||
|
||||
const canActivate = guard.canActivate(
|
||||
{
|
||||
data: {
|
||||
requiredPermissionAny: [
|
||||
{ action: PermissionAction.View, type: PermissionType.Tag },
|
||||
{
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.DocumentType,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any,
|
||||
routerState.snapshot
|
||||
)
|
||||
|
||||
expect(canActivate).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should not activate when no required permission is granted', () => {
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserCan')
|
||||
.mockImplementation(() => false)
|
||||
|
||||
const canActivate = guard.canActivate(
|
||||
{
|
||||
data: {
|
||||
requiredPermissionAny: [
|
||||
{ action: PermissionAction.View, type: PermissionType.Tag },
|
||||
{
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.DocumentType,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any,
|
||||
routerState.snapshot
|
||||
)
|
||||
|
||||
expect(canActivate).toHaveProperty('root')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,12 +20,20 @@ export class PermissionsGuard {
|
||||
route: ActivatedRouteSnapshot,
|
||||
state: RouterStateSnapshot
|
||||
): boolean | UrlTree {
|
||||
const requiredPermissionAny: { action: any; type: any }[] =
|
||||
route.data.requiredPermissionAny
|
||||
|
||||
if (
|
||||
(route.data.requireAdmin && !this.permissionsService.isAdmin()) ||
|
||||
(route.data.requiredPermission &&
|
||||
!this.permissionsService.currentUserCan(
|
||||
route.data.requiredPermission.action,
|
||||
route.data.requiredPermission.type
|
||||
)) ||
|
||||
(Array.isArray(requiredPermissionAny) &&
|
||||
requiredPermissionAny.length > 0 &&
|
||||
!requiredPermissionAny.some((p) =>
|
||||
this.permissionsService.currentUserCan(p.action, p.type)
|
||||
))
|
||||
) {
|
||||
// Check if tour is running 1 = TourState.ON
|
||||
|
||||
@@ -33,6 +33,7 @@ const mail_rules = [
|
||||
action: MailAction.MarkRead,
|
||||
assign_title_from: MailMetadataTitleOption.FromSubject,
|
||||
assign_owner_from_rule: true,
|
||||
stop_processing: false,
|
||||
},
|
||||
{
|
||||
name: 'Mail Rule 2',
|
||||
@@ -52,6 +53,7 @@ const mail_rules = [
|
||||
action: MailAction.Delete,
|
||||
assign_title_from: MailMetadataTitleOption.FromSubject,
|
||||
assign_owner_from_rule: true,
|
||||
stop_processing: false,
|
||||
},
|
||||
{
|
||||
name: 'Mail Rule 3',
|
||||
@@ -71,6 +73,7 @@ const mail_rules = [
|
||||
action: MailAction.Flag,
|
||||
assign_title_from: MailMetadataTitleOption.FromSubject,
|
||||
assign_owner_from_rule: false,
|
||||
stop_processing: false,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -125,6 +125,7 @@ import {
|
||||
sliders2Vertical,
|
||||
sortAlphaDown,
|
||||
sortAlphaUpAlt,
|
||||
stack,
|
||||
stars,
|
||||
tag,
|
||||
tagFill,
|
||||
@@ -343,6 +344,7 @@ const icons = {
|
||||
sliders2Vertical,
|
||||
sortAlphaDown,
|
||||
sortAlphaUpAlt,
|
||||
stack,
|
||||
stars,
|
||||
tagFill,
|
||||
tag,
|
||||
|
||||
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-02-03 20:10+0000\n"
|
||||
"POT-Creation-Date: 2026-02-13 17:37+0000\n"
|
||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
@@ -2220,7 +2220,7 @@ msgstr ""
|
||||
msgid "account"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:157 paperless_mail/models.py:318
|
||||
#: paperless_mail/models.py:157 paperless_mail/models.py:326
|
||||
msgid "folder"
|
||||
msgstr ""
|
||||
|
||||
@@ -2312,26 +2312,36 @@ msgstr ""
|
||||
msgid "Assign the rule owner to documents"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:326
|
||||
msgid "uid"
|
||||
#: paperless_mail/models.py:305
|
||||
msgid "Stop processing further rules"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:308
|
||||
msgid ""
|
||||
"If True, no further rules will be processed after this one if any document "
|
||||
"is queued."
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:334
|
||||
msgid "subject"
|
||||
msgid "uid"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:342
|
||||
msgid "subject"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:350
|
||||
msgid "received"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:349
|
||||
#: paperless_mail/models.py:357
|
||||
msgid "processed"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:355
|
||||
#: paperless_mail/models.py:363
|
||||
msgid "status"
|
||||
msgstr ""
|
||||
|
||||
#: paperless_mail/models.py:363
|
||||
#: paperless_mail/models.py:371
|
||||
msgid "error"
|
||||
msgstr ""
|
||||
|
||||
@@ -575,6 +575,11 @@ class MailAccountHandler(LoggingMixin):
|
||||
rule,
|
||||
supports_gmail_labels=supports_gmail_labels,
|
||||
)
|
||||
if total_processed_files > 0 and rule.stop_processing:
|
||||
self.log.debug(
|
||||
f"Rule {rule}: Stopping processing rules due to stop_processing flag",
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
self.log.exception(
|
||||
f"Rule {rule}: Error while processing rule: {e}",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.1.6 on 2025-02-24 16:07
|
||||
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("paperless_mail", "0002_optimize_integer_field_sizes"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="mailrule",
|
||||
name="stop_processing",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="If True, no further rules will be processed after this one if any document is consumed.",
|
||||
verbose_name="Stop processing further rules",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -301,6 +301,14 @@ class MailRule(document_models.ModelWithOwner):
|
||||
default=True,
|
||||
)
|
||||
|
||||
stop_processing = models.BooleanField(
|
||||
_("Stop processing further rules"),
|
||||
default=False,
|
||||
help_text=_(
|
||||
"If True, no further rules will be processed after this one if any document is queued.",
|
||||
),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.account.name}.{self.name}"
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ class MailRuleSerializer(OwnedObjectSerializer):
|
||||
"user_can_change",
|
||||
"permissions",
|
||||
"set_permissions",
|
||||
"stop_processing",
|
||||
]
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
|
||||
@@ -1692,6 +1692,39 @@ class TestTasks(TestCase):
|
||||
result = tasks.process_mail_accounts(account_ids=[account_b.id])
|
||||
self.assertIn("No new", result)
|
||||
|
||||
@mock.patch("paperless_mail.tasks.MailAccountHandler.handle_mail_account")
|
||||
def test_rule_with_stop_processing(self, m):
|
||||
"""
|
||||
GIVEN:
|
||||
- Mail account with a rule with stop_processing=True
|
||||
WHEN:
|
||||
- Mail account is processed
|
||||
THEN:
|
||||
- Should only process the first rule
|
||||
"""
|
||||
m.side_effect = lambda account: 6
|
||||
|
||||
account = MailAccount.objects.create(
|
||||
name="A",
|
||||
imap_server="A",
|
||||
username="A",
|
||||
password="A",
|
||||
)
|
||||
MailRule.objects.create(
|
||||
name="A",
|
||||
account=account,
|
||||
stop_processing=True,
|
||||
)
|
||||
MailRule.objects.create(
|
||||
name="B",
|
||||
account=account,
|
||||
)
|
||||
|
||||
result = tasks.process_mail_accounts()
|
||||
|
||||
self.assertEqual(m.call_count, 1)
|
||||
self.assertIn("Added 6", result)
|
||||
|
||||
|
||||
class TestMailAccountTestView(APITestCase):
|
||||
def setUp(self) -> None:
|
||||
@@ -1777,8 +1810,8 @@ class TestMailAccountTestView(APITestCase):
|
||||
)
|
||||
def test_mail_account_test_view_refresh_token_fails(
|
||||
self,
|
||||
mock_mock_refresh_account_oauth_token,
|
||||
):
|
||||
mock_mock_refresh_account_oauth_token: mock.MagicMock,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Mail account with expired token
|
||||
|
||||
78
uv.lock
generated
78
uv.lock
generated
@@ -2181,7 +2181,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "llama-index-core"
|
||||
version = "0.14.13"
|
||||
version = "0.14.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||
@@ -2214,9 +2214,9 @@ dependencies = [
|
||||
{ name = "typing-inspect", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||
{ name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/54/d6043a088e5e9c1d62300db7ad0ef417c6b9a92f7b4a5cade066aeafdaca/llama_index_core-0.14.13.tar.gz", hash = "sha256:c3b30d20ae0407e5d0a1d35bb3376a98e242661ebfc22da754b5a3da1f8108c0", size = 11589074, upload-time = "2026-01-21T20:44:16.287Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/04/fdbb528681342d362cce55a58a52b620394b1d332c53f8d8d4c54a33a228/llama_index_core-0.14.14.tar.gz", hash = "sha256:505a252c7e357cb12fb9bef41498ae2be3f2baa07666a939f8107f3faef2823b", size = 11587282, upload-time = "2026-02-10T23:07:54.27Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/59/9769f03f1cccadcc014b3b65c166de18999b51459a0f0a579d80f6c91d80/llama_index_core-0.14.13-py3-none-any.whl", hash = "sha256:392f0a5a09433e9dea786964ef5fe5ca2a2b10aee9f979a9507c19a14da2a20a", size = 11934761, upload-time = "2026-01-21T20:44:18.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/96/962ef4088b489e2e4c5392910744bd1206bfb89de4c0d98e93cf0125f985/llama_index_core-0.14.14-py3-none-any.whl", hash = "sha256:b62512a4ba1911b50b30d25d721535b30c9242f5f2dd6a4d5a4a712cca7b570e", size = 11930841, upload-time = "2026-02-10T23:07:56.698Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2287,14 +2287,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "llama-index-vector-stores-faiss"
|
||||
version = "0.5.2"
|
||||
version = "0.5.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "llama-index-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2d/5f/c4ae340f178f202cf09dcc24dd0953a41d9ab24bc33e1f7220544ba86e41/llama_index_vector_stores_faiss-0.5.2.tar.gz", hash = "sha256:924504765e68b1f84ec602feb2d9516be6a6c12fad5e133f19cc5da3b23f4282", size = 5910, upload-time = "2025-12-17T21:01:13.21Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c5/e6/57da31b38d173cd9124fdcdd47487b9a917b69bd49e8f6e551407ccfa860/llama_index_vector_stores_faiss-0.5.3.tar.gz", hash = "sha256:9620b1e27e96233fda88878c453532fba6061cf7ba7a53698a34703faab21ece", size = 6048, upload-time = "2026-02-12T14:22:14.612Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/c1/c8317250c2a83d1d439814d1a7f41fa34a23c224b3099da898f08a249859/llama_index_vector_stores_faiss-0.5.2-py3-none-any.whl", hash = "sha256:72a3a03d9f25c70bbcc8c61aa860cd1db69f2a8070606ecc3266d767b71ff2a2", size = 7605, upload-time = "2025-12-17T21:01:12.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/ad/ad192dd624ca2875b8ca74e55fddf9b083d6614524004f7830379d0a0cfd/llama_index_vector_stores_faiss-0.5.3-py3-none-any.whl", hash = "sha256:ef186e38a820e696a1adca15432c8539d73f2959eb05671011db21091a286c8c", size = 7738, upload-time = "2026-02-12T14:22:13.756Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2766,9 +2766,9 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mysqlclient"
|
||||
version = "2.2.7"
|
||||
version = "2.2.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/68/810093cb579daae426794bbd9d88aa830fae296e85172d18cb0f0e5dd4bc/mysqlclient-2.2.7.tar.gz", hash = "sha256:24ae22b59416d5fcce7e99c9d37548350b4565baac82f95e149cac6ce4163845", size = 91383, upload-time = "2025-01-10T12:06:00.763Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/b0/9df076488cb2e536d40ce6dbd4273c1f20a386e31ffe6e7cb613902b3c2a/mysqlclient-2.2.8.tar.gz", hash = "sha256:8ed20c5615a915da451bb308c7d0306648a4fd9a2809ba95c992690006306199", size = 92287, upload-time = "2026-02-10T10:58:37.405Z" }
|
||||
|
||||
[[package]]
|
||||
name = "nest-asyncio"
|
||||
@@ -4618,24 +4618,24 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.0"
|
||||
version = "0.15.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5480,11 +5480,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-markdown"
|
||||
version = "3.10.0.20251106"
|
||||
version = "3.10.2.20260211"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/de/e4/060f0dadd9b551cae77d6407f2bc84b168f918d90650454aff219c1b3ed2/types_markdown-3.10.0.20251106.tar.gz", hash = "sha256:12836f7fcbd7221db8baeb0d3a2f820b95050d0824bfa9665c67b4d144a1afa1", size = 19486, upload-time = "2025-11-06T03:06:44.317Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/2e/35b30a09f6ee8a69142408d3ceb248c4454aa638c0a414d8704a3ef79563/types_markdown-3.10.2.20260211.tar.gz", hash = "sha256:66164310f88c11a58c6c706094c6f8c537c418e3525d33b76276a5fbd66b01ce", size = 19768, upload-time = "2026-02-11T04:19:29.497Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/92/58/f666ca9391f2a8bd33bb0b0797cde6ac3e764866708d5f8aec6fab215320/types_markdown-3.10.0.20251106-py3-none-any.whl", hash = "sha256:2c39512a573899b59efae07e247ba088a75b70e3415e81277692718f430afd7e", size = 25862, upload-time = "2025-11-06T03:06:43.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/c9/659fa2df04b232b0bfcd05d2418e683080e91ec68f636f3c0a5a267350e7/types_markdown-3.10.2.20260211-py3-none-any.whl", hash = "sha256:2d94d08587e3738203b3c4479c449845112b171abe8b5cadc9b0c12fcf3e99da", size = 25854, upload-time = "2026-02-11T04:19:28.647Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6094,7 +6094,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "zensical"
|
||||
version = "0.0.21"
|
||||
version = "0.0.23"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||
@@ -6105,18 +6105,18 @@ dependencies = [
|
||||
{ name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||
{ name = "tomli", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux')" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/50/2655b5f72d0c72f4366be580f5e2354ff05280d047ea986fe89570e44589/zensical-0.0.21.tar.gz", hash = "sha256:c13563836fa63a3cabeffd83fe3a770ca740cfa5ae7b85df85d89837e31b3b4a", size = 3819731, upload-time = "2026-02-04T17:47:59.396Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/ab/a65452b4e769552fd5a78c4996d6cf322630d896ddfd55c5433d96485e8b/zensical-0.0.23.tar.gz", hash = "sha256:5c4fc3aaf075df99d8cf41b9f2566e4d588180d9a89493014d3607dfe50ac4bc", size = 3822451, upload-time = "2026-02-11T21:24:38.373Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/98/90710d232cb35b633815fa7b493da542391b89283b6103a5bb4ae9fc0dd9/zensical-0.0.21-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:67404cc70c330246dfb7269bcdb60a25be0bb60a212a09c9c50229a1341b1f84", size = 12237120, upload-time = "2026-02-04T17:47:28.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/fb/4280b3781157e8f051711732192f949bf29beeafd0df3e33c1c8bf9b7a1a/zensical-0.0.21-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d4fd253ccfbf5af56434124f13bac01344e456c020148369b18d8836b6537c3c", size = 12118047, upload-time = "2026-02-04T17:47:31.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/b3/b7f85ae9cf920cf9f17bf157ae6c274919477148feb7716bf735636caa0e/zensical-0.0.21-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:440e40cdc30a29bf7466bcd6f43ed7bd1c54ea3f1a0fefca65619358b481a5bc", size = 12473440, upload-time = "2026-02-04T17:47:33.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/ac/1dc6e98f79ed19b9f103c88a0bd271f9140565d7d26b64bc1542b3ef6d91/zensical-0.0.21-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:368e832fc8068e75dc45cab59379db4cefcd81eb116f48d058db8fb7b7aa8d14", size = 12412588, upload-time = "2026-02-04T17:47:36.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/76/16a580f6dd32b387caa4a41615451e7dddd1917a2ff2e5b08744f41b4e11/zensical-0.0.21-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f4ab962d47f9dd73510eed168469326c7a452554dfbfdb9cdf85efc7140244df", size = 12749438, upload-time = "2026-02-04T17:47:38.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/30/4baaa1c910eee61db5f49d0d45f2e550a0027218c618f3dd7f8da966a019/zensical-0.0.21-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b846d53dfce007f056ff31848f87f3f2a388228e24d4851c0cafdce0fa204c9b", size = 12514504, upload-time = "2026-02-04T17:47:41.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/77/931fccae5580b94409a0448a26106f922dcfa7822e7b93cacd2876dd63a8/zensical-0.0.21-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:daac1075552d230d52d621d2e4754ba24d5afcaa201a7a991f1a8d57e320c9de", size = 12647832, upload-time = "2026-02-04T17:47:44.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/82/3cf75de64340829d55c87c36704f4d1d8c952bd2cdc8a7bc48cbfb8ab333/zensical-0.0.21-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:7b380f545adb6d40896f9bd698eb0e1540ed4258d35b83f55f91658d0fdae312", size = 12678537, upload-time = "2026-02-04T17:47:46.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/91/6f4938dceeaa241f78bbfaf58a94acef10ba18be3468795173e3087abeb6/zensical-0.0.21-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:5c2227fdab64616bea94b40b8340bafe00e2e23631cc58eeea1e7267167e6ac5", size = 12822164, upload-time = "2026-02-04T17:47:49.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/4e/a9c9d25ef0766f767db7b4f09da68da9b3d8a28c3d68cfae01f8e3f9e297/zensical-0.0.21-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2e0f5154d236ed0f98662ee68785b67e8cd2138ea9d5e26070649e93c22eeee0", size = 12785632, upload-time = "2026-02-04T17:47:52.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/86/035aa02bd36d26a03a1885bc22a73d4fe61ba0e21d0033cc42baf13d24f6/zensical-0.0.23-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:35d6d3eb803fe73a67187a1a25443408bd02a8dd50e151f4a4bafd40de3f0928", size = 12242966, upload-time = "2026-02-11T21:24:05.894Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/68/335dfbb7efc972964f0610736a0ad243dd8a5dcc2ec76b9ddb84c847a4a4/zensical-0.0.23-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:5973267460a190f348f24d445ff0c01e8ed334fd075947687b305e68257f6b18", size = 12125173, upload-time = "2026-02-11T21:24:08.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/9c/d567da04fbeb077df5cf06a94f947af829ebef0ff5ca7d0ba4910a6cbdf6/zensical-0.0.23-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:953adf1f0b346a6c65fc6e05e6cc1c38a6440fec29c50c76fb29700cc1927006", size = 12489636, upload-time = "2026-02-11T21:24:10.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/6e/481a3ecf8a7b63a35c67f5be1ea548185d55bb1dacead54f76a9550197b2/zensical-0.0.23-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49c1cbd6131dafa056be828e081759184f9b8dd24b99bf38d1e77c8c31b0c720", size = 12421313, upload-time = "2026-02-11T21:24:13.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/aa/a95481547f708432636f5f8155917c90d877c244c62124a084f7448b60b2/zensical-0.0.23-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5b7fe22c5d33b2b91899c5df7631ad4ce9cccfabac2560cc92ba73eafe2d297", size = 12761031, upload-time = "2026-02-11T21:24:17.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/9f/ce1c5af9afd11fe3521a90441aba48c484f98730c6d833d69ee4387ae2e9/zensical-0.0.23-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a3679d6bf6374f503afb74d9f6061da5de83c25922f618042b63a30b16f0389", size = 12527415, upload-time = "2026-02-11T21:24:19.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/b8/13a5d4d99f3b77e7bf4e791ef991a611ca2f108ed7eddf20858544ab0a91/zensical-0.0.23-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:54d981e21a19c3dcec6e7fa77c4421db47389dfdff20d29fea70df8e1be4062e", size = 12665352, upload-time = "2026-02-11T21:24:22.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/84/3d0a187ed941826ca26b19a661c41685d8017b2a019afa0d353eb2ebbdba/zensical-0.0.23-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:afde7865cc3c79c99f6df4a911d638fb2c3b472a1b81367d47163f8e3c36f910", size = 12689042, upload-time = "2026-02-11T21:24:26.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/65/12466408f428f2cf7140b32d484753db0891debae3c956f4c076b51eeb17/zensical-0.0.23-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:c484674d7b0a3e6d39db83914db932249bccdef2efaf8a5669671c66c16f584d", size = 12834779, upload-time = "2026-02-11T21:24:28.788Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/ab/0771ac6ffb30e4f04c20374e3beca9e71c3f81112219cdbd86cdc0e3d337/zensical-0.0.23-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:927d12fe2851f355fb3206809e04641d6651bdd2ff4afe9c205721aa3a32aa82", size = 12797057, upload-time = "2026-02-11T21:24:31.383Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user