diff --git a/packages/dashboard-frontend/jest.config.js b/packages/dashboard-frontend/jest.config.js index bf9170ef0d..9d8a4b4280 100644 --- a/packages/dashboard-frontend/jest.config.js +++ b/packages/dashboard-frontend/jest.config.js @@ -25,6 +25,9 @@ module.exports = { 'vscode-languageserver-protocol/lib/common/utils/is', 'vscode-languageserver-protocol/lib/main': 'vscode-languageserver-protocol/lib/node/main', }, + modulePathIgnorePatterns: [ + '__mocks__/index.tsx', + ], globals: { 'ts-jest': { tsconfig: 'tsconfig.test.json', diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/StartWorkspace/index.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/StartWorkspace/index.tsx index 83d761bcbe..e67f8a934b 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/StartWorkspace/index.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/StartWorkspace/index.tsx @@ -62,6 +62,11 @@ class StartingStepStartWorkspace extends ProgressStep { return true; } + // show/hide spinner near the step title + if (this.props.hasChildren !== nextProps.hasChildren) { + return true; + } + const workspace = this.findTargetWorkspace(this.props); const nextWorkspace = this.findTargetWorkspace(nextProps); diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/WorkspaceConditions/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/WorkspaceConditions/__tests__/__snapshots__/index.spec.tsx.snap new file mode 100644 index 0000000000..4ccd6f5d26 --- /dev/null +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/WorkspaceConditions/__tests__/__snapshots__/index.spec.tsx.snap @@ -0,0 +1,52 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Starting steps, checking workspace conditions snapshot - condition failed 1`] = ` +
+
+
+ 1 +
+ + Something happened + +
+`; + +exports[`Starting steps, checking workspace conditions snapshot - condition in-progress 1`] = ` +
+
+ 0 +
+ + Preparing networking + +
+`; + +exports[`Starting steps, checking workspace conditions snapshot - condition ready 1`] = ` +
+
+ 1 +
+ + Networking ready + +
+`; diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/WorkspaceConditions/__tests__/fixtures.ts b/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/WorkspaceConditions/__tests__/fixtures.ts new file mode 100644 index 0000000000..68cfccaaf7 --- /dev/null +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/WorkspaceConditions/__tests__/fixtures.ts @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { ConditionType } from '../../../utils'; + +export const conditionChangedTo: { + [key: string]: [condition: ConditionType, prevCondition: ConditionType | undefined]; +} = { + inProgress1: [ + { + status: 'False', + type: 'Started', + }, + undefined, + ], + inProgress2: [ + { + status: 'False', + type: 'Started', + }, + { + status: 'False', + type: 'Started', + }, + ], + + done1: [ + { + status: 'True', + type: 'Started', + }, + { + status: 'False', + type: 'Started', + }, + ], + done2: [ + { + status: 'Unknown', + type: 'Started', + }, + { + status: 'True', + type: 'Started', + }, + ], + + fail1: [ + { + status: 'Unknown', + type: 'Started', + }, + { + status: 'False', + type: 'Started', + }, + ], + fail2: [ + { + status: 'False', + type: 'Started', + message: 'Workspace stopped due to error', + }, + { + status: 'Unknown', + type: 'Started', + }, + ], + fail3: [ + { + status: 'True', + type: 'Started', + reason: 'Failed', + }, + undefined, + ], +}; + +export const conditionStatusFalse: ConditionType = { + message: 'Preparing networking', + status: 'False', + type: 'RoutingReady', +}; +export const conditionStatusTrue: ConditionType = { + message: 'Networking ready', + status: 'True', + type: 'RoutingReady', +}; +export const conditionError: ConditionType = { + status: 'True', + type: 'FailedStart', + reason: 'Failure', + message: 'Something happened', +}; +export const conditionStatusUnknown: ConditionType = { + status: 'Unknown', + type: 'RoutingReady', +}; diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/WorkspaceConditions/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/WorkspaceConditions/__tests__/index.spec.tsx index 8970d4cb47..0a7b9c9906 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/WorkspaceConditions/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/WorkspaceConditions/__tests__/index.spec.tsx @@ -15,14 +15,16 @@ import { screen, waitFor } from '@testing-library/react'; import { createMemoryHistory } from 'history'; import React from 'react'; -import { Provider } from 'react-redux'; -import { Store } from 'redux'; -import StartingStepWorkspaceConditions, { ConditionType } from '..'; +import StartingStepWorkspaceConditions from '..'; import { WorkspaceParams } from '../../../../../Routes/routes'; import getComponentRenderer from '../../../../../services/__mocks__/getComponentRenderer'; -import { DevWorkspaceBuilder } from '../../../../../store/__mocks__/devWorkspaceBuilder'; -import { FakeStoreBuilder } from '../../../../../store/__mocks__/storeBuilder'; -import { MIN_STEP_DURATION_MS } from '../../../const'; +import { ConditionType } from '../../../utils'; +import { + conditionChangedTo, + conditionError, + conditionStatusFalse, + conditionStatusTrue, +} from './fixtures'; jest.mock('../../../TimeLimit'); jest.mock('../../../StepTitle'); @@ -32,7 +34,7 @@ const mockOnRestart = jest.fn(); const mockOnError = jest.fn(); const mockOnHideError = jest.fn(); -const { renderComponent } = getComponentRenderer(getComponent); +const { renderComponent, createSnapshot } = getComponentRenderer(getComponent); const namespace = 'che-user'; const workspaceName = 'test-workspace'; @@ -40,26 +42,9 @@ const matchParams: WorkspaceParams = { namespace, workspaceName, }; -const startTimeout = 500; describe('Starting steps, checking workspace conditions', () => { - const conditionInProgress: ConditionType = { - message: 'Preparing networking', - status: 'False', - type: 'RoutingReady', - }; - const conditionReady: ConditionType = { - message: 'Networking ready', - status: 'True', - type: 'RoutingReady', - }; - const conditionFailed: ConditionType = { - status: 'Unknown', - type: 'RoutingReady', - }; - beforeEach(() => { - getStoreBuilder(); jest.useFakeTimers(); }); @@ -69,238 +54,107 @@ describe('Starting steps, checking workspace conditions', () => { jest.useRealTimers(); }); - test('condition not found', async () => { - const store = getStoreBuilder() - .withDevWorkspaces({ - workspaces: [ - new DevWorkspaceBuilder() - .withName(workspaceName) - .withNamespace(namespace) - .withStatus({ phase: 'STARTING' }) - .build(), - ], - }) - .build(); - - renderComponent(store, conditionInProgress); - - jest.runAllTimers(); - - // nothing should happen - expect(mockOnError).not.toHaveBeenCalled(); - expect(mockOnNextStep).not.toHaveBeenCalled(); - expect(mockOnRestart).not.toHaveBeenCalled(); + test('snapshot - condition in-progress', () => { + const snapshot = createSnapshot(conditionStatusFalse); + expect(snapshot.toJSON()).toMatchSnapshot(); }); - test('condition is ready initially', async () => { - const devworkspace = new DevWorkspaceBuilder() - .withName(workspaceName) - .withNamespace(namespace) - .withStatus({ phase: 'STARTING' }) - .build(); - devworkspace.status!.conditions = [conditionReady]; - const store = getStoreBuilder() - .withDevWorkspaces({ - workspaces: [devworkspace], - }) - .build(); + test('snapshot - condition ready', () => { + const snapshot = createSnapshot(conditionStatusTrue); + expect(snapshot.toJSON()).toMatchSnapshot(); + }); - renderComponent(store, conditionReady); + test('snapshot - condition failed', () => { + const snapshot = createSnapshot(conditionError); + expect(snapshot.toJSON()).toMatchSnapshot(); + }); - jest.advanceTimersByTime(MIN_STEP_DURATION_MS); + test('in progress 1', async () => { + const [condition] = conditionChangedTo.inProgress1; + renderComponent(condition); + + expect(screen.queryByTestId('isError')).toBeFalsy(); + expect(screen.queryByTestId('distance')?.textContent).toEqual('0'); + }); + + test('in progress 2', async () => { + const [condition, prevCondition] = conditionChangedTo.inProgress2; + const { reRenderComponent } = renderComponent(prevCondition!); + + expect(screen.queryByTestId('isError')).toBeFalsy(); + expect(screen.queryByTestId('distance')?.textContent).toEqual('0'); - await waitFor(() => expect(mockOnNextStep).toHaveBeenCalled()); + reRenderComponent(condition); - expect(mockOnError).not.toHaveBeenCalled(); - expect(mockOnRestart).not.toHaveBeenCalled(); + expect(screen.queryByTestId('isError')).toBeFalsy(); + expect(screen.queryByTestId('distance')?.textContent).toEqual('0'); }); - test('condition is ready later', async () => { - const devworkspace = new DevWorkspaceBuilder() - .withName(workspaceName) - .withNamespace(namespace) - .withStatus({ phase: 'STARTING' }) - .build(); - devworkspace.status!.conditions = [conditionInProgress]; - const store = getStoreBuilder() - .withDevWorkspaces({ - workspaces: [devworkspace], - }) - .build(); - - const { reRenderComponent } = renderComponent(store, conditionInProgress); - - jest.advanceTimersByTime(MIN_STEP_DURATION_MS); - // need to flush promises - await Promise.resolve(); - - // nothing should happen - expect(mockOnError).not.toHaveBeenCalled(); - expect(mockOnNextStep).not.toHaveBeenCalled(); - expect(mockOnRestart).not.toHaveBeenCalled(); - - const nextDevworkspace = new DevWorkspaceBuilder() - .withName(workspaceName) - .withNamespace(namespace) - .withStatus({ phase: 'STARTING' }) - .build(); - nextDevworkspace.status!.conditions = [conditionReady]; - const nextStore = getStoreBuilder() - .withDwServerConfig({ - timeouts: { - inactivityTimeout: -1, - runTimeout: -1, - startTimeout, - }, - }) - .withDevWorkspaces({ - workspaces: [nextDevworkspace], - }) - .build(); - reRenderComponent(nextStore, conditionInProgress); - - // jest.advanceTimersByTime(MIN_STEP_DURATION_MS); - jest.runAllTimers(); - - await waitFor(() => expect(mockOnNextStep).toHaveBeenCalled()); - expect(mockOnError).not.toHaveBeenCalled(); - expect(mockOnRestart).not.toHaveBeenCalled(); + test('done 1', async () => { + const [condition, prevCondition] = conditionChangedTo.done1; + const { reRenderComponent } = renderComponent(prevCondition!); + + expect(screen.queryByTestId('isError')).toBeFalsy(); + expect(screen.queryByTestId('distance')?.textContent).toEqual('0'); + + reRenderComponent(condition); + + expect(screen.queryByTestId('isError')).toBeFalsy(); + expect(screen.queryByTestId('distance')?.textContent).toEqual('1'); }); - test('condition is failed later', async () => { - const devworkspace = new DevWorkspaceBuilder() - .withUID('wksp-123') - .withName(workspaceName) - .withNamespace(namespace) - .withStatus({ phase: 'STARTING' }) - .build(); - devworkspace.status!.conditions = [conditionInProgress]; - const store = getStoreBuilder() - .withDevWorkspaces({ - workspaces: [devworkspace], - }) - .build(); - - const { reRenderComponent } = renderComponent(store, conditionInProgress); - - jest.advanceTimersByTime(MIN_STEP_DURATION_MS); - // need to flush promises - await Promise.resolve(); - - // nothing should happen - expect(mockOnError).not.toHaveBeenCalled(); - expect(mockOnNextStep).not.toHaveBeenCalled(); - expect(mockOnRestart).not.toHaveBeenCalled(); - - const nextDevworkspace = new DevWorkspaceBuilder() - .withUID('wksp-123') - .withName(workspaceName) - .withNamespace(namespace) - .withStatus({ phase: 'STARTING' }) - .build(); - nextDevworkspace.status!.conditions = [conditionFailed]; - const nextStore = getStoreBuilder() - .withDwServerConfig({ - timeouts: { - inactivityTimeout: -1, - runTimeout: -1, - startTimeout, - }, - }) - .withDevWorkspaces({ - workspaces: [nextDevworkspace], - }) - .build(); - reRenderComponent(nextStore, conditionInProgress); - - jest.runAllTimers(); - - /* step marked as failed */ - await waitFor(() => expect(screen.queryByTestId('isError')).toBeTruthy()); + test('done 2', async () => { + const [condition, prevCondition] = conditionChangedTo.done2; + const { reRenderComponent } = renderComponent(prevCondition!); + + expect(screen.queryByTestId('isError')).toBeFalsy(); + // expect(screen.queryByTestId('distance')?.textContent).toEqual('0'); - /* no alerts should fire */ - expect(mockOnError).not.toHaveBeenCalled(); - expect(mockOnNextStep).not.toHaveBeenCalled(); - expect(mockOnRestart).not.toHaveBeenCalled(); + reRenderComponent(condition); + + expect(screen.queryByTestId('isError')).toBeFalsy(); + expect(screen.queryByTestId('distance')?.textContent).toEqual('1'); }); - test('workspace fails after condition is ready', async () => { - const devworkspace = new DevWorkspaceBuilder() - .withUID('wksp-123') - .withName(workspaceName) - .withNamespace(namespace) - .withStatus({ phase: 'STARTING' }) - .build(); - devworkspace.status!.conditions = [conditionReady]; - const store = getStoreBuilder() - .withDevWorkspaces({ - workspaces: [devworkspace], - }) - .build(); - - const { reRenderComponent } = renderComponent(store, conditionInProgress); - - jest.advanceTimersByTime(MIN_STEP_DURATION_MS); - // need to flush promises - await Promise.resolve(); - - // nothing should happen - expect(mockOnError).not.toHaveBeenCalled(); - expect(mockOnNextStep).not.toHaveBeenCalled(); - expect(mockOnRestart).not.toHaveBeenCalled(); - - const nextDevworkspace = new DevWorkspaceBuilder() - .withUID('wksp-123') - .withName(workspaceName) - .withNamespace(namespace) - .withStatus({ phase: 'STARTING' }) - .build(); - nextDevworkspace.status!.conditions = [conditionFailed]; - const nextStore = getStoreBuilder() - .withDwServerConfig({ - timeouts: { - inactivityTimeout: -1, - runTimeout: -1, - startTimeout, - }, - }) - .withDevWorkspaces({ - workspaces: [nextDevworkspace], - }) - .build(); - reRenderComponent(nextStore, conditionInProgress); - - jest.runAllTimers(); - // try to flush promises - await Promise.resolve(); - - /* step marked as failed */ + test('fail 1', async () => { + const [condition, prevCondition] = conditionChangedTo.fail1; + const { reRenderComponent } = renderComponent(prevCondition!); + expect(screen.queryByTestId('isError')).toBeFalsy(); + expect(screen.queryByTestId('distance')?.textContent).toEqual('0'); - /* no alerts should fire */ - expect(mockOnError).not.toHaveBeenCalled(); - expect(mockOnNextStep).not.toHaveBeenCalled(); - expect(mockOnRestart).not.toHaveBeenCalled(); + reRenderComponent(condition); + + await waitFor(() => expect(screen.queryByTestId('isError')).toBeTruthy()); + expect(screen.queryByTestId('distance')?.textContent).toEqual('1'); }); -}); -function getStoreBuilder() { - return new FakeStoreBuilder().withDwServerConfig({ - timeouts: { - inactivityTimeout: -1, - runTimeout: -1, - startTimeout, - }, + test('fail 2', async () => { + const [condition, prevCondition] = conditionChangedTo.fail2; + const { reRenderComponent } = renderComponent(prevCondition!); + + expect(screen.queryByTestId('isError')).toBeFalsy(); + expect(screen.queryByTestId('distance')?.textContent).toEqual('0'); + + reRenderComponent(condition); + + await waitFor(() => expect(screen.queryByTestId('isError')).toBeTruthy()); + expect(screen.queryByTestId('distance')?.textContent).toEqual('1'); }); -} -function getComponent( - store: Store, - condition: ConditionType, - _matchParams = matchParams, -): React.ReactElement { + test('fail 3', async () => { + const [condition] = conditionChangedTo.fail3; + + renderComponent(condition); + + await waitFor(() => expect(screen.queryByTestId('isError')).toBeTruthy()); + expect(screen.queryByTestId('distance')?.textContent).toEqual('1'); + }); +}); + +function getComponent(condition: ConditionType, _matchParams = matchParams): React.ReactElement { const history = createMemoryHistory(); - const component = ( + return ( ); - return {component}; } diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/WorkspaceConditions/__tests__/utils.spec.ts b/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/WorkspaceConditions/__tests__/utils.spec.ts new file mode 100644 index 0000000000..9e1ff65c8c --- /dev/null +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/WorkspaceConditions/__tests__/utils.spec.ts @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { isConditionReady, isConditionError } from '../../../utils'; +import { + conditionChangedTo, + conditionError, + conditionStatusFalse, + conditionStatusTrue, + conditionStatusUnknown, +} from './fixtures'; + +describe('utils', () => { + test('isReady', () => { + expect(isConditionReady(...conditionChangedTo.inProgress1)).toEqual(false); + expect(isConditionReady(...conditionChangedTo.inProgress2)).toEqual(false); + + expect(isConditionReady(...conditionChangedTo.done1)).toEqual(true); + expect(isConditionReady(...conditionChangedTo.done2)).toEqual(true); + + expect(isConditionReady(...conditionChangedTo.fail1)).toEqual(true); + expect(isConditionReady(...conditionChangedTo.fail2)).toEqual(true); + expect(isConditionReady(...conditionChangedTo.fail3)).toEqual(true); + + expect(isConditionReady(conditionStatusFalse, undefined)).toEqual(false); + expect(isConditionReady(conditionStatusTrue, undefined)).toEqual(true); + expect(isConditionReady(conditionStatusUnknown, undefined)).toEqual(false); + expect(isConditionReady(conditionError, undefined)).toEqual(true); + }); + + test('isError', () => { + expect(isConditionError(...conditionChangedTo.inProgress1)).toEqual(false); + expect(isConditionError(...conditionChangedTo.inProgress2)).toEqual(false); + + expect(isConditionError(...conditionChangedTo.done1)).toEqual(false); + expect(isConditionError(...conditionChangedTo.done2)).toEqual(false); + + expect(isConditionError(...conditionChangedTo.fail1)).toEqual(true); + expect(isConditionError(...conditionChangedTo.fail2)).toEqual(true); + expect(isConditionError(...conditionChangedTo.fail3)).toEqual(true); + + expect(isConditionError(conditionStatusFalse, undefined)).toEqual(false); + expect(isConditionError(conditionStatusTrue, undefined)).toEqual(false); + expect(isConditionError(conditionStatusUnknown, undefined)).toEqual(false); + expect(isConditionError(conditionError, undefined)).toEqual(true); + }); +}); diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/WorkspaceConditions/index.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/WorkspaceConditions/index.tsx index 3744c3d331..f84816c07c 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/WorkspaceConditions/index.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/StartingSteps/WorkspaceConditions/index.tsx @@ -10,106 +10,74 @@ * Red Hat, Inc. - initial API and implementation */ -import { V1alpha2DevWorkspaceStatusConditions } from '@devfile/api'; import common from '@eclipse-che/common'; import { AlertVariant } from '@patternfly/react-core'; +import isEqual from 'lodash/isEqual'; import React from 'react'; -import { connect, ConnectedProps } from 'react-redux'; import { WorkspaceParams } from '../../../../Routes/routes'; -import { delay } from '../../../../services/helpers/delay'; -import { findTargetWorkspace } from '../../../../services/helpers/factoryFlow/findTargetWorkspace'; import { AlertItem, LoaderTab } from '../../../../services/helpers/types'; -import { Workspace } from '../../../../services/workspace-adapter'; -import { AppState } from '../../../../store'; -import { selectStartTimeout } from '../../../../store/ServerConfig/selectors'; -import * as WorkspaceStore from '../../../../store/Workspaces'; -import { selectAllWorkspaces } from '../../../../store/Workspaces/selectors'; -import { MIN_STEP_DURATION_MS } from '../../const'; import { ProgressStep, ProgressStepProps, ProgressStepState } from '../../ProgressStep'; import { ProgressStepTitle } from '../../StepTitle'; +import { ConditionType, isConditionError, isConditionReady } from '../../utils'; + import styles from './index.module.css'; -export type ConditionType = V1alpha2DevWorkspaceStatusConditions & - ( - | { - message: string; - status: 'True' | 'False'; - } - | { - status: 'Unknown'; - } - ); - -export type Props = MappedProps & - ProgressStepProps & { - condition: ConditionType; - matchParams: WorkspaceParams; - }; +export type Props = ProgressStepProps & { + condition: ConditionType; + matchParams: WorkspaceParams; +}; export type State = ProgressStepState & { - isFailed: boolean; + isError: boolean; isReady: boolean; + condition: ConditionType; }; -export class StartingStepWorkspaceConditions extends ProgressStep { +export default class StartingStepWorkspaceConditions extends ProgressStep { constructor(props: Props) { super(props); - const { condition } = this.props; - - this.state = { - isReady: false, - isFailed: false, - name: condition.message || condition.type, - }; + this.state = this.buildState(props, undefined, undefined); } protected get name(): string { return this.state.name; } - private init() { - const workspace = this.findTargetWorkspace(this.props); - const condition = this.findTargetCondition(workspace); + private buildState(props: Props, prevProps: Props | undefined, state: State | undefined): State { + const condition = props.condition; + const prevCondition = prevProps?.condition; - const isReady = this.state.isReady === true || condition?.status === 'True'; - const isFailed = - isReady === false && (condition === undefined || condition.status === 'Unknown'); - - if (isReady !== this.state.isReady || isFailed !== this.state.isFailed) { - this.setState({ - isReady, - isFailed, - }); + let name: string; + if (state === undefined) { + name = condition.message || condition.type; + } else { + name = condition.message || state.name; } - this.prepareAndRun(); + return { + isReady: isConditionReady(condition, prevCondition), + isError: isConditionError(condition, prevCondition), + name, + condition, + }; } public componentDidMount() { - this.init(); + const state = this.buildState(this.props, undefined, this.state); + this.setState(state); } - public async componentDidUpdate() { - this.init(); + public async componentDidUpdate(prevProps: Props) { + const state = this.buildState(this.props, prevProps, this.state); + this.setState(state); } public shouldComponentUpdate(nextProps: Props, nextState: State): boolean { - const workspace = this.findTargetWorkspace(this.props); - const nextWorkspace = this.findTargetWorkspace(nextProps); - - // change workspace status, etc. - if (workspace?.uid !== nextWorkspace?.uid || workspace?.status !== nextWorkspace?.status) { - return true; - } - - const condition = this.findTargetCondition(workspace); - const nextCondition = this.findTargetCondition(nextWorkspace); - - if (nextCondition !== undefined && nextCondition.status !== condition?.status) { + if (isEqual(this.props.condition, nextProps.condition) === false) { return true; } - if (this.state.isReady !== nextState.isReady || this.state.isFailed !== nextState.isFailed) { + if (isEqual(this.state.condition, nextState.condition) === false) { return true; } @@ -119,29 +87,7 @@ export class StartingStepWorkspaceConditions extends ProgressStep public componentWillUnmount() { this.toDispose.dispose(); } - - protected findTargetWorkspace(props: Props): Workspace | undefined { - return findTargetWorkspace(props.allWorkspaces, props.matchParams); - } - - private findTargetCondition(workspace: Workspace | undefined): ConditionType | undefined { - if (workspace?.ref.status?.conditions === undefined) { - return; - } - - const condition = workspace.ref.status.conditions.find( - condition => condition.type === this.props.condition.type, - ); - return condition ? (condition as ConditionType) : undefined; - } - protected async runStep(): Promise { - await delay(MIN_STEP_DURATION_MS); - - if (this.state.isReady) { - return true; - } - return false; } @@ -174,10 +120,9 @@ export class StartingStepWorkspaceConditions extends ProgressStep render() { const { hasChildren } = this.props; - const { isFailed, isReady } = this.state; + const { isError, isReady } = this.state; const distance = isReady ? 1 : 0; - const isError = isFailed; const isWarning = false; return ( @@ -195,15 +140,3 @@ export class StartingStepWorkspaceConditions extends ProgressStep ); } } - -const mapStateToProps = (state: AppState) => ({ - allWorkspaces: selectAllWorkspaces(state), - startTimeout: selectStartTimeout(state), -}); - -const connector = connect(mapStateToProps, WorkspaceStore.actionCreators, null, { - // forwardRef is mandatory for using `@react-mock/state` in unit tests - forwardRef: true, -}); -type MappedProps = ConnectedProps; -export default connector(StartingStepWorkspaceConditions); diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/StepTitle/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/components/WorkspaceProgress/StepTitle/__tests__/__snapshots__/index.spec.tsx.snap index 1d9d8ee037..066e912c3c 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/StepTitle/__tests__/__snapshots__/index.spec.tsx.snap +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/StepTitle/__tests__/__snapshots__/index.spec.tsx.snap @@ -54,7 +54,7 @@ Array [ /> , Step 1 diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/StepTitle/index.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/StepTitle/index.tsx index 551d329352..3510f997c3 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/StepTitle/index.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/StepTitle/index.tsx @@ -29,7 +29,9 @@ export class ProgressStepTitle extends React.Component { let readiness = styles.ready; if (distance === 0) { - readiness = isError ? styles.error : styles.progress; + readiness = styles.progress; + } else if (isError) { + readiness = styles.error; } const fullClassName = [readiness, className].filter(c => c).join(' '); diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/__tests__/index.spec.tsx index 6a53cfb7c4..9396e30600 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/__tests__/index.spec.tsx @@ -423,16 +423,29 @@ describe('LoaderProgress', () => { expect(within(steps[3]).getByTestId('step-name')).toHaveTextContent('Open workspace'); }); - test('with condition steps', () => { + test('with condition steps', async () => { const store = new FakeStoreBuilder() .withDevWorkspaces({ workspaces: [devWorkspace] }) .build(); - renderComponent(history, store, searchParams, false); + renderComponent(history, store, searchParams, false, { + activeStepId: Step.START, + alertItems: [], + conditions: [], + doneSteps: [Step.INITIALIZE, Step.LIMIT_CHECK], + hasBeenStarted: true, + initialLoaderMode: { + mode: 'workspace', + workspaceParams: { + namespace, + workspaceName, + }, + }, + }); const steps = getSteps(); - expect(steps.length).toEqual(6); + await waitFor(() => expect(getSteps().length).toEqual(6)); expect(within(steps[0]).getByTestId('step-name')).toHaveTextContent('Initialize'); expect(within(steps[1]).getByTestId('step-name')).toHaveTextContent( diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/index.tsx b/packages/dashboard-frontend/src/components/WorkspaceProgress/index.tsx index 96b235e9ba..8d2d5fe39b 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/index.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/index.tsx @@ -10,6 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ +import { V1alpha2DevWorkspaceStatusConditions } from '@devfile/api'; import { History } from 'history'; import isEqual from 'lodash/isEqual'; import React from 'react'; @@ -37,9 +38,8 @@ import CreatingStepInitialize from './CreatingSteps/Initialize'; import StartingStepInitialize from './StartingSteps/Initialize'; import StartingStepOpenWorkspace from './StartingSteps/OpenWorkspace'; import StartingStepStartWorkspace from './StartingSteps/StartWorkspace'; -import StartingStepWorkspaceConditions, { - ConditionType, -} from './StartingSteps/WorkspaceConditions'; +import StartingStepWorkspaceConditions from './StartingSteps/WorkspaceConditions'; +import { ConditionType, isWorkspaceStatusCondition } from './utils'; import WorkspaceProgressWizard, { WorkspaceProgressWizardStep } from './Wizard'; export type Props = MappedProps & { @@ -51,7 +51,8 @@ export type Props = MappedProps & { export type State = { activeStepId: StepId; alertItems: AlertItem[]; - conditions: ConditionType[]; + conditions: V1alpha2DevWorkspaceStatusConditions[]; + hasBeenStarted: boolean; doneSteps: StepId[]; factoryParams: FactoryParams; initialLoaderMode: LoaderMode; @@ -85,6 +86,7 @@ class Progress extends React.Component { activeStepId: Step.INITIALIZE, alertItems: [], conditions: [], + hasBeenStarted: false, doneSteps: [], factoryParams, initialLoaderMode, @@ -120,29 +122,41 @@ class Progress extends React.Component { } public componentDidMount(): void { - this.init(); + this.init(this.props, this.state, undefined); } - public componentDidUpdate(): void { - this.init(); + public componentDidUpdate(prevProps: Props): void { + this.init(this.props, this.state, prevProps); } - private init(): void { - const workspace = this.findTargetWorkspace(this.props); + private init(props: Props, state: State, prevProps: Props | undefined): void { + const workspace = this.findTargetWorkspace(props); + const prevWorkspace = this.findTargetWorkspace(prevProps); + + if ( + (prevWorkspace === undefined || prevWorkspace.status !== DevWorkspaceStatus.STARTING) && + workspace?.status === DevWorkspaceStatus.STARTING && + state.activeStepId === Step.START + ) { + this.setState({ + hasBeenStarted: true, + }); + } + if ( workspace && (workspace.status === DevWorkspaceStatus.STARTING || - workspace.status === DevWorkspaceStatus.RUNNING) + workspace.status === DevWorkspaceStatus.RUNNING || + workspace.status === DevWorkspaceStatus.FAILING || + workspace.status === DevWorkspaceStatus.FAILED) ) { - const conditions = (workspace.ref.status?.conditions || []).filter( - condition => condition.message, - ) as ConditionType[]; + const conditions = workspace.ref.status?.conditions || []; const lastScore = this.scoreConditions(this.state.conditions); const score = this.scoreConditions(conditions); if ( score > lastScore || - (score === lastScore && isEqual(this.state.conditions, conditions) === false) + (score !== 0 && score === lastScore && isEqual(this.state.conditions, conditions) === false) ) { this.setState({ conditions, @@ -151,7 +165,11 @@ class Progress extends React.Component { } } - private findTargetWorkspace(props: Props): Workspace | undefined { + private findTargetWorkspace(props?: Props): Workspace | undefined { + if (props === undefined) { + return; + } + const { allWorkspaces, history } = props; const loaderMode = getLoaderMode(history.location); @@ -162,7 +180,7 @@ class Progress extends React.Component { return findTargetWorkspace(allWorkspaces, loaderMode.workspaceParams); } - private scoreConditions(conditions: ConditionType[]): number { + private scoreConditions(conditions: V1alpha2DevWorkspaceStatusConditions[]): number { const typeScore = { Started: 1, DevWorkspaceResolved: 1, @@ -248,6 +266,7 @@ class Progress extends React.Component { activeStepId: newActiveStep, doneSteps: newDoneSteps, conditions: [], + hasBeenStarted: false, }); if (tab) { @@ -503,7 +522,12 @@ class Progress extends React.Component { const matchParams = loaderMode.mode === 'workspace' ? loaderMode.workspaceParams : undefined; - const conditionSteps = this.buildConditionSteps(); + // hide spinner near this (parent) step + const showChildren = this.state.hasBeenStarted; + + // this (parent) step cannot be active if it contains child steps + // we need this step to be activated and start workspace and only then allow to appear condition steps + const conditionSteps = showChildren ? this.buildConditionSteps() : []; const steps = conditionSteps.length > 0 ? { steps: conditionSteps } : {}; return [ @@ -512,7 +536,7 @@ class Progress extends React.Component { name: ( this.handleStepsShowAlert(Step.START, alertItem)} onHideError={key => this.handleCloseStepAlert(key)} onNextStep={() => this.handleStepsGoToNext(Step.START)} @@ -550,29 +574,39 @@ class Progress extends React.Component { return []; } - return conditions.map(condition => { - const stepId: ConditionStepId = `condition-${condition.type}`; - const distance = condition.status === 'True' ? 1 : 0; - const isFinishedStep = distance === 1; + // Children steps get hidden when all of them are finished. This usually + // happens in the middle of a devWorkspace starting flow and makes + // the condition sub-steps to flicker. The fix is to wait until + // the condition of type 'Ready' is done and only then set all + // condition steps as finished. + const areFinishedSteps = conditions.some( + condition => condition.type === 'Ready' && condition.status === 'True', + ); - return { - id: stepId, - isFinishedStep, - name: ( - this.handleStepsShowAlert(stepId, alertItem)} - onHideError={key => this.handleCloseStepAlert(key)} - onNextStep={() => this.handleStepsGoToNext(stepId)} - onRestart={tab => this.handleStepsRestart(stepId, tab)} - /> - ), - }; - }); + return conditions + .filter((condition): condition is ConditionType => isWorkspaceStatusCondition(condition)) + .map(condition => { + const stepId: ConditionStepId = `condition-${condition.type}`; + const distance = condition.status === 'True' ? 1 : 0; + + return { + id: stepId, + isFinishedStep: areFinishedSteps, + name: ( + this.handleStepsShowAlert(stepId, alertItem)} + onHideError={key => this.handleCloseStepAlert(key)} + onNextStep={() => this.handleStepsGoToNext(stepId)} + onRestart={tab => this.handleStepsRestart(stepId, tab)} + /> + ), + }; + }); } private handleSwitchToNextStep(nextStepId: StepId | undefined, prevStepId: StepId): void { diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/scoreConditions.ts b/packages/dashboard-frontend/src/components/WorkspaceProgress/scoreConditions.ts deleted file mode 100644 index 6d3f1c6493..0000000000 --- a/packages/dashboard-frontend/src/components/WorkspaceProgress/scoreConditions.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright (c) 2018-2023 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -import { ConditionType } from './StartingSteps/WorkspaceConditions'; - -export function scoreConditions(conditions: ConditionType[]): number { - const typeScore = { - Started: 1, - DevWorkspaceResolved: 1, - StorageReady: 1, - RoutingReady: 1, - ServiceAccountReady: 1, - PullSecretsReady: 1, - DeploymentReady: 1, - }; - - return conditions.reduce((acc, condition) => { - if (typeScore[condition.type] !== undefined) { - return acc + typeScore[condition.type]; - } - return acc; - }, 0); -} diff --git a/packages/dashboard-frontend/src/components/WorkspaceProgress/utils.ts b/packages/dashboard-frontend/src/components/WorkspaceProgress/utils.ts new file mode 100644 index 0000000000..d5796172b6 --- /dev/null +++ b/packages/dashboard-frontend/src/components/WorkspaceProgress/utils.ts @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2018-2023 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { V1alpha2DevWorkspaceStatusConditions } from '@devfile/api'; + +export type ConditionType = V1alpha2DevWorkspaceStatusConditions & { + status: 'True' | 'False' | 'Unknown'; +}; + +export function isWorkspaceStatusCondition( + condition: V1alpha2DevWorkspaceStatusConditions, +): condition is ConditionType { + return ( + (condition as ConditionType).status === 'False' || + (condition as ConditionType).status === 'True' || + (condition as ConditionType).status === 'Unknown' + ); +} + +export function isConditionReady( + condition: ConditionType, + prevCondition: ConditionType | undefined, +): boolean { + return ( + isConditionError(condition, prevCondition) === true || + condition.status === 'True' || + (condition.status === 'Unknown' && prevCondition?.status === 'True') + ); +} + +export function isConditionError( + condition: ConditionType, + prevCondition: ConditionType | undefined, +): boolean { + return ( + (prevCondition?.status === 'False' && condition.status === 'Unknown') || + (prevCondition?.status === 'Unknown' && + condition.status === 'False' && + condition.message === 'Workspace stopped due to error') || + condition.reason !== undefined + ); +} + +export function scoreConditions(conditions: ConditionType[]): number { + const typeScore = { + Started: 1, + DevWorkspaceResolved: 1, + StorageReady: 1, + RoutingReady: 1, + ServiceAccountReady: 1, + PullSecretsReady: 1, + DeploymentReady: 1, + }; + + return conditions.reduce((acc, condition) => { + if (typeScore[condition.type] !== undefined) { + return acc + typeScore[condition.type]; + } + return acc; + }, 0); +}