mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-07-28 18:24:38 -05:00
Enhancement: confirm buttons (#5680)
This commit is contained in:
@@ -112,6 +112,7 @@ import { SwitchComponent } from './components/common/input/switch/switch.compone
|
||||
import { ConfigComponent } from './components/admin/config/config.component'
|
||||
import { FileComponent } from './components/common/input/file/file.component'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { ConfirmButtonComponent } from './components/common/confirm-button/confirm-button.component'
|
||||
import {
|
||||
archive,
|
||||
arrowCounterclockwise,
|
||||
@@ -439,6 +440,7 @@ function initializeApp(settings: SettingsService) {
|
||||
SwitchComponent,
|
||||
ConfigComponent,
|
||||
FileComponent,
|
||||
ConfirmButtonComponent,
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
|
@@ -319,7 +319,15 @@
|
||||
</div>
|
||||
<div class="mb-2 col-auto">
|
||||
<label class="form-label" for="name_{{view.id}}" i18n>Actions</label>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger form-control" (click)="deleteSavedView(view)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.SavedView }" i18n>Delete</button>
|
||||
|
||||
<pngx-confirm-button
|
||||
label="Delete"
|
||||
i18n-label
|
||||
(confirm)="deleteSavedView(view)"
|
||||
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.SavedView }"
|
||||
buttonClasses="btn-sm btn-outline-danger form-control"
|
||||
iconName="trash">
|
||||
</pngx-confirm-button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
@@ -38,6 +38,7 @@ import { PageHeaderComponent } from '../../common/page-header/page-header.compon
|
||||
import { SettingsComponent } from './settings.component'
|
||||
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
|
||||
|
||||
const savedViews = [
|
||||
{ id: 1, name: 'view1', show_in_sidebar: true, show_on_dashboard: true },
|
||||
@@ -83,6 +84,7 @@ describe('SettingsComponent', () => {
|
||||
PermissionsUserComponent,
|
||||
PermissionsGroupComponent,
|
||||
IfOwnerDirective,
|
||||
ConfirmButtonComponent,
|
||||
],
|
||||
providers: [CustomDatePipe, DatePipe, PermissionsGuard],
|
||||
imports: [
|
||||
|
@@ -0,0 +1,22 @@
|
||||
<button
|
||||
type="button"
|
||||
class="btn {{buttonClasses}}"
|
||||
(click)="onClick($event)"
|
||||
[disabled]="disabled"
|
||||
[ngbPopover]="popoverContent"
|
||||
[autoClose]="true"
|
||||
(hidden)="confirming = false"
|
||||
#popover="ngbPopover"
|
||||
popoverClass="popover-slim"
|
||||
>
|
||||
@if (iconName) {
|
||||
<i-bs [class.me-1]="label" name="{{iconName}}"></i-bs>
|
||||
}
|
||||
<ng-container>{{label}}</ng-container>
|
||||
</button>
|
||||
|
||||
<ng-template #popoverContent>
|
||||
<div>
|
||||
{{confirmMessage}} <button class="btn btn-link btn-sm text-danger p-0 m-0 lh-1" type="button" (click)="onConfirm($event)">Yes</button>
|
||||
</div>
|
||||
</ng-template>
|
@@ -0,0 +1,12 @@
|
||||
// Taken from bootstrap rules, obv
|
||||
::ng-deep .input-group > pngx-confirm-button:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback) > button,
|
||||
::ng-deep .btn-group > pngx-confirm-button:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback) > button {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
::ng-deep .input-group:not(.has-validation) > pngx-confirm-button:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating) > button,
|
||||
::ng-deep .btn-group:not(.has-validation) > pngx-confirm-button:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating) > button {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
@@ -0,0 +1,37 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
|
||||
import { ConfirmButtonComponent } from './confirm-button.component'
|
||||
import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
|
||||
describe('ConfirmButtonComponent', () => {
|
||||
let component: ConfirmButtonComponent
|
||||
let fixture: ComponentFixture<ConfirmButtonComponent>
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ConfirmButtonComponent],
|
||||
imports: [NgbPopoverModule, NgxBootstrapIconsModule.pick(allIcons)],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(ConfirmButtonComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should show confirm on click', () => {
|
||||
expect(component.popover.isOpen()).toBeFalsy()
|
||||
expect(component.confirming).toBeFalsy()
|
||||
component.onClick(new MouseEvent('click'))
|
||||
expect(component.popover.isOpen()).toBeTruthy()
|
||||
expect(component.confirming).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should emit confirm on confirm', () => {
|
||||
const confirmSpy = jest.spyOn(component.confirm, 'emit')
|
||||
component.onConfirm(new MouseEvent('click'))
|
||||
expect(confirmSpy).toHaveBeenCalled()
|
||||
expect(component.popover.isOpen()).toBeFalsy()
|
||||
expect(component.confirming).toBeFalsy()
|
||||
})
|
||||
})
|
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
Output,
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-confirm-button',
|
||||
templateUrl: './confirm-button.component.html',
|
||||
styleUrl: './confirm-button.component.scss',
|
||||
})
|
||||
export class ConfirmButtonComponent {
|
||||
@Input()
|
||||
label: string
|
||||
|
||||
@Input()
|
||||
confirmMessage: string = $localize`Are you sure?`
|
||||
|
||||
@Input()
|
||||
buttonClasses: string = 'btn-primary'
|
||||
|
||||
@Input()
|
||||
iconName: string
|
||||
|
||||
@Input()
|
||||
disabled: boolean = false
|
||||
|
||||
@Output()
|
||||
confirm: EventEmitter<void> = new EventEmitter<void>()
|
||||
|
||||
@ViewChild('popover') popover: NgbPopover
|
||||
|
||||
public confirming: boolean = false
|
||||
|
||||
public onClick(event: MouseEvent) {
|
||||
if (!this.confirming) {
|
||||
this.confirming = true
|
||||
this.popover.open()
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
|
||||
public onConfirm(event: MouseEvent) {
|
||||
this.confirm.emit()
|
||||
this.confirming = false
|
||||
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
}
|
@@ -38,9 +38,13 @@
|
||||
@if(trigger.id > -1) {
|
||||
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{trigger.id}}</span>
|
||||
}
|
||||
<button type="button" class="btn btn-link text-danger ms-2" (click)="removeTrigger(i)">
|
||||
<i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
<pngx-confirm-button
|
||||
label="Delete"
|
||||
i18n-label
|
||||
(confirm)="removeTrigger(i)"
|
||||
buttonClasses="btn-link text-danger ms-2"
|
||||
iconName="trash">
|
||||
</pngx-confirm-button>
|
||||
</button>
|
||||
</div>
|
||||
<div ngbAccordionCollapse>
|
||||
@@ -76,9 +80,13 @@
|
||||
@if(action.id > -1) {
|
||||
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{action.id}}</span>
|
||||
}
|
||||
<button type="button" class="btn btn-link text-danger ms-2" (click)="removeAction(i)">
|
||||
<i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
<pngx-confirm-button
|
||||
label="Delete"
|
||||
i18n-label
|
||||
(confirm)="removeAction(i)"
|
||||
buttonClasses="btn-link text-danger ms-2"
|
||||
iconName="trash">
|
||||
</pngx-confirm-button>
|
||||
</button>
|
||||
</div>
|
||||
<div ngbAccordionCollapse>
|
||||
|
@@ -38,6 +38,7 @@ import {
|
||||
WorkflowActionType,
|
||||
} from 'src/app/data/workflow-action'
|
||||
import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model'
|
||||
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
|
||||
|
||||
const workflow: Workflow = {
|
||||
name: 'Workflow 1',
|
||||
@@ -85,6 +86,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
PermissionsUserComponent,
|
||||
PermissionsGroupComponent,
|
||||
SafeHtmlPipe,
|
||||
ConfirmButtonComponent,
|
||||
],
|
||||
providers: [
|
||||
NgbActiveModal,
|
||||
|
@@ -41,9 +41,14 @@
|
||||
}
|
||||
<span class="visually-hidden" i18n>Copy</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="generateAuthToken()" i18n-title title="Regenerate auth token">
|
||||
<i-bs width="1.2em" height="1.2em" name="arrow-repeat"></i-bs>
|
||||
</button>
|
||||
<pngx-confirm-button
|
||||
title="Regenerate auth token"
|
||||
i18n-title
|
||||
buttonClasses=" btn-outline-secondary"
|
||||
iconName="arrow-repeat"
|
||||
[disabled]="!hasUsablePassword"
|
||||
(confirm)="generateAuthToken()">
|
||||
</pngx-confirm-button>
|
||||
</div>
|
||||
<span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied" i18n>Copied!</span>
|
||||
</div>
|
||||
@@ -60,14 +65,16 @@
|
||||
[disablePopover]="hasUsablePassword"
|
||||
triggers="mouseenter:mouseleave">
|
||||
{{account.name}} ({{account.provider}})
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-danger btn-sm ms-2 align-baseline"
|
||||
[disabled]="!hasUsablePassword && socialAccounts.length === 1"
|
||||
(click)="disconnectSocialAccount(account.id)"
|
||||
i18n-title title="Disconnect {{ account.name }} social account">
|
||||
<ng-container i18n>Disconnect</ng-container> <i-bs name="trash"></i-bs>
|
||||
</button>
|
||||
<pngx-confirm-button
|
||||
label="Disconnect"
|
||||
i18n-label
|
||||
title="Disconnect {{ account.name }} social account"
|
||||
i18n-title
|
||||
buttonClasses="btn-outline-danger btn-sm ms-2 align-baseline"
|
||||
iconName="trash"
|
||||
[disabled]="!hasUsablePassword"
|
||||
(confirm)="disconnectSocialAccount(account.id)">
|
||||
</pngx-confirm-button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
@@ -21,6 +21,7 @@ import { of, throwError } from 'rxjs'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { ConfirmButtonComponent } from '../confirm-button/confirm-button.component'
|
||||
|
||||
const socialAccount = {
|
||||
id: 1,
|
||||
@@ -52,6 +53,7 @@ describe('ProfileEditDialogComponent', () => {
|
||||
ProfileEditDialogComponent,
|
||||
TextComponent,
|
||||
PasswordComponent,
|
||||
ConfirmButtonComponent,
|
||||
],
|
||||
providers: [NgbActiveModal],
|
||||
imports: [
|
||||
|
@@ -88,33 +88,39 @@
|
||||
<div class="btn-group d-none d-sm-block">
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="filterDocuments(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||
<i-bs width="1em" height="1em" name="filter"></i-bs> <ng-container i18n>Documents</ng-container>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" [disabled]="!userCanEdit(object)">
|
||||
<i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" [disabled]="!userCanDelete(object)">
|
||||
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (!isLoading) {
|
||||
<div class="d-flex mb-2">
|
||||
@if (collectionSize > 0) {
|
||||
<div>
|
||||
<ng-container i18n>{collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}}</ng-container>
|
||||
@if (selectedObjects.size > 0) {
|
||||
({{selectedObjects.size}} selected)
|
||||
}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" [disabled]="!userCanEdit(object)">
|
||||
<i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<pngx-confirm-button
|
||||
label="Delete"
|
||||
i18n-label
|
||||
(confirm)="deleteObject(object)"
|
||||
*pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }"
|
||||
[disabled]="!userCanDelete(object)"
|
||||
buttonClasses=" btn-sm btn-outline-danger"
|
||||
iconName="trash">
|
||||
</pngx-confirm-button>
|
||||
</div>
|
||||
}
|
||||
@if (collectionSize > 20) {
|
||||
<ngb-pagination class="ms-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (!isLoading) {
|
||||
<div class="d-flex mb-2">
|
||||
@if (collectionSize > 0) {
|
||||
<div>
|
||||
<ng-container i18n>{collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}}</ng-container>
|
||||
@if (selectedObjects.size > 0) {
|
||||
({{selectedObjects.size}} selected)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (collectionSize > 20) {
|
||||
<ngb-pagination class="ms-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
@@ -13,6 +13,7 @@ import {
|
||||
NgbModalModule,
|
||||
NgbModalRef,
|
||||
NgbPaginationModule,
|
||||
NgbPopoverModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
@@ -37,6 +38,7 @@ import { MATCH_NONE } from 'src/app/data/matching-model'
|
||||
import { MATCH_LITERAL } from 'src/app/data/matching-model'
|
||||
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
|
||||
import { BulkEditObjectOperation } from 'src/app/services/rest/abstract-name-filter-service'
|
||||
|
||||
const tags: Tag[] = [
|
||||
@@ -76,6 +78,7 @@ describe('ManagementListComponent', () => {
|
||||
SafeHtmlPipe,
|
||||
ConfirmDialogComponent,
|
||||
PermissionsDialogComponent,
|
||||
ConfirmButtonComponent,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
@@ -97,6 +100,7 @@ describe('ManagementListComponent', () => {
|
||||
NgbModalModule,
|
||||
RouterTestingModule.withRoutes(routes),
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
NgbPopoverModule,
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
@@ -193,27 +197,23 @@ describe('ManagementListComponent', () => {
|
||||
})
|
||||
|
||||
it('should support delete, show notification on error / success', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const deleteSpy = jest.spyOn(tagService, 'delete')
|
||||
const reloadSpy = jest.spyOn(component, 'reloadData')
|
||||
|
||||
const deleteButton = fixture.debugElement.queryAll(By.css('button'))[8]
|
||||
deleteButton.triggerEventHandler('click')
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
const editDialog = modal.componentInstance as ConfirmDialogComponent
|
||||
const deleteButton = fixture.debugElement.query(
|
||||
By.directive(ConfirmButtonComponent)
|
||||
)
|
||||
|
||||
// fail first
|
||||
deleteSpy.mockReturnValueOnce(throwError(() => new Error('error deleting')))
|
||||
editDialog.confirmClicked.emit()
|
||||
deleteButton.nativeElement.dispatchEvent(new Event('confirm'))
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
|
||||
// succeed
|
||||
deleteSpy.mockReturnValueOnce(of(true))
|
||||
editDialog.confirmClicked.emit()
|
||||
deleteButton.nativeElement.dispatchEvent(new Event('confirm'))
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
@@ -15,10 +15,7 @@ import {
|
||||
MATCH_NONE,
|
||||
} from 'src/app/data/matching-model'
|
||||
import { ObjectWithId } from 'src/app/data/object-with-id'
|
||||
import {
|
||||
ObjectWithPermissions,
|
||||
PermissionsObject,
|
||||
} from 'src/app/data/object-with-permissions'
|
||||
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
||||
import {
|
||||
SortableDirective,
|
||||
SortEvent,
|
||||
@@ -197,34 +194,21 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
|
||||
])
|
||||
}
|
||||
|
||||
openDeleteDialog(object: T) {
|
||||
var activeModal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
activeModal.componentInstance.title = $localize`Confirm delete`
|
||||
activeModal.componentInstance.messageBold = this.getDeleteMessage(object)
|
||||
activeModal.componentInstance.message = $localize`Associated documents will not be deleted.`
|
||||
activeModal.componentInstance.btnClass = 'btn-danger'
|
||||
activeModal.componentInstance.btnCaption = $localize`Delete`
|
||||
activeModal.componentInstance.confirmClicked.subscribe(() => {
|
||||
activeModal.componentInstance.buttonsEnabled = false
|
||||
this.service
|
||||
.delete(object)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
activeModal.close()
|
||||
this.reloadData()
|
||||
},
|
||||
error: (error) => {
|
||||
activeModal.componentInstance.buttonsEnabled = true
|
||||
this.toastService.showError(
|
||||
$localize`Error while deleting element`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
deleteObject(object: T) {
|
||||
this.service
|
||||
.delete(object)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.reloadData()
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error while deleting element`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
get nameFilter() {
|
||||
|
Reference in New Issue
Block a user