All files / src/app/shared/components/common/modal-dialog modal-dialog.component.ts

83.87% Statements 26/31
37.5% Branches 3/8
75% Functions 6/8
83.33% Lines 25/30

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 16424x 24x                         24x 24x 24x 24x                                                                                                                           24x     4x   4x   4x                 4x   4x   4x     4x 4x             2x   2x         2x     1x     2x             1x 1x             1x 1x                                         4x      
import { DOCUMENT } from '@angular/common';
import {
  ChangeDetectionStrategy,
  Component,
  DestroyRef,
  EventEmitter,
  Inject,
  Input,
  OnDestroy,
  Output,
  TemplateRef,
  ViewChild,
  inject,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NgbModal, NgbModalOptions, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { Subject, race, take } from 'rxjs';
import { v4 as uuid } from 'uuid';
 
export interface ModalOptions extends NgbModalOptions {
  /** Modal Title */
  titleText?: string;
 
  /** Modal confirm button Label. */
  confirmText?: string;
 
  /** Modal confirm button disabled. */
  confirmDisabled?: boolean;
 
  /** Modal reject button Label. */
  rejectText?: string;
 
  /** Icon properties to display an icon in front of the title, e.g. 'exclamation-triangle-fill' */
  icon?: string;
 
  /**  Icon styling classes, e.g. iconClass: 'text-warning pe-2' */
  iconClass?: string;
}
 
/**
 * The Modal Dialog Component displays a generic (ng-bootstrap) modal.
 * It supports two mutually exclusive usage modes:
 *
 * **Mode 1 – Options-driven (simple):**
 * The dialog header (title, icon, close button) and footer (confirm/reject buttons) are rendered
 * automatically based on the `[options]` input. Provide your content as plain `ng-content`,
 * which will be placed inside the modal body.
 *
 * @example
 * <ish-modal-dialog [options]="{ titleText: 'Confirm', confirmText: 'OK', rejectText: 'Cancel' }"
 *                   (confirmed)="onConfirmed($event)">
 *   <p>Are you sure?</p>
 * </ish-modal-dialog>
 *
 * ---
 *
 * **Mode 2 – Custom sections (advanced):**
 * For full control, project elements with a `header` and/or `body` attribute.
 * When provided, these replace the corresponding options-driven sections entirely.
 *
 * - `[header]` replaces the entire modal header (title, icon, close button).
 * - `[body]` replaces the entire modal body AND footer (you must provide your own buttons).
 *
 * @example
 * <!-- Custom body (including footer), options-driven header -->
 * <ish-modal-dialog [options]="{ titleText: 'Info' }">
 *   <div body>
 *     <div class="modal-body">Full control over body and footer.</div>
 *     <div class="modal-footer"><button (click)="doSomething()">Custom Action</button></div>
 *   </div>
 * </ish-modal-dialog>
 *
 * @see https://ng-bootstrap.github.io/#/components/modal/api#NgbModal
 */
@Component({
  selector: 'ish-modal-dialog',
  templateUrl: './modal-dialog.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ModalDialogComponent<T> implements OnDestroy {
  @Input({ required: true }) options: ModalOptions;
 
  @Output() readonly confirmed = new EventEmitter<T>();
 
  @Output() readonly closed = new EventEmitter<T>();
 
  @Output() readonly shown = new EventEmitter<T>();
 
  @ViewChild('template') modalDialogTemplate: TemplateRef<unknown>;
 
  // visible-for-testing
  ngbModalRef: NgbModalRef;
 
  data: T;
 
  uuid = uuid();
 
  private hide$ = new Subject<void>();
 
  private destroyRef = inject(DestroyRef);
 
  constructor(
    private ngbModal: NgbModal,
    @Inject(DOCUMENT) private document: Document
  ) {}
 
  /**
   * Configure and show modal dialog.
   */
  show(data?: T) {
    this.data = data ? data : undefined;
 
    this.ngbModalRef = this.ngbModal.open(this.modalDialogTemplate, {
      ...this.options,
      ariaLabelledBy: this.options.titleText ? `modal-title-${this.uuid}` : this.options.ariaLabelledBy,
    });
 
    race(this.ngbModalRef.dismissed, this.hide$)
      .pipe(take(1), takeUntilDestroyed(this.destroyRef))
      .subscribe(() => {
        this.closed.emit(this.data);
      });
 
    this.shown.emit(this.data);
  }
 
  /**
   * Hides modal dialog.
   */
  hide() {
    this.ngbModalRef.close();
    this.hide$.next();
  }
 
  /**
   * Emits input data or undefined and hides modal.
   */
  confirm() {
    this.confirmed.emit(this.data);
    this.hide();
  }
 
  /**
   * Scrolls to an anchor element
   *
   * @param anchor The ID of the anchor element.
   */
  // not-dead-code
  scrollToAnchor(anchor: string) {
    if (this.options.scrollable) {
      const element = this.document.getElementById(anchor);
      if (element) {
        setTimeout(() => {
          element.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' });
        }, 200);
      }
    }
  }
 
  ngOnDestroy(): void {
    this.hide$.complete(); // complete open hide$ subscription
  }
}