diff --git a/e2e/tests/note/detail-fullscreen-note-resize.spec.ts b/e2e/tests/note/detail-fullscreen-note-resize.spec.ts index ae13447fb1..321b96b731 100644 --- a/e2e/tests/note/detail-fullscreen-note-resize.spec.ts +++ b/e2e/tests/note/detail-fullscreen-note-resize.spec.ts @@ -32,8 +32,11 @@ test.describe('Detail panel fullscreen note - resize', () => { await expect(detailPanel).toBeVisible(); // Open the fullscreen markdown editor from the notes inline-markdown. - await detailPanel - .locator('inline-markdown button') + // The controls are opacity:0 until the notes area is hovered. + const noteMarkdown = detailPanel.locator('inline-markdown').first(); + await noteMarkdown.hover(); + await noteMarkdown + .locator('button') .filter({ has: page.locator('mat-icon', { hasText: 'fullscreen' }) }) .first() .click(); diff --git a/src/app/ui/inline-markdown/inline-markdown.component.spec.ts b/src/app/ui/inline-markdown/inline-markdown.component.spec.ts index 1e7206d5bf..c6afe88c45 100644 --- a/src/app/ui/inline-markdown/inline-markdown.component.spec.ts +++ b/src/app/ui/inline-markdown/inline-markdown.component.spec.ts @@ -1,5 +1,5 @@ import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; -import { MatDialog } from '@angular/material/dialog'; +import { MatDialog, MatDialogState } from '@angular/material/dialog'; import { MarkdownModule } from 'ngx-markdown'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { InlineMarkdownComponent } from './inline-markdown.component'; @@ -1867,15 +1867,18 @@ describe('InlineMarkdownComponent', () => { describe('fullscreen editor save-and-close on navigation (#8434)', () => { let afterClosed$: Subject; + let store: MockStore; let closeSpy: jasmine.Spy; let unsubscribeSpy: jasmine.Spy; let locationCb: ((value: PopStateEvent) => void) | undefined; + let dialogState: MatDialogState; beforeEach(() => { afterClosed$ = new Subject(); closeSpy = jasmine.createSpy('close'); unsubscribeSpy = jasmine.createSpy('unsubscribe'); locationCb = undefined; + dialogState = MatDialogState.OPEN; // Capture the Location listener so a "navigation" can be simulated. const location = TestBed.inject(Location); @@ -1887,11 +1890,16 @@ describe('InlineMarkdownComponent', () => { mockMatDialog.open.and.returnValue({ afterClosed: () => afterClosed$.asObservable(), componentInstance: { close: closeSpy }, + getState: () => dialogState, } as never); + store = TestBed.inject(MockStore); + spyOn(store, 'dispatch'); fixture.componentRef.setInput('taskId', 'task-1'); fixture.detectChanges(); }); + const navigate = (): void => locationCb!({} as PopStateEvent); + // The default closeOnNavigation disposes the overlay with no result on a // navigation, dropping the edit (#8434); we must opt out so we can close it // through the save path instead. @@ -1909,11 +1917,52 @@ describe('InlineMarkdownComponent', () => { component.openFullScreen(); expect(closeSpy).not.toHaveBeenCalled(); - locationCb!({} as PopStateEvent); + navigate(); expect(closeSpy).toHaveBeenCalledTimes(1); }); + // A breakpoint-crossing resize can emit more than one popstate; the second + // must not re-trigger close() and re-run the dialog's exit animation. + it('does not close again once the dialog is already closing', () => { + component.openFullScreen(); + navigate(); + dialogState = MatDialogState.CLOSING; + + navigate(); + + expect(closeSpy).toHaveBeenCalledTimes(1); + }); + + // The point of #8434: a navigation-close must PERSIST the edit, not just + // close. When still alive the note routes out via `changed`. + it('persists the edit via `changed` when a navigation closes the dialog', () => { + spyOn(component.changed, 'emit'); + component.openFullScreen(); + + navigate(); + // The dialog resolves through its save path with the typed content. + afterClosed$.next('typed before resize'); + + expect(component.changed.emit).toHaveBeenCalledWith('typed before resize'); + }); + + // The production scenario: the breakpoint switch destroys this host while + // the editor is open, so the save must land via the direct dispatch (#8432). + it('persists directly when a navigation closes the dialog after host destroy', () => { + component.openFullScreen(); + component.ngOnDestroy(); + + navigate(); + afterClosed$.next('typed before resize'); + + expect(store.dispatch).toHaveBeenCalledWith( + TaskSharedActions.updateTask({ + task: { id: 'task-1', changes: { notes: 'typed before resize' } }, + }), + ); + }); + it('stops listening for navigations once the dialog has closed', () => { component.openFullScreen(); diff --git a/src/app/ui/inline-markdown/inline-markdown.component.ts b/src/app/ui/inline-markdown/inline-markdown.component.ts index d8700737fe..edfc24fc63 100644 --- a/src/app/ui/inline-markdown/inline-markdown.component.ts +++ b/src/app/ui/inline-markdown/inline-markdown.component.ts @@ -17,7 +17,7 @@ import { } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatIconButton } from '@angular/material/button'; -import { MatDialog } from '@angular/material/dialog'; +import { MatDialog, MatDialogState } from '@angular/material/dialog'; import { MatIcon } from '@angular/material/icon'; import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu'; import { MatTooltip } from '@angular/material/tooltip'; @@ -382,18 +382,19 @@ export class InlineMarkdownComponent implements OnInit, OnDestroy { }, }); - // Reinstate close-on-navigation as a save-and-close. closeOnNavigation - // (above) is the overlay's `disposeOnNavigation`, which on a Location - // change (popstate/hashchange) DISPOSES the overlay with no result — - // dropping the edit. We listen to the same Location signal but close - // through the dialog's save path, so an Android back-button press or the - // involuntary navigation a breakpoint-crossing resize triggers persists - // the note instead of losing it. Not tied to takeUntilDestroyed so it - // still fires after a breakpoint switch destroys this host mid-edit; torn - // down in the afterClosed handler below. - const locationSub = this._location.subscribe(() => - dialogRef.componentInstance?.close(), - ); + // Reinstate close-on-navigation as a save-and-close: listen to the same + // Location signal closeOnNavigation uses, but route through the dialog's + // save path so an Android back-button press or the breakpoint-crossing + // resize above persists the note instead of dropping it. Not tied to + // takeUntilDestroyed so it still fires after a breakpoint switch destroys + // this host mid-edit; torn down in the afterClosed handler below. + const locationSub = this._location.subscribe(() => { + // A breakpoint-crossing resize can emit more than one popstate; only act + // while the dialog is still open so we don't re-run its exit animation. + if (dialogRef.getState() === MatDialogState.OPEN) { + dialogRef.componentInstance?.close(); + } + }); // Intentionally NOT torn down with takeUntilDestroyed: this MUST still fire // after the component is destroyed — see the `_isDestroyed` branch below.