Skip to content

Commit 7fcfbc4

Browse files
committed
fix(a11y): add Start/Stop workspace kebab on loader page (CRW-10282)
WCAG 2.2.2 (Pause, Stop, Hide, Level A) requires a mechanism to stop any auto-updating animation lasting more than 5 seconds. The workspace start page showed a continuous spinner with no way to stop the process. Add a kebab actions dropdown (EllipsisVIcon) to the loader page header, matching the pattern used in PR #1515. The dropdown contains: - Start: enabled when workspace is stopped/failed, calls startWorkspace() - Stop: enabled when workspace is starting/running, calls stopWorkspace() Also preserve Events tab content after stopping: remove the STOPPED guard in WorkspaceEvents so users can review startup events after pressing Stop (the event data stays in the Redux store and is still useful for debugging a failed start). Implementation follows PR #1515: - Header: accepts optional 'actions' prop rendered right-aligned - LoaderPage: builds the Dropdown with Start/Stop DropdownItems; visible only when a workspace object exists - LoaderContainer: connects startWorkspace + stopWorkspace dispatch Signed-off-by: Oleksii Orel <oorel@redhat.com>
1 parent f87012b commit 7fcfbc4

5 files changed

Lines changed: 173 additions & 16 deletions

File tree

packages/dashboard-frontend/src/components/Header/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
// Note: PageSectionVariants.default was removed in PF6
3434

3535
type Props = {
36+
actions?: React.ReactNode;
3637
hideBreadcrumbs?: boolean;
3738
status: WorkspaceStatus | DevWorkspaceStatus | DeprecatedWorkspaceStatus;
3839
containerScc: string | undefined;
@@ -46,7 +47,7 @@ class Header extends React.PureComponent<Props> {
4647
}
4748

4849
public render(): React.ReactElement {
49-
const { title, status, containerScc, hideBreadcrumbs } = this.props;
50+
const { actions, title, status, containerScc, hideBreadcrumbs } = this.props;
5051

5152
return (
5253
<PageSection>
@@ -71,6 +72,7 @@ class Header extends React.PureComponent<Props> {
7172
<FlexItem>
7273
<WorkspaceStatusLabel status={status} containerScc={containerScc} />
7374
</FlexItem>
75+
{actions && <FlexItem align={{ default: 'alignRight' }}>{actions}</FlexItem>}
7476
</Flex>
7577
</StackItem>
7678
</Stack>

packages/dashboard-frontend/src/components/WorkspaceEvents/index.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,7 @@ class WorkspaceEvents extends React.PureComponent<Props> {
7373

7474
const workspace = this.findWorkspace(workspaceUID, allWorkspaces);
7575

76-
if (
77-
workspaceUID === undefined ||
78-
workspace === undefined ||
79-
workspace.status === DevWorkspaceStatus.STOPPED
80-
) {
76+
if (workspaceUID === undefined || workspace === undefined) {
8177
return (
8278
<EmptyState icon={FileIcon} titleText="No events to show.">
8379
<EmptyStateBody>Events will be streamed for a starting workspace.</EmptyStateBody>

packages/dashboard-frontend/src/containers/Loader/index.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ import { LoaderPage } from '@/pages/Loader';
1919
import { WorkspaceRouteParams } from '@/Routes';
2020
import { findTargetWorkspace } from '@/services/helpers/factoryFlow/findTargetWorkspace';
2121
import { getLoaderMode } from '@/services/helpers/factoryFlow/getLoaderMode';
22-
import { LoaderTab } from '@/services/helpers/types';
22+
import { DevWorkspaceStatus, LoaderTab } from '@/services/helpers/types';
2323
import { Workspace } from '@/services/workspace-adapter';
2424
import { RootState } from '@/store';
25+
import { workspacesActionCreators } from '@/store/Workspaces';
2526
import { selectAllWorkspaces } from '@/store/Workspaces/selectors';
2627

2728
type RouteParams = Partial<WorkspaceRouteParams> | undefined;
@@ -74,11 +75,20 @@ class LoaderContainer extends React.Component<Props, State> {
7475
this.props.navigate(location);
7576
}
7677

78+
private handleStartWorkspace(workspace: Workspace): void {
79+
this.props.startWorkspace(workspace);
80+
}
81+
82+
private handleStopWorkspace(workspace: Workspace): void {
83+
this.props.stopWorkspace(workspace);
84+
}
85+
7786
render(): React.ReactElement {
7887
const { location, navigate } = this.props;
7988
const { tabParam, searchParams } = this.state;
8089

8190
const workspace = this.findTargetWorkspace(this.props);
91+
const workspaceStatus = (workspace?.status as DevWorkspaceStatus) || DevWorkspaceStatus.STOPPED;
8292

8393
return (
8494
<LoaderPage
@@ -87,7 +97,10 @@ class LoaderContainer extends React.Component<Props, State> {
8797
searchParams={searchParams}
8898
tabParam={tabParam}
8999
workspace={workspace}
100+
workspaceStatus={workspaceStatus}
90101
onTabChange={tab => this.handleTabChange(tab)}
102+
onStartWorkspace={() => workspace && this.handleStartWorkspace(workspace)}
103+
onStopWorkspace={() => workspace && this.handleStopWorkspace(workspace)}
91104
/>
92105
);
93106
}
@@ -107,7 +120,12 @@ const mapStateToProps = (state: RootState) => ({
107120
allWorkspaces: selectAllWorkspaces(state),
108121
});
109122

110-
const connector = connect(mapStateToProps, null, null, {
123+
const mapDispatchToProps = {
124+
startWorkspace: workspacesActionCreators.startWorkspace,
125+
stopWorkspace: workspacesActionCreators.stopWorkspace,
126+
};
127+
128+
const connector = connect(mapStateToProps, mapDispatchToProps, null, {
111129
// forwardRef is mandatory for using `@react-mock/state` in unit tests
112130
forwardRef: true,
113131
});

packages/dashboard-frontend/src/pages/Loader/__tests__/index.spec.tsx

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { Store } from 'redux';
1919

2020
import getComponentRenderer from '@/services/__mocks__/getComponentRenderer';
2121
import devfileApi from '@/services/devfileApi';
22-
import { LoaderTab } from '@/services/helpers/types';
22+
import { DevWorkspaceStatus, LoaderTab } from '@/services/helpers/types';
2323
import { constructWorkspace, Workspace } from '@/services/workspace-adapter';
2424
import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder';
2525
import { MockStoreBuilder } from '@/store/__mocks__/mockStore';
@@ -33,6 +33,8 @@ jest.mock('@/components/WorkspaceEvents');
3333
const { renderComponent } = getComponentRenderer(getComponent);
3434

3535
const mockOnTabChange = jest.fn();
36+
const mockOnStartWorkspace = jest.fn();
37+
const mockOnStopWorkspace = jest.fn();
3638

3739
const namespace = 'user-che';
3840
const workspaceName = 'wksp-test';
@@ -65,6 +67,7 @@ describe('Loader page', () => {
6567
renderComponent(store, {
6668
tabParam,
6769
workspace,
70+
workspaceStatus: DevWorkspaceStatus.STARTING,
6871
});
6972

7073
const tabButtonLogs = screen.getByRole('tab', { name: 'Logs' });
@@ -77,6 +80,7 @@ describe('Loader page', () => {
7780
renderComponent(store, {
7881
tabParam: LoaderTab.Logs,
7982
workspace,
83+
workspaceStatus: DevWorkspaceStatus.STARTING,
8084
});
8185

8286
const tabpanelProgress = screen.queryByRole('tabpanel', { name: 'Progress' });
@@ -93,6 +97,7 @@ describe('Loader page', () => {
9397
const { reRenderComponent } = renderComponent(store, {
9498
tabParam,
9599
workspace: undefined,
100+
workspaceStatus: DevWorkspaceStatus.STOPPED,
96101
});
97102

98103
expect(screen.queryByRole('heading')).toHaveTextContent('Creating a workspace');
@@ -111,15 +116,70 @@ describe('Loader page', () => {
111116
reRenderComponent(storeReady, {
112117
tabParam,
113118
workspace: constructWorkspace(devWorkspaceReady),
119+
workspaceStatus: DevWorkspaceStatus.RUNNING,
114120
});
115121

116122
expect(screen.queryByRole('heading')).toHaveTextContent('Starting workspace');
117123
});
124+
125+
it('should show actions kebab when workspace is defined', () => {
126+
renderComponent(store, {
127+
tabParam,
128+
workspace,
129+
workspaceStatus: DevWorkspaceStatus.STARTING,
130+
});
131+
132+
expect(screen.getByRole('button', { name: 'Workspace actions' })).toBeInTheDocument();
133+
});
134+
135+
it('should not show actions kebab when workspace is undefined', () => {
136+
renderComponent(store, {
137+
tabParam,
138+
workspace: undefined,
139+
workspaceStatus: DevWorkspaceStatus.STOPPED,
140+
});
141+
142+
expect(screen.queryByRole('button', { name: 'Workspace actions' })).toBeNull();
143+
});
144+
145+
it('should call onStopWorkspace when Stop is clicked in kebab', async () => {
146+
renderComponent(store, {
147+
tabParam,
148+
workspace,
149+
workspaceStatus: DevWorkspaceStatus.STARTING,
150+
});
151+
152+
await userEvent.click(screen.getByRole('button', { name: 'Workspace actions' }));
153+
await userEvent.click(screen.getByRole('menuitem', { name: /stop/i }));
154+
155+
await waitFor(() => expect(mockOnStopWorkspace).toHaveBeenCalledTimes(1));
156+
});
157+
158+
it('should call onStartWorkspace when Start is clicked in kebab (workspace stopped)', async () => {
159+
renderComponent(store, {
160+
tabParam,
161+
workspace,
162+
workspaceStatus: DevWorkspaceStatus.STOPPED,
163+
});
164+
165+
await userEvent.click(screen.getByRole('button', { name: 'Workspace actions' }));
166+
await userEvent.click(screen.getByRole('menuitem', { name: /start/i }));
167+
168+
await waitFor(() => expect(mockOnStartWorkspace).toHaveBeenCalledTimes(1));
169+
});
118170
});
119171

120172
function getComponent(
121173
store: Store,
122-
props: Omit<Props, 'onTabChange' | 'searchParams' | 'location' | 'navigate'>,
174+
props: Omit<
175+
Props,
176+
| 'onTabChange'
177+
| 'onStartWorkspace'
178+
| 'onStopWorkspace'
179+
| 'searchParams'
180+
| 'location'
181+
| 'navigate'
182+
>,
123183
): React.ReactElement {
124184
return (
125185
<Provider store={store}>
@@ -129,7 +189,10 @@ function getComponent(
129189
tabParam={props.tabParam}
130190
searchParams={new URLSearchParams()}
131191
workspace={props.workspace}
192+
workspaceStatus={props.workspaceStatus}
132193
onTabChange={mockOnTabChange}
194+
onStartWorkspace={mockOnStartWorkspace}
195+
onStopWorkspace={mockOnStopWorkspace}
133196
/>
134197
</Provider>
135198
);

packages/dashboard-frontend/src/pages/Loader/index.tsx

Lines changed: 84 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,17 @@
1010
* Red Hat, Inc. - initial API and implementation
1111
*/
1212

13-
import { PageSection, PageSectionVariants, Tab, Tabs } from '@patternfly/react-core';
13+
import {
14+
Dropdown,
15+
DropdownItem,
16+
DropdownList,
17+
MenuToggle,
18+
PageSection,
19+
PageSectionVariants,
20+
Tab,
21+
Tabs,
22+
} from '@patternfly/react-core';
23+
import { EllipsisVIcon, PlayIcon, StopIcon } from '@patternfly/react-icons';
1424
import React from 'react';
1525
import { Location, NavigateFunction } from 'react-router-dom';
1626

@@ -34,11 +44,15 @@ export type Props = {
3444
searchParams: URLSearchParams;
3545
tabParam: string | undefined;
3646
workspace: Workspace | undefined;
47+
workspaceStatus: DevWorkspaceStatus;
3748
onTabChange: (tab: LoaderTab) => void;
49+
onStartWorkspace: () => void;
50+
onStopWorkspace: () => void;
3851
};
3952

4053
export type State = {
4154
activeTabKey: LoaderTab;
55+
isActionsOpen: boolean;
4256
};
4357

4458
export class LoaderPage extends React.PureComponent<Props, State> {
@@ -55,13 +69,13 @@ export class LoaderPage extends React.PureComponent<Props, State> {
5569

5670
this.state = {
5771
activeTabKey,
72+
isActionsOpen: false,
5873
};
5974

6075
this.appliedSafeMode = {};
6176
}
6277

6378
componentDidMount(): void {
64-
// hide top and side bars
6579
this.context.hideAll();
6680
}
6781

@@ -75,12 +89,23 @@ export class LoaderPage extends React.PureComponent<Props, State> {
7589
this.props.onTabChange(tab);
7690
}
7791

92+
private handleActionsToggle = () => {
93+
this.setState(prev => ({ isActionsOpen: !prev.isActionsOpen }));
94+
};
95+
7896
render(): React.ReactNode {
79-
const { searchParams, workspace, location, navigate } = this.props;
80-
const { activeTabKey } = this.state;
97+
const {
98+
searchParams,
99+
workspace,
100+
workspaceStatus,
101+
location,
102+
navigate,
103+
onStartWorkspace,
104+
onStopWorkspace,
105+
} = this.props;
106+
const { activeTabKey, isActionsOpen } = this.state;
81107

82108
let pageTitle = workspace ? `Starting workspace ${workspace.name}` : 'Creating a workspace';
83-
const workspaceStatus = workspace?.status || DevWorkspaceStatus.STOPPED;
84109
if (getRestartInSafeModeLocation(location) || this.appliedSafeMode[location.pathname]) {
85110
pageTitle += ' with default devfile';
86111
this.appliedSafeMode[location.pathname] = true;
@@ -94,10 +119,63 @@ export class LoaderPage extends React.PureComponent<Props, State> {
94119

95120
const containerScc = workspace ? WorkspaceAdapter.getContainerScc(workspace.ref) : undefined;
96121

122+
const isWorkspaceActive =
123+
workspaceStatus === DevWorkspaceStatus.STARTING ||
124+
workspaceStatus === DevWorkspaceStatus.RUNNING;
125+
126+
const actionsDropdown = workspace ? (
127+
<Dropdown
128+
isOpen={isActionsOpen}
129+
onOpenChange={(open: boolean) => this.setState({ isActionsOpen: open })}
130+
toggle={(toggleRef: React.Ref<HTMLButtonElement>) => (
131+
<MenuToggle
132+
ref={toggleRef}
133+
variant="plain"
134+
onClick={this.handleActionsToggle}
135+
isExpanded={isActionsOpen}
136+
aria-label="Workspace actions"
137+
>
138+
<EllipsisVIcon />
139+
</MenuToggle>
140+
)}
141+
popperProps={{ position: 'right' }}
142+
>
143+
<DropdownList>
144+
<DropdownItem
145+
key="start"
146+
icon={<PlayIcon />}
147+
onClick={() => {
148+
this.setState({ isActionsOpen: false });
149+
onStartWorkspace();
150+
}}
151+
isDisabled={isWorkspaceActive}
152+
>
153+
Start
154+
</DropdownItem>
155+
<DropdownItem
156+
key="stop"
157+
icon={<StopIcon />}
158+
onClick={() => {
159+
this.setState({ isActionsOpen: false });
160+
onStopWorkspace();
161+
}}
162+
isDisabled={!isWorkspaceActive}
163+
>
164+
Stop
165+
</DropdownItem>
166+
</DropdownList>
167+
</Dropdown>
168+
) : undefined;
169+
97170
return (
98171
<React.Fragment>
99172
<Head pageName={pageTitle} />
100-
<Header title={pageTitle} status={workspaceStatus} containerScc={containerScc} />
173+
<Header
174+
title={pageTitle}
175+
status={workspaceStatus}
176+
containerScc={containerScc}
177+
actions={actionsDropdown}
178+
/>
101179
<PageSection
102180
variant={PageSectionVariants.default}
103181
isFilled={true}

0 commit comments

Comments
 (0)