Skip to content

Commit 2794fcd

Browse files
Reader Spaces: stepped create wizard + Feeds/Topics reorganization (#112470)
* Reader Spaces: make create a stepped wizard and reorganize the modal Turn space creation into a guided multi-step wizard (Identity → Layout → Feeds → Topics) with Back/Next/Create navigation and a step indicator, so the options behind the former tabs aren't missed. Editing keeps the tabbed layout. Reorganize the shared modal into Identity, Layout, Feeds, and Topics (tags + languages) sections, add per-section descriptions, and rename the user-facing "Sources" wording to "Feeds" across the modal and the feed empty state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Reader Spaces: address review feedback * Reader Spaces: clarify topics tab test props type * Reader Spaces: wait for create wizard final step * Reader Spaces: update sidebar create flow test --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent cbf5dcc commit 2794fcd

13 files changed

Lines changed: 407 additions & 160 deletions

File tree

client/reader/sidebar/spaces/test/index.test.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,14 @@ function render( ui: React.ReactElement, initialState?: object ) {
8484
} );
8585
}
8686

87+
async function reachCreateStep( user: ReturnType< typeof userEvent.setup > ) {
88+
await user.type( screen.getByLabelText( 'Name' ), 'Reading' );
89+
await user.click( screen.getByRole( 'button', { name: 'Next' } ) );
90+
await user.click( screen.getByRole( 'button', { name: 'Next' } ) );
91+
await user.click( screen.getByRole( 'button', { name: 'Next' } ) );
92+
await screen.findByRole( 'button', { name: 'Create' } );
93+
}
94+
8795
describe( 'ReaderSidebarSpaces', () => {
8896
beforeEach( () => {
8997
jest.mocked( page ).mockClear();
@@ -247,7 +255,7 @@ describe( 'ReaderSidebarSpaces', () => {
247255
render( <ReaderSidebarSpaces path={ OPEN_PATH } /> );
248256

249257
await user.click( screen.getByRole( 'button', { name: 'Create a space' } ) );
250-
await user.type( screen.getByLabelText( 'Name' ), 'Reading' );
258+
await reachCreateStep( user );
251259
await user.click( screen.getByRole( 'button', { name: 'Create' } ) );
252260

253261
// The redirect happens in the create mutation's onSuccess, after the POST

client/reader/spaces/AGENTS.md

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,16 @@ only, and wired to the real `wpcom/v2` backend.
2929

3030
## Editing a space (RSM-4117)
3131

32-
`customize-modal/` is the **single upsert editor** for a space: a `TabPanel` with
33-
**Identity** (name, tags, accent color, icon), **Layout** (the feed-layout
34-
presets), **Sources** (the subscription add/remove list — migrated here from the
35-
old standalone `sources-modal/`, which no longer exists), and **Delete** (edit
36-
mode only, destructive _Delete space_ action that confirms via
37-
`confirm-delete.tsx`). The **Customize** header button opens edit mode on
38-
Identity. `create-modal/index.tsx` is a thin wrapper around the same upsert modal
39-
in create mode; after create, the sidebar navigates to the new space route
40-
without an action hash.
32+
`customize-modal/` is the **single upsert editor** for a space. Edit mode uses a
33+
`TabPanel` with **Identity** (name, accent color, icon), **Layout** (the
34+
feed-layout presets), **Feeds** (the subscription add/remove list — internally
35+
still keyed as `sources` because the API/client model maps wire `follows` to
36+
`sources`), **Topics** (tags and languages), and **Delete** (edit mode only,
37+
destructive _Delete space_ action that confirms via `confirm-delete.tsx`). The
38+
**Customize** header button opens edit mode on Identity. `create-modal/index.tsx`
39+
is a thin wrapper around the same upsert modal in create mode, rendered as a
40+
step-by-step wizard over Identity → Layout → Feeds → Topics; after create, the
41+
sidebar navigates to the new space route without an action hash.
4142

4243
- **Save/Create batches the editable fields.** "Save changes" and "Create" send
4344
the same draft model: `name`, `tags`, `feeds`, and
@@ -105,8 +106,8 @@ without an action hash.
105106

106107
- Create and edit share the upsert implementation in `customize-modal/index.tsx`;
107108
`create-modal/index.tsx` only adapts the existing public `CreateSpaceModal`
108-
export to create mode. Keep Identity/Layout/Sources behavior in the shared
109-
upsert modal so create and edit do not drift.
109+
export to create mode. Keep the Identity/Layout/Feeds/Topics draft behavior in
110+
the shared upsert modal so create and edit do not drift.
110111
- Validation: name required, <= `MAX_SPACE_NAME_LENGTH`, and case-insensitive
111112
duplicate against the existing names (edit passes the list with the current
112113
space removed). The error message is rendered manually

client/reader/spaces/create-modal/test/index.test.tsx

Lines changed: 87 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,23 @@ function setup( {
107107
return { queryClient, onClose, onCreated, user };
108108
}
109109

110+
type User = ReturnType< typeof userEvent.setup >;
111+
112+
// Walk from the opening Identity step to the Feeds step. The name is required to
113+
// leave the first step, so it is entered here.
114+
async function reachFeedsStep( user: User, name = 'Reading' ) {
115+
await user.type( screen.getByLabelText( 'Name' ), name );
116+
await user.click( screen.getByRole( 'button', { name: 'Next' } ) ); // Identity → Layout
117+
await user.click( screen.getByRole( 'button', { name: 'Next' } ) ); // Layout → Feeds
118+
}
119+
120+
// Walk all the way to the final Topics step, where Create lives.
121+
async function reachTopicsStep( user: User, name = 'Reading' ) {
122+
await reachFeedsStep( user, name );
123+
await user.click( screen.getByRole( 'button', { name: 'Next' } ) ); // Feeds → Topics
124+
await screen.findByRole( 'button', { name: 'Create' } );
125+
}
126+
110127
describe( 'CreateSpaceModal', () => {
111128
beforeEach( () => {
112129
mockSubscriptions = [];
@@ -121,46 +138,65 @@ describe( 'CreateSpaceModal', () => {
121138
expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument();
122139
} );
123140

124-
it( 'keeps Create disabled until a valid name is entered', async () => {
141+
it( 'keeps Next disabled until a valid name is entered', async () => {
125142
const { user } = setup();
126143

127-
expect( screen.getByRole( 'button', { name: 'Create' } ) ).toBeDisabled();
144+
expect( screen.getByRole( 'button', { name: 'Next' } ) ).toBeDisabled();
128145

129146
await user.type( screen.getByLabelText( 'Name' ), 'Reading' );
130147

131-
expect( screen.getByRole( 'button', { name: 'Create' } ) ).toBeEnabled();
148+
expect( screen.getByRole( 'button', { name: 'Next' } ) ).toBeEnabled();
132149
} );
133150

134-
it( 'uses the shared tabbed upsert modal while creating', async () => {
151+
it( 'steps through the identity, layout, feeds and topics sections', async () => {
135152
mockSubscriptions = [ mockSubscription ];
136153
const { user } = setup();
137154

138155
const dialog = screen.getByRole( 'dialog', { name: 'Create a new space' } );
139156

140-
expect( within( dialog ).getByRole( 'tab', { name: 'Identity' } ) ).toBeVisible();
141-
expect( within( dialog ).getByRole( 'tab', { name: 'Layout' } ) ).toBeVisible();
142-
expect( within( dialog ).getByRole( 'tab', { name: 'Sources' } ) ).toBeVisible();
143-
expect( within( dialog ).queryByRole( 'tab', { name: 'Delete' } ) ).not.toBeInTheDocument();
157+
// The wizard replaces the tab strip with one step at a time.
158+
expect( within( dialog ).queryByRole( 'tab' ) ).not.toBeInTheDocument();
159+
expect( within( dialog ).getByLabelText( 'Name' ) ).toBeVisible();
160+
expect( within( dialog ).getByLabelText( 'Step 1 of 4' ) ).toBeInTheDocument();
144161

145-
await user.click( within( dialog ).getByRole( 'tab', { name: 'Layout' } ) );
162+
await user.type( within( dialog ).getByLabelText( 'Name' ), 'Reading' );
163+
await user.click( within( dialog ).getByRole( 'button', { name: 'Next' } ) );
164+
165+
// Layout step.
146166
expect( within( dialog ).getByRole( 'radio', { name: /Classic/ } ) ).toBeChecked();
167+
await user.click( within( dialog ).getByRole( 'button', { name: 'Next' } ) );
147168

148-
await user.click( within( dialog ).getByRole( 'tab', { name: 'Sources' } ) );
169+
// Feeds step: only the subscription picker.
149170
expect(
150-
within( dialog ).getByText( 'Choose which of your subscriptions appear in this space.' )
171+
within( dialog ).getByText(
172+
'Pick the subscriptions whose posts make up this space’s main feed.'
173+
)
151174
).toBeVisible();
175+
expect( within( dialog ).getByRole( 'button', { name: 'All subscriptions' } ) ).toBeVisible();
152176
expect( within( dialog ).getByRole( 'listitem', { name: 'Example Blog' } ) ).toBeVisible();
177+
expect( within( dialog ).queryByRole( 'combobox', { name: 'Tags' } ) ).not.toBeInTheDocument();
178+
await user.click( within( dialog ).getByRole( 'button', { name: 'Next' } ) );
179+
180+
// Topics step is last, so it carries the Create button.
181+
expect( within( dialog ).getByRole( 'combobox', { name: 'Tags' } ) ).toBeVisible();
182+
expect( within( dialog ).getByRole( 'combobox', { name: 'Languages' } ) ).toBeVisible();
183+
expect( await within( dialog ).findByRole( 'button', { name: 'Create' } ) ).toBeVisible();
184+
185+
// Back returns to the previous step.
186+
await user.click( within( dialog ).getByRole( 'button', { name: 'Back' } ) );
187+
expect( within( dialog ).getByRole( 'button', { name: 'All subscriptions' } ) ).toBeVisible();
153188
} );
154189

155-
it( 'shows a required error once the name is cleared', async () => {
190+
it( 'cannot advance past the identity step without a name', async () => {
156191
const { user } = setup();
157192

158-
const input = screen.getByLabelText( 'Name' );
159-
await user.type( input, 'Reading' );
160-
await user.clear( input );
193+
expect( screen.getByRole( 'button', { name: 'Next' } ) ).toBeDisabled();
194+
195+
await user.type( screen.getByLabelText( 'Name' ), 'Reading' );
196+
await user.clear( screen.getByLabelText( 'Name' ) );
161197

162198
expect( await screen.findByText( 'Name is required' ) ).toBeVisible();
163-
expect( screen.getByRole( 'button', { name: 'Create' } ) ).toBeDisabled();
199+
expect( screen.getByRole( 'button', { name: 'Next' } ) ).toBeDisabled();
164200
} );
165201

166202
it( 'rejects a name longer than the maximum length', async () => {
@@ -169,7 +205,7 @@ describe( 'CreateSpaceModal', () => {
169205
await user.type( screen.getByLabelText( 'Name' ), 'a'.repeat( 51 ) );
170206

171207
expect( await screen.findByText( /50 characters or fewer/ ) ).toBeVisible();
172-
expect( screen.getByRole( 'button', { name: 'Create' } ) ).toBeDisabled();
208+
expect( screen.getByRole( 'button', { name: 'Next' } ) ).toBeDisabled();
173209
} );
174210

175211
it( 'rejects a duplicate name regardless of case', async () => {
@@ -178,7 +214,7 @@ describe( 'CreateSpaceModal', () => {
178214
await user.type( screen.getByLabelText( 'Name' ), 'work' );
179215

180216
expect( await screen.findByText( 'A space with this name already exists' ) ).toBeVisible();
181-
expect( screen.getByRole( 'button', { name: 'Create' } ) ).toBeDisabled();
217+
expect( screen.getByRole( 'button', { name: 'Next' } ) ).toBeDisabled();
182218
} );
183219

184220
it( 'creates a space with identity and layout settings, updates the caches, and closes', async () => {
@@ -187,17 +223,25 @@ describe( 'CreateSpaceModal', () => {
187223
const onBody = jest.fn();
188224
mockCreateEndpoint( 'Reading', onBody );
189225

226+
// Identity step.
190227
await user.type( screen.getByLabelText( 'Name' ), 'Reading' );
191228
await user.click(
192229
within( screen.getByRole( 'radiogroup', { name: 'Accent color' } ) ).getByRole( 'radio', {
193230
name: 'Green',
194231
} )
195232
);
196233
await user.click( screen.getByRole( 'radio', { name: 'Star' } ) );
197-
await user.click( screen.getByRole( 'tab', { name: 'Layout' } ) );
234+
await user.click( screen.getByRole( 'button', { name: 'Next' } ) );
235+
236+
// Layout step.
198237
await user.click( screen.getByRole( 'radio', { name: /Classic/ } ) );
199-
await user.click( screen.getByRole( 'tab', { name: 'Sources' } ) );
238+
await user.click( screen.getByRole( 'button', { name: 'Next' } ) );
239+
240+
// Feeds step.
200241
await user.click( screen.getByRole( 'button', { name: 'Add Example Blog' } ) );
242+
await user.click( screen.getByRole( 'button', { name: 'Next' } ) );
243+
244+
// Topics step (last) carries the Create button.
201245
await user.click( screen.getByRole( 'button', { name: 'Create' } ) );
202246

203247
await waitFor( () => expect( onClose ).toHaveBeenCalled() );
@@ -243,10 +287,11 @@ describe( 'CreateSpaceModal', () => {
243287
const onBody = jest.fn();
244288
mockCreateEndpoint( 'Leitura', onBody );
245289

290+
// The languages field lives on the final Topics step.
291+
await reachTopicsStep( user, 'Leitura' );
246292
const dialog = screen.getByRole( 'dialog', { name: 'Create a new space' } );
247293
expect( within( dialog ).getByText( 'Português' ) ).toBeVisible();
248294

249-
await user.type( screen.getByLabelText( 'Name' ), 'Leitura' );
250295
await user.click( screen.getByRole( 'button', { name: 'Create' } ) );
251296

252297
await waitFor( () => expect( onClose ).toHaveBeenCalled() );
@@ -255,12 +300,31 @@ describe( 'CreateSpaceModal', () => {
255300
);
256301
} );
257302

303+
it( 'sends topics entered in the wizard when creating', async () => {
304+
const { user, onClose } = setup();
305+
const onBody = jest.fn();
306+
mockCreateEndpoint( 'Reading', onBody );
307+
308+
await reachTopicsStep( user );
309+
await user.type( screen.getByRole( 'combobox', { name: 'Tags' } ), 'design[Enter]' );
310+
await user.type( screen.getByRole( 'combobox', { name: 'Languages' } ), 'English[Enter]' );
311+
await user.click( screen.getByRole( 'button', { name: 'Create' } ) );
312+
313+
await waitFor( () => expect( onClose ).toHaveBeenCalled() );
314+
expect( onBody ).toHaveBeenCalledWith(
315+
expect.objectContaining( {
316+
tags: [ 'design' ],
317+
languages: [ 'en' ],
318+
} )
319+
);
320+
} );
321+
258322
it( 'sends no languages when the account has no locale', async () => {
259323
const { user, onClose } = setup();
260324
const onBody = jest.fn();
261325
mockCreateEndpoint( 'Reading', onBody );
262326

263-
await user.type( screen.getByLabelText( 'Name' ), 'Reading' );
327+
await reachTopicsStep( user );
264328
await user.click( screen.getByRole( 'button', { name: 'Create' } ) );
265329

266330
await waitFor( () => expect( onClose ).toHaveBeenCalled() );
@@ -271,7 +335,7 @@ describe( 'CreateSpaceModal', () => {
271335
const { user, onCreated } = setup();
272336
mockCreateEndpoint( 'Reading' );
273337

274-
await user.type( screen.getByLabelText( 'Name' ), 'Reading' );
338+
await reachTopicsStep( user );
275339
await user.click( screen.getByRole( 'button', { name: 'Create' } ) );
276340

277341
await waitFor( () =>

client/reader/spaces/customize-modal/identity-tab.tsx

Lines changed: 1 addition & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,14 @@
1-
import { FormTokenField, TextControl, __experimentalVStack as VStack } from '@wordpress/components';
1+
import { TextControl, __experimentalVStack as VStack } from '@wordpress/components';
22
import { useTranslate } from 'i18n-calypso';
33
import { useState } from 'react';
44
import { SpaceColorPicker } from 'calypso/reader/spaces/color-picker';
55
import { SpaceIconPicker } from 'calypso/reader/spaces/icon-picker';
6-
import {
7-
SPACE_LANGUAGE_SUGGESTIONS,
8-
getLanguageCodeByName,
9-
getLanguageName,
10-
resolveLanguageTokens,
11-
} from 'calypso/reader/spaces/languages';
126
import type { SpaceColor, SpaceIcon, SpaceTextColor } from '@automattic/api-core';
137

148
interface Props {
159
name: string;
1610
onNameChange: ( name: string ) => void;
1711
nameError: string | null;
18-
tags: string[];
19-
onTagsChange: ( tags: string[] ) => void;
20-
languages: string[];
21-
onLanguagesChange: ( languages: string[] ) => void;
2212
color: SpaceTextColor;
2313
onColorChange: ( color: SpaceTextColor ) => void;
2414
iconColor: SpaceColor;
@@ -31,10 +21,6 @@ export function IdentityTab( {
3121
name,
3222
onNameChange,
3323
nameError,
34-
tags,
35-
onTagsChange,
36-
languages,
37-
onLanguagesChange,
3824
color,
3925
onColorChange,
4026
iconColor,
@@ -64,36 +50,6 @@ export function IdentityTab( {
6450
{ nameError }
6551
</p>
6652
) : null }
67-
<FormTokenField
68-
__next40pxDefaultSize
69-
label={ translate( 'Tags' ) }
70-
value={ tags }
71-
placeholder={ translate( 'Add tags' ) }
72-
onChange={ ( tokens ) =>
73-
onTagsChange(
74-
tokens.map( ( token ) => ( typeof token === 'string' ? token : token.value ) )
75-
)
76-
}
77-
help={ translate( 'Type and press Enter to add; click x to remove.' ) }
78-
/>
79-
<FormTokenField
80-
__next40pxDefaultSize
81-
__experimentalExpandOnFocus
82-
// Restrict tokens to known languages so only valid base codes are
83-
// stored; free-typed text that doesn't resolve to a language is rejected.
84-
__experimentalValidateInput={ ( input: string ) =>
85-
getLanguageCodeByName( input ) !== undefined
86-
}
87-
label={ translate( 'Languages' ) }
88-
// The field works in display names; the parent state is base codes.
89-
value={ languages.map( getLanguageName ) }
90-
suggestions={ SPACE_LANGUAGE_SUGGESTIONS }
91-
placeholder={ translate( 'Add languages' ) }
92-
onChange={ ( tokens ) => onLanguagesChange( resolveLanguageTokens( tokens, languages ) ) }
93-
help={ translate(
94-
'Filters Discover results to these languages. Starts from your account language; add more as needed.'
95-
) }
96-
/>
9753
</VStack>
9854

9955
<VStack spacing={ 2 }>

0 commit comments

Comments
 (0)