diff --git a/src/locales/en.json b/src/locales/en.json index 51e07f3021a..a5b93153a35 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -151,6 +151,8 @@ "pad.modals.disconnected": "You have been disconnected.", "pad.modals.disconnected.explanation": "The connection to the server was lost", "pad.modals.disconnected.cause": "The server may be unavailable. Please notify the service administrator if this continues to happen.", + "pad.gritter.unacceptedCommit.title": "Unsaved edit", + "pad.gritter.unacceptedCommit.text": "Your recent edit is still not saved. Reconnect and try again.", "pad.share": "Share this pad", "pad.share.readonly": "Read only", diff --git a/src/static/js/collab_client.ts b/src/static/js/collab_client.ts index 9820921ef4f..c90f92e80d3 100644 --- a/src/static/js/collab_client.ts +++ b/src/static/js/collab_client.ts @@ -141,6 +141,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad) const acceptCommit = () => { editor.applyPreparedChangesetToBase(); + stateMessage = null; setStateIdle(); try { callbacks.onInternalAction('commitAcceptedByServer'); @@ -488,6 +489,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad) sendMessage, getCurrentRevisionNumber, getMissedChanges, + hasUnacceptedCommit: () => stateMessage != null, callWhenNotCommitting, addHistoricalAuthors: tellAceAboutHistoricalAuthors, setChannelState, diff --git a/src/static/js/pad.ts b/src/static/js/pad.ts index d9cc4e902ed..c2cd22c8c23 100644 --- a/src/static/js/pad.ts +++ b/src/static/js/pad.ts @@ -655,6 +655,14 @@ const pad = { pad.handleOptionsChange(opts); } }, + showUnacceptedCommitWarning: () => { + $.gritter.add({ + title: html10n.get('pad.gritter.unacceptedCommit.title'), + text: html10n.get('pad.gritter.unacceptedCommit.text'), + sticky: true, + class_name: 'disconnected unsaved-warning', + }); + }, handleChannelStateChange: (newState, message) => { const oldFullyConnected = !!padconnectionstatus.isFullyConnected(); const wasConnecting = (padconnectionstatus.getStatus().what === 'connecting'); @@ -692,6 +700,7 @@ const pad = { padimpexp.disable(); padconnectionstatus.disconnected(message); + if (pad.collabClient.hasUnacceptedCommit()) pad.showUnacceptedCommitWarning(); } const newFullyConnected = !!padconnectionstatus.isFullyConnected(); if (newFullyConnected !== oldFullyConnected) { diff --git a/src/tests/frontend-new/specs/unaccepted_commit_warning.spec.ts b/src/tests/frontend-new/specs/unaccepted_commit_warning.spec.ts new file mode 100644 index 00000000000..10e5a3117c1 --- /dev/null +++ b/src/tests/frontend-new/specs/unaccepted_commit_warning.spec.ts @@ -0,0 +1,39 @@ +import {expect, test} from '@playwright/test'; +import {clearPadContent, goToNewPad, writeToPad} from '../helper/padHelper'; + +test.describe('unaccepted commit warning', () => { + test('hasUnacceptedCommit clears once the server acknowledges the commit', + async ({page}) => { + await goToNewPad(page); + await clearPadContent(page); + await writeToPad(page, 'trigger a commit'); + + // Wait for the commit to round-trip. The fix clears the pending marker inside + // acceptCommit(); without it the boolean stays true indefinitely. + await expect.poll(async () => await page.evaluate(() => + (window as any).pad?.collabClient?.hasUnacceptedCommit?.() ?? null, + ), {timeout: 10000}).toBe(false); + }); + + test('disconnect with a pending commit surfaces the unsaved-edit gritter', + async ({page}) => { + await goToNewPad(page); + await page.waitForFunction(() => (window as any).pad?.collabClient != null); + + await page.evaluate(() => { + const p: any = (window as any).pad; + // Force the pending-commit predicate to true and simulate a disconnect so + // the warning code path executes deterministically. + p.collabClient.hasUnacceptedCommit = () => true; + p.handleChannelStateChange('DISCONNECTED', { + type: 'disconnect', + explanation: 'test', + cause: 'test', + forIE: false, + canRetry: false, + }); + }); + + await expect(page.locator('.unsaved-warning').first()).toBeVisible(); + }); +});