用 Angular 自己打造訊息彈跳視窗(Modal)
文章目錄
我們可能會使用 JavaScript 的內建方法 alert()
、confirm()
和 prompt()
輕鬆地顯示彈跳視窗。
本文將應用 rxjs 的 BehaviorSubject
、Subject
、Observable
以 Angular 客製化訊息彈跳視窗,以實現類似 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
}
建立彈跳視窗服務
- 使用 Angular CLI 建立彈跳視窗服務,來管理彈跳視窗的顯示。
ng g s msg-modal
- 建立訊息發佈者和訂閱者,並建立方法來發佈訊息。
- 建立訊息發佈者
_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());
}
}
建立彈跳視窗元件
- 使用 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");
});
});