Files
ISA-Frontend/libs/ui/dialog/README.md
Lorenz Hilpert 2b5da00249 feat(checkout): add reward order confirmation feature with schema migrations
- Add new reward-order-confirmation feature library with components and store
- Implement checkout completion orchestrator service for order finalization
- Migrate checkout/oms/crm models to Zod schemas for better type safety
- Add order creation facade and display order schemas
- Update shopping cart facade with order completion flow
- Add comprehensive tests for shopping cart facade
- Update routing to include order confirmation page
2025-10-21 14:28:52 +02:00

25 KiB

@isa/ui/dialog

A comprehensive dialog system for Angular applications built on Angular CDK Dialog with preset components for common use cases.

Overview

The Dialog library provides a flexible, type-safe dialog system with five pre-built dialog components covering common scenarios: messages, confirmations, text input, number input, and feedback. Built on Angular CDK Dialog, it offers a simple injection-based API, customizable styling, backdrop control, and full TypeScript support for data and result types.

Table of Contents

Features

  • 5 preset dialog types - Message, Confirmation, TextInput, NumberInput, Feedback
  • Type-safe API - Full TypeScript support for data and result types
  • Injection-based API - Use injectDialog() in component constructors or dependency injection
  • CDK Dialog foundation - Built on @angular/cdk/dialog for robust dialog management
  • Customizable appearance - Control title, width, height, backdrop, and CSS classes
  • Auto-close support - Optional automatic dismiss for feedback dialogs
  • Form validation - Built-in validation support for input dialogs
  • Flexible return types - Type-safe result handling with DialogRef
  • Backdrop control - Enable/disable backdrop clicks to close
  • Custom content - Extend base components for custom dialog implementations

Quick Start

1. Import and Inject Dialog

import { Component, inject } from '@angular/core';
import { injectMessageDialog } from '@isa/ui/dialog';

@Component({
  selector: 'app-my-component',
  template: `<button (click)="showMessage()">Show Message</button>`
})
export class MyComponent {
  #openMessageDialog = injectMessageDialog();

  showMessage() {
    this.#openMessageDialog({
      title: 'Information',
      data: {
        message: 'Operation completed successfully!'
      }
    });
  }
}

2. Confirmation Dialog

import { injectConfirmationDialog } from '@isa/ui/dialog';

@Component({
  selector: 'app-delete-button',
  template: `<button (click)="confirmDelete()">Delete</button>`
})
export class DeleteButtonComponent {
  #openConfirmDialog = injectConfirmationDialog();

  async confirmDelete() {
    const result = await this.#openConfirmDialog({
      title: 'Confirm Deletion',
      data: {
        message: 'Are you sure you want to delete this item?',
        confirmText: 'Delete',
        closeText: 'Cancel'
      }
    }).closed.toPromise();

    if (result?.confirmed) {
      console.log('User confirmed deletion');
      // Perform deletion
    }
  }
}

3. Text Input Dialog

import { injectTextInputDialog } from '@isa/ui/dialog';
import { Validators } from '@angular/forms';

@Component({
  selector: 'app-rename-dialog',
  template: `<button (click)="renameItem()">Rename</button>`
})
export class RenameDialogComponent {
  #openTextInputDialog = injectTextInputDialog();

  async renameItem() {
    const result = await this.#openTextInputDialog({
      title: 'Rename Item',
      data: {
        message: 'Enter a new name for this item:',
        inputLabel: 'Name',
        inputDefaultValue: 'Current Name',
        inputValidation: [
          {
            errorKey: 'required',
            inputValidator: Validators.required,
            errorText: 'Name is required'
          },
          {
            errorKey: 'minlength',
            inputValidator: Validators.minLength(3),
            errorText: 'Name must be at least 3 characters'
          }
        ],
        confirmText: 'Save',
        closeText: 'Cancel'
      }
    }).closed.toPromise();

    if (result?.inputValue) {
      console.log('New name:', result.inputValue);
      // Update item name
    }
  }
}

Core Concepts

Dialog System Architecture

The library consists of three layers:

1. Base Components

  • DialogComponent - Container shell for all dialogs
  • DialogContentDirective - Base class for dialog content components

2. Preset Components

  • MessageDialogComponent - Simple informational messages
  • ConfirmationDialogComponent - Yes/No confirmations
  • TextInputDialogComponent - Text input with validation
  • NumberInputDialogComponent - Numeric input with validation
  • FeedbackDialogComponent - Success feedback with auto-close
  • FeedbackErrorDialogComponent - Error feedback

3. Injection API

  • injectDialog() - Generic dialog injector factory
  • injectMessageDialog() - Pre-configured message dialog
  • injectConfirmationDialog() - Pre-configured confirmation dialog
  • injectTextInputDialog() - Pre-configured text input dialog
  • injectNumberInputDialog() - Pre-configured number input dialog
  • injectFeedbackDialog() - Pre-configured feedback dialog
  • injectFeedbackErrorDialog() - Pre-configured error feedback dialog

Type-Safe Data and Results

Each dialog type is strongly typed for both input data and return value:

// Message Dialog
interface MessageDialogData {
  message: string;
  closeText?: string;
}
// Returns: void

// Confirmation Dialog
interface ConfirmationDialogData {
  message: string;
  confirmText?: string;
  closeText?: string;
}
interface ConfirmationDialogResult {
  confirmed: boolean;
}

// Text Input Dialog
interface TextInputDialogData {
  message: string;
  inputLabel?: string;
  inputDefaultValue?: string;
  inputValidation?: TextInputValidation[];
  confirmText?: string;
  closeText?: string;
}
interface TextInputDialogResult {
  inputValue?: string;
}

// Number Input Dialog
interface NumberInputDialogData {
  message: string;
  subMessage?: string;
  subMessageValue?: string;
  inputLabel?: string;
  inputValue?: number;
  inputDefaultValue?: number;
  inputValidation?: NumberInputValidation[];
  confirmText?: string;
  closeText?: string;
}
interface NumberInputDialogResult {
  inputValue?: number;
}

// Feedback Dialog
interface FeedbackDialogData {
  message: string;
  autoClose?: boolean;
  autoCloseDelay?: number;
}
// Returns: void

Dialog Options

Common options available for all dialogs:

interface InjectDialogOptions {
  title?: string;              // Dialog title
  displayClose?: boolean;      // Show close (X) button (default: false)
  classList?: string[];        // Additional CSS classes
  width?: string;              // Fixed width
  height?: string;             // Fixed height
  minWidth?: string;           // Minimum width (default: '30rem')
  maxWidth?: string;           // Maximum width
  minHeight?: string;          // Minimum height
  maxHeight?: string;          // Maximum height
  hasBackdrop?: boolean;       // Show backdrop (default: true)
  disableClose?: boolean;      // Disable backdrop click to close (default: true)
}

API Reference

injectDialog()

Generic factory function for creating custom dialogs.

Type Parameters:

  • C extends DialogContentDirective<D, R> - Dialog content component type
  • D - Input data type (inferred from component)
  • R - Result type (inferred from component)

Parameters:

  • componentType: ComponentType<C> - The dialog content component class
  • injectOptions?: InjectDialogOptions - Default options for the dialog

Returns: Function (openOptions?) => DialogRef<R>

Usage:

import { injectDialog } from '@isa/ui/dialog';
import { MyCustomDialogComponent } from './my-custom-dialog.component';

@Component({...})
export class MyComponent {
  #openCustomDialog = injectDialog(MyCustomDialogComponent, {
    title: 'Custom Dialog',
    width: '600px'
  });

  openDialog() {
    const dialogRef = this.#openCustomDialog({
      data: { /* custom data */ }
    });

    dialogRef.closed.subscribe(result => {
      console.log('Dialog closed with result:', result);
    });
  }
}

injectMessageDialog()

Pre-configured message dialog injector.

Returns: Function (options?) => DialogRef<void>

Usage:

#openMessageDialog = injectMessageDialog();

showInfo() {
  this.#openMessageDialog({
    title: 'Information',
    data: {
      message: 'This is an informational message.',
      closeText: 'OK'
    }
  });
}

injectConfirmationDialog()

Pre-configured confirmation dialog injector.

Parameters:

  • options?: OpenDialogOptions<ConfirmationDialogData> - Optional default options

Returns: Function (options?) => DialogRef<ConfirmationDialogResult>

Usage:

#openConfirmDialog = injectConfirmationDialog();

async confirmAction() {
  const result = await this.#openConfirmDialog({
    title: 'Confirm Action',
    data: {
      message: 'Are you sure?',
      confirmText: 'Yes',
      closeText: 'No'
    }
  }).closed.toPromise();

  return result?.confirmed ?? false;
}

injectTextInputDialog()

Pre-configured text input dialog injector.

Returns: Function (options?) => DialogRef<TextInputDialogResult>

Usage:

#openTextInputDialog = injectTextInputDialog();

async getTextInput() {
  const result = await this.#openTextInputDialog({
    title: 'Enter Value',
    data: {
      message: 'Please enter a value:',
      inputLabel: 'Value',
      inputDefaultValue: '',
      inputValidation: [
        {
          errorKey: 'required',
          inputValidator: Validators.required,
          errorText: 'This field is required'
        }
      ]
    }
  }).closed.toPromise();

  return result?.inputValue;
}

injectNumberInputDialog()

Pre-configured number input dialog injector.

Returns: Function (options?) => DialogRef<NumberInputDialogResult>

Usage:

#openNumberInputDialog = injectNumberInputDialog();

async getQuantity() {
  const result = await this.#openNumberInputDialog({
    title: 'Enter Quantity',
    data: {
      message: 'How many items would you like?',
      inputLabel: 'Quantity',
      inputDefaultValue: 1,
      inputValidation: [
        {
          errorKey: 'min',
          inputValidator: Validators.min(1),
          errorText: 'Minimum quantity is 1'
        },
        {
          errorKey: 'max',
          inputValidator: Validators.max(100),
          errorText: 'Maximum quantity is 100'
        }
      ]
    }
  }).closed.toPromise();

  return result?.inputValue ?? 1;
}

injectFeedbackDialog()

Pre-configured feedback dialog injector with auto-close support.

Parameters:

  • options?: OpenDialogOptions<FeedbackDialogData> - Optional default options

Returns: Function (options?) => DialogRef<void>

Default Options:

  • disableClose: false - Allows backdrop click to close
  • minWidth: '20rem' - Compact size for feedback
  • classList: ['gap-0'] - Removes default spacing

Usage:

#openFeedbackDialog = injectFeedbackDialog();

showSuccess() {
  this.#openFeedbackDialog({
    data: {
      message: 'Changes saved successfully!',
      autoClose: true,
      autoCloseDelay: 1500
    }
  });
}

injectFeedbackErrorDialog()

Pre-configured error feedback dialog injector.

Parameters:

  • options?: OpenDialogOptions<FeedbackErrorDialogData> - Optional default options

Returns: Function (options?) => DialogRef<void>

Usage:

#openErrorDialog = injectFeedbackErrorDialog();

showError(error: Error) {
  this.#openErrorDialog({
    data: {
      message: `An error occurred: ${error.message}`
    }
  });
}

Usage Examples

Basic Message Dialog

import { Component, inject } from '@angular/core';
import { injectMessageDialog } from '@isa/ui/dialog';

@Component({
  selector: 'app-welcome',
  template: `<button (click)="showWelcome()">Show Welcome</button>`
})
export class WelcomeComponent {
  #openMessageDialog = injectMessageDialog();

  showWelcome() {
    this.#openMessageDialog({
      title: 'Welcome!',
      displayClose: true,
      data: {
        message: 'Welcome to our application. Click OK to continue.',
        closeText: 'OK'
      }
    });
  }
}

Confirmation with Custom Styling

import { Component } from '@angular/core';
import { injectConfirmationDialog } from '@isa/ui/dialog';

@Component({
  selector: 'app-dangerous-action',
  template: `<button (click)="performDangerousAction()">Delete All</button>`
})
export class DangerousActionComponent {
  #openConfirmDialog = injectConfirmationDialog({
    classList: ['dialog-danger'],
    width: '500px'
  });

  async performDangerousAction() {
    const result = await this.#openConfirmDialog({
      title: 'Warning!',
      data: {
        message: 'This action cannot be undone. Delete all items?',
        confirmText: 'Delete Everything',
        closeText: 'Cancel'
      }
    }).closed.toPromise();

    if (result?.confirmed) {
      // Perform dangerous action
      console.log('User confirmed dangerous action');
    }
  }
}

Text Input with Complex Validation

import { Component } from '@angular/core';
import { injectTextInputDialog } from '@isa/ui/dialog';
import { Validators } from '@angular/forms';

@Component({
  selector: 'app-email-form',
  template: `<button (click)="getEmail()">Enter Email</button>`
})
export class EmailFormComponent {
  #openTextInputDialog = injectTextInputDialog();

  async getEmail() {
    const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;

    const result = await this.#openTextInputDialog({
      title: 'Email Address',
      data: {
        message: 'Please enter your email address:',
        inputLabel: 'Email',
        inputDefaultValue: '',
        inputValidation: [
          {
            errorKey: 'required',
            inputValidator: Validators.required,
            errorText: 'Email is required'
          },
          {
            errorKey: 'email',
            inputValidator: Validators.email,
            errorText: 'Invalid email format'
          },
          {
            errorKey: 'pattern',
            inputValidator: Validators.pattern(emailPattern),
            errorText: 'Email must be valid'
          }
        ],
        confirmText: 'Submit',
        closeText: 'Cancel'
      }
    }).closed.toPromise();

    if (result?.inputValue) {
      console.log('Email entered:', result.inputValue);
      // Process email
    }
  }
}

Number Input for Quantity

import { Component } from '@angular/core';
import { injectNumberInputDialog } from '@isa/ui/dialog';
import { Validators } from '@angular/forms';

@Component({
  selector: 'app-order-quantity',
  template: `<button (click)="selectQuantity()">Select Quantity</button>`
})
export class OrderQuantityComponent {
  #openNumberInputDialog = injectNumberInputDialog();

  async selectQuantity() {
    const result = await this.#openNumberInputDialog({
      title: 'Order Quantity',
      data: {
        message: 'Select the number of items to order:',
        subMessage: 'Available:',
        subMessageValue: '50 units',
        inputLabel: 'Quantity',
        inputValue: 1,
        inputDefaultValue: 1,
        inputValidation: [
          {
            errorKey: 'required',
            inputValidator: Validators.required,
            errorText: 'Quantity is required'
          },
          {
            errorKey: 'min',
            inputValidator: Validators.min(1),
            errorText: 'Minimum quantity is 1'
          },
          {
            errorKey: 'max',
            inputValidator: Validators.max(50),
            errorText: 'Maximum quantity is 50'
          }
        ],
        confirmText: 'Add to Cart',
        closeText: 'Cancel'
      }
    }).closed.toPromise();

    if (result?.inputValue) {
      console.log('Quantity selected:', result.inputValue);
      // Add to cart
    }
  }
}

Feedback Dialog with Auto-Close

import { Component } from '@angular/core';
import { injectFeedbackDialog } from '@isa/ui/dialog';

@Component({
  selector: 'app-save-button',
  template: `<button (click)="saveData()">Save</button>`
})
export class SaveButtonComponent {
  #openFeedbackDialog = injectFeedbackDialog();

  async saveData() {
    try {
      // Simulate save operation
      await this.performSave();

      // Show success feedback
      this.#openFeedbackDialog({
        data: {
          message: 'Data saved successfully!',
          autoClose: true,
          autoCloseDelay: 2000
        }
      });
    } catch (error) {
      console.error('Save failed:', error);
    }
  }

  private async performSave(): Promise<void> {
    // Simulate async save
    return new Promise(resolve => setTimeout(resolve, 1000));
  }
}

Custom Dialog Component

// custom-dialog.component.ts
import { Component } from '@angular/core';
import { DialogContentDirective } from '@isa/ui/dialog';
import { ButtonComponent } from '@isa/ui/buttons';

interface CustomDialogData {
  title: string;
  items: string[];
}

interface CustomDialogResult {
  selectedItem: string;
}

@Component({
  selector: 'app-custom-dialog',
  template: `
    <h2>{{ data.title }}</h2>
    <ul>
      @for (item of data.items; track item) {
        <li>
          <button uiButton (click)="selectItem(item)">
            {{ item }}
          </button>
        </li>
      }
    </ul>
    <button uiButton (click)="close()">Cancel</button>
  `,
  imports: [ButtonComponent]
})
export class CustomDialogComponent extends DialogContentDirective<
  CustomDialogData,
  CustomDialogResult
> {
  selectItem(item: string) {
    this.close({ selectedItem: item });
  }
}

// Using the custom dialog
import { injectDialog } from '@isa/ui/dialog';
import { CustomDialogComponent } from './custom-dialog.component';

@Component({
  selector: 'app-selector',
  template: `<button (click)="openSelector()">Select Item</button>`
})
export class SelectorComponent {
  #openCustomDialog = injectDialog(CustomDialogComponent, {
    title: 'Select an Item'
  });

  async openSelector() {
    const result = await this.#openCustomDialog({
      data: {
        title: 'Choose Your Option',
        items: ['Option A', 'Option B', 'Option C']
      }
    }).closed.toPromise();

    if (result) {
      console.log('Selected:', result.selectedItem);
    }
  }
}

Dialog Presets

MessageDialog

Purpose: Display simple informational messages Use Case: Alerts, notifications, information display Returns: void

ConfirmationDialog

Purpose: Get user confirmation for actions Use Case: Delete confirmations, action confirmations Returns: { confirmed: boolean }

TextInputDialog

Purpose: Collect text input with validation Use Case: Rename, create new item, enter search query Returns: { inputValue?: string } Features: Full reactive forms validation support

NumberInputDialog

Purpose: Collect numeric input with validation Use Case: Quantity selection, price entry, numeric configuration Returns: { inputValue?: number } Features: Validation, default values, min/max constraints

FeedbackDialog

Purpose: Show success feedback Use Case: Save confirmation, operation success Returns: void Features: Auto-close after configurable delay

FeedbackErrorDialog

Purpose: Show error feedback Use Case: Error messages, operation failures Returns: void Features: Error styling, persistent display

Advanced Usage

Handling Dialog Results

// Using async/await
async openDialogAsync() {
  const result = await this.#openConfirmDialog({...}).closed.toPromise();
  if (result?.confirmed) {
    // Handle confirmation
  }
}

// Using subscribe
openDialogSubscribe() {
  const dialogRef = this.#openConfirmDialog({...});

  dialogRef.closed.subscribe(result => {
    if (result?.confirmed) {
      // Handle confirmation
    }
  });
}

// Using observable operators
openDialogOperators() {
  this.#openConfirmDialog({...}).closed
    .pipe(
      filter(result => result?.confirmed),
      switchMap(() => this.performAction())
    )
    .subscribe();
}

Dynamic Dialog Configuration

@Component({...})
export class DynamicDialogComponent {
  #openConfirmDialog = injectConfirmationDialog();

  confirmWithDynamicConfig(isDangerous: boolean) {
    this.#openConfirmDialog({
      title: isDangerous ? 'Warning!' : 'Confirm',
      classList: isDangerous ? ['dialog-danger'] : [],
      width: isDangerous ? '600px' : '400px',
      data: {
        message: isDangerous
          ? 'This action is irreversible!'
          : 'Do you want to proceed?',
        confirmText: isDangerous ? 'I Understand' : 'OK'
      }
    });
  }
}

Preventing Backdrop Close

// Dialog cannot be closed by clicking backdrop
#openDialog = injectDialog(MyDialogComponent, {
  disableClose: true,
  hasBackdrop: true
});

// Dialog can be closed by clicking backdrop
#openDialog = injectDialog(MyDialogComponent, {
  disableClose: false,
  hasBackdrop: true
});

Testing

The library uses Jest with Spectator for testing.

Running Tests

# Run tests for this library
npx nx test ui-dialog --skip-nx-cache

# Run tests with coverage
npx nx test ui-dialog --code-coverage --skip-nx-cache

# Run tests in watch mode
npx nx test ui-dialog --watch

Test Coverage

The library includes comprehensive unit tests covering:

  • Dialog opening and closing - Lifecycle management
  • Data passing - Input data and result handling
  • Validation - Form validation in input dialogs
  • Auto-close - Feedback dialog auto-dismiss
  • Configuration - All dialog options and variants
  • Type safety - TypeScript type inference

Example Test

import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { DialogContentDirective } from './dialog-content.directive';

describe('DialogContentDirective', () => {
  let spectator: Spectator<DialogContentDirective<any, any>>;
  const createComponent = createComponentFactory(DialogContentDirective);

  beforeEach(() => {
    spectator = createComponent();
  });

  it('should create', () => {
    expect(spectator.component).toBeTruthy();
  });

  it('should close with result', () => {
    const result = { value: 'test' };
    spectator.component.close(result);
    // Assert dialog closed with result
  });
});

Architecture Notes

Design Patterns

1. Injection-Based API

Uses Angular's dependency injection for dialog configuration:

// Inject dialog factory in constructor/field
#openDialog = injectDialog(MyComponent, defaultOptions);

// Call factory to open dialog
this.#openDialog({ data: {...}, ...overrideOptions });

Benefits:

  • Type-safe configuration
  • Compile-time checking
  • Clear API surface
  • Easy testing

2. Two-Tier Configuration

Supports both default options (inject-time) and override options (open-time):

// Default options at injection
#openDialog = injectDialog(Component, {
  width: '400px',
  title: 'Default Title'
});

// Override at open-time
this.#openDialog({
  title: 'Custom Title',  // Overrides default
  // width: '400px' inherited from default
  data: {...}
});

3. Base Component Extension

All preset dialogs extend DialogContentDirective:

export class MessageDialogComponent extends DialogContentDirective<
  MessageDialogData,
  void
> {}

Benefits:

  • Automatic data/dialogRef injection
  • Consistent API
  • Type inference
  • Reusable close() method

Known Architectural Considerations

1. Close Button Display (Important)

Current State:

  • displayClose defaults to false (#5275)
  • Changing default to true would require reviewing all existing dialogs
  • Some dialogs may have logic that depends on undefined being returned when X is clicked

Consideration:

  • Explicit opt-in prevents breaking changes
  • Future: Consider migrating to true default with migration guide

Impact: Medium - Affects dialog UX consistency

2. DialogRef Observable Pattern

Current State:

  • DialogRef.closed is an Observable
  • Requires .toPromise() for async/await usage
  • Multiple subscription patterns supported

Consideration:

  • Could provide both Promise and Observable APIs
  • Observable is more flexible but requires RxJS knowledge

Impact: Low - Current pattern is Angular-idiomatic

Performance Considerations

  1. OnPush Change Detection - All dialog components use OnPush
  2. Lazy Loading - Dialog content only instantiated when opened
  3. CDK Overlay - Efficient positioning and backdrop management
  4. Auto-Close Cleanup - Feedback dialogs properly clean up timers

Dependencies

Required Libraries

  • @angular/core - Angular framework
  • @angular/cdk/dialog - Dialog primitives
  • @angular/cdk/portal - Component portal system
  • @angular/forms - Reactive forms (for input dialogs)
  • @isa/ui/buttons - Button components
  • @isa/ui/input-controls - Input components (for input dialogs)
  • @ng-icons/core - Icon system
  • @isa/icons - ISA icon library
  • rxjs - Reactive programming

Path Alias

Import from: @isa/ui/dialog

License

Internal ISA Frontend library - not for external distribution.