diff --git a/packages/dashboard-frontend/src/containers/Loader/AbstractStep.tsx b/packages/dashboard-frontend/src/containers/Loader/AbstractStep.tsx index a3e7e89da..5ef304000 100644 --- a/packages/dashboard-frontend/src/containers/Loader/AbstractStep.tsx +++ b/packages/dashboard-frontend/src/containers/Loader/AbstractStep.tsx @@ -10,7 +10,6 @@ * Red Hat, Inc. - initial API and implementation */ -import common from '@eclipse-che/common'; import React from 'react'; import { Cancellation, pseudoCancellable } from 'real-cancellable-promise'; import { List, LoaderStep } from '../../components/Loader/Step'; @@ -24,8 +23,9 @@ export type LoaderStepProps = { onRestart: () => void; }; export type LoaderStepState = { - lastError?: string; + lastError?: unknown; }; + export abstract class AbstractLoaderStep< P extends LoaderStepProps, S extends LoaderStepState, @@ -61,9 +61,8 @@ export abstract class AbstractLoaderStep< const currentStep = loaderSteps.get(currentStepIndex).value; currentStep.hasError = true; - const lastError = common.helpers.errors.getMessage(e); this.setState({ - lastError, + lastError: e, }); } diff --git a/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/ApplyDevfile/index.tsx b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/ApplyDevfile/index.tsx index 569ac8b4d..e7a749ee8 100644 --- a/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/ApplyDevfile/index.tsx +++ b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/ApplyDevfile/index.tsx @@ -13,7 +13,9 @@ import React from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { RouteComponentProps, withRouter } from 'react-router-dom'; +import { isEqual } from 'lodash'; import { AlertVariant } from '@patternfly/react-core'; +import { helpers } from '@eclipse-che/common'; import { AppState } from '../../../../../store'; import * as WorkspacesStore from '../../../../../store/Workspaces'; import { DisposableCollection } from '../../../../../services/helpers/disposable'; @@ -84,7 +86,7 @@ class StepApplyDevfile extends AbstractLoaderStep { } // current step failed - if (this.state.lastError !== nextState.lastError) { + if (!isEqual(this.state.lastError, nextState.lastError)) { return true; } @@ -142,7 +144,10 @@ class StepApplyDevfile extends AbstractLoaderStep { } if (shouldCreate === false) { - throw new Error(this.state.lastError || 'The workspace creation unexpectedly failed.'); + if (this.state.lastError instanceof Error) { + throw this.state.lastError; + } + throw new Error('The workspace creation unexpectedly failed.'); } const devfile = factoryResolverConverted?.devfileV2; @@ -211,7 +216,7 @@ class StepApplyDevfile extends AbstractLoaderStep { key: 'factory-loader-' + getRandomString(4), title: 'Failed to create the workspace', variant: AlertVariant.danger, - children: lastError, + children: helpers.errors.getMessage(lastError), }; return ( diff --git a/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/ApplyResources/index.tsx b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/ApplyResources/index.tsx index e55cde126..b3362de47 100644 --- a/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/ApplyResources/index.tsx +++ b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/ApplyResources/index.tsx @@ -13,7 +13,9 @@ import React from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { RouteComponentProps, withRouter } from 'react-router-dom'; +import { isEqual } from 'lodash'; import { AlertVariant } from '@patternfly/react-core'; +import { helpers } from '@eclipse-che/common'; import { AppState } from '../../../../../store'; import * as FactoryResolverStore from '../../../../../store/FactoryResolver'; import * as WorkspacesStore from '../../../../../store/Workspaces'; @@ -91,7 +93,7 @@ class StepApplyResources extends AbstractLoaderStep { } // current step failed - if (this.state.lastError !== nextState.lastError) { + if (!isEqual(this.state.lastError, nextState.lastError)) { return true; } @@ -149,7 +151,10 @@ class StepApplyResources extends AbstractLoaderStep { } if (shouldCreate === false) { - throw new Error(this.state.lastError || 'The workspace creation unexpectedly failed.'); + if (this.state.lastError instanceof Error) { + throw this.state.lastError; + } + throw new Error('The workspace creation unexpectedly failed.'); } const resources = devWorkspaceResources[sourceUrl]?.resources; @@ -210,7 +215,7 @@ class StepApplyResources extends AbstractLoaderStep { key: 'factory-loader-' + getRandomString(4), title: 'Failed to create the workspace', variant: AlertVariant.danger, - children: lastError, + children: helpers.errors.getMessage(lastError), }; return ( diff --git a/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/CreateWorkspace/index.tsx b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/CreateWorkspace/index.tsx index 124b8c1f1..23d8018a9 100644 --- a/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/CreateWorkspace/index.tsx +++ b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/CreateWorkspace/index.tsx @@ -12,6 +12,7 @@ import React from 'react'; import { AlertVariant } from '@patternfly/react-core'; +import { helpers } from '@eclipse-che/common'; import { DisposableCollection } from '../../../../../services/helpers/disposable'; import { delay } from '../../../../../services/helpers/delay'; import { FactoryLoaderPage } from '../../../../../pages/Loader/Factory'; @@ -71,7 +72,7 @@ export default class StepCreateWorkspace extends AbstractLoaderStep { } // current step failed - if (this.state.lastError !== nextState.lastError) { + if (!isEqual(this.state.lastError, nextState.lastError)) { return true; } @@ -170,7 +171,10 @@ class StepFetchDevfile extends AbstractLoaderStep { } if (shouldResolve === false) { - throw new Error(this.state.lastError || 'Failed to resolve the devfile.'); + if (this.state.lastError instanceof Error) { + throw this.state.lastError; + } + throw new Error('Failed to resolve the devfile.'); } // start resolving the devfile @@ -243,7 +247,7 @@ class StepFetchDevfile extends AbstractLoaderStep { oauthUrlTmp.toString() + '&redirect_after_login=' + redirectUrl.toString(); window.location.href = fullOauthUrl; } catch (e) { - throw new Error(`Failed to open authentication page. ${common.helpers.errors.getMessage(e)}`); + throw new Error(`Failed to open authentication page. ${helpers.errors.getMessage(e)}`); } } @@ -314,7 +318,7 @@ class StepFetchDevfile extends AbstractLoaderStep { key: 'factory-loader-fetch-devfile', title: 'Failed to create the workspace', variant: AlertVariant.danger, - children: lastError, + children: helpers.errors.getMessage(lastError), }; return ( diff --git a/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/FetchResources/index.tsx b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/FetchResources/index.tsx index 79527dcc7..f78379897 100644 --- a/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/FetchResources/index.tsx +++ b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/FetchResources/index.tsx @@ -12,7 +12,9 @@ import React from 'react'; import { connect, ConnectedProps } from 'react-redux'; +import { isEqual } from 'lodash'; import { AlertVariant } from '@patternfly/react-core'; +import { helpers } from '@eclipse-che/common'; import { AppState } from '../../../../../store'; import * as DevfileRegistriesStore from '../../../../../store/DevfileRegistries'; import { DisposableCollection } from '../../../../../services/helpers/disposable'; @@ -84,7 +86,7 @@ class StepFetchResources extends AbstractLoaderStep { } // current step failed - if (this.state.lastError !== nextState.lastError) { + if (!isEqual(this.state.lastError, nextState.lastError)) { return true; } @@ -146,7 +148,10 @@ class StepFetchResources extends AbstractLoaderStep { } if (shouldResolve === false) { - throw new Error(lastError || 'Failed to fetch pre-built resources'); + if (lastError instanceof Error) { + throw lastError; + } + throw new Error('Failed to fetch pre-built resources'); } await this.props.requestResources(sourceUrl); @@ -186,7 +191,7 @@ class StepFetchResources extends AbstractLoaderStep { key: 'factory-loader-fetch-resources', title: 'Failed to create the workspace', variant: AlertVariant.danger, - children: lastError, + children: helpers.errors.getMessage(lastError), }; return ( diff --git a/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Initialize/index.tsx b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Initialize/index.tsx index 2861f2664..8fc7b3b76 100644 --- a/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Initialize/index.tsx +++ b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Initialize/index.tsx @@ -13,7 +13,9 @@ import React from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { generatePath } from 'react-router-dom'; +import { isEqual } from 'lodash'; import { AlertVariant } from '@patternfly/react-core'; +import { helpers } from '@eclipse-che/common'; import { AppState } from '../../../../../store'; import { selectInfrastructureNamespaces } from '../../../../../store/InfrastructureNamespaces/selectors'; import { DisposableCollection } from '../../../../../services/helpers/disposable'; @@ -60,7 +62,7 @@ class StepInitialize extends AbstractLoaderStep { } // current step failed - if (this.state.lastError !== nextState.lastError) { + if (!isEqual(this.state.lastError, nextState.lastError)) { return true; } @@ -136,7 +138,7 @@ class StepInitialize extends AbstractLoaderStep { key: 'factory-loader-initialize', title: 'Failed to create the workspace', variant: AlertVariant.danger, - children: lastError, + children: helpers.errors.getMessage(lastError), }; return ( diff --git a/packages/dashboard-frontend/src/containers/Loader/Workspace/Steps/Initialize/index.tsx b/packages/dashboard-frontend/src/containers/Loader/Workspace/Steps/Initialize/index.tsx index f95ce3afb..abd0b198d 100644 --- a/packages/dashboard-frontend/src/containers/Loader/Workspace/Steps/Initialize/index.tsx +++ b/packages/dashboard-frontend/src/containers/Loader/Workspace/Steps/Initialize/index.tsx @@ -12,11 +12,13 @@ import React from 'react'; import { connect, ConnectedProps } from 'react-redux'; +import { isEqual } from 'lodash'; import { AlertVariant } from '@patternfly/react-core'; +import { helpers } from '@eclipse-che/common'; import { AppState } from '../../../../../store'; import { selectAllWorkspaces } from '../../../../../store/Workspaces/selectors'; import * as WorkspaceStore from '../../../../../store/Workspaces'; -import { WorkspaceLoaderPage } from '../../../../../pages/Loader/Workspace'; +import WorkspaceLoaderPage from '../../../../../pages/Loader/Workspace'; import { Workspace } from '../../../../../services/workspace-adapter'; import { DevWorkspaceStatus } from '../../../../../services/helpers/types'; import { DisposableCollection } from '../../../../../services/helpers/disposable'; @@ -69,7 +71,7 @@ class StepInitialize extends AbstractLoaderStep { return true; } // set the error for the current step - if (this.state.lastError !== nextState.lastError) { + if (!isEqual(this.state.lastError, nextState.lastError)) { return true; } return false; @@ -161,9 +163,9 @@ class StepInitialize extends AbstractLoaderStep { key: 'ide-loader-initialize', title: 'Failed to open the workspace', variant: AlertVariant.danger, - children: lastError, + children: helpers.errors.getMessage(lastError), + error: lastError, }; - return ( { return true; } // set the error for the current step - if (this.state.lastError !== nextState.lastError) { + if (!isEqual(this.state.lastError, nextState.lastError)) { return true; } return false; @@ -147,7 +149,7 @@ class StepOpenWorkspace extends AbstractLoaderStep { key: 'ide-loader-open-ide', title: 'Failed to open the workspace', variant: AlertVariant.danger, - children: lastError, + children: common.helpers.errors.getMessage(lastError), }; return ( diff --git a/packages/dashboard-frontend/src/containers/Loader/Workspace/Steps/StartWorkspace/index.tsx b/packages/dashboard-frontend/src/containers/Loader/Workspace/Steps/StartWorkspace/index.tsx index e7cc740db..43c07b029 100644 --- a/packages/dashboard-frontend/src/containers/Loader/Workspace/Steps/StartWorkspace/index.tsx +++ b/packages/dashboard-frontend/src/containers/Loader/Workspace/Steps/StartWorkspace/index.tsx @@ -14,10 +14,11 @@ import React from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { AlertVariant } from '@patternfly/react-core'; import common from '@eclipse-che/common'; +import { isEqual } from 'lodash'; import { AppState } from '../../../../../store'; import { selectAllWorkspaces, selectLogs } from '../../../../../store/Workspaces/selectors'; import * as WorkspaceStore from '../../../../../store/Workspaces'; -import { WorkspaceLoaderPage } from '../../../../../pages/Loader/Workspace'; +import WorkspaceLoaderPage from '../../../../../pages/Loader/Workspace'; import { DevWorkspaceStatus } from '../../../../../services/helpers/types'; import { DisposableCollection } from '../../../../../services/helpers/disposable'; import { delay } from '../../../../../services/helpers/delay'; @@ -77,7 +78,7 @@ class StepStartWorkspace extends AbstractLoaderStep { return true; } // set the error for the current step - if (this.state.lastError !== nextState.lastError) { + if (!isEqual(this.state.lastError, nextState.lastError)) { return true; } return false; @@ -165,14 +166,9 @@ class StepStartWorkspace extends AbstractLoaderStep { this.state.shouldStart && workspaceStatusIs(workspace, DevWorkspaceStatus.STOPPED, DevWorkspaceStatus.FAILED) ) { - try { - await this.props.startWorkspace(workspace); - - // do not switch to the next step - return false; - } catch (e) { - throw new Error(common.helpers.errors.getMessage(e)); - } + await this.props.startWorkspace(workspace); + // do not switch to the next step + return false; } // switch to the next step @@ -198,7 +194,8 @@ class StepStartWorkspace extends AbstractLoaderStep { key: 'ide-loader-start-workspace', title: 'Failed to open the workspace', variant: AlertVariant.danger, - children: lastError, + children: common.helpers.errors.getMessage(lastError), + error: lastError, }; return ( diff --git a/packages/dashboard-frontend/src/pages/Loader/Workspace/__mocks__/index.tsx b/packages/dashboard-frontend/src/pages/Loader/Workspace/__mocks__/index.tsx index 26159fdf3..310627558 100644 --- a/packages/dashboard-frontend/src/pages/Loader/Workspace/__mocks__/index.tsx +++ b/packages/dashboard-frontend/src/pages/Loader/Workspace/__mocks__/index.tsx @@ -13,7 +13,7 @@ import React from 'react'; import { Props, State } from '..'; -export class WorkspaceLoaderPage extends React.PureComponent { +export default class WorkspaceLoaderPage extends React.PureComponent { render(): React.ReactNode { const { alertItem, currentStepId, steps, onRestart } = this.props; const wizardSteps = steps.map(step => ( diff --git a/packages/dashboard-frontend/src/pages/Loader/Workspace/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/Loader/Workspace/__tests__/index.spec.tsx index dc93efbb6..3260eeb4f 100644 --- a/packages/dashboard-frontend/src/pages/Loader/Workspace/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/Loader/Workspace/__tests__/index.spec.tsx @@ -11,10 +11,10 @@ */ import React from 'react'; -import { screen } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { AlertVariant } from '@patternfly/react-core'; -import { WorkspaceLoaderPage } from '..'; +import WorkspaceLoaderPage from '..'; import { LoadingStep, List, LoaderStep } from '../../../../components/Loader/Step'; import { getWorkspaceLoadingSteps, @@ -22,6 +22,14 @@ import { } from '../../../../components/Loader/Step/buildSteps'; import { AlertItem, LoaderTab } from '../../../../services/helpers/types'; import getComponentRenderer from '../../../../services/__mocks__/getComponentRenderer'; +import { Provider } from 'react-redux'; +import { Store } from 'redux'; +import { FakeStoreBuilder } from '../../../../store/__mocks__/storeBuilder'; +import { DevWorkspaceBuilder } from '../../../../store/__mocks__/devWorkspaceBuilder'; +import { Workspace, WorkspaceAdapter } from '../../../../services/workspace-adapter'; +import { RunningWorkspacesExceededError } from '../../../../store/Workspaces/devWorkspaces'; +import { DevWorkspace } from '../../../../services/devfileApi/devWorkspace'; +import { actionCreators } from '../../../../store/Workspaces'; jest.mock('react-tooltip', () => { return function DummyTooltip(): React.ReactElement { @@ -39,6 +47,14 @@ const mockOnWorkspaceRestart = jest.fn(); const currentStepId = LoadingStep.INITIALIZE; +const mockStopWorkspace = jest.fn(); +jest.mock('../../../../store/Workspaces'); +(actionCreators.stopWorkspace as jest.Mock).mockImplementation( + (...args) => + async () => + mockStopWorkspace(...args), +); + describe('Workspace loader page', () => { let steps: List; @@ -96,21 +112,227 @@ describe('Workspace loader page', () => { expect(activeTab?.textContent).toEqual(LoaderTab.Logs.toString()); }); + + describe('workspaces runningLimit has been reached', () => { + let startedWorkspace1: DevWorkspace; + let startedWorkspace2: DevWorkspace; + let stoppedWorkspace: DevWorkspace; + let currentWorkspace: DevWorkspace; + + beforeEach(() => { + const namespace = 'user-che'; + + startedWorkspace1 = new DevWorkspaceBuilder() + .withName('bash') + .withNamespace(namespace) + .withMetadata({ uid: 'uid-bash' }) + .build(); + startedWorkspace1.spec.started = true; + + startedWorkspace2 = new DevWorkspaceBuilder() + .withName('python-hello-world') + .withNamespace(namespace) + .withMetadata({ uid: 'uid-python-hello-world' }) + .build(); + startedWorkspace2.spec.started = true; + + stoppedWorkspace = new DevWorkspaceBuilder() + .withName('nodejs-web-app') + .withNamespace(namespace) + .withMetadata({ uid: 'uid-nodejs-web-app' }) + .build(); + + currentWorkspace = new DevWorkspaceBuilder() + .withName('golang-example') + .withNamespace(namespace) + .withMetadata({ uid: 'uid-golang-example' }) + .build(); + }); + + it('should show options if workspace running limit has been reached, and there is only one running workspace', () => { + const alertItem: AlertItem = { + key: 'alert-id', + title: + 'Failed to start the workspace golang-example, reason: You are not allowed to start more workspaces.', + variant: AlertVariant.danger, + error: new RunningWorkspacesExceededError('You are not allowed to start more workspaces.'), + }; + + const store = new FakeStoreBuilder() + .withDevWorkspaces({ + workspaces: [startedWorkspace1, stoppedWorkspace, currentWorkspace], + }) + .build(); + + renderComponent( + { steps: steps.values, alertItem, workspace: new WorkspaceAdapter(currentWorkspace) }, + store, + ); + + const alert = screen.getByTestId('action-links'); + const buttons = within(alert).getAllByRole('button'); + expect(buttons.length).toEqual(2); + expect(buttons[0].textContent).toEqual( + 'Close running workspace (bash) and restart golang-example', + ); + expect(buttons[1].textContent).toEqual( + 'Switch to running workspace (bash) to save any changes', + ); + }); + + it('should switch to running workspace when there is only one running workspace and button clicked', async () => { + createWindowMock({ + href: 'https://che-host/dashboard/#/ide/user-che/golang-example', + origin: 'https://che-host', + }); + window.open = jest.fn(); + + const alertItem: AlertItem = { + key: 'alert-id', + title: + 'Failed to start the workspace golang-example, reason: You are not allowed to start more workspaces.', + variant: AlertVariant.danger, + error: new RunningWorkspacesExceededError('You are not allowed to start more workspaces.'), + }; + + const store = new FakeStoreBuilder() + .withDevWorkspaces({ + workspaces: [startedWorkspace1, stoppedWorkspace, currentWorkspace], + }) + .build(); + + renderComponent( + { steps: steps.values, alertItem, workspace: new WorkspaceAdapter(currentWorkspace) }, + store, + ); + + const alert = screen.getByTestId('action-links'); + const buttons = within(alert).getAllByRole('button'); + expect(buttons.length).toEqual(2); + expect(buttons[1].textContent).toEqual( + 'Switch to running workspace (bash) to save any changes', + ); + + userEvent.click(buttons[1]); + await waitFor(() => + expect(window.open).toHaveBeenCalledWith( + 'https://che-host/dashboard/#/ide/user-che/bash', + 'uid-bash', + ), + ); + }); + + it('should close running workspace and restart when there is only one running workspace and button clicked', async () => { + createWindowMock({ + href: 'https://che-host/dashboard/#/ide/user-che/golang-example', + origin: 'https://che-host', + }); + window.open = jest.fn(); + + const alertItem: AlertItem = { + key: 'alert-id', + title: + 'Failed to start the workspace golang-example, reason: You are not allowed to start more workspaces.', + variant: AlertVariant.danger, + error: new RunningWorkspacesExceededError('You are not allowed to start more workspaces.'), + }; + + const store = new FakeStoreBuilder() + .withDevWorkspaces({ + workspaces: [startedWorkspace1, stoppedWorkspace, currentWorkspace], + }) + .build(); + + renderComponent( + { steps: steps.values, alertItem, workspace: new WorkspaceAdapter(currentWorkspace) }, + store, + ); + + const alert = screen.getByTestId('action-links'); + const buttons = within(alert).getAllByRole('button'); + expect(buttons.length).toEqual(2); + expect(buttons[0].textContent).toEqual( + 'Close running workspace (bash) and restart golang-example', + ); + + userEvent.click(buttons[0]); + + await waitFor(() => expect(mockStopWorkspace).toHaveBeenCalled()); + await waitFor(() => expect(mockOnWorkspaceRestart).toHaveBeenCalled()); + }); + + it('should show options if workspace running limit has been reached, and there more than one running workspaces', async () => { + createWindowMock({ origin: 'https://che-host' }); + + const alertItem: AlertItem = { + key: 'alert-id', + title: + 'Failed to start the workspace golang-example, reason: You are not allowed to start more workspaces.', + variant: AlertVariant.danger, + error: new RunningWorkspacesExceededError('You are not allowed to start more workspaces.'), + }; + + const store = new FakeStoreBuilder() + .withDevWorkspaces({ + workspaces: [startedWorkspace1, startedWorkspace2, stoppedWorkspace, currentWorkspace], + }) + .build(); + + const spyWindowLocation = jest.spyOn(window.location, 'href', 'set'); + + renderComponent( + { steps: steps.values, alertItem, workspace: new WorkspaceAdapter(currentWorkspace) }, + store, + ); + + const button = screen.getByRole('button', { name: 'Return to dashboard' }); + userEvent.click(button); + await waitFor(() => + expect(spyWindowLocation).toHaveBeenCalledWith('https://che-host/dashboard/'), + ); + }); + }); + + function createWindowMock(location: { href?: string; origin?: string }) { + delete (window as any).location; + (window.location as any) = { + origin: location.origin, + }; + Object.defineProperty(window.location, 'href', { + set: () => { + // no-op + }, + get: () => { + return location.href; + }, + configurable: true, + }); + } }); -function getComponent(props: { - steps: LoaderStep[]; - initialTab?: keyof typeof LoaderTab; - alertItem?: AlertItem; -}): React.ReactElement { +function getComponent( + props: { + steps: LoaderStep[]; + initialTab?: keyof typeof LoaderTab; + alertItem?: AlertItem; + workspace?: Workspace; + }, + store?: Store, +): React.ReactElement { + if (!store) { + store = new FakeStoreBuilder().build(); + } + return ( - + + + ); } diff --git a/packages/dashboard-frontend/src/pages/Loader/Workspace/index.tsx b/packages/dashboard-frontend/src/pages/Loader/Workspace/index.tsx index 30b5d4878..accfa5d1b 100644 --- a/packages/dashboard-frontend/src/pages/Loader/Workspace/index.tsx +++ b/packages/dashboard-frontend/src/pages/Loader/Workspace/index.tsx @@ -11,14 +11,25 @@ */ import React from 'react'; +import { connect, ConnectedProps } from 'react-redux'; +import * as WorkspaceStore from '../../../store/Workspaces'; import { LoaderStep } from '../../../components/Loader/Step'; import { AlertItem, LoaderTab } from '../../../services/helpers/types'; import { ActionCallback } from '../../../components/Loader/Alert'; -import { Workspace } from '../../../services/workspace-adapter'; +import { Workspace, WorkspaceAdapter } from '../../../services/workspace-adapter'; import { CommonLoaderPage } from '../Common'; +import { AppState } from '../../../store'; +import { buildIdeLoaderLocation } from '../../../services/helpers/location'; +import { selectAllDevWorkspaces } from '../../../store/Workspaces/devWorkspaces/selectors'; +import { RunningWorkspacesExceededError } from '../../../store/Workspaces/devWorkspaces'; +import { lazyInject } from '../../../inversify.config'; +import { AppAlerts } from '../../../services/alerts/appAlerts'; +import { AlertVariant } from '@patternfly/react-core'; +import getRandomString from '../../../services/helpers/random'; +import common from '@eclipse-che/common'; -export type Props = { +export type Props = MappedProps & { alertItem: AlertItem | undefined; currentStepId: number; steps: LoaderStep[]; @@ -31,7 +42,10 @@ export type State = { isPopupAlertVisible: boolean; }; -export class WorkspaceLoaderPage extends React.PureComponent { +class WorkspaceLoaderPage extends React.PureComponent { + @lazyInject(AppAlerts) + private appAlerts: AppAlerts; + constructor(props: Props) { super(props); @@ -57,11 +71,54 @@ export class WorkspaceLoaderPage extends React.PureComponent { }); } - render(): React.ReactNode { - const { alertItem, currentStepId, steps, workspace } = this.props; - const { activeTabKey } = this.state; + private getActionCallbacks(): ActionCallback[] { + if (this.props.alertItem?.error instanceof RunningWorkspacesExceededError) { + const runningWorkspaces = this.props.allWorkspaces.filter( + workspace => workspace.spec.started, + ); + if (runningWorkspaces.length > 1) { + return [ + { + title: `Return to dashboard`, + callback: () => { + window.location.href = window.location.origin + '/dashboard/'; + }, + }, + ]; + } + if (runningWorkspaces.length === 1) { + const runningWorkspace = new WorkspaceAdapter(runningWorkspaces[0]); + return [ + { + title: `Close running workspace (${runningWorkspace.name}) and restart ${this.props.workspace?.name}`, + callback: () => { + this.props + .stopWorkspace(runningWorkspace) + .then(() => { + this.handleRestart(false); + }) + .catch(err => { + this.appAlerts.showAlert({ + key: 'workspace-loader-page-' + getRandomString(4), + title: common.helpers.errors.getMessage(err), + variant: AlertVariant.danger, + }); + }); + }, + }, + { + title: `Switch to running workspace (${runningWorkspace.name}) to save any changes`, + callback: () => { + const ideLoader = buildIdeLoaderLocation(runningWorkspace); + const url = window.location.href.split('#')[0]; + window.open(`${url}#${ideLoader.pathname}`, runningWorkspace.uid); + }, + }, + ]; + } + } - const actionCallbacks: ActionCallback[] = [ + return [ { title: 'Restart', callback: () => this.handleRestart(false), @@ -71,10 +128,15 @@ export class WorkspaceLoaderPage extends React.PureComponent { callback: () => this.handleRestart(true), }, ]; + } + + render(): React.ReactNode { + const { alertItem, currentStepId, steps, workspace } = this.props; + const { activeTabKey } = this.state; return ( { ); } } + +const mapStateToProps = (state: AppState) => ({ + allWorkspaces: selectAllDevWorkspaces(state), +}); + +const connector = connect(mapStateToProps, WorkspaceStore.actionCreators); +type MappedProps = ConnectedProps; +export default connector(WorkspaceLoaderPage); diff --git a/packages/dashboard-frontend/src/services/helpers/types.ts b/packages/dashboard-frontend/src/services/helpers/types.ts index 3966672e4..c3c757cec 100644 --- a/packages/dashboard-frontend/src/services/helpers/types.ts +++ b/packages/dashboard-frontend/src/services/helpers/types.ts @@ -19,6 +19,7 @@ export interface AlertItem { title: string; variant: AlertVariant; children?: React.ReactNode; + error?: unknown; } export interface FactoryResolver { diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/index.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/index.ts index 5699701f4..aa647e5b4 100644 --- a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/index.ts +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/index.ts @@ -54,6 +54,13 @@ export interface State { workspacesLogs: WorkspacesLogs; } +export class RunningWorkspacesExceededError extends Error { + constructor(message) { + super(message); + this.name = 'RunningWorkspacesExceededError'; + } +} + interface RequestDevWorkspacesAction extends Action { type: 'REQUEST_DEVWORKSPACE'; } @@ -281,8 +288,6 @@ export const actionCreators: ActionCreators = { ): AppThunk> => async (dispatch, getState): Promise => { dispatch({ type: 'REQUEST_DEVWORKSPACE' }); - // await delay(500); - // throw new Error('asdfjkl;'); try { const { workspaces } = await devWorkspaceClient.getAllWorkspaces( workspace.metadata.namespace, @@ -290,7 +295,7 @@ export const actionCreators: ActionCreators = { const runningWorkspaces = workspaces.filter(w => w.spec.started === true); const runningLimit = selectRunningWorkspacesLimit(getState()); if (runningWorkspaces.length >= runningLimit) { - throw new Error('You are not allowed to start more workspaces.'); + throw new RunningWorkspacesExceededError('You are not allowed to start more workspaces.'); } await devWorkspaceClient.updateDebugMode(workspace, debugWorkspace); let updatedWorkspace: devfileApi.DevWorkspace; @@ -358,7 +363,11 @@ export const actionCreators: ActionCreators = { type: 'RECEIVE_DEVWORKSPACE_ERROR', error: errorMessage, }); - throw errorMessage; + + if (common.helpers.errors.isError(e)) { + throw e; + } + throw new Error(errorMessage); } },