diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
index 4e3c67c..734287d 100644
--- a/src/app/app.routes.ts
+++ b/src/app/app.routes.ts
@@ -15,4 +15,11 @@ export const routes: Routes = [
(m) => m.CreationalOpsComponent
),
},
+ {
+ path: 'rxjs/flattening-operators',
+ loadComponent: () =>
+ import('./rxjs/flattening-ops/flattening-ops.component').then(
+ (m) => m.FlatteningOpsComponent
+ ),
+ },
];
diff --git a/src/app/rxjs/flattening-ops/flattening-ops.component.html b/src/app/rxjs/flattening-ops/flattening-ops.component.html
new file mode 100644
index 0000000..2e43b18
--- /dev/null
+++ b/src/app/rxjs/flattening-ops/flattening-ops.component.html
@@ -0,0 +1,7 @@
+
+ Flattening Operators
+
+
+ConcatMap: {{ concatMapMessage$ | async }}
+
+MergeMap: {{ mergeMapMessage$ | async }}
diff --git a/src/app/rxjs/flattening-ops/flattening-ops.component.scss b/src/app/rxjs/flattening-ops/flattening-ops.component.scss
new file mode 100644
index 0000000..e69de29
diff --git a/src/app/rxjs/flattening-ops/flattening-ops.component.spec.ts b/src/app/rxjs/flattening-ops/flattening-ops.component.spec.ts
new file mode 100644
index 0000000..dec3728
--- /dev/null
+++ b/src/app/rxjs/flattening-ops/flattening-ops.component.spec.ts
@@ -0,0 +1,72 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { dispatchFakeEvent, findEl } from './../../spec-helpers/element.spec-helper';
+
+import { FlatteningOpsComponent } from './flattening-ops.component';
+
+describe('FlatteningOpsComponent', () => {
+ let component: FlatteningOpsComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [FlatteningOpsComponent]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(FlatteningOpsComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should log the concatMap flattened observable', (done) => {
+ // Arrange
+ const expected = ['0', '1', '2', '3']; // Match actual emissions
+ const receivedValues: string[] = [];
+
+ // Act
+ component.concatMapMessage$.subscribe({
+ next: (value) => {
+ receivedValues.push(value); // Capture emitted values
+ },
+ complete: () => {
+ console.info('concatMap', receivedValues);
+
+ // Assert
+ expect(receivedValues).toEqual(expected); // Assert emissions directly
+ done(); // Mark test as completed
+ }
+ });
+
+ // Assert
+ dispatchFakeEvent(findEl(fixture, 'concatmap-heading').nativeElement, 'click'); // Trigger the fake event
+ fixture.detectChanges();
+ })
+
+ it('should log the mergeMap flattened observable', (done) => {
+ // Arrange
+ const expected = ['0', '1', '2', '0', '3', '1', '2', '3']; // Match actual emissions
+ const receivedValues: string[] = [];
+
+ // Act
+ component.mergeMapMessage$.subscribe({
+ next: (value) => {
+ receivedValues.push(value); // Capture emitted values
+ },
+ complete: () => {
+ console.info('mergeMap', receivedValues);
+
+ // Assert
+ expect(receivedValues).toEqual(expected); // Assert emissions directly
+ done(); // Mark test as completed
+ }
+ });
+
+ // Assert
+ dispatchFakeEvent(findEl(fixture, 'mergemap-heading').nativeElement, 'click'); // Trigger the fake event (1st)
+ setTimeout(() => {
+ dispatchFakeEvent(findEl(fixture, 'mergemap-heading').nativeElement, 'click'); // Trigger the fake event (2nd)
+ fixture.detectChanges();
+ }, 100 * 2 + 10 /* Start at the (end-1) of the 1st click + 10ms */)
+ fixture.detectChanges();
+ })
+});
diff --git a/src/app/rxjs/flattening-ops/flattening-ops.component.ts b/src/app/rxjs/flattening-ops/flattening-ops.component.ts
new file mode 100644
index 0000000..2f09251
--- /dev/null
+++ b/src/app/rxjs/flattening-ops/flattening-ops.component.ts
@@ -0,0 +1,60 @@
+import { AsyncPipe } from "@angular/common";
+import { Component, DestroyRef, ElementRef, inject, ViewChild } from '@angular/core';
+import { concatMap, fromEvent, interval, map, mergeMap, of, Subject, take, takeUntil, tap } from "rxjs";
+
+@Component({
+ selector: 'app-flattening-ops',
+ standalone: true,
+ imports: [AsyncPipe],
+ templateUrl: './flattening-ops.component.html',
+ styleUrl: './flattening-ops.component.scss'
+})
+export class FlatteningOpsComponent {
+
+ protected readonly destroy = inject(DestroyRef);
+
+ concatMapMessage$ = new Subject();
+ @ViewChild('concatMapHeading') concatMapHeading!: ElementRef;
+
+ mergeMapMessage$ = new Subject();
+ mergeMapClickCount = 0;
+ @ViewChild('mergeMapHeading') mergeMapHeading!: ElementRef;
+
+ ngAfterViewInit() {
+ this.registerConcatMap();
+ }
+
+ onMergeMapClickHandler() {
+ if (!this.mergeMapClickCount) { // only if first click
+ setTimeout(() => this.mergeMapMessage$.complete(), 100 * 8 + 10);
+ }
+
+ this.registerMergeMap().subscribe();
+ this.mergeMapClickCount++;
+ }
+
+ registerConcatMap() {
+ fromEvent(this.concatMapHeading.nativeElement, 'click').pipe(
+ tap(() => {
+ setTimeout(() => this.concatMapMessage$.complete(), 150 * 4 + 10)
+ }),
+ takeUntil(this.concatMapMessage$),
+ concatMap(() => interval(150).pipe(take(4))),
+ ).subscribe({
+ next: (number) => {
+ this.concatMapMessage$.next(number.toString());
+ // Logs: 0, 1, 2, 3 on every click
+ },
+ })
+ }
+
+ registerMergeMap() {
+ return of({}).pipe(
+ takeUntil(this.mergeMapMessage$),
+ mergeMap(() => interval(100).pipe(take(4))),
+ map((number) => {
+ this.mergeMapMessage$.next(number.toString())
+ })
+ );
+ }
+}
diff --git a/src/app/spec-helpers/element.spec-helper.ts b/src/app/spec-helpers/element.spec-helper.ts
new file mode 100644
index 0000000..0a0bfc8
--- /dev/null
+++ b/src/app/spec-helpers/element.spec-helper.ts
@@ -0,0 +1,242 @@
+/* istanbul ignore file */
+
+import { DebugElement } from '@angular/core';
+import { ComponentFixture } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+
+/**
+ * Spec helpers for working with the DOM
+ */
+
+/**
+ * Returns a selector for the `data-testid` attribute with the given attribute value.
+ *
+ * @param testId Test id set by `data-testid`
+ *
+ */
+export function testIdSelector(testId: string): string {
+ return `[data-testid="${testId}"]`;
+}
+
+/**
+ * Finds a single element inside the Component by the given CSS selector.
+ * Throws an error if no element was found.
+ *
+ * @param fixture Component fixture
+ * @param selector CSS selector
+ *
+ */
+export function queryByCss(
+ fixture: ComponentFixture,
+ selector: string,
+): DebugElement {
+ // The return type of DebugElement#query() is declared as DebugElement,
+ // but the actual return type is DebugElement | null.
+ // See https://github.com/angular/angular/issues/22449.
+ const debugElement = fixture.debugElement.query(By.css(selector));
+ // Fail on null so the return type is always DebugElement.
+ if (!debugElement) {
+ throw new Error(`queryByCss: Element with ${selector} not found`);
+ }
+ return debugElement;
+}
+
+/**
+ * Finds an element inside the Component by the given `data-testid` attribute.
+ * Throws an error if no element was found.
+ *
+ * @param fixture Component fixture
+ * @param testId Test id set by `data-testid`
+ *
+ */
+export function findEl(fixture: ComponentFixture, testId: string): DebugElement {
+ return queryByCss(fixture, testIdSelector(testId));
+}
+
+/**
+ * Finds all elements with the given `data-testid` attribute.
+ *
+ * @param fixture Component fixture
+ * @param testId Test id set by `data-testid`
+ */
+export function findEls(fixture: ComponentFixture, testId: string): DebugElement[] {
+ return fixture.debugElement.queryAll(By.css(testIdSelector(testId)));
+}
+
+/**
+ * Gets the text content of an element with the given `data-testid` attribute.
+ *
+ * @param fixture Component fixture
+ * @param testId Test id set by `data-testid`
+ */
+export function getText(fixture: ComponentFixture, testId: string): string {
+ return findEl(fixture, testId).nativeElement.textContent;
+}
+
+/**
+ * Expects that the element with the given `data-testid` attribute
+ * has the given text content.
+ *
+ * @param fixture Component fixture
+ * @param testId Test id set by `data-testid`
+ * @param text Expected text
+ */
+export function expectText(
+ fixture: ComponentFixture,
+ testId: string,
+ text: string,
+): void {
+ expect(getText(fixture, testId)).toBe(text);
+}
+
+/**
+ * Expects that the element with the given `data-testid` attribute
+ * has the given text content.
+ *
+ * @param fixture Component fixture
+ * @param text Expected text
+ */
+export function expectContainedText(fixture: ComponentFixture, text: string): void {
+ expect(fixture.nativeElement.textContent).toContain(text);
+}
+
+/**
+ * Expects that a component has the given text content.
+ * Both the component text content and the expected text are trimmed for reliability.
+ *
+ * @param fixture Component fixture
+ * @param text Expected text
+ */
+export function expectContent(fixture: ComponentFixture, text: string): void {
+ expect(fixture.nativeElement.textContent).toBe(text);
+}
+
+/**
+ * Dispatches a fake event (synthetic event) at the given element.
+ *
+ * @param element Element that is the target of the event
+ * @param type Event name, e.g. `input`
+ * @param bubbles Whether the event bubbles up in the DOM tree
+ */
+export function dispatchFakeEvent(
+ element: EventTarget,
+ type: string,
+ bubbles: boolean = false,
+): void {
+ const event = document.createEvent('Event');
+ event.initEvent(type, bubbles, false);
+ element.dispatchEvent(event);
+}
+
+/**
+ * Enters text into a form field (`input`, `textarea` or `select` element).
+ * Triggers appropriate events so Angular takes notice of the change.
+ * If you listen for the `change` event on `input` or `textarea`,
+ * you need to trigger it separately.
+ *
+ * @param element Form field
+ * @param value Form field value
+ */
+export function setFieldElementValue(
+ element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement,
+ value: string,
+): void {
+ element.value = value;
+ // Dispatch an `input` or `change` fake event
+ // so Angular form bindings take notice of the change.
+ const isSelect = element instanceof HTMLSelectElement;
+ dispatchFakeEvent(element, isSelect ? 'change' : 'input', isSelect ? false : true);
+}
+
+/**
+ * Sets the value of a form field with the given `data-testid` attribute.
+ *
+ * @param fixture Component fixture
+ * @param testId Test id set by `data-testid`
+ * @param value Form field value
+ */
+export function setFieldValue(
+ fixture: ComponentFixture,
+ testId: string,
+ value: string,
+): void {
+ setFieldElementValue(findEl(fixture, testId).nativeElement, value);
+}
+
+/**
+ * Checks or unchecks a checkbox or radio button.
+ * Triggers appropriate events so Angular takes notice of the change.
+ *
+ * @param fixture Component fixture
+ * @param testId Test id set by `data-testid`
+ * @param checked Whether the checkbox or radio should be checked
+ */
+export function checkField(
+ fixture: ComponentFixture,
+ testId: string,
+ checked: boolean,
+): void {
+ const { nativeElement } = findEl(fixture, testId);
+ nativeElement.checked = checked;
+ // Dispatch a `change` fake event so Angular form bindings take notice of the change.
+ dispatchFakeEvent(nativeElement, 'change');
+}
+
+/**
+ * Makes a fake click event that provides the most important properties.
+ * Sets the button to left.
+ * The event can be passed to DebugElement#triggerEventHandler.
+ *
+ * @param target Element that is the target of the click event
+ */
+export function makeClickEvent(target: EventTarget): Partial {
+ return {
+ preventDefault(): void { },
+ stopPropagation(): void { },
+ stopImmediatePropagation(): void { },
+ type: 'click',
+ target,
+ currentTarget: target,
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ };
+}
+
+/**
+ * Emulates a left click on the element with the given `data-testid` attribute.
+ *
+ * @param fixture Component fixture
+ * @param testId Test id set by `data-testid`
+ */
+export function click(fixture: ComponentFixture, testId: string): void {
+ const element = findEl(fixture, testId);
+ const event = makeClickEvent(element.nativeElement);
+ element.triggerEventHandler('click', event);
+}
+
+/**
+ * Finds a nested Component by its selector, e.g. `app-example`.
+ * Throws an error if no element was found.
+ * Use this only for shallow component testing.
+ * When finding other elements, use `findEl` / `findEls` and `data-testid` attributes.
+ *
+ * @param fixture Fixture of the parent Component
+ * @param selector Element selector, e.g. `app-example`
+ */
+export function findComponent(
+ fixture: ComponentFixture,
+ selector: string,
+): DebugElement {
+ return queryByCss(fixture, selector);
+}
+
+/**
+ * Finds all nested Components by its selector, e.g. `app-example`.
+ */
+export function findComponents(
+ fixture: ComponentFixture,
+ selector: string,
+): DebugElement[] {
+ return fixture.debugElement.queryAll(By.css(selector));
+}