Skip to content

Commit 12144bf

Browse files
dabowmanclaude
andcommitted
Sync connection modal: Redesign with error-specific actions
Redesign the sync disconnection modal to follow the standard Modal pattern with a header title, left-aligned body, and right-aligned footer buttons — replacing the previous centered splash layout. - Use Modal `title` prop instead of hidden header with large icon - Add "Back to {PostType}" button with dynamic post type label - Add manual "Retry" button alongside auto-retry countdown - Promote "Copy Post Content" to primary when Retry is hidden - Add `canRetry` flag to error messages: `authentication-failed` hides Retry (permissions can't be resolved by retrying) - Stabilize useEffect dependencies with primitive values - Replace hardcoded colors with `@wordpress/base-styles` tokens - Add unit tests for `getSyncErrorMessages` Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a5d641c commit 12144bf

File tree

4 files changed

+144
-94
lines changed

4 files changed

+144
-94
lines changed

packages/editor/src/components/sync-connection-modal/index.js

Lines changed: 66 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,17 @@ import {
1515
import {
1616
Button,
1717
Modal,
18-
Icon,
1918
__experimentalHStack as HStack,
2019
__experimentalVStack as VStack,
2120
} from '@wordpress/components';
2221
import { useState, useEffect, useRef } from '@wordpress/element';
2322
import { __, sprintf, _n } from '@wordpress/i18n';
24-
import { error as errorIcon } from '@wordpress/icons';
2523

2624
/**
2725
* Internal dependencies
2826
*/
2927
import { getSyncErrorMessages } from '../../utils/sync-error-messages';
28+
import { store as editorStore } from '../../store';
3029
import { unlock } from '../../lock-unlock';
3130
import { useRetryCountdown } from './use-retry-countdown';
3231

@@ -35,6 +34,7 @@ const { retrySyncConnection } = unlock( coreDataPrivateApis );
3534

3635
// Debounce time for initial disconnected status to allow connection to establish.
3736
const INITIAL_DISCONNECTED_DEBOUNCE_MS = 5000;
37+
const noop = () => {};
3838

3939
/**
4040
* Sync connection modal that displays when any entity reports a disconnection.
@@ -43,8 +43,15 @@ const INITIAL_DISCONNECTED_DEBOUNCE_MS = 5000;
4343
* @return {Element|null} The modal component or null if not disconnected.
4444
*/
4545
export function SyncConnectionModal() {
46-
const connectionState = useSelect( ( selectFn ) => {
47-
return selectFn( coreDataStore ).getSyncConnectionStatus() || null;
46+
const { connectionState, postType } = useSelect( ( selectFn ) => {
47+
const currentPostType = selectFn( editorStore ).getCurrentPostType();
48+
return {
49+
connectionState:
50+
selectFn( coreDataStore ).getSyncConnectionStatus() || null,
51+
postType: currentPostType
52+
? selectFn( coreDataStore ).getPostType( currentPostType )
53+
: null,
54+
};
4855
}, [] );
4956

5057
const { secondsRemaining, markRetrying } = useRetryCountdown(
@@ -63,23 +70,24 @@ export function SyncConnectionModal() {
6370
// Once true, disconnected status will show immediately without debounce.
6471
const hasInitializedRef = useRef( false );
6572

66-
useEffect( () => {
67-
const status = connectionState?.status;
73+
const connectionStatus = connectionState?.status;
74+
const connectionErrorCode = connectionState?.error?.code;
6875

76+
useEffect( () => {
6977
// Clear any pending debounce timer when status changes.
7078
if ( debounceTimerRef.current ) {
7179
clearTimeout( debounceTimerRef.current );
7280
debounceTimerRef.current = null;
7381
}
7482

75-
if ( status === 'connected' ) {
83+
if ( connectionStatus === 'connected' ) {
7684
hasInitializedRef.current = true;
7785
setSyncConnectionMessage( null );
78-
} else if ( status === 'disconnected' ) {
86+
} else if ( connectionStatus === 'disconnected' ) {
7987
const showModal = () => {
8088
hasInitializedRef.current = true;
8189
setSyncConnectionMessage(
82-
getSyncErrorMessages( connectionState.error ?? {} )
90+
getSyncErrorMessages( { code: connectionErrorCode } )
8391
);
8492
};
8593

@@ -99,97 +107,89 @@ export function SyncConnectionModal() {
99107
clearTimeout( debounceTimerRef.current );
100108
}
101109
};
102-
}, [ connectionState ] );
110+
}, [ connectionStatus, connectionErrorCode ] );
103111

104112
if ( ! syncConnectionMessage ) {
105113
return null;
106114
}
107115

108-
const { title, description } = syncConnectionMessage;
116+
const { title, description, canRetry } = syncConnectionMessage;
109117

110118
let retryCountdownText;
111119
if ( secondsRemaining > 0 ) {
112120
retryCountdownText = sprintf(
113121
/* translators: %d: number of seconds until retry */
114122
_n(
115-
'Retrying in %d second\u2026',
116-
'Retrying in %d seconds\u2026',
123+
'Retrying connection in %d second\u2026',
124+
'Retrying connection in %d seconds\u2026',
117125
secondsRemaining
118126
),
119127
secondsRemaining
120128
);
121129
} else if ( secondsRemaining === 0 ) {
122130
retryCountdownText = __( 'Retrying\u2026' );
123-
} else {
124-
// &nbsp; to preserve layout when countdown is hidden
125-
retryCountdownText = '\u00A0';
126131
}
127132

133+
let editPostHref = 'edit.php';
134+
if ( postType?.slug ) {
135+
editPostHref = `edit.php?post_type=${ postType.slug }`;
136+
}
137+
138+
const isRetrying = secondsRemaining === 0;
139+
128140
return (
129141
<BlockCanvasCover.Fill>
130142
<Modal
131-
__experimentalHideHeader
132-
icon={ errorIcon }
143+
className="editor-sync-connection-modal"
133144
isDismissible={ false }
134-
isFullScreen={ false }
135-
onRequestClose={ () => {} }
145+
onRequestClose={ noop }
136146
shouldCloseOnClickOutside={ false }
137147
shouldCloseOnEsc={ false }
148+
size="medium"
149+
title={ title }
138150
>
139-
<div className="editor-sync-connection-modal__container">
140-
<VStack alignment="center" justify="center" spacing={ 1 }>
141-
<Icon fill="#ccc" icon={ errorIcon } size={ 64 } />
142-
<h1>{ title }</h1>
143-
<div
144-
className="editor-sync-connection-modal__retry"
145-
style={ {
146-
visibility:
147-
secondsRemaining !== null
148-
? 'visible'
149-
: 'hidden',
150-
} }
151+
<VStack spacing={ 6 }>
152+
<p>{ description }</p>
153+
{ retryCountdownText && (
154+
<p className="editor-sync-connection-modal__retry-countdown">
155+
{ retryCountdownText }
156+
</p>
157+
) }
158+
<HStack justify="right">
159+
<Button
160+
__next40pxDefaultSize
161+
href={ editPostHref }
162+
isDestructive
163+
variant="tertiary"
164+
>
165+
{ sprintf(
166+
/* translators: %s: Post type name (e.g., "Posts", "Pages"). */
167+
__( 'Back to %s' ),
168+
postType?.labels?.name ?? __( 'Posts' )
169+
) }
170+
</Button>
171+
<Button
172+
__next40pxDefaultSize
173+
ref={ copyButtonRef }
174+
variant={ canRetry ? 'secondary' : 'primary' }
151175
>
152-
<p className="editor-sync-connection-modal__retry-countdown">
153-
{ retryCountdownText }
154-
</p>
176+
{ __( 'Copy Post Content' ) }
177+
</Button>
178+
{ canRetry && (
155179
<Button
156-
variant="link"
157-
style={ {
158-
visibility:
159-
secondsRemaining === 0
160-
? 'hidden'
161-
: 'visible',
162-
} }
180+
__next40pxDefaultSize
181+
isBusy={ isRetrying }
182+
variant="primary"
163183
onClick={ () => {
164184
markRetrying();
165185
retrySyncConnection();
166186
} }
167187
>
168-
{ __( 'Retry now' ) }
169-
</Button>
170-
</div>
171-
<p className="editor-sync-connection-modal__description">
172-
{ description }
173-
</p>
174-
<HStack spacing={ 2 } justify="center">
175-
<Button
176-
__next40pxDefaultSize
177-
ref={ copyButtonRef }
178-
variant="primary"
179-
>
180-
{ __( 'Copy post content' ) }
181-
</Button>
182-
<Button
183-
__next40pxDefaultSize
184-
href="edit.php"
185-
isDestructive
186-
variant="secondary"
187-
>
188-
{ __( 'Edit another post' ) }
188+
{ __( 'Retry' ) }
189189
</Button>
190-
</HStack>
191-
</VStack>
192-
</div>
190+
) }
191+
</HStack>
192+
</VStack>
193193
</Modal>
194194
</BlockCanvasCover.Fill>
195195
);
Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,9 @@
1-
.editor-sync-connection-modal__container {
2-
align-items: center;
3-
display: flex;
4-
margin: auto;
5-
max-width: 30rem;
6-
}
7-
8-
.editor-sync-connection-modal__description {
9-
font-size: 1.3rem;
10-
text-align: center;
11-
}
1+
@use "@wordpress/base-styles/colors" as *;
122

13-
.editor-sync-connection-modal__retry {
14-
text-align: center;
15-
min-height: 3em;
3+
.editor-sync-connection-modal p {
4+
margin: 0;
165
}
176

187
.editor-sync-connection-modal__retry-countdown {
19-
margin-top: 0;
20-
margin-bottom: 0;
8+
color: $gray-700;
219
}

packages/editor/src/utils/sync-error-messages.js

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,31 +8,36 @@ import { __ } from '@wordpress/i18n';
88
*/
99
const ERROR_MESSAGES = {
1010
'authentication-failed': {
11-
title: __( 'Authentication Failed' ),
11+
title: __( 'Unable to connect' ),
1212
description: __(
13-
'Authentication with the collaborative editing server failed. ' +
14-
'Please verify that you have the necessary permissions.'
13+
"Real-time collaboration couldn't verify your permissions. " +
14+
'Check that you have access to edit this post, or contact your site administrator.'
1515
),
16+
canRetry: false,
1617
},
1718
'connection-expired': {
18-
title: __( 'Connection Expired' ),
19+
title: __( 'Connection expired' ),
1920
description: __(
20-
'The connection to the collaborative editing server has expired.'
21+
'Your connection to real-time collaboration has timed out. ' +
22+
'Editing is paused to prevent conflicts with other editors.'
2123
),
24+
canRetry: true,
2225
},
2326
'connection-limit-exceeded': {
24-
title: __( 'Connection Limit Exceeded' ),
27+
title: __( 'Too many editors connected' ),
2528
description: __(
26-
'The collaborative editing server has reached its maximum connection capacity. ' +
27-
'Please try again later or contact your site administrator.'
29+
'Real-time collaboration has reached its connection limit. ' +
30+
'Try again later or contact your site administrator.'
2831
),
32+
canRetry: true,
2933
},
3034
'unknown-error': {
31-
title: __( 'Disconnected' ),
35+
title: __( 'Connection lost' ),
3236
description: __(
33-
'You are currently disconnected from the collaborative editing server. ' +
34-
'Editing is temporarily disabled to prevent conflicts.'
37+
'The connection to real-time collaboration was interrupted. ' +
38+
'Editing is paused to prevent conflicts with other editors.'
3539
),
40+
canRetry: true,
3641
},
3742
};
3843

@@ -42,7 +47,7 @@ const ERROR_MESSAGES = {
4247
* Provides default messages based on error.code.
4348
*
4449
* @param {Object} error - Connection error object.
45-
* @return {Object} Object with title and description strings.
50+
* @return {Object} Object with title, description, and canRetry flag.
4651
*/
4752
export function getSyncErrorMessages( error ) {
4853
if ( ERROR_MESSAGES[ error?.code ] ) {
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* Internal dependencies
3+
*/
4+
import { getSyncErrorMessages } from '../sync-error-messages';
5+
6+
describe( 'getSyncErrorMessages', () => {
7+
it.each( [
8+
'authentication-failed',
9+
'connection-expired',
10+
'connection-limit-exceeded',
11+
'unknown-error',
12+
] )(
13+
'should return title, description, and canRetry for "%s"',
14+
( code ) => {
15+
const result = getSyncErrorMessages( { code } );
16+
expect( result ).toEqual(
17+
expect.objectContaining( {
18+
title: expect.any( String ),
19+
description: expect.any( String ),
20+
canRetry: expect.any( Boolean ),
21+
} )
22+
);
23+
}
24+
);
25+
26+
it( 'should set canRetry to false for authentication-failed', () => {
27+
const result = getSyncErrorMessages( {
28+
code: 'authentication-failed',
29+
} );
30+
expect( result.canRetry ).toBe( false );
31+
} );
32+
33+
it( 'should set canRetry to true for retryable errors', () => {
34+
expect(
35+
getSyncErrorMessages( { code: 'connection-expired' } ).canRetry
36+
).toBe( true );
37+
expect(
38+
getSyncErrorMessages( { code: 'connection-limit-exceeded' } )
39+
.canRetry
40+
).toBe( true );
41+
expect(
42+
getSyncErrorMessages( { code: 'unknown-error' } ).canRetry
43+
).toBe( true );
44+
} );
45+
46+
it( 'should fall back to unknown-error for unrecognized codes', () => {
47+
const result = getSyncErrorMessages( { code: 'some-new-error' } );
48+
const unknownResult = getSyncErrorMessages( { code: 'unknown-error' } );
49+
expect( result ).toBe( unknownResult );
50+
} );
51+
52+
it( 'should fall back to unknown-error when error is undefined', () => {
53+
const result = getSyncErrorMessages( undefined );
54+
const unknownResult = getSyncErrorMessages( { code: 'unknown-error' } );
55+
expect( result ).toBe( unknownResult );
56+
} );
57+
} );

0 commit comments

Comments
 (0)