mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-07-28 18:24:38 -05:00
Enhancement: dashboard improvements, drag-n-drop reorder dashboard views (#4252)
* Updated dashboard * Make entire screen dropzone on dashboard too * Floating upload widget status alerts * Visual tweaks: spacing, borders * Better empty view widget * Support drag + drop reorder of dashboard saved views * Update messages.xlf * Disable dashbaord dnd if global dnd active * Remove ngx-file-drop dep, rebuild file-drop & upload files widget * Revert custom file drop implementation * Try patch-package fix * Simplify dropzone transitions to make more reliable * Update messages.xlf * Update dashboard.spec.ts * Fix coverage
This commit is contained in:
@@ -1,16 +1,10 @@
|
||||
<pngx-toasts></pngx-toasts>
|
||||
|
||||
<ngx-file-drop dropZoneClassName="main-dropzone" contentClassName="main-content" [disabled]="!dragDropEnabled"
|
||||
(onFileDrop)="dropped($event)" (onFileOver)="fileOver()" (onFileLeave)="fileLeave()">
|
||||
<ng-template ngx-file-drop-content-tmp>
|
||||
<div class="global-dropzone-overlay fade" [class.show]="fileIsOver" [class.hide]="hidden">
|
||||
<h2 i18n>Drop files to begin upload</h2>
|
||||
</div>
|
||||
<div [class.inert]="fileIsOver">
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ngx-file-drop>
|
||||
<pngx-file-drop>
|
||||
<ng-container content>
|
||||
<router-outlet></router-outlet>
|
||||
</ng-container>
|
||||
</pngx-file-drop>
|
||||
|
||||
<tour-step-template>
|
||||
<ng-template #tourStep let-step="step">
|
||||
|
@@ -2,14 +2,11 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||
import {
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
discardPeriodicTasks,
|
||||
fakeAsync,
|
||||
tick,
|
||||
} from '@angular/core/testing'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { Router } from '@angular/router'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import { NgxFileDropModule } from 'ngx-file-drop'
|
||||
import { TourService, TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { Subject } from 'rxjs'
|
||||
import { routes } from './app-routing.module'
|
||||
@@ -21,8 +18,9 @@ import {
|
||||
} from './services/consumer-status.service'
|
||||
import { PermissionsService } from './services/permissions.service'
|
||||
import { ToastService, Toast } from './services/toast.service'
|
||||
import { UploadDocumentsService } from './services/upload-documents.service'
|
||||
import { SettingsService } from './services/settings.service'
|
||||
import { FileDropComponent } from './components/file-drop/file-drop.component'
|
||||
import { NgxFileDropModule } from 'ngx-file-drop'
|
||||
|
||||
describe('AppComponent', () => {
|
||||
let component: AppComponent
|
||||
@@ -33,11 +31,10 @@ describe('AppComponent', () => {
|
||||
let toastService: ToastService
|
||||
let router: Router
|
||||
let settingsService: SettingsService
|
||||
let uploadDocumentsService: UploadDocumentsService
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [AppComponent, ToastsComponent],
|
||||
declarations: [AppComponent, ToastsComponent, FileDropComponent],
|
||||
providers: [],
|
||||
imports: [
|
||||
HttpClientTestingModule,
|
||||
@@ -53,7 +50,6 @@ describe('AppComponent', () => {
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
router = TestBed.inject(Router)
|
||||
uploadDocumentsService = TestBed.inject(UploadDocumentsService)
|
||||
fixture = TestBed.createComponent(AppComponent)
|
||||
component = fixture.componentInstance
|
||||
})
|
||||
@@ -72,6 +68,7 @@ describe('AppComponent', () => {
|
||||
}))
|
||||
|
||||
it('should display toast on document consumed with link if user has access', () => {
|
||||
const navigateSpy = jest.spyOn(router, 'navigate')
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
let toast: Toast
|
||||
toastService.getToasts().subscribe((toasts) => (toast = toasts[0]))
|
||||
@@ -81,9 +78,13 @@ describe('AppComponent', () => {
|
||||
.spyOn(consumerStatusService, 'onDocumentConsumptionFinished')
|
||||
.mockReturnValue(fileStatusSubject)
|
||||
component.ngOnInit()
|
||||
fileStatusSubject.next(new FileStatus())
|
||||
const status = new FileStatus()
|
||||
status.documentId = 1
|
||||
fileStatusSubject.next(status)
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
expect(toast.action).not.toBeUndefined()
|
||||
toast.action()
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['documents', status.documentId])
|
||||
})
|
||||
|
||||
it('should display toast on document consumed without link if user does not have access', () => {
|
||||
@@ -138,45 +139,4 @@ describe('AppComponent', () => {
|
||||
fileStatusSubject.next(new FileStatus())
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should disable drag-drop if on dashboard', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
jest.spyOn(router, 'url', 'get').mockReturnValueOnce('/dashboard')
|
||||
expect(component.dragDropEnabled).toBeFalsy()
|
||||
jest.spyOn(router, 'url', 'get').mockReturnValueOnce('/documents')
|
||||
expect(component.dragDropEnabled).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should enable drag-drop if user has permissions', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
expect(component.dragDropEnabled).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should disable drag-drop if user does not have permissions', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false)
|
||||
expect(component.dragDropEnabled).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should support drag drop', fakeAsync(() => {
|
||||
expect(component.fileIsOver).toBeFalsy()
|
||||
component.fileOver()
|
||||
tick(1)
|
||||
fixture.detectChanges()
|
||||
expect(component.fileIsOver).toBeTruthy()
|
||||
const dropzone = fixture.debugElement.query(
|
||||
By.css('.global-dropzone-overlay')
|
||||
)
|
||||
expect(dropzone).not.toBeNull()
|
||||
component.fileLeave()
|
||||
tick(700)
|
||||
fixture.detectChanges()
|
||||
expect(dropzone.classes['hide']).toBeTruthy()
|
||||
// drop
|
||||
const toastSpy = jest.spyOn(toastService, 'show')
|
||||
const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFiles')
|
||||
component.dropped([])
|
||||
tick(3000)
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
expect(uploadSpy).toHaveBeenCalled()
|
||||
}))
|
||||
})
|
||||
|
@@ -5,8 +5,6 @@ import { Router } from '@angular/router'
|
||||
import { Subscription, first } from 'rxjs'
|
||||
import { ConsumerStatusService } from './services/consumer-status.service'
|
||||
import { ToastService } from './services/toast.service'
|
||||
import { NgxFileDropEntry } from 'ngx-file-drop'
|
||||
import { UploadDocumentsService } from './services/upload-documents.service'
|
||||
import { TasksService } from './services/tasks.service'
|
||||
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import {
|
||||
@@ -25,16 +23,11 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
successSubscription: Subscription
|
||||
failedSubscription: Subscription
|
||||
|
||||
private fileLeaveTimeoutID: any
|
||||
fileIsOver: boolean = false
|
||||
hidden: boolean = true
|
||||
|
||||
constructor(
|
||||
private settings: SettingsService,
|
||||
private consumerStatusService: ConsumerStatusService,
|
||||
private toastService: ToastService,
|
||||
private router: Router,
|
||||
private uploadDocumentsService: UploadDocumentsService,
|
||||
private tasksService: TasksService,
|
||||
public tourService: TourService,
|
||||
private renderer: Renderer2,
|
||||
@@ -250,42 +243,4 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
public get dragDropEnabled(): boolean {
|
||||
return (
|
||||
!this.router.url.includes('dashboard') &&
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.Add,
|
||||
PermissionType.Document
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
public fileOver() {
|
||||
// allows transition
|
||||
setTimeout(() => {
|
||||
this.fileIsOver = true
|
||||
}, 1)
|
||||
this.hidden = false
|
||||
// stop fileLeave timeout
|
||||
clearTimeout(this.fileLeaveTimeoutID)
|
||||
}
|
||||
|
||||
public fileLeave(immediate: boolean = false) {
|
||||
const ms = immediate ? 0 : 500
|
||||
|
||||
this.fileLeaveTimeoutID = setTimeout(() => {
|
||||
this.fileIsOver = false
|
||||
// await transition completed
|
||||
setTimeout(() => {
|
||||
this.hidden = true
|
||||
}, 150)
|
||||
}, ms)
|
||||
}
|
||||
|
||||
public dropped(files: NgxFileDropEntry[]) {
|
||||
this.fileLeave(true)
|
||||
this.uploadDocumentsService.uploadFiles(files)
|
||||
this.toastService.showInfo($localize`Initiating upload...`, 3000)
|
||||
}
|
||||
}
|
||||
|
@@ -99,6 +99,8 @@ import { ConsumptionTemplatesComponent } from './components/manage/consumption-t
|
||||
import { ConsumptionTemplateEditDialogComponent } from './components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component'
|
||||
import { MailComponent } from './components/manage/mail/mail.component'
|
||||
import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component'
|
||||
import { DndModule } from 'ngx-drag-drop'
|
||||
import { FileDropComponent } from './components/file-drop/file-drop.component'
|
||||
|
||||
import localeAf from '@angular/common/locales/af'
|
||||
import localeAr from '@angular/common/locales/ar'
|
||||
@@ -241,6 +243,7 @@ function initializeApp(settings: SettingsService) {
|
||||
ConsumptionTemplateEditDialogComponent,
|
||||
MailComponent,
|
||||
UsersAndGroupsComponent,
|
||||
FileDropComponent,
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
@@ -254,6 +257,7 @@ function initializeApp(settings: SettingsService) {
|
||||
NgSelectModule,
|
||||
ColorSliderModule,
|
||||
TourNgBootstrapModule,
|
||||
DndModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
|
@@ -1,4 +1,4 @@
|
||||
<nav class="navbar navbar-dark sticky-top bg-primary flex-md-nowrap p-0 shadow">
|
||||
<nav class="navbar navbar-dark sticky-top bg-primary flex-md-nowrap p-0 shadow-sm">
|
||||
<button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse"
|
||||
data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"
|
||||
(click)="isMenuCollapsed = !isMenuCollapsed">
|
||||
|
@@ -99,10 +99,6 @@ main {
|
||||
}
|
||||
}
|
||||
|
||||
.col-slim {
|
||||
padding-left: calc(50px + $grid-gutter-width) !important;
|
||||
}
|
||||
|
||||
.sidebar-slim-toggler {
|
||||
display: block;
|
||||
position: absolute;
|
||||
|
@@ -1,7 +1,9 @@
|
||||
<div class="row pt-3 pb-3 pb-md-1 mb-3 border-bottom align-items-center">
|
||||
<div class="row pt-3 pb-3 pb-md-2 align-items-center">
|
||||
<div class="col-md text-truncate">
|
||||
<p class="h2 text-truncate" style="line-height: 1.4">{{title}}</p>
|
||||
<p *ngIf="subTitle" class="h5 text-truncate" style="line-height: 1.4">{{subTitle}}</p>
|
||||
<h3 class="text-truncate" style="line-height: 1.4">
|
||||
{{title}}
|
||||
<span *ngIf="subTitle" class="h6 mb-0 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle}}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="btn-toolbar col col-md-auto">
|
||||
<ng-content></ng-content>
|
||||
|
@@ -25,7 +25,7 @@ describe('PageHeaderComponent', () => {
|
||||
component.title = 'Foo'
|
||||
component.subTitle = 'Bar'
|
||||
fixture.detectChanges()
|
||||
expect(fixture.nativeElement.textContent).toContain('FooBar')
|
||||
expect(fixture.nativeElement.textContent).toContain('Foo Bar')
|
||||
})
|
||||
|
||||
it('should set html title', () => {
|
||||
|
@@ -1,29 +1,46 @@
|
||||
<pngx-page-header title="Dashboard" [subTitle]="subtitle" i18n-title>
|
||||
<pngx-logo extra_classes="d-none d-md-block"></pngx-logo>
|
||||
<pngx-logo extra_classes="d-none d-md-block mt-n2 me-1" height="3.5rem"></pngx-logo>
|
||||
</pngx-page-header>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div tourAnchor="tour.dashboard">
|
||||
<ng-container *ngIf="savedViewService.loading">
|
||||
<div class="col-auto col-lg-8 col-xl-9 mb-4">
|
||||
<div class="row row-cols-1 g-4" tourAnchor="tour.dashboard"
|
||||
dndDropzone
|
||||
[dndDisableIf]="settingsService.globalDropzoneActive"
|
||||
dndEffectAllowed="move"
|
||||
(dndDrop)="onDrop($event)"
|
||||
>
|
||||
<div *ngIf="savedViewService.loading" class="col">
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
<ng-container i18n>Loading...</ng-container>
|
||||
</div>
|
||||
|
||||
<div *ngIf="settingsService.offerTour()" class="col">
|
||||
<pngx-welcome-widget (dismiss)="completeTour()"></pngx-welcome-widget>
|
||||
</div>
|
||||
|
||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||
<div *ngFor="let v of dashboardViews" class="col">
|
||||
<pngx-saved-view-widget
|
||||
[savedView]="v"
|
||||
(dndStart)="onDragStart($event)"
|
||||
(dndMoved)="onDragged(v)"
|
||||
(dndEnd)="onDragEnd($event)"
|
||||
>
|
||||
</pngx-saved-view-widget>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<pngx-welcome-widget *ngIf="settingsService.offerTour()" (dismiss)="completeTour()"></pngx-welcome-widget>
|
||||
|
||||
<div *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||
<ng-container *ngFor="let v of savedViewService.dashboardViews; first as isFirst">
|
||||
<pngx-saved-view-widget [savedView]="v"></pngx-saved-view-widget>
|
||||
</ng-container>
|
||||
<div class="p-1" dndPlaceholderRef></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto col-lg-4 col-xl-3">
|
||||
<div class="row row-cols-1 g-4">
|
||||
<div class="col">
|
||||
<pngx-statistics-widget></pngx-statistics-widget>
|
||||
</div>
|
||||
<div class="col">
|
||||
<pngx-upload-file-widget></pngx-upload-file-widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
|
||||
<pngx-statistics-widget></pngx-statistics-widget>
|
||||
|
||||
<pngx-upload-file-widget></pngx-upload-file-widget>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -13,16 +13,59 @@ import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { SavedViewWidgetComponent } from './widgets/saved-view-widget/saved-view-widget.component'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { NgxFileDropModule } from 'ngx-file-drop'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { LogoComponent } from '../common/logo/logo.component'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { DndDropEvent, DndModule } from 'ngx-drag-drop'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
|
||||
|
||||
const saved_views = [
|
||||
{
|
||||
name: 'Saved View 0',
|
||||
id: 0,
|
||||
show_on_dashboard: true,
|
||||
show_in_sidebar: true,
|
||||
sort_field: 'name',
|
||||
sort_reverse: true,
|
||||
filter_rules: [],
|
||||
},
|
||||
{
|
||||
name: 'Saved View 1',
|
||||
id: 1,
|
||||
show_on_dashboard: false,
|
||||
show_in_sidebar: false,
|
||||
sort_field: 'name',
|
||||
sort_reverse: true,
|
||||
filter_rules: [],
|
||||
},
|
||||
{
|
||||
name: 'Saved View 2',
|
||||
id: 2,
|
||||
show_on_dashboard: true,
|
||||
show_in_sidebar: false,
|
||||
sort_field: 'name',
|
||||
sort_reverse: true,
|
||||
filter_rules: [],
|
||||
},
|
||||
{
|
||||
name: 'Saved View 3',
|
||||
id: 3,
|
||||
show_on_dashboard: true,
|
||||
show_in_sidebar: false,
|
||||
sort_field: 'name',
|
||||
sort_reverse: true,
|
||||
filter_rules: [],
|
||||
},
|
||||
]
|
||||
|
||||
describe('DashboardComponent', () => {
|
||||
let component: DashboardComponent
|
||||
let fixture: ComponentFixture<DashboardComponent>
|
||||
let settingsService: SettingsService
|
||||
let tourService: TourService
|
||||
let toastService: ToastService
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
@@ -47,33 +90,22 @@ describe('DashboardComponent', () => {
|
||||
{
|
||||
provide: SavedViewService,
|
||||
useValue: {
|
||||
dashboardViews: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'saved view 1',
|
||||
show_on_dashboard: true,
|
||||
sort_field: 'added',
|
||||
sort_reverse: true,
|
||||
filter_rules: [],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'saved view 2',
|
||||
show_on_dashboard: true,
|
||||
sort_field: 'created',
|
||||
sort_reverse: true,
|
||||
filter_rules: [],
|
||||
},
|
||||
],
|
||||
listAll: () =>
|
||||
of({
|
||||
all: [saved_views.map((v) => v.id)],
|
||||
count: saved_views.length,
|
||||
results: saved_views,
|
||||
}),
|
||||
dashboardViews: saved_views.filter((v) => v.show_on_dashboard),
|
||||
},
|
||||
},
|
||||
],
|
||||
imports: [
|
||||
NgbAlertModule,
|
||||
HttpClientTestingModule,
|
||||
NgxFileDropModule,
|
||||
RouterTestingModule,
|
||||
TourNgBootstrapModule,
|
||||
DndModule,
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
@@ -82,7 +114,11 @@ describe('DashboardComponent', () => {
|
||||
first_name: 'Foo',
|
||||
last_name: 'Bar',
|
||||
}
|
||||
jest.spyOn(settingsService, 'get').mockImplementation((key) => {
|
||||
if (key === SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER) return [0, 2, 3]
|
||||
})
|
||||
tourService = TestBed.inject(TourService)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
fixture = TestBed.createComponent(DashboardComponent)
|
||||
component = fixture.componentInstance
|
||||
|
||||
@@ -100,7 +136,7 @@ describe('DashboardComponent', () => {
|
||||
it('should show dashboard widgets', () => {
|
||||
expect(
|
||||
fixture.debugElement.queryAll(By.directive(SavedViewWidgetComponent))
|
||||
).toHaveLength(2)
|
||||
).toHaveLength(saved_views.filter((v) => v.show_on_dashboard).length)
|
||||
})
|
||||
|
||||
it('should end tour service if still running and welcome widget dismissed', () => {
|
||||
@@ -116,4 +152,44 @@ describe('DashboardComponent', () => {
|
||||
component.completeTour()
|
||||
expect(settingsCompleteTourSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should disable global dropzone on start drag + drop, re-enable after', () => {
|
||||
expect(settingsService.globalDropzoneEnabled).toBeTruthy()
|
||||
component.onDragStart(null)
|
||||
expect(settingsService.globalDropzoneEnabled).toBeFalsy()
|
||||
component.onDragEnd(null)
|
||||
expect(settingsService.globalDropzoneEnabled).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should update saved view sorting on drag + drop, show info', () => {
|
||||
const settingsSpy = jest.spyOn(settingsService, 'updateDashboardViewsSort')
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
||||
component.onDrop({ index: 2, data: saved_views[0] } as DndDropEvent)
|
||||
component.onDragged(saved_views[0])
|
||||
expect(settingsSpy).toHaveBeenCalledWith([
|
||||
saved_views[2],
|
||||
saved_views[0],
|
||||
saved_views[3],
|
||||
])
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
component.onDrop({ data: saved_views[3] } as DndDropEvent)
|
||||
})
|
||||
|
||||
it('should update saved view sorting on drag + drop, show info2', () => {
|
||||
jest.spyOn(settingsService, 'get').mockImplementation((key) => {
|
||||
if (key === SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER) return []
|
||||
})
|
||||
fixture.destroy()
|
||||
fixture = TestBed.createComponent(DashboardComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||
jest
|
||||
.spyOn(settingsService, 'storeSettings')
|
||||
.mockReturnValue(throwError(() => new Error('unable to save')))
|
||||
component.onDrop({ index: 2, data: saved_views[0] } as DndDropEvent)
|
||||
component.onDragged(saved_views[0])
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
@@ -3,6 +3,10 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
|
||||
import { DndDropEvent } from 'ngx-drag-drop'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-dashboard',
|
||||
@@ -10,12 +14,33 @@ import { TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||
styleUrls: ['./dashboard.component.scss'],
|
||||
})
|
||||
export class DashboardComponent extends ComponentWithPermissions {
|
||||
public dashboardViews: PaperlessSavedView[] = []
|
||||
constructor(
|
||||
public settingsService: SettingsService,
|
||||
public savedViewService: SavedViewService,
|
||||
private tourService: TourService
|
||||
private tourService: TourService,
|
||||
private toastService: ToastService
|
||||
) {
|
||||
super()
|
||||
|
||||
this.savedViewService.listAll().subscribe(() => {
|
||||
const sorted: number[] = this.settingsService.get(
|
||||
SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER
|
||||
)
|
||||
this.dashboardViews =
|
||||
sorted?.length > 0
|
||||
? sorted
|
||||
.map((id) =>
|
||||
this.savedViewService.dashboardViews.find((v) => v.id === id)
|
||||
)
|
||||
.concat(
|
||||
this.savedViewService.dashboardViews.filter(
|
||||
(v) => !sorted.includes(v.id)
|
||||
)
|
||||
)
|
||||
.filter((v) => v)
|
||||
: [...this.savedViewService.dashboardViews]
|
||||
})
|
||||
}
|
||||
|
||||
get subtitle() {
|
||||
@@ -33,4 +58,35 @@ export class DashboardComponent extends ComponentWithPermissions {
|
||||
this.settingsService.completeTour()
|
||||
}
|
||||
}
|
||||
|
||||
onDragStart(event: DragEvent) {
|
||||
this.settingsService.globalDropzoneEnabled = false
|
||||
}
|
||||
|
||||
onDragged(v: PaperlessSavedView) {
|
||||
const index = this.dashboardViews.indexOf(v)
|
||||
this.dashboardViews.splice(index, 1)
|
||||
this.settingsService
|
||||
.updateDashboardViewsSort(this.dashboardViews)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastService.showInfo($localize`Dashboard updated`)
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError($localize`Error updating dashboard`, e)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onDragEnd(event: DragEvent) {
|
||||
this.settingsService.globalDropzoneEnabled = true
|
||||
}
|
||||
|
||||
onDrop(event: DndDropEvent) {
|
||||
if (typeof event.index === 'undefined') {
|
||||
event.index = this.dashboardViews.length
|
||||
}
|
||||
|
||||
this.dashboardViews.splice(event.index, 0, event.data)
|
||||
}
|
||||
}
|
||||
|
@@ -1,22 +1,38 @@
|
||||
<pngx-widget-frame [title]="savedView.name" [loading]="loading">
|
||||
<pngx-widget-frame
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
|
||||
[title]="savedView.name"
|
||||
[loading]="loading"
|
||||
[draggable]="savedView"
|
||||
(dndStart)="dndStart.emit($event)"
|
||||
(dndMoved)="dndMoved.emit($event)"
|
||||
(dndCanceled)="dndCanceled.emit($event)"
|
||||
(dndEnd)="dndEnd.emit($event)"
|
||||
>
|
||||
|
||||
<a class="btn-link" header-buttons [routerLink]="[]" (click)="showAll()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }" i18n>Show all</a>
|
||||
<a *ngIf="documents.length" class="btn-link" header-buttons [routerLink]="[]" (click)="showAll()" i18n>Show all</a>
|
||||
|
||||
|
||||
<table content class="table table-sm table-hover table-borderless mb-0">
|
||||
<table *ngIf="documents.length; else empty" content class="table table-hover mb-0 align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" i18n>Created</th>
|
||||
<th scope="col" i18n>Title</th>
|
||||
<th scope="col" class="d-none d-md-table-cell" i18n>Tags</th>
|
||||
<th scope="col" class="d-none d-md-table-cell" i18n>Correspondent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||
<tbody>
|
||||
<tr *ngFor="let doc of documents" (mouseleave)="mouseLeaveCard()">
|
||||
<td><a routerLink="/documents/{{doc.id}}" class="d-block text-dark text-decoration-none">{{doc.created_date | customDate}}</a></td>
|
||||
<td class="position-relative">
|
||||
<a routerLink="/documents/{{doc.id}}" title="Edit" i18n-title class="d-block text-dark text-decoration-none">{{doc.title | documentTitle}}<pngx-tag [tag]="t" *ngFor="let t of doc.tags$ | async" class="ms-1" (click)="clickTag(t, $event)"></pngx-tag></a>
|
||||
<td class="py-2 py-md-3"><a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none">{{doc.created_date | customDate}}</a></td>
|
||||
<td class="py-2 py-md-3">
|
||||
<a routerLink="/documents/{{doc.id}}" title="Edit" i18n-title class="btn-link text-dark text-decoration-none">{{doc.title | documentTitle}}</a>
|
||||
</td>
|
||||
<td class="py-2 py-md-3 d-none d-md-table-cell">
|
||||
<pngx-tag [tag]="t" *ngFor="let t of doc.tags$ | async" class="ms-1" (click)="clickTag(t, $event)"></pngx-tag>
|
||||
</td>
|
||||
<td class="position-relative py-2 py-md-3 d-none d-md-table-cell">
|
||||
<a *ngIf="doc.correspondent !== null" class="btn-link" routerLink="/documents" [queryParams]="getCorrespondentQueryParams(doc.correspondent)">{{(doc.correspondent$ | async)?.name}}</a>
|
||||
<div class="btn-group position-absolute top-50 end-0 translate-middle-y">
|
||||
<a [href]="getPreviewUrl(doc)" title="View Preview" i18n-title target="_blank" class="btn btn-sm px-4 py-0 btn-dark border-dark-subtle"
|
||||
<a [href]="getPreviewUrl(doc)" title="View Preview" i18n-title target="_blank" class="btn px-4 btn-dark border-dark-subtle"
|
||||
[ngbPopover]="previewContent" [popoverTitle]="doc.title | documentTitle"
|
||||
autoClose="true" popoverClass="shadow popover-preview" container="body" (mouseenter)="mouseEnterPreview(doc)" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover">
|
||||
<svg class="buttonicon-xs" fill="currentColor">
|
||||
@@ -26,7 +42,7 @@
|
||||
<ng-template #previewContent>
|
||||
<object [data]="getPreviewUrl(doc) | safeUrl" class="preview" width="100%"></object>
|
||||
</ng-template>
|
||||
<a [href]="getDownloadUrl(doc)" class="btn btn-sm px-4 py-0 btn-dark border-dark-subtle" title="Download" i18n-title (click)="$event.stopPropagation()">
|
||||
<a [href]="getDownloadUrl(doc)" class="btn px-4 btn-dark border-dark-subtle" title="Download" i18n-title (click)="$event.stopPropagation()">
|
||||
<svg class="buttonicon-xs" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#download"/>
|
||||
</svg>
|
||||
@@ -37,4 +53,8 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<ng-template #empty>
|
||||
<p i18n class="text-center text-muted mb-0 fst-italic">No documents</p>
|
||||
</ng-template>
|
||||
|
||||
</pngx-widget-frame>
|
||||
|
@@ -5,9 +5,12 @@ table {
|
||||
|
||||
th:first-child {
|
||||
width: 25%;
|
||||
@media (min-width: 768px) {
|
||||
width: 15%;
|
||||
}
|
||||
}
|
||||
|
||||
tbody app-tag {
|
||||
tbody pngx-tag {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -22,3 +25,8 @@ tr:hover .btn-group {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
td.py-3 {
|
||||
padding-top: 0.75em !important;
|
||||
padding-bottom: 0.75em !important;
|
||||
}
|
||||
|
@@ -28,6 +28,7 @@ import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
|
||||
import { SavedViewWidgetComponent } from './saved-view-widget.component'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe'
|
||||
import { DndModule } from 'ngx-drag-drop'
|
||||
|
||||
const savedView: PaperlessSavedView = {
|
||||
id: 1,
|
||||
@@ -52,6 +53,7 @@ const documentResults = [
|
||||
{
|
||||
id: 3,
|
||||
title: 'doc3',
|
||||
correspondent: 0,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -89,6 +91,7 @@ describe('SavedViewWidgetComponent', () => {
|
||||
HttpClientTestingModule,
|
||||
NgbModule,
|
||||
RouterTestingModule.withRoutes(routes),
|
||||
DndModule,
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
|
@@ -1,23 +1,29 @@
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
QueryList,
|
||||
ViewChildren,
|
||||
} from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { Params, Router } from '@angular/router'
|
||||
import { Subject, takeUntil } from 'rxjs'
|
||||
import { PaperlessDocument } from 'src/app/data/paperless-document'
|
||||
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
|
||||
import { ConsumerStatusService } from 'src/app/services/consumer-status.service'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { PaperlessTag } from 'src/app/data/paperless-tag'
|
||||
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
|
||||
import {
|
||||
FILTER_CORRESPONDENT,
|
||||
FILTER_HAS_TAGS_ALL,
|
||||
} from 'src/app/data/filter-rule-type'
|
||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
|
||||
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-saved-view-widget',
|
||||
@@ -38,7 +44,8 @@ export class SavedViewWidgetComponent
|
||||
private router: Router,
|
||||
private list: DocumentListViewService,
|
||||
private consumerStatusService: ConsumerStatusService,
|
||||
public openDocumentsService: OpenDocumentsService
|
||||
public openDocumentsService: OpenDocumentsService,
|
||||
public documentListViewService: DocumentListViewService
|
||||
) {
|
||||
super()
|
||||
}
|
||||
@@ -46,6 +53,18 @@ export class SavedViewWidgetComponent
|
||||
@Input()
|
||||
savedView: PaperlessSavedView
|
||||
|
||||
@Output()
|
||||
dndStart: EventEmitter<DragEvent> = new EventEmitter()
|
||||
|
||||
@Output()
|
||||
dndMoved: EventEmitter<DragEvent> = new EventEmitter()
|
||||
|
||||
@Output()
|
||||
dndCanceled: EventEmitter<DragEvent> = new EventEmitter()
|
||||
|
||||
@Output()
|
||||
dndEnd: EventEmitter<DragEvent> = new EventEmitter()
|
||||
|
||||
documents: PaperlessDocument[] = []
|
||||
|
||||
unsubscribeNotifier: Subject<any> = new Subject()
|
||||
@@ -141,4 +160,15 @@ export class SavedViewWidgetComponent
|
||||
mouseLeaveCard() {
|
||||
this.popover?.close()
|
||||
}
|
||||
|
||||
getCorrespondentQueryParams(correspondentId: number): Params {
|
||||
return correspondentId !== undefined
|
||||
? queryParamsFromFilterRules([
|
||||
{
|
||||
rule_type: FILTER_CORRESPONDENT,
|
||||
value: correspondentId.toString(),
|
||||
},
|
||||
])
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
@@ -42,5 +42,32 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-group border-light mt-3">
|
||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }">
|
||||
<a *ngIf="statistics?.tag_count > 0" class="list-group-item d-flex justify-content-between align-items-center" routerLink="/tags/">
|
||||
<ng-container i18n>Tags</ng-container>:
|
||||
<span class="badge bg-secondary text-light rounded-pill">{{statistics?.tag_count | number}}</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
||||
<a *ngIf="statistics?.correspondent_count > 0" class="list-group-item d-flex justify-content-between align-items-center" routerLink="/correspondents/">
|
||||
<ng-container i18n>Correspondents</ng-container>:
|
||||
<span class="badge bg-secondary text-light rounded-pill">{{statistics?.correspondent_count | number}}</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
||||
<a *ngIf="statistics?.document_type_count > 0" class="list-group-item d-flex justify-content-between align-items-center" routerLink="/documenttypes/">
|
||||
<ng-container i18n>Document Types</ng-container>:
|
||||
<span class="badge bg-secondary text-light rounded-pill">{{statistics?.document_type_count | number}}</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
||||
<a *ngIf="statistics?.storage_path_count > 0" class="list-group-item d-flex justify-content-between align-items-center" routerLink="/documenttypes/">
|
||||
<ng-container i18n>Storage Paths</ng-container>:
|
||||
<span class="badge bg-secondary text-light rounded-pill">{{statistics?.storage_path_count | number}}</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
</pngx-widget-frame>
|
||||
|
@@ -11,24 +11,42 @@ import { environment } from 'src/environments/environment'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { DndModule } from 'ngx-drag-drop'
|
||||
import {
|
||||
ConsumerStatusService,
|
||||
FileStatus,
|
||||
} from 'src/app/services/consumer-status.service'
|
||||
import { Subject } from 'rxjs'
|
||||
|
||||
describe('StatisticsWidgetComponent', () => {
|
||||
let component: StatisticsWidgetComponent
|
||||
let fixture: ComponentFixture<StatisticsWidgetComponent>
|
||||
let httpTestingController: HttpTestingController
|
||||
let consumerStatusService: ConsumerStatusService
|
||||
const fileStatusSubject = new Subject<FileStatus>()
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [StatisticsWidgetComponent, WidgetFrameComponent],
|
||||
declarations: [
|
||||
StatisticsWidgetComponent,
|
||||
WidgetFrameComponent,
|
||||
IfPermissionsDirective,
|
||||
],
|
||||
providers: [PermissionsGuard],
|
||||
imports: [
|
||||
HttpClientTestingModule,
|
||||
NgbModule,
|
||||
RouterTestingModule.withRoutes(routes),
|
||||
DndModule,
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(StatisticsWidgetComponent)
|
||||
consumerStatusService = TestBed.inject(ConsumerStatusService)
|
||||
jest
|
||||
.spyOn(consumerStatusService, 'onDocumentConsumptionFinished')
|
||||
.mockReturnValue(fileStatusSubject)
|
||||
component = fixture.componentInstance
|
||||
|
||||
httpTestingController = TestBed.inject(HttpTestingController)
|
||||
@@ -43,6 +61,12 @@ describe('StatisticsWidgetComponent', () => {
|
||||
expect(req.request.method).toEqual('GET')
|
||||
})
|
||||
|
||||
it('should reload after doc is consumed', () => {
|
||||
const reloadSpy = jest.spyOn(component, 'reload')
|
||||
fileStatusSubject.next(new FileStatus())
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should display inbox link with count', () => {
|
||||
const mockStats = {
|
||||
documents_total: 200,
|
||||
@@ -107,4 +131,62 @@ describe('StatisticsWidgetComponent', () => {
|
||||
'CSV(10%)'
|
||||
)
|
||||
})
|
||||
|
||||
it('should limit mime types to 5 max', () => {
|
||||
const mockStats = {
|
||||
documents_total: 222,
|
||||
documents_inbox: 18,
|
||||
inbox_tag: 10,
|
||||
document_file_type_counts: [
|
||||
{
|
||||
mime_type: 'application/pdf',
|
||||
mime_type_count: 160,
|
||||
},
|
||||
{
|
||||
mime_type: 'text/plain',
|
||||
mime_type_count: 20,
|
||||
},
|
||||
{
|
||||
mime_type: 'text/csv',
|
||||
mime_type_count: 20,
|
||||
},
|
||||
{
|
||||
mime_type: 'application/vnd.oasis.opendocument.text',
|
||||
mime_type_count: 11,
|
||||
},
|
||||
{
|
||||
mime_type: 'application/msword',
|
||||
mime_type_count: 9,
|
||||
},
|
||||
{
|
||||
mime_type: 'image/jpeg',
|
||||
mime_type_count: 2,
|
||||
},
|
||||
],
|
||||
character_count: 162312,
|
||||
}
|
||||
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}statistics/`
|
||||
)
|
||||
|
||||
req.flush(mockStats)
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(fixture.nativeElement.textContent.replace(/\s/g, '')).toContain(
|
||||
'PDF(72.1%)'
|
||||
)
|
||||
expect(fixture.nativeElement.textContent.replace(/\s/g, '')).toContain(
|
||||
'TXT(9%)'
|
||||
)
|
||||
expect(fixture.nativeElement.textContent.replace(/\s/g, '')).toContain(
|
||||
'CSV(9%)'
|
||||
)
|
||||
expect(fixture.nativeElement.textContent.replace(/\s/g, '')).toContain(
|
||||
'ODT(5%)'
|
||||
)
|
||||
expect(fixture.nativeElement.textContent.replace(/\s/g, '')).toContain(
|
||||
'Other(0.9%)'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
@@ -6,6 +6,7 @@ import { ConsumerStatusService } from 'src/app/services/consumer-status.service'
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import * as mimeTypeNames from 'mime-names'
|
||||
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
|
||||
|
||||
export interface Statistics {
|
||||
documents_total?: number
|
||||
@@ -13,6 +14,10 @@ export interface Statistics {
|
||||
inbox_tag?: number
|
||||
document_file_type_counts?: DocumentFileType[]
|
||||
character_count?: number
|
||||
tag_count?: number
|
||||
correspondent_count?: number
|
||||
document_type_count?: number
|
||||
storage_path_count?: number
|
||||
}
|
||||
|
||||
interface DocumentFileType {
|
||||
@@ -25,14 +30,19 @@ interface DocumentFileType {
|
||||
templateUrl: './statistics-widget.component.html',
|
||||
styleUrls: ['./statistics-widget.component.scss'],
|
||||
})
|
||||
export class StatisticsWidgetComponent implements OnInit, OnDestroy {
|
||||
export class StatisticsWidgetComponent
|
||||
extends ComponentWithPermissions
|
||||
implements OnInit, OnDestroy
|
||||
{
|
||||
loading: boolean = true
|
||||
|
||||
constructor(
|
||||
private http: HttpClient,
|
||||
private consumerStatusService: ConsumerStatusService,
|
||||
private documentListViewService: DocumentListViewService
|
||||
) {}
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
statistics: Statistics = {}
|
||||
|
||||
@@ -87,7 +97,7 @@ export class StatisticsWidgetComponent implements OnInit, OnDestroy {
|
||||
this.reload()
|
||||
this.subscription = this.consumerStatusService
|
||||
.onDocumentConsumptionFinished()
|
||||
.subscribe((status) => {
|
||||
.subscribe(() => {
|
||||
this.reload()
|
||||
})
|
||||
}
|
||||
|
@@ -1,24 +1,26 @@
|
||||
<pngx-widget-frame title="Upload new documents" i18n-title *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Document }">
|
||||
<div header-buttons>
|
||||
<a *ngIf="getStatusSuccess().length > 0" (click)="dismissCompleted()" [routerLink]="[]" >
|
||||
<span class="me-1" i18n="This button dismisses all status messages about processed documents on the dashboard (failed and successful)">Dismiss completed</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1rem" height="1rem" fill="currentColor" class="bi bi-check2-all" viewBox="0 0 16 16">
|
||||
<path d="M12.354 4.354a.5.5 0 0 0-.708-.708L5 10.293 1.854 7.146a.5.5 0 1 0-.708.708l3.5 3.5a.5.5 0 0 0 .708 0l7-7zm-4.208 7l-.896-.897.707-.707.543.543 6.646-6.647a.5.5 0 0 1 .708.708l-7 7a.5.5 0 0 1-.708 0z"/>
|
||||
<path d="M5.354 7.146l.896.897-.707.707-.897-.896a.5.5 0 1 1 .708-.708z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div content tourAnchor="tour.upload-widget">
|
||||
<form>
|
||||
<ngx-file-drop dropZoneLabel="Drop documents here or" browseBtnLabel="Browse files" (onFileDrop)="dropped($event)"
|
||||
(onFileOver)="fileOver($event)" (onFileLeave)="fileLeave($event)" dropZoneClassName="bg-light card"
|
||||
multiple="true" contentClassName="justify-content-center d-flex align-items-center py-5 px-2" [showBrowseBtn]=true
|
||||
browseBtnClassName="btn btn-sm btn-outline-primary ms-2" i18n-dropZoneLabel i18n-browseBtnLabel>
|
||||
</ngx-file-drop>
|
||||
<form class="justify-content-center d-flex flex-column align-items-center py-3 px-2">
|
||||
<span class="text-muted" i18n>Drop documents anywhere or</span>
|
||||
<button class="btn btn-sm btn-outline-primary mt-3" (click)="fileUpload.click()" i18n>Browse files</button>
|
||||
<input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
|
||||
</form>
|
||||
<p class="mt-3" *ngIf="getStatus().length > 0">{{getStatusSummary()}}</p>
|
||||
<div *ngFor="let status of getStatus()">
|
||||
<ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container>
|
||||
<div class="fixed-bottom p-2 p-md-4" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-md-3 offset-lg-2'">
|
||||
<div class="row d-flex justify-content-end">
|
||||
<div class="col col-lg-4 col-xl-3 d-flex px-4 justify-content-between align-items-center">
|
||||
<p class="m-0 small text-muted" *ngIf="getStatus().length > 0">{{getStatusSummary()}}</p>
|
||||
<a *ngIf="getStatusCompleted().length > 0" class="btn-link" (click)="dismissCompleted()" [routerLink]="[]" >
|
||||
<span class="me-1" i18n="This button dismisses all status messages about processed documents on the dashboard (failed and successful)">Dismiss completed</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1rem" height="1rem" fill="currentColor" class="bi bi-check2-all" viewBox="0 0 16 16">
|
||||
<path d="M12.354 4.354a.5.5 0 0 0-.708-.708L5 10.293 1.854 7.146a.5.5 0 1 0-.708.708l3.5 3.5a.5.5 0 0 0 .708 0l7-7zm-4.208 7l-.896-.897.707-.707.543.543 6.646-6.647a.5.5 0 0 1 .708.708l-7 7a.5.5 0 0 1-.708 0z"/>
|
||||
<path d="M5.354 7.146l.896.897-.707.707-.897-.896a.5.5 0 1 1 .708-.708z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngFor="let status of getStatus()">
|
||||
<ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="getStatusHidden().length" class="alerts-hidden">
|
||||
<p *ngIf="!alertsExpanded" class="mt-3 mb-0 text-center">
|
||||
@@ -36,19 +38,23 @@
|
||||
</pngx-widget-frame>
|
||||
|
||||
<ng-template #consumerAlert let-status>
|
||||
<ngb-alert type="secondary" class="mt-2 mb-0" [dismissible]="isFinished(status)" (closed)="dismiss(status)">
|
||||
<h6 class="alert-heading">{{status.filename}}</h6>
|
||||
<p class="mb-0 pb-1" *ngIf="!isFinished(status) || (isFinished(status) && !status.documentId)">{{status.message}}</p>
|
||||
<ngb-progressbar [value]="status.getProgress()" [max]="1" [type]="getStatusColor(status)"></ngb-progressbar>
|
||||
<div *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||
<div *ngIf="isFinished(status)">
|
||||
<button *ngIf="status.documentId" class="btn btn-sm btn-outline-primary btn-open" routerLink="/documents/{{status.documentId}}" (click)="dismiss(status)">
|
||||
<small i18n>Open document</small>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1rem" height="1rem" fill="currentColor" class="bi bi-arrow-right-short" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M4 8a.5.5 0 0 1 .5-.5h5.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H4.5A.5.5 0 0 1 4 8z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="row d-flex justify-content-end">
|
||||
<div class="col col-lg-4 col-xl-3">
|
||||
<ngb-alert type="secondary" class="mt-2 mb-0" [dismissible]="isFinished(status)" (closed)="dismiss(status)">
|
||||
<h6 class="alert-heading">{{status.filename}}</h6>
|
||||
<p class="mb-0 pb-1" *ngIf="!isFinished(status) || (isFinished(status) && !status.documentId)">{{status.message}}</p>
|
||||
<ngb-progressbar [value]="status.getProgress()" [max]="1" [type]="getStatusColor(status)"></ngb-progressbar>
|
||||
<div *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||
<div *ngIf="isFinished(status)">
|
||||
<button *ngIf="status.documentId" class="btn btn-sm btn-outline-primary btn-open" routerLink="/documents/{{status.documentId}}" (click)="dismiss(status)">
|
||||
<small i18n>Open document</small>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1rem" height="1rem" fill="currentColor" class="bi bi-arrow-right-short" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M4 8a.5.5 0 0 1 .5-.5h5.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H4.5A.5.5 0 0 1 4 8z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ngb-alert>
|
||||
</div>
|
||||
</ngb-alert>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
@@ -1,5 +1,10 @@
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import {
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
fakeAsync,
|
||||
tick,
|
||||
} from '@angular/core/testing'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import {
|
||||
@@ -8,7 +13,6 @@ import {
|
||||
NgbAlert,
|
||||
NgbCollapse,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxFileDropModule } from 'ngx-file-drop'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
@@ -21,6 +25,7 @@ import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
|
||||
import { UploadFileWidgetComponent } from './upload-file-widget.component'
|
||||
import { DndModule } from 'ngx-drag-drop'
|
||||
|
||||
describe('UploadFileWidgetComponent', () => {
|
||||
let component: UploadFileWidgetComponent
|
||||
@@ -48,8 +53,8 @@ describe('UploadFileWidgetComponent', () => {
|
||||
HttpClientTestingModule,
|
||||
NgbModule,
|
||||
RouterTestingModule.withRoutes(routes),
|
||||
NgxFileDropModule,
|
||||
NgbAlertModule,
|
||||
DndModule,
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
@@ -61,13 +66,21 @@ describe('UploadFileWidgetComponent', () => {
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should support drop files', () => {
|
||||
it('should support browse files', () => {
|
||||
const fileInput = fixture.debugElement.query(By.css('input'))
|
||||
const clickSpy = jest.spyOn(fileInput.nativeElement, 'click')
|
||||
fixture.debugElement
|
||||
.query(By.css('button'))
|
||||
.nativeElement.dispatchEvent(new Event('click'))
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should upload files', () => {
|
||||
const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFiles')
|
||||
component.dropped([])
|
||||
fixture.debugElement
|
||||
.query(By.css('input'))
|
||||
.nativeElement.dispatchEvent(new Event('change'))
|
||||
expect(uploadSpy).toHaveBeenCalled()
|
||||
// coverage
|
||||
component.fileLeave(null)
|
||||
component.fileOver(null)
|
||||
})
|
||||
|
||||
it('should generate stats summary', () => {
|
||||
@@ -114,11 +127,15 @@ describe('UploadFileWidgetComponent', () => {
|
||||
expect(dismissSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should allow dismissing all alerts', () => {
|
||||
const dismissSpy = jest.spyOn(consumerStatusService, 'dismissCompleted')
|
||||
it('should allow dismissing all alerts', fakeAsync(() => {
|
||||
mockConsumerStatuses(consumerStatusService)
|
||||
fixture.detectChanges()
|
||||
const dismissSpy = jest.spyOn(consumerStatusService, 'dismiss')
|
||||
component.dismissCompleted()
|
||||
expect(dismissSpy).toHaveBeenCalled()
|
||||
})
|
||||
tick(1000)
|
||||
fixture.detectChanges()
|
||||
expect(dismissSpy).toHaveBeenCalledTimes(6)
|
||||
}))
|
||||
})
|
||||
|
||||
function mockConsumerStatuses(consumerStatusService) {
|
||||
|
@@ -1,11 +1,13 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { NgxFileDropEntry } from 'ngx-file-drop'
|
||||
import { Component, QueryList, ViewChildren } from '@angular/core'
|
||||
import { NgbAlert } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
|
||||
import {
|
||||
ConsumerStatusService,
|
||||
FileStatus,
|
||||
FileStatusPhase,
|
||||
} from 'src/app/services/consumer-status.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||
|
||||
const MAX_ALERTS = 5
|
||||
@@ -18,9 +20,12 @@ const MAX_ALERTS = 5
|
||||
export class UploadFileWidgetComponent extends ComponentWithPermissions {
|
||||
alertsExpanded = false
|
||||
|
||||
@ViewChildren(NgbAlert) alerts: QueryList<NgbAlert>
|
||||
|
||||
constructor(
|
||||
private consumerStatusService: ConsumerStatusService,
|
||||
private uploadDocumentsService: UploadDocumentsService
|
||||
private uploadDocumentsService: UploadDocumentsService,
|
||||
public settingsService: SettingsService
|
||||
) {
|
||||
super()
|
||||
}
|
||||
@@ -69,6 +74,10 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
|
||||
return this.consumerStatusService.getConsumerStatus(FileStatusPhase.SUCCESS)
|
||||
}
|
||||
|
||||
getStatusCompleted() {
|
||||
return this.consumerStatusService.getConsumerStatusCompleted()
|
||||
}
|
||||
|
||||
getTotalUploadProgress() {
|
||||
let current = 0
|
||||
let max = 0
|
||||
@@ -106,14 +115,16 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
|
||||
}
|
||||
|
||||
dismissCompleted() {
|
||||
this.consumerStatusService.dismissCompleted()
|
||||
this.alerts.forEach((a) => a.close())
|
||||
}
|
||||
|
||||
public fileOver(event) {}
|
||||
public onFileSelected(event: Event) {
|
||||
this.uploadDocumentsService.uploadFiles(
|
||||
(event.target as HTMLInputElement).files
|
||||
)
|
||||
}
|
||||
|
||||
public fileLeave(event) {}
|
||||
|
||||
public dropped(files: NgxFileDropEntry[]) {
|
||||
this.uploadDocumentsService.uploadFiles(files)
|
||||
get slimSidebarEnabled(): boolean {
|
||||
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
|
||||
}
|
||||
}
|
||||
|
@@ -1,7 +1,21 @@
|
||||
<div class="card mb-3 shadow-sm bg-light">
|
||||
<div class="card shadow-sm bg-light"
|
||||
[dndDraggable]="draggable"
|
||||
dndEffectAllowed="move"
|
||||
[dndDisableIf]="!draggable"
|
||||
(dndStart)="dndStart.emit($event)"
|
||||
(dndMoved)="dndMoved.emit($event)"
|
||||
(dndCanceled)="dndCanceled.emit($event)"
|
||||
(dndEnd)="dndEnd.emit($event)">
|
||||
<div class="card-header">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0">{{title}}</h5>
|
||||
<div class="d-flex">
|
||||
<div *ngIf="draggable" class="ms-n2 me-1" dndHandle>
|
||||
<svg class="sidebaricon text-muted" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#grip-vertical"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h6 class="card-title mb-0">{{title}}</h6>
|
||||
</div>
|
||||
<ng-container *ngIf="loading">
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||
<div class="visually-hidden" i18n>Loading...</div>
|
||||
|
@@ -0,0 +1,3 @@
|
||||
svg {
|
||||
cursor: move;
|
||||
}
|
||||
|
@@ -4,6 +4,7 @@ import { By } from '@angular/platform-browser'
|
||||
import { NgbAlertModule, NgbAlert } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
import { WidgetFrameComponent } from './widget-frame.component'
|
||||
import { DndModule } from 'ngx-drag-drop'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
@@ -29,7 +30,7 @@ describe('WidgetFrameComponent', () => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [WidgetFrameComponent, WidgetFrameComponent],
|
||||
providers: [PermissionsGuard],
|
||||
imports: [NgbAlertModule],
|
||||
imports: [NgbAlertModule, DndModule],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(WidgetFrameComponent)
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-widget-frame',
|
||||
@@ -13,4 +13,19 @@ export class WidgetFrameComponent {
|
||||
|
||||
@Input()
|
||||
loading: boolean = false
|
||||
|
||||
@Input()
|
||||
draggable: any
|
||||
|
||||
@Output()
|
||||
dndStart: EventEmitter<DragEvent> = new EventEmitter()
|
||||
|
||||
@Output()
|
||||
dndMoved: EventEmitter<DragEvent> = new EventEmitter()
|
||||
|
||||
@Output()
|
||||
dndCanceled: EventEmitter<DragEvent> = new EventEmitter()
|
||||
|
||||
@Output()
|
||||
dndEnd: EventEmitter<DragEvent> = new EventEmitter()
|
||||
}
|
||||
|
@@ -81,7 +81,7 @@
|
||||
|
||||
</pngx-page-header>
|
||||
|
||||
<div class="row sticky-top pt-3 pt-sm-4 pb-3 pb-lg-4 bg-body">
|
||||
<div class="row sticky-top pb-3 bg-body">
|
||||
<pngx-filter-editor [hidden]="isBulkEditing" [(filterRules)]="list.filterRules" [unmodifiedFilterRules]="unmodifiedFilterRules" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor>
|
||||
<pngx-bulk-editor [hidden]="!isBulkEditing"></pngx-bulk-editor>
|
||||
</div>
|
||||
|
14
src-ui/src/app/components/file-drop/file-drop.component.html
Normal file
14
src-ui/src/app/components/file-drop/file-drop.component.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<div [class.pe-none]="fileIsOver">
|
||||
<ng-content select="[content]"></ng-content>
|
||||
</div>
|
||||
|
||||
<div class="global-dropzone-overlay position-fixed top-0 start-0 bottom-0 end-0 text-center pe-none fade" [class.show]="fileIsOver" [class.hide]="hidden">
|
||||
<h2 class="pe-none position-absolute top-50 start-50 translate-middle" i18n>Drop files to begin upload</h2>
|
||||
</div>
|
||||
|
||||
<ngx-file-drop
|
||||
dropZoneClassName="visually-hidden"
|
||||
contentClassName="visually-hidden"
|
||||
(onFileDrop)="dropped($event)"
|
||||
#ngxFileDrop>
|
||||
</ngx-file-drop>
|
@@ -0,0 +1,8 @@
|
||||
.global-dropzone-overlay {
|
||||
background-color: hsla(var(--pngx-primary), var(--pngx-primary-lightness), .8);
|
||||
z-index: 1200;
|
||||
|
||||
h2 {
|
||||
color: var(--pngx-primary-text-contrast)
|
||||
}
|
||||
}
|
177
src-ui/src/app/components/file-drop/file-drop.component.spec.ts
Normal file
177
src-ui/src/app/components/file-drop/file-drop.component.spec.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||
import {
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
discardPeriodicTasks,
|
||||
fakeAsync,
|
||||
flush,
|
||||
tick,
|
||||
} from '@angular/core/testing'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||
import { ToastsComponent } from '../common/toasts/toasts.component'
|
||||
import { FileDropComponent } from './file-drop.component'
|
||||
import { NgxFileDropEntry, NgxFileDropModule } from 'ngx-file-drop'
|
||||
|
||||
describe('FileDropComponent', () => {
|
||||
let component: FileDropComponent
|
||||
let fixture: ComponentFixture<FileDropComponent>
|
||||
let permissionsService: PermissionsService
|
||||
let toastService: ToastService
|
||||
let settingsService: SettingsService
|
||||
let uploadDocumentsService: UploadDocumentsService
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [FileDropComponent, ToastsComponent],
|
||||
providers: [],
|
||||
imports: [HttpClientTestingModule, NgxFileDropModule],
|
||||
}).compileComponents()
|
||||
|
||||
permissionsService = TestBed.inject(PermissionsService)
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
uploadDocumentsService = TestBed.inject(UploadDocumentsService)
|
||||
|
||||
fixture = TestBed.createComponent(FileDropComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should enable drag-drop if user has permissions', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
expect(component.dragDropEnabled).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should disable drag-drop if user does not have permissions', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false)
|
||||
expect(component.dragDropEnabled).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should disable drag-drop if disabled in settings', fakeAsync(() => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
settingsService.globalDropzoneEnabled = false
|
||||
expect(component.dragDropEnabled).toBeFalsy()
|
||||
|
||||
component.onDragOver(new Event('dragover') as DragEvent)
|
||||
tick(1)
|
||||
fixture.detectChanges()
|
||||
expect(component.fileIsOver).toBeFalsy()
|
||||
const dropzone = fixture.debugElement.query(
|
||||
By.css('.global-dropzone-overlay')
|
||||
)
|
||||
expect(dropzone.classes['hide']).toBeTruthy()
|
||||
component.onDragLeave(new Event('dragleave') as DragEvent)
|
||||
tick(700)
|
||||
fixture.detectChanges()
|
||||
// drop
|
||||
const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFiles')
|
||||
const dragEvent = new Event('drop')
|
||||
dragEvent['dataTransfer'] = {
|
||||
files: {
|
||||
item: () => {},
|
||||
length: 0,
|
||||
},
|
||||
}
|
||||
component.onDrop(dragEvent as DragEvent)
|
||||
tick(3000)
|
||||
expect(uploadSpy).not.toHaveBeenCalled()
|
||||
}))
|
||||
|
||||
it('should support drag drop, initiate upload', fakeAsync(() => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
expect(component.fileIsOver).toBeFalsy()
|
||||
component.onDragOver(new Event('dragover') as DragEvent)
|
||||
tick(1)
|
||||
fixture.detectChanges()
|
||||
expect(component.fileIsOver).toBeTruthy()
|
||||
const dropzone = fixture.debugElement.query(
|
||||
By.css('.global-dropzone-overlay')
|
||||
)
|
||||
component.onDragLeave(new Event('dragleave') as DragEvent)
|
||||
tick(700)
|
||||
fixture.detectChanges()
|
||||
expect(dropzone.classes['hide']).toBeTruthy()
|
||||
// drop
|
||||
const toastSpy = jest.spyOn(toastService, 'show')
|
||||
const uploadSpy = jest.spyOn(
|
||||
UploadDocumentsService.prototype as any,
|
||||
'uploadFile'
|
||||
)
|
||||
const dragEvent = new Event('drop')
|
||||
dragEvent['dataTransfer'] = {
|
||||
files: {
|
||||
item: () => {
|
||||
return new File(
|
||||
[new Blob(['testing'], { type: 'application/pdf' })],
|
||||
'file.pdf'
|
||||
)
|
||||
},
|
||||
length: 1,
|
||||
} as unknown as FileList,
|
||||
}
|
||||
component.onDrop(dragEvent as DragEvent)
|
||||
component.dropped([
|
||||
{
|
||||
fileEntry: {
|
||||
isFile: true,
|
||||
file: (callback) => {
|
||||
callback(
|
||||
new File(
|
||||
[new Blob(['testing'], { type: 'application/pdf' })],
|
||||
'file.pdf'
|
||||
)
|
||||
)
|
||||
},
|
||||
},
|
||||
} as unknown as NgxFileDropEntry,
|
||||
])
|
||||
tick(3000)
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
expect(uploadSpy).toHaveBeenCalled()
|
||||
discardPeriodicTasks()
|
||||
}))
|
||||
|
||||
it('should ignore events if disabled', fakeAsync(() => {
|
||||
settingsService.globalDropzoneEnabled = false
|
||||
expect(settingsService.globalDropzoneActive).toBeFalsy()
|
||||
component.onDragOver(new Event('dragover') as DragEvent)
|
||||
expect(settingsService.globalDropzoneActive).toBeFalsy()
|
||||
settingsService.globalDropzoneActive = true
|
||||
component.onDragLeave(new Event('dragleave') as DragEvent)
|
||||
expect(settingsService.globalDropzoneActive).toBeTruthy()
|
||||
component.onDrop(new Event('drop') as DragEvent)
|
||||
expect(settingsService.globalDropzoneActive).toBeTruthy()
|
||||
}))
|
||||
|
||||
it('should hide if app loses focus', fakeAsync(() => {
|
||||
const leaveSpy = jest.spyOn(component, 'onDragLeave')
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
settingsService.globalDropzoneEnabled = true
|
||||
component.onDragOver(new Event('dragover') as DragEvent)
|
||||
tick(1)
|
||||
expect(component.hidden).toBeFalsy()
|
||||
expect(component.fileIsOver).toBeTruthy()
|
||||
jest.spyOn(document, 'hidden', 'get').mockReturnValue(true)
|
||||
component.onVisibilityChange()
|
||||
expect(leaveSpy).toHaveBeenCalled()
|
||||
flush()
|
||||
}))
|
||||
|
||||
it('should hide on window blur', fakeAsync(() => {
|
||||
const leaveSpy = jest.spyOn(component, 'onDragLeave')
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
settingsService.globalDropzoneEnabled = true
|
||||
component.onDragOver(new Event('dragover') as DragEvent)
|
||||
tick(1)
|
||||
expect(component.hidden).toBeFalsy()
|
||||
expect(component.fileIsOver).toBeTruthy()
|
||||
jest.spyOn(document, 'hidden', 'get').mockReturnValue(true)
|
||||
component.onWindowBlur()
|
||||
expect(leaveSpy).toHaveBeenCalled()
|
||||
flush()
|
||||
}))
|
||||
})
|
98
src-ui/src/app/components/file-drop/file-drop.component.ts
Normal file
98
src-ui/src/app/components/file-drop/file-drop.component.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Component, HostListener, ViewChild } from '@angular/core'
|
||||
import { NgxFileDropComponent, NgxFileDropEntry } from 'ngx-file-drop'
|
||||
import {
|
||||
PermissionsService,
|
||||
PermissionAction,
|
||||
PermissionType,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-file-drop',
|
||||
templateUrl: './file-drop.component.html',
|
||||
styleUrls: ['./file-drop.component.scss'],
|
||||
})
|
||||
export class FileDropComponent {
|
||||
private fileLeaveTimeoutID: any
|
||||
fileIsOver: boolean = false
|
||||
hidden: boolean = true
|
||||
|
||||
constructor(
|
||||
private settings: SettingsService,
|
||||
private toastService: ToastService,
|
||||
private uploadDocumentsService: UploadDocumentsService,
|
||||
private permissionsService: PermissionsService
|
||||
) {}
|
||||
|
||||
public get dragDropEnabled(): boolean {
|
||||
return (
|
||||
this.settings.globalDropzoneEnabled &&
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.Add,
|
||||
PermissionType.Document
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewChild('ngxFileDrop') ngxFileDrop: NgxFileDropComponent
|
||||
|
||||
@HostListener('dragover', ['$event ']) onDragOver(event: DragEvent) {
|
||||
if (!this.dragDropEnabled) return
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
this.settings.globalDropzoneActive = true
|
||||
// allows transition
|
||||
setTimeout(() => {
|
||||
this.fileIsOver = true
|
||||
}, 1)
|
||||
this.hidden = false
|
||||
// stop fileLeave timeout
|
||||
clearTimeout(this.fileLeaveTimeoutID)
|
||||
}
|
||||
|
||||
@HostListener('dragleave', ['$event']) public onDragLeave(
|
||||
event: DragEvent,
|
||||
immediate: boolean = false
|
||||
) {
|
||||
if (!this.dragDropEnabled) return
|
||||
event?.preventDefault()
|
||||
event?.stopImmediatePropagation()
|
||||
this.settings.globalDropzoneActive = false
|
||||
|
||||
const ms = immediate ? 0 : 500
|
||||
|
||||
this.fileLeaveTimeoutID = setTimeout(() => {
|
||||
this.fileIsOver = false
|
||||
// await transition completed
|
||||
setTimeout(() => {
|
||||
this.hidden = true
|
||||
}, 150)
|
||||
}, ms)
|
||||
}
|
||||
|
||||
@HostListener('drop', ['$event']) public onDrop(event: DragEvent) {
|
||||
if (!this.dragDropEnabled) return
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
// pass event onto ngx-file-drop to handle files
|
||||
this.ngxFileDrop.dropFiles(event)
|
||||
this.onDragLeave(event, true)
|
||||
}
|
||||
|
||||
public dropped(files: NgxFileDropEntry[]) {
|
||||
this.uploadDocumentsService.onNgxFileDrop(files)
|
||||
if (files.length > 0)
|
||||
this.toastService.showInfo($localize`Initiating upload...`, 3000)
|
||||
}
|
||||
|
||||
@HostListener('window:blur', ['$event']) public onWindowBlur() {
|
||||
if (this.fileIsOver) this.onDragLeave(null)
|
||||
}
|
||||
|
||||
@HostListener('document:visibilitychange', ['$event'])
|
||||
public onVisibilityChange() {
|
||||
if (document.hidden && this.fileIsOver) this.onDragLeave(null)
|
||||
}
|
||||
}
|
@@ -41,6 +41,8 @@ export const SETTINGS_KEYS = {
|
||||
'general-settings:update-checking:backend-setting',
|
||||
SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE:
|
||||
'general-settings:saved-views:warn-on-unsaved-change',
|
||||
DASHBOARD_VIEWS_SORT_ORDER:
|
||||
'general-settings:saved-views:dashboard-views-sort-order',
|
||||
TOUR_COMPLETE: 'general-settings:tour-complete',
|
||||
DEFAULT_PERMS_OWNER: 'general-settings:permissions:default-owner',
|
||||
DEFAULT_PERMS_VIEW_USERS: 'general-settings:permissions:default-view-users',
|
||||
@@ -180,4 +182,9 @@ export const SETTINGS: PaperlessUiSetting[] = [
|
||||
type: 'array',
|
||||
default: [],
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER,
|
||||
type: 'array',
|
||||
default: [],
|
||||
},
|
||||
]
|
||||
|
@@ -230,7 +230,10 @@ export class ConsumerStatusService {
|
||||
|
||||
dismissCompleted() {
|
||||
this.consumerStatus = this.consumerStatus.filter(
|
||||
(status) => status.phase != FileStatusPhase.SUCCESS
|
||||
(status) =>
|
||||
![FileStatusPhase.SUCCESS, FileStatusPhase.FAILED].includes(
|
||||
status.phase
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
@@ -15,6 +15,7 @@ import {
|
||||
SETTINGS_KEYS,
|
||||
} from '../data/paperless-uisettings'
|
||||
import { SettingsService } from './settings.service'
|
||||
import { PaperlessSavedView } from '../data/paperless-saved-view'
|
||||
|
||||
describe('SettingsService', () => {
|
||||
let httpTestingController: HttpTestingController
|
||||
@@ -277,4 +278,22 @@ describe('SettingsService', () => {
|
||||
)[0]
|
||||
expect(req.request.method).toEqual('POST')
|
||||
})
|
||||
|
||||
it('should update saved view sorting', () => {
|
||||
httpTestingController
|
||||
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
||||
.flush(ui_settings)
|
||||
const setSpy = jest.spyOn(settingsService, 'set')
|
||||
settingsService.updateDashboardViewsSort([
|
||||
{ id: 1 } as PaperlessSavedView,
|
||||
{ id: 4 } as PaperlessSavedView,
|
||||
])
|
||||
expect(setSpy).toHaveBeenCalledWith(
|
||||
SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER,
|
||||
[1, 4]
|
||||
)
|
||||
httpTestingController
|
||||
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
||||
.flush(ui_settings)
|
||||
})
|
||||
})
|
||||
|
@@ -26,6 +26,7 @@ import { PaperlessUser } from '../data/paperless-user'
|
||||
import { PermissionsService } from './permissions.service'
|
||||
import { SavedViewService } from './rest/saved-view.service'
|
||||
import { ToastService } from './toast.service'
|
||||
import { PaperlessSavedView } from '../data/paperless-saved-view'
|
||||
|
||||
export interface LanguageOption {
|
||||
code: string
|
||||
@@ -54,6 +55,9 @@ export class SettingsService {
|
||||
return this._renderer
|
||||
}
|
||||
|
||||
public globalDropzoneEnabled: boolean = true
|
||||
public globalDropzoneActive: boolean = false
|
||||
|
||||
constructor(
|
||||
rendererFactory: RendererFactory2,
|
||||
@Inject(DOCUMENT) private document,
|
||||
@@ -531,4 +535,13 @@ export class SettingsService {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
updateDashboardViewsSort(
|
||||
dashboardViews: PaperlessSavedView[]
|
||||
): Observable<any> {
|
||||
this.set(SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER, [
|
||||
...new Set(dashboardViews.map((v) => v.id)),
|
||||
])
|
||||
return this.storeSettings()
|
||||
}
|
||||
}
|
||||
|
@@ -5,12 +5,39 @@ import {
|
||||
HttpTestingController,
|
||||
} from '@angular/common/http/testing'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { HttpEventType, HttpResponse } from '@angular/common/http'
|
||||
import { HttpEventType } from '@angular/common/http'
|
||||
import {
|
||||
ConsumerStatusService,
|
||||
FileStatusPhase,
|
||||
} from './consumer-status.service'
|
||||
|
||||
const files = [
|
||||
{
|
||||
lastModified: 1693349892540,
|
||||
lastModifiedDate: new Date(),
|
||||
name: 'file1.pdf',
|
||||
size: 386,
|
||||
type: 'application/pdf',
|
||||
},
|
||||
{
|
||||
lastModified: 1695618533892,
|
||||
lastModifiedDate: new Date(),
|
||||
name: 'file2.pdf',
|
||||
size: 358265,
|
||||
type: 'application/pdf',
|
||||
},
|
||||
]
|
||||
|
||||
const fileList = {
|
||||
item: (x) => {
|
||||
return new File(
|
||||
[new Blob(['testing'], { type: files[x].type })],
|
||||
files[x].name
|
||||
)
|
||||
},
|
||||
length: files.length,
|
||||
} as unknown as FileList
|
||||
|
||||
describe('UploadDocumentsService', () => {
|
||||
let httpTestingController: HttpTestingController
|
||||
let uploadDocumentsService: UploadDocumentsService
|
||||
@@ -32,66 +59,30 @@ describe('UploadDocumentsService', () => {
|
||||
})
|
||||
|
||||
it('calls post_document api endpoint on upload', () => {
|
||||
const fileEntry = {
|
||||
name: 'file.pdf',
|
||||
isDirectory: false,
|
||||
isFile: true,
|
||||
file: (callback) => {
|
||||
return callback(
|
||||
new File(
|
||||
[new Blob(['testing'], { type: 'application/pdf' })],
|
||||
'file.pdf'
|
||||
)
|
||||
)
|
||||
},
|
||||
}
|
||||
uploadDocumentsService.uploadFiles([
|
||||
{
|
||||
relativePath: 'path/to/file.pdf',
|
||||
fileEntry,
|
||||
},
|
||||
])
|
||||
const req = httpTestingController.expectOne(
|
||||
uploadDocumentsService.uploadFiles(fileList)
|
||||
const req = httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/post_document/`
|
||||
)
|
||||
expect(req.request.method).toEqual('POST')
|
||||
expect(req[0].request.method).toEqual('POST')
|
||||
|
||||
req.flush('123-456')
|
||||
req[0].flush('123-456')
|
||||
})
|
||||
|
||||
it('updates progress during upload and failure', () => {
|
||||
const fileEntry = {
|
||||
name: 'file.pdf',
|
||||
isDirectory: false,
|
||||
isFile: true,
|
||||
file: (callback) => {
|
||||
return callback(
|
||||
new File(
|
||||
[new Blob(['testing'], { type: 'application/pdf' })],
|
||||
'file.pdf'
|
||||
)
|
||||
)
|
||||
},
|
||||
}
|
||||
uploadDocumentsService.uploadFiles([
|
||||
{
|
||||
relativePath: 'path/to/file.pdf',
|
||||
fileEntry,
|
||||
},
|
||||
])
|
||||
uploadDocumentsService.uploadFiles(fileList)
|
||||
|
||||
expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength(
|
||||
1
|
||||
2
|
||||
)
|
||||
expect(
|
||||
consumerStatusService.getConsumerStatus(FileStatusPhase.UPLOADING)
|
||||
).toHaveLength(0)
|
||||
|
||||
const req = httpTestingController.expectOne(
|
||||
const req = httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/post_document/`
|
||||
)
|
||||
|
||||
req.event({
|
||||
req[0].event({
|
||||
type: HttpEventType.UploadProgress,
|
||||
loaded: 100,
|
||||
total: 300,
|
||||
@@ -103,6 +94,52 @@ describe('UploadDocumentsService', () => {
|
||||
})
|
||||
|
||||
it('updates progress on failure', () => {
|
||||
uploadDocumentsService.uploadFiles(fileList)
|
||||
|
||||
let req = httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/post_document/`
|
||||
)
|
||||
|
||||
expect(
|
||||
consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED)
|
||||
).toHaveLength(0)
|
||||
|
||||
req[0].flush(
|
||||
{},
|
||||
{
|
||||
status: 400,
|
||||
statusText: 'failed',
|
||||
}
|
||||
)
|
||||
|
||||
expect(
|
||||
consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED)
|
||||
).toHaveLength(1)
|
||||
|
||||
uploadDocumentsService.uploadFiles(fileList)
|
||||
|
||||
req = httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/post_document/`
|
||||
)
|
||||
|
||||
req[0].flush(
|
||||
{},
|
||||
{
|
||||
status: 500,
|
||||
statusText: 'failed',
|
||||
}
|
||||
)
|
||||
|
||||
expect(
|
||||
consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED)
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('accepts files via drag and drop', () => {
|
||||
const uploadSpy = jest.spyOn(
|
||||
UploadDocumentsService.prototype as any,
|
||||
'uploadFile'
|
||||
)
|
||||
const fileEntry = {
|
||||
name: 'file.pdf',
|
||||
isDirectory: false,
|
||||
@@ -116,54 +153,16 @@ describe('UploadDocumentsService', () => {
|
||||
)
|
||||
},
|
||||
}
|
||||
uploadDocumentsService.uploadFiles([
|
||||
uploadDocumentsService.onNgxFileDrop([
|
||||
{
|
||||
relativePath: 'path/to/file.pdf',
|
||||
fileEntry,
|
||||
},
|
||||
])
|
||||
expect(uploadSpy).toHaveBeenCalled()
|
||||
|
||||
let req = httpTestingController.expectOne(
|
||||
let req = httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/post_document/`
|
||||
)
|
||||
|
||||
expect(
|
||||
consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED)
|
||||
).toHaveLength(0)
|
||||
|
||||
req.flush(
|
||||
{},
|
||||
{
|
||||
status: 400,
|
||||
statusText: 'failed',
|
||||
}
|
||||
)
|
||||
|
||||
expect(
|
||||
consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED)
|
||||
).toHaveLength(1)
|
||||
|
||||
uploadDocumentsService.uploadFiles([
|
||||
{
|
||||
relativePath: 'path/to/file.pdf',
|
||||
fileEntry,
|
||||
},
|
||||
])
|
||||
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/post_document/`
|
||||
)
|
||||
|
||||
req.flush(
|
||||
{},
|
||||
{
|
||||
status: 500,
|
||||
statusText: 'failed',
|
||||
}
|
||||
)
|
||||
|
||||
expect(
|
||||
consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED)
|
||||
).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
@@ -19,56 +19,61 @@ export class UploadDocumentsService {
|
||||
private consumerStatusService: ConsumerStatusService
|
||||
) {}
|
||||
|
||||
uploadFiles(files: NgxFileDropEntry[]) {
|
||||
onNgxFileDrop(files: NgxFileDropEntry[]) {
|
||||
for (const droppedFile of files) {
|
||||
if (droppedFile.fileEntry.isFile) {
|
||||
const fileEntry = droppedFile.fileEntry as FileSystemFileEntry
|
||||
fileEntry.file((file: File) => {
|
||||
let formData = new FormData()
|
||||
formData.append('document', file, file.name)
|
||||
let status = this.consumerStatusService.newFileUpload(file.name)
|
||||
|
||||
status.message = $localize`Connecting...`
|
||||
|
||||
this.uploadSubscriptions[file.name] = this.documentService
|
||||
.uploadDocument(formData)
|
||||
.subscribe({
|
||||
next: (event) => {
|
||||
if (event.type == HttpEventType.UploadProgress) {
|
||||
status.updateProgress(
|
||||
FileStatusPhase.UPLOADING,
|
||||
event.loaded,
|
||||
event.total
|
||||
)
|
||||
status.message = $localize`Uploading...`
|
||||
} else if (event.type == HttpEventType.Response) {
|
||||
status.taskId = event.body['task_id']
|
||||
status.message = $localize`Upload complete, waiting...`
|
||||
this.uploadSubscriptions[file.name]?.complete()
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
switch (error.status) {
|
||||
case 400: {
|
||||
this.consumerStatusService.fail(
|
||||
status,
|
||||
error.error.document
|
||||
)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
this.consumerStatusService.fail(
|
||||
status,
|
||||
$localize`HTTP error: ${error.status} ${error.statusText}`
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
this.uploadSubscriptions[file.name]?.complete()
|
||||
},
|
||||
})
|
||||
})
|
||||
fileEntry.file((file: File) => this.uploadFile(file))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uploadFiles(files: FileList) {
|
||||
for (let index = 0; index < files.length; index++) {
|
||||
this.uploadFile(files.item(index))
|
||||
}
|
||||
}
|
||||
|
||||
private uploadFile(file: File) {
|
||||
let formData = new FormData()
|
||||
formData.append('document', file, file.name)
|
||||
let status = this.consumerStatusService.newFileUpload(file.name)
|
||||
|
||||
status.message = $localize`Connecting...`
|
||||
|
||||
this.uploadSubscriptions[file.name] = this.documentService
|
||||
.uploadDocument(formData)
|
||||
.subscribe({
|
||||
next: (event) => {
|
||||
if (event.type == HttpEventType.UploadProgress) {
|
||||
status.updateProgress(
|
||||
FileStatusPhase.UPLOADING,
|
||||
event.loaded,
|
||||
event.total
|
||||
)
|
||||
status.message = $localize`Uploading...`
|
||||
} else if (event.type == HttpEventType.Response) {
|
||||
status.taskId = event.body['task_id']
|
||||
status.message = $localize`Upload complete, waiting...`
|
||||
this.uploadSubscriptions[file.name]?.complete()
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
switch (error.status) {
|
||||
case 400: {
|
||||
this.consumerStatusService.fail(status, error.error.document)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
this.consumerStatusService.fail(
|
||||
status,
|
||||
$localize`HTTP error: ${error.status} ${error.statusText}`
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
this.uploadSubscriptions[file.name]?.complete()
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@@ -16,6 +16,12 @@ body {
|
||||
transition: background-color 0.3s ease, border-color 0.3s ease;
|
||||
}
|
||||
|
||||
@media(min-width: 768px) {
|
||||
.col-slim {
|
||||
padding-left: calc(50px + $grid-gutter-width) !important;
|
||||
}
|
||||
}
|
||||
|
||||
svg.logo {
|
||||
.leaf {
|
||||
fill: var(--bs-primary) !important;
|
||||
@@ -478,50 +484,6 @@ table.table {
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.main-dropzone {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
&.ngx-file-drop__drop-zone--over {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
.global-dropzone-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background-color: hsla(var(--pngx-primary), var(--pngx-primary-lightness), .8);
|
||||
z-index: 1055; // $zindex-modal
|
||||
pointer-events: none !important;
|
||||
user-select: none !important;
|
||||
text-align: center;
|
||||
padding-top: 25%;
|
||||
|
||||
h2 {
|
||||
color: var(--pngx-primary-text-contrast)
|
||||
}
|
||||
|
||||
&.show {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
&.hide {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ngx-file-drop__drop-zone--over .global-dropzone-overlay {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.inert {
|
||||
pointer-events: none !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
.alert-primary {
|
||||
--bs-alert-color: var(--bs-primary);
|
||||
--bs-alert-bg: var(--pngx-primary-faded);
|
||||
|
@@ -214,6 +214,10 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
|
||||
}
|
||||
}
|
||||
|
||||
.card .table {
|
||||
border-color: var(--bs-gray-800);
|
||||
}
|
||||
|
||||
.alert-secondary {
|
||||
background-color: var(--bs-light);
|
||||
border-color: var(--pngx-bg-darker);
|
||||
|
Reference in New Issue
Block a user