[web] Add offline indicator to toolbar (#35368)

GitOrigin-RevId: 81127bff21afb6aadcf175ba1767a2629f01d747
This commit is contained in:
Kristina
2026-09-17 11:23:02 +00:00
committed by Copybot
parent d3920e76d3
commit 77ce277a8b
10 changed files with 261 additions and 15 deletions
@@ -277,6 +277,7 @@
"change_to_group_plan": "",
"change_to_this_plan": "",
"changes_applied": "",
"changes_saved_in_browser_sync_when_online": "",
"changes_to_these_settings_will_be_applied_to_all_new_projects": "",
"changing_the_position_of_your_figure": "",
"changing_the_position_of_your_table": "",
@@ -2499,6 +2500,7 @@
"you_have_x_licenses_and_your_plan_supports_up_to_y": "",
"you_have_x_licenses_on_your_subscription": "",
"you_need_to_configure_your_sso_settings": "",
"you_re_offline": "",
"you_unpaused_your_subscription": "",
"you_will_be_able_to_reassign_subscription": "",
"youll_get_best_results_in_visual_but_can_be_used_in_source": "",
@@ -0,0 +1,52 @@
import { useTranslation } from 'react-i18next'
import MaterialIcon from '@/shared/components/material-icon'
import OLTooltip from '@/shared/components/ol/ol-tooltip'
import { useUnsavedDocsContext } from '@/features/ide-react/context/unsaved-docs-context'
import { useFeatureFlag } from '@/shared/context/split-test-context'
export function OfflineIndicatorContent() {
const { t } = useTranslation()
return (
<OLTooltip
id="tooltip-offline-indicator"
description={t('changes_saved_in_browser_sync_when_online')}
overlayProps={{ delay: 0, placement: 'bottom' }}
>
<div className="ide-redesign-toolbar-offline-indicator">
<MaterialIcon type="wifi_off" />
<span>{t('you_re_offline')}</span>
</div>
</OLTooltip>
)
}
export default function OfflineIndicator() {
const { t } = useTranslation()
const { unsavedDocs } = useUnsavedDocsContext()
const improvedFlakyConnections = useFeatureFlag(
'intermittent-connection-improvements'
)
if (!improvedFlakyConnections) {
return null
}
// TODO: use shared hook for this when merged
const MAX_UNSAVED_ALERT_SECONDS = 15
const isOffline = [...unsavedDocs.values()].some(
seconds => seconds >= MAX_UNSAVED_ALERT_SECONDS
)
return (
<>
{isOffline && <OfflineIndicatorContent />}
<div className="visually-hidden" role="status">
{isOffline && (
<>
<span>{t('you_re_offline')}</span>
<span>{t('changes_saved_in_browser_sync_when_online')}</span>
</>
)}
</div>
</>
)
}
@@ -19,6 +19,8 @@ export const OnlineUsers = () => {
[openDoc]
)
if (!onlineUsersArray.length) return null
return (
<div className="ide-redesign-online-users">
<OnlineUsersWidget onlineUsers={onlineUsersArray} goToUser={goToUser} />
@@ -19,6 +19,7 @@ import { useFeatureFlag } from '@/shared/context/split-test-context'
import OLIconButton from '@/shared/components/ol/ol-icon-button'
import OLTooltip from '@/shared/components/ol/ol-tooltip'
import SplitTestBadge from '@/shared/components/split-test-badge'
import OfflineIndicator from './offline-indicator'
const [publishModalModules] = importOverleafModules('publishModalToolbarButton')
const SubmitProjectButton = publishModalModules?.import.default
@@ -128,15 +129,18 @@ export const Toolbar = () => {
{showUpgradePrompt && upgradeButtonRelocation && <UpgradeButton />}
</div>
<ToolbarProjectTitle />
<div className="ide-redesign-toolbar-actions">
<OnlineUsers />
{!isRestrictedTokenMember && <ShowHistoryButton />}
<ChangeLayoutButton />
{shouldDisplaySubmitButton && cobranding && (
<SubmitProjectButton cobranding={cobranding} />
)}
<ShareProjectButton />
{showUpgradePrompt && !upgradeButtonRelocation && <UpgradeButton />}
<div className="ide-redesign-toolbar-actions-wrapper">
<OfflineIndicator />
<div className="ide-redesign-toolbar-actions">
<OnlineUsers />
{!isRestrictedTokenMember && <ShowHistoryButton />}
<ChangeLayoutButton />
{shouldDisplaySubmitButton && cobranding && (
<SubmitProjectButton cobranding={cobranding} />
)}
<ShareProjectButton />
{showUpgradePrompt && !upgradeButtonRelocation && <UpgradeButton />}
</div>
</div>
</nav>
)
@@ -4,10 +4,14 @@ import { UnsavedDocsAlert } from '@/features/ide-react/components/unsaved-docs/u
import { createPortal } from 'react-dom'
import { useGlobalAlertsContainer } from '@/features/ide-react/context/global-alerts-context'
import { useUnsavedDocsContext } from '@/features/ide-react/context/unsaved-docs-context'
import { useFeatureFlag } from '@/shared/context/split-test-context'
export const UnsavedDocs: FC = () => {
const { unsavedDocs, isLocked } = useUnsavedDocsContext()
const globalAlertsContainer = useGlobalAlertsContainer()
const improvedFlakyConnections = useFeatureFlag(
'intermittent-connection-improvements'
)
if (!globalAlertsContainer) {
return null
@@ -18,7 +22,8 @@ export const UnsavedDocs: FC = () => {
{isLocked &&
createPortal(<UnsavedDocsLockedAlert />, globalAlertsContainer)}
{unsavedDocs.size > 0 &&
{!improvedFlakyConnections &&
unsavedDocs.size > 0 &&
createPortal(
<UnsavedDocsAlert unsavedDocs={unsavedDocs} />,
globalAlertsContainer
@@ -26,9 +26,9 @@ type UnsavedDocsContextValue = {
isLocked: boolean
}
const UnsavedDocsContext = createContext<UnsavedDocsContextValue | undefined>(
undefined
)
export const UnsavedDocsContext = createContext<
UnsavedDocsContextValue | undefined
>(undefined)
export const UnsavedDocsProvider: FC<React.PropsWithChildren> = ({
children,
@@ -44,7 +44,7 @@
}
.ide-redesign-toolbar-menu,
.ide-redesign-toolbar-actions {
.ide-redesign-toolbar-actions-wrapper {
flex: 0 0 auto;
}
@@ -187,10 +187,16 @@
gap: var(--spacing-05);
}
.ide-redesign-toolbar-actions-wrapper {
display: flex;
align-items: center;
gap: var(--spacing-06);
padding-left: var(--spacing-05);
}
.ide-redesign-toolbar-actions {
display: flex;
gap: var(--spacing-04);
padding-left: var(--spacing-05);
}
.ide-redesign-toolbar-button-container {
@@ -199,6 +205,22 @@
justify-content: center;
}
.ide-redesign-toolbar-offline-indicator {
display: flex;
align-items: center;
gap: var(--spacing-03);
color: var(--content-secondary-themed);
font-size: var(--font-size-02);
line-height: var(--line-height-02);
cursor: default;
.material-symbols {
font-size: 16px;
line-height: 16px;
font-weight: 400;
}
}
.ide-redesign-online-users {
display: flex;
}
+2
View File
@@ -357,6 +357,7 @@
"change_to_group_plan": "Change to a group plan",
"change_to_this_plan": "Change to this plan",
"changes_applied": "Changes applied",
"changes_saved_in_browser_sync_when_online": "Your changes are saved in the browser. We’ll sync your work when you’re back online.",
"changes_to_these_settings_will_be_applied_to_all_new_projects": "Changes to these settings will be applied to all new projects. Existing projects will not be affected.",
"changing_the_position_of_your_figure": "Changing the position of your figure",
"changing_the_position_of_your_table": "Changing the position of your table",
@@ -3155,6 +3156,7 @@
"you_have_x_licenses_and_your_plan_supports_up_to_y": "You have allocated __addedUsersSize__ licenses and your plan supports up to __groupSize__.",
"you_have_x_licenses_on_your_subscription": "You have __groupSize__ licenses on your subscription.",
"you_need_to_configure_your_sso_settings": "You need to configure and test your SSO settings before enabling SSO",
"you_re_offline": "You’re offline",
"you_unpaused_your_subscription": "You unpaused your subscription.",
"you_will_be_able_to_contact_us_any_time_to_share_your_feedback": "<0>You will be able to contact us</0> any time to share your feedback",
"you_will_be_able_to_reassign_subscription": "You will be able to reassign their subscription membership to another person in your organization",
@@ -0,0 +1,83 @@
import OfflineIndicator from '@/features/ide-react/components/toolbar/offline-indicator'
import { UnsavedDocsContext } from '@/features/ide-react/context/unsaved-docs-context'
import { IdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter'
import { SplitTestProvider } from '@/shared/context/split-test-context'
const defaultIdeReactContextValue = {
projectId: 'test-project',
eventEmitter: new IdeEventEmitter(),
startedFreeTrial: false,
setStartedFreeTrial: () => {},
reportError: () => {},
projectJoined: true,
permissionsLevel: 'owner' as const,
setPermissionsLevel: () => {},
outOfSync: false,
setOutOfSync: () => {},
}
const defaultUnsavedDocsContextValue = {
unsavedDocs: new Map<string, number>(),
isLocked: false,
}
const mount = ({
unsavedDocs = new Map<string, number>(),
}: {
unsavedDocs?: Map<string, number>
} = {}) => {
cy.mount(
<SplitTestProvider>
<IdeReactContext.Provider value={defaultIdeReactContextValue}>
<UnsavedDocsContext.Provider
value={{ ...defaultUnsavedDocsContextValue, unsavedDocs }}
>
<OfflineIndicator />
</UnsavedDocsContext.Provider>
</IdeReactContext.Provider>
</SplitTestProvider>
)
}
const enableFlag = () => {
cy.window().then(win => {
win.metaAttributesCache.set('ol-splitTestVariants', {
'intermittent-connection-improvements': 'enabled',
})
})
}
const unsavedDoc = () => new Map<string, number>([['doc-1', 20]])
describe('<OfflineIndicator />', function () {
it('is hidden when the feature flag is disabled', function () {
mount({ unsavedDocs: unsavedDoc() })
cy.findByText('You’re offline').should('not.exist')
cy.findByRole('status').should('not.exist')
})
it('is hidden when the flag is enabled but there are no unsaved docs', function () {
enableFlag()
mount()
cy.findByText('You’re offline').should('not.exist')
cy.findByRole('status').should('be.empty')
})
it('is shown when the flag is enabled and there are unsaved docs', function () {
enableFlag()
mount({ unsavedDocs: unsavedDoc() })
cy.findAllByText('You’re offline').first().should('be.visible')
})
it('announces the offline status to screen readers via a live region', function () {
enableFlag()
mount({ unsavedDocs: unsavedDoc() })
cy.findByRole('status').within(() => {
cy.findByText('You’re offline').should('exist')
cy.findByText(
'Your changes are saved in the browser. We’ll sync your work when you’re back online.'
).should('exist')
})
})
})
@@ -0,0 +1,74 @@
import { UnsavedDocs } from '@/features/ide-react/components/unsaved-docs/unsaved-docs'
import { UnsavedDocsContext } from '@/features/ide-react/context/unsaved-docs-context'
import { GlobalAlertsProvider } from '@/features/ide-react/context/global-alerts-context'
import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
import { ConnectionContext } from '@/features/ide-react/context/connection-context'
import { SplitTestProvider } from '@/shared/context/split-test-context'
const defaultUnsavedDocsContextValue = {
unsavedDocs: new Map<string, number>([['doc1', 16]]),
isLocked: false,
}
const mockSocket = {
socket: { transport: { name: 'websocket' } },
} as any
const mockConnectionContextValue = {
socket: mockSocket,
connectionState: { readyState: WebSocket.OPEN },
isConnected: true,
isStillReconnecting: false,
secondsUntilReconnect: () => 0,
tryReconnectNow: () => {},
registerUserActivity: () => {},
closeConnection: () => {},
getSocketDebuggingInfo: () => ({ id: '' }),
} as any
const mockFileTreePathContextValue = {
dirname: () => '',
pathInFolder: (docId: string) => (docId === 'doc1' ? 'main.tex' : null),
findEntityByPath: (path: string) =>
path === 'main.tex'
? { entity: { _id: 'doc1', name: 'main.tex' }, type: 'doc' as const }
: null,
previewByPath: () => null,
}
const mount = () => {
cy.mount(
<SplitTestProvider>
<GlobalAlertsProvider>
<ConnectionContext.Provider value={mockConnectionContextValue}>
<FileTreePathContext.Provider value={mockFileTreePathContextValue}>
<UnsavedDocsContext.Provider value={defaultUnsavedDocsContextValue}>
<UnsavedDocs />
</UnsavedDocsContext.Provider>
</FileTreePathContext.Provider>
</ConnectionContext.Provider>
</GlobalAlertsProvider>
</SplitTestProvider>
)
}
const enableFlag = () => {
cy.window().then(win => {
win.metaAttributesCache.set('ol-splitTestVariants', {
'intermittent-connection-improvements': 'enabled',
})
})
}
describe('<UnsavedDocs />', function () {
it('shows UnsavedDocsAlert when flag is disabled', function () {
mount()
cy.findByRole('alert').should('exist')
})
it('suppresses UnsavedDocsAlert when flag is enabled', function () {
enableFlag()
mount()
cy.findByRole('alert').should('not.exist')
})
})