用 Angular 自己打造訊息彈跳視窗(Modal)

文章目錄

我們可能會使用 JavaScript 的內建方法 alert()confirm()prompt() 輕鬆地顯示彈跳視窗。 本文將應用 rxjsBehaviorSubjectSubjectObservableAngular 客製化訊息彈跳視窗,以實現類似 alert()confirm() 的功能。

參考 W3Schools 的範例來了解這些方法的效果:alert()confirm()prompt()

建立彈跳視窗模型

創建 msg-modal.ts 檔案來定義彈跳視窗模型。例如標題、內容和確認操作發佈者。

export interface IMsgModal {
  title?: MsgType,
  content: string,
  isConfirm?: Subject<boolean>
}
export enum MsgType {
  Info = 1,
  Error = 2,
  Success = 3
}

建立彈跳視窗服務

  1. 使用 Angular CLI 建立彈跳視窗服務,來管理彈跳視窗的顯示。
ng g s msg-modal
  1. 建立訊息發佈者和訂閱者,並建立方法來發佈訊息。
  • 建立訊息發佈者 _msgModal
  • 建立訊息訂閱者 msgModal$ 接收訊息 。
  • 建立方法 setMsgModal() 發佈普通訊息。
  • 建立方法 setConfirmModal() 發佈需經確認的訊息。
    • 建立確認結果發佈者 isConfirm,傳送到彈跳視窗以發佈確認結果。
    • 訂閱確認結果發佈者,接收並回傳確認結果。
export class MsgModalService {

  private _msgModal = new BehaviorSubject<IMsgModal>({content: ""}); // 發佈者
  public readonly msgModal$ = this._msgModal.asObservable(); // 訂閱者

  constructor() { }

  setMsgModal(info: IMsgModal) {
    this._msgModal.next(info);
  }

  setConfirmModal(info: IMsgModal): Promise<boolean> {
    const isConfirm = new Subject<boolean>();
    this._msgModal.next({...info, isConfirm: isConfirm});
    return firstValueFrom(isConfirm.asObservable());
  }
}

建立彈跳視窗元件

  1. 使用 Angular CLI 建立彈跳視窗元件。
ng g c msg-modal

設計彈跳視窗的模板和樣式

  • 你也可以使用網頁框架,例如 bootstrap Modal 來協助設計彈跳視窗的外觀。
<!-- msg-modal.component.html -->
<div class="msg-modal" *ngIf="msgList.length > 0">
  <div class="modal-content" *ngFor="let msg of msgList; let i = index;">
    <ng-container *ngIf="msg.title">
      <header class="modal-header" [ngClass]="color(msg.title)">
        <h5>{{ title(msg.title) }}</h5>
      </header>
    </ng-container>

    <section class="modal-body" [ngClass]="color(msg.title)">
      <p>{{ msg.content }}</p>
    </section>

    <footer class="modal-footer">
      <ng-container *ngIf="msg.isConfirm else close">
        <button class="btn btn-secondary" (click)="pushConfirm(msg.isConfirm, true, i)">OK</button>
        <button class="btn btn-primary" (click)="pushConfirm(msg.isConfirm, false, i)">Cancel</button>
      </ng-container>
      <ng-template #close>
        <button class="btn btn-primary" (click)="closeModal(i)">Close</button>
      </ng-template>
    </footer>
  </div>
</div>
/* msg-modal.component.css */
.msg-modal {
  background: rgba(0,0,0,0.6);
  position: fixed;
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100%;
  z-index: 2001;
}
.msg-modal .modal-content {
  position: fixed;
  display: flex;
  flex-direction: column;
  width: 480px;
  background: var(--white);
  border-radius: 0.25rem;
}
.modal-header {
  padding: 1em;
}
.modal-header h5 {
  font-weight: 400;
  font-size: 1.75em;
  margin: 0;
}

.modal-body {
  padding: 1em;
  border: 1px solid var(--light-gray);
}
.modal-body p {
  margin: 0;
}

.modal-footer {
  padding: 1em;
  text-align: right;
}
.modal-footer .btn {
  min-width: 60px;
}
.modal-footer .btn:not(:last-child) {
  margin-right: 1em;
}

.modal-content .modal-body {
  flex-grow: 1;
  display: grid;
  justify-content: center;
  align-items: center;
  white-space: pre-line;
  word-break: break-word;
  height: 15vh;
  overflow-y: auto;
}

@media screen and (max-width: 576px) {
  .msg-modal .modal-content {
      width: 80%;
  }
}
export class MsgModalComponent implements OnInit {

  msgList: Array<IMsgModal> = [];

  constructor(private _msgModalService: MsgModalService) { }

  title(type: MsgType) { return MsgType[type] };

  color(title?: MsgType): string {
    let color;
    switch (title) {
      case MsgType.Info:
        color = "txt-info"
        break;
      case MsgType.Error:
        color = "txt-danger"
        break;
      case MsgType.Success:
        color = "txt-success"
        break;
      default:
        color = "txt-info"
        break;
    }
    return color;
  }
}

接收訊息

在彈跳視窗元件中,訂閱訊息發佈者,接收並顯示收到的訊息。

export class MsgModalComponent implements OnInit {

  msgList: Array<IMsgModal> = [];
  msgModal$: Observable<IMsgModal> = this._msgModalService.msgModal$;

  constructor(private _msgModalService: MsgModalService) { }

  ngOnInit(): void {
    this.msgModal$.subscribe((msg) => {
      if (msg.content) this.msgList.unshift(msg);
    });
  }
}

發佈確認結果

在彈跳視窗元件中,當使用者按下「確認」或「取消」按鈕後,透過方法 pushConfirm() 發佈確認結果給訂閱者。

export class MsgModalComponent implements OnInit {

  msgList: Array<IMsgModal> = [];

  pushConfirm(isConfirm: Subject<boolean>, result: boolean, i: number) {
    isConfirm.next(result);
    this.closeModal(i);
  }
}

關閉訊息

在彈跳視窗元件中,當使用者按下關閉按鈕,透過方法 closeModal() 將訊息從訊息清單中移除。

export class MsgModalComponent implements OnInit {

  msgList: Array<IMsgModal> = [];

  closeModal(i: number) {
    this.msgList.splice(i, 1);
  }
}

發佈訊息

在元件中使用彈跳視窗服務的方法 setMsgModal() 發佈訊息。

export class AppComponent implements OnInit {

  constructor(private _msgModalService: MsgModalService) { }

  ngOnInit() {
    _msgModalService.setMsgModal({title: MsgType.Success, content: "訊息發佈成功!"});
  }
}

發佈需經確認的訊息並接收結果

在元件中使用彈跳視窗服務的方法 setConfirmModal() 發佈需經確認的訊息,並等待確認結果「確認」或「取消」。

export class AppComponent {

  constructor(private _msgModalService: MsgModalService) { }

  async delete() {
    const isConfirm = await _msgModalService.setConfirmModal({content: "Are you sure to delete the data?"});
    if(isConfirm) { // 使用者確認
      console.log("the user confirm to delete the data.");
    }
    else { // 使用者取消
      console.log("the user cancel to delete the data.");
    }
  }
}

彈跳視窗服務單元測試

// msg-modal.service.spec.ts
describe('MsgModalService', () => {
  let service: MsgModalService;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule]
    });
    service = TestBed.inject(MsgModalService);
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });

  it('should update msgModal behavior subject with new info', () => {
    const newInfo: IMsgModal = { content: 'Test message' };
    service.setMsgModal(newInfo);

    service.msgModal$.subscribe((info) => {
      expect(info).toEqual(newInfo);
    });
  });

  it('should return a promise true', async () => {
    const confirmInfo: IMsgModal = { content: 'Confirm message' };
    const promise = service.setConfirmModal(confirmInfo);

    service.msgModal$.subscribe((info) => {
      if (info.isConfirm) {
        info.isConfirm.next(true);
      }
    });

    const result = await promise;
    expect(result).toBe(true);
  });

  it('should return a promise false', async () => {
    const confirmInfo: IMsgModal = { content: 'Confirm message' };
    const promise = service.setConfirmModal(confirmInfo);

    service.msgModal$.subscribe((info) => {
      if (info.isConfirm) {
        info.isConfirm.next(false);
      }
    });

    const result = await promise;
    expect(result).toBe(false);
  });
});

彈跳視窗元件單元測試

// msg-modal.component.spec.ts
describe('MsgModalComponent', () => {
  let component: MsgModalComponent;
  let fixture: ComponentFixture<MsgModalComponent>;
  let msgModalService: jasmine.SpyObj<MsgModalService>;
  let msgModal$: BehaviorSubject<IMsgModal>;

  beforeEach(async () => {
    const msgModalSpy = jasmine.createSpyObj('MsgModalService', ['setMsgModal', 'setConfirmModal']);
    msgModal$ = new BehaviorSubject<IMsgModal>({ content: '' });
    msgModalSpy.msgModal$ = msgModal$.asObservable();
    
    await TestBed.configureTestingModule({
      declarations: [ MsgModalComponent ],
      imports: [ HttpClientTestingModule ],
      providers: [
        { provide: MsgModalService, useValue: msgModalSpy }
      ]
    })
    .compileComponents();
    
    fixture = TestBed.createComponent(MsgModalComponent);
    component = fixture.componentInstance;
    msgModalService = TestBed.inject(MsgModalService) as jasmine.SpyObj<MsgModalService>;
    fixture.detectChanges();
  });

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

  it('should handle message updates correctly', () => {
    const initialMsg: IMsgModal = { content: 'Initial message', title: MsgType.Info };
    const newMsg: IMsgModal = { content: 'New message', title: MsgType.Error };
    
    component.ngOnInit();
    msgModal$.next(initialMsg);
    msgModal$.next(newMsg);

    expect(component.msgList.length).toBe(2);
    expect(component.msgList[0]).toEqual(newMsg);
    expect(component.msgList[1]).toEqual(initialMsg);
  });

  it('should show the button when msg isConfirm', () => {
    const testMsg: IMsgModal = { content: 'Test message', title: MsgType.Info, isConfirm: new Subject<boolean>() };

    component.ngOnInit();
    msgModal$.next(testMsg);
    fixture.detectChanges();

    const okBtn = fixture.debugElement.query(By.css('.btn-secondary'));
    const cancelBtn = fixture.debugElement.query(By.css('.btn-primary'));

    expect(okBtn.nativeElement.innerText).toBe("OK");
    expect(cancelBtn.nativeElement.innerText).toBe("Cancel");
  });

  it('should return correct title', () => {
    expect(component.title(MsgType.Info)).toBe("Info");
    expect(component.title(MsgType.Error)).toBe("Error");
    expect(component.title(MsgType.Success)).toBe("Success");
  });

  it('should return correct color class', () => {
    expect(component.color(MsgType.Info)).toBe("txt-info");
    expect(component.color(MsgType.Error)).toBe("txt-danger");
    expect(component.color(MsgType.Success)).toBe("txt-success");
    expect(component.color(undefined)).toBe("txt-info");
  });

  it('should push confirm result and close modal', () => {
    const isConfirm = new Subject<boolean>();
    const testResult = true;
    const testIndex = 1;
    const spyIsConfirmNext = spyOn(isConfirm, 'next').and.callThrough();
    const spyCloseModal = spyOn(component, 'closeModal').and.callThrough();
  
    component.pushConfirm(isConfirm, testResult, testIndex);

    expect(spyIsConfirmNext).toHaveBeenCalledWith(testResult);
    expect(spyCloseModal).toHaveBeenCalledWith(testIndex);
  });

  it('should item in msgList be removed when closeModal', () => {
    component.msgList = [{content: "A"}, {content: "B"}, {content: "C"}];
    component.closeModal(1);

    expect(component.msgList.length).toBe(2);
    expect(component.msgList[0].content).toBe("A");
    expect(component.msgList[1].content).toBe("C");
  });
});

參考文章