From 9017bacaca64b56cd4c6524a26d9c895ea12a309 Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Wed, 5 Oct 2022 17:06:49 +0300 Subject: [PATCH] feat: handle gracefully when an invalid devfile is found in a git repository Signed-off-by: Oleksii Orel --- .deps/prod.md | 10 +- packages/dashboard-frontend/package.json | 2 +- .../Factory/Steps/Apply/Devfile/index.tsx | 110 ++++++++++-- .../Steps/CheckExistingWorkspaces/index.tsx | 12 +- .../Factory/Steps/Fetch/Devfile/index.tsx | 64 ++++++- .../GetStartedTab/SamplesListGallery.tsx | 6 +- .../index.module.css | 27 +++ .../ApplyingDevfileErrorItems/index.tsx | 165 +++++++++++++++++ .../index.module.css | 27 +++ .../CreateWorkspaceErrorItems/index.tsx | 167 ++++++++++++++++++ .../applyingDevfileErrorItems.spec.tsx.snap | 98 ++++++++++ .../createWorkspaceErrorItems.spec.tsx.snap | 98 ++++++++++ .../applyingDevfileErrorItems.spec.tsx | 32 ++++ .../createWorkspaceErrorItems.spec.tsx | 30 ++++ .../src/services/bootstrap/index.ts | 12 +- .../src/store/DevfileRegistries/selectors.ts | 33 ++++ .../src/store/FactoryResolver/index.ts | 4 +- yarn.lock | 4 +- 18 files changed, 861 insertions(+), 40 deletions(-) create mode 100644 packages/dashboard-frontend/src/pages/Loader/Factory/ApplyingDevfileErrorItems/index.module.css create mode 100644 packages/dashboard-frontend/src/pages/Loader/Factory/ApplyingDevfileErrorItems/index.tsx create mode 100644 packages/dashboard-frontend/src/pages/Loader/Factory/CreateWorkspaceErrorItems/index.module.css create mode 100644 packages/dashboard-frontend/src/pages/Loader/Factory/CreateWorkspaceErrorItems/index.tsx create mode 100644 packages/dashboard-frontend/src/pages/Loader/Factory/__tests__/__snapshots__/applyingDevfileErrorItems.spec.tsx.snap create mode 100644 packages/dashboard-frontend/src/pages/Loader/Factory/__tests__/__snapshots__/createWorkspaceErrorItems.spec.tsx.snap create mode 100644 packages/dashboard-frontend/src/pages/Loader/Factory/__tests__/applyingDevfileErrorItems.spec.tsx create mode 100644 packages/dashboard-frontend/src/pages/Loader/Factory/__tests__/createWorkspaceErrorItems.spec.tsx diff --git a/.deps/prod.md b/.deps/prod.md index 2fdf108843..8851be1c68 100644 --- a/.deps/prod.md +++ b/.deps/prod.md @@ -7,11 +7,11 @@ | `@eclipse-che/api@7.44.0` | EPL-2.0 | ecd.che | | [`@eclipse-che/che-code-devworkspace-handler@1.64.0-dev-210b722`](git+https://github.com/che-incubator/che-code.git) | EPL-2.0 | ecd.che | | [`@eclipse-che/che-theia-devworkspace-handler@0.0.1-1642670698`](git+https://github.com/eclipse-che/che-theia.git) | EPL-2.0 | ecd.che | -| [`@eclipse-che/common@7.54.0-next`](https://github.com/eclipse-che/che-dashboard) | EPL-2.0 | ecd.che | -| [`@eclipse-che/dashboard-backend@7.54.0-next`](https://github.com/eclipse-che/che-dashboard) | EPL-2.0 | ecd.che | -| [`@eclipse-che/dashboard-frontend@7.54.0-next`](git://github.com/eclipse/che-dashboard.git) | EPL-2.0 | ecd.che | +| [`@eclipse-che/common@7.56.0-next`](https://github.com/eclipse-che/che-dashboard) | EPL-2.0 | ecd.che | +| [`@eclipse-che/dashboard-backend@7.56.0-next`](https://github.com/eclipse-che/che-dashboard) | EPL-2.0 | ecd.che | +| [`@eclipse-che/dashboard-frontend@7.56.0-next`](git://github.com/eclipse/che-dashboard.git) | EPL-2.0 | ecd.che | | [`@eclipse-che/devfile-converter@0.0.1-d624e3e`](git+https://github.com/che-incubator/devfile-converter.git) | EPL-2.0 | ecd.che | -| [`@eclipse-che/workspace-client@0.0.1-1632305737`](https://github.com/eclipse/che-workspace-client) | EPL-2.0 | ecd.che | +| [`@eclipse-che/workspace-client@0.0.1-1663851810`](https://github.com/eclipse/che-workspace-client) | EPL-2.0 | ecd.che | | [`@fastify/ajv-compiler@1.1.0`](git+https://github.com/fastify/ajv-compiler.git) | MIT | clearlydefined | | [`@fastify/cors@7.0.0`](git+https://github.com/fastify/fastify-cors.git) | MIT | clearlydefined | | [`@fastify/error@3.0.0`](git+https://github.com/fastify/fastify-error.git) | MIT | clearlydefined | @@ -324,7 +324,7 @@ | [`pino-std-serializers@3.2.0`](git+ssh://git@github.com/pinojs/pino-std-serializers.git) | MIT | clearlydefined | | [`pino@6.14.0`](git+https://github.com/pinojs/pino.git) | MIT | clearlydefined | | [`popper.js@1.16.1`](git+https://github.com/FezVrasta/popper.js.git) | MIT | [CQ22353](https://dev.eclipse.org/ipzilla/show_bug.cgi?id=22353) | -| [`postcss@8.4.14`](https://github.com/postcss/postcss.git) | MIT | clearlydefined | +| [`postcss@8.4.14`](https://github.com/postcss/postcss.git) | MIT | #3545 | | [`prettier@2.0.5`](https://github.com/prettier/prettier.git) | MIT | #1523 | | [`private@0.1.8`](git://github.com/benjamn/private.git) | MIT | clearlydefined | | [`process-warning@1.0.0`](git+https://github.com/fastify/processs-warning.git) | MIT | clearlydefined | diff --git a/packages/dashboard-frontend/package.json b/packages/dashboard-frontend/package.json index 31a72902cc..e78cfe0c7a 100644 --- a/packages/dashboard-frontend/package.json +++ b/packages/dashboard-frontend/package.json @@ -59,7 +59,7 @@ "process": "^0.11.10", "qs": "^6.9.4", "react": "^16.14.0", - "react-copy-to-clipboard": "^5.0.2", + "react-copy-to-clipboard": "^5.1.0", "react-dom": "^16.12.0", "react-helmet": "^6.1.0", "react-pluralize": "^1.6.3", diff --git a/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Apply/Devfile/index.tsx b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Apply/Devfile/index.tsx index 8c77eeff57..0a7f3e5538 100644 --- a/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Apply/Devfile/index.tsx +++ b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Apply/Devfile/index.tsx @@ -14,7 +14,7 @@ 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 common, { helpers } from '@eclipse-che/common'; import { AppState } from '../../../../../../store'; import * as WorkspacesStore from '../../../../../../store/Workspaces'; import { DisposableCollection } from '../../../../../../services/helpers/disposable'; @@ -36,6 +36,9 @@ import { FactoryParams } from '../../../types'; import buildFactoryParams from '../../../buildFactoryParams'; import { AbstractLoaderStep, LoaderStepProps, LoaderStepState } from '../../../../AbstractStep'; import { AlertItem } from '../../../../../../services/helpers/types'; +import { selectDefaultDevfile } from '../../../../../../store/DevfileRegistries/selectors'; +import { getProjectName } from '../../../../../../services/helpers/getProjectName'; +import CreateWorkspaceErrorItems from '../../../../../../pages/Loader/Factory/CreateWorkspaceErrorItems'; export type Props = MappedProps & LoaderStepProps & { @@ -48,6 +51,13 @@ export type State = LoaderStepState & { shouldCreate: boolean; // should the loader create a workspace }; +export class CreateWorkspaceError extends Error { + constructor(message: string) { + super(message); + this.name = 'CreateWorkspaceError'; + } +} + class StepApplyDevfile extends AbstractLoaderStep { protected readonly toDispose = new DisposableCollection(); @@ -126,12 +136,45 @@ class StepApplyDevfile extends AbstractLoaderStep { this.props.onRestart(); } + private updateCurrentDevfile(devfile: devfileApi.Devfile): void { + const { factoryResolver, allWorkspaces, defaultDevfile } = this.props; + const { factoryParams } = this.state; + const { factoryId, policiesCreate, storageType } = factoryParams; + // check if it is a using default devfile flow + if (factoryResolver === undefined && isEqual(devfile, defaultDevfile)) { + if (devfile.projects === undefined) { + devfile.projects = []; + } + if (devfile.projects.length === 0) { + // adds a default project from the source URL + const sourceUrl = new URL(factoryParams.sourceUrl); + const name = getProjectName(factoryParams.sourceUrl); + const origin = sourceUrl.pathname.endsWith('.git') + ? `${sourceUrl.origin}${sourceUrl.pathname}` + : `${sourceUrl.origin}${sourceUrl.pathname}.git`; + devfile.projects[0] = { git: { remotes: { origin } }, name }; + // change default name + devfile.metadata.name = name; + devfile.metadata.generateName = name; + } + } + // test the devfile name to decide if we need to append a suffix to is + const nameConflict = allWorkspaces.some(w => devfile.metadata.name === w.name); + const appendSuffix = policiesCreate === 'perclick' || nameConflict; + + const updatedDevfile = prepareDevfile(devfile, factoryId, storageType, appendSuffix); + + this.setState({ + devfile: updatedDevfile, + newWorkspaceName: updatedDevfile.metadata.name, + }); + } + protected async runStep(): Promise { await delay(MIN_STEP_DURATION_MS); - const { factoryResolverConverted } = this.props; - const { shouldCreate, factoryParams, devfile } = this.state; - const { factoryId, policiesCreate, storageType } = factoryParams; + const { factoryResolverConverted, factoryResolver, defaultDevfile } = this.props; + const { shouldCreate, devfile } = this.state; const workspace = this.findTargetWorkspace(this.props, this.state); if (workspace !== undefined) { @@ -150,25 +193,28 @@ class StepApplyDevfile extends AbstractLoaderStep { } if (devfile === undefined) { + if (factoryResolver === undefined) { + const _devfile = defaultDevfile; + if (_devfile === undefined) { + throw new Error('Failed to resolve the default devfile.'); + } + this.updateCurrentDevfile(_devfile); + return false; + } const _devfile = factoryResolverConverted?.devfileV2; if (_devfile === undefined) { throw new Error('Failed to resolve the devfile.'); } - - // test the devfile name to decide if we need to append a suffix to is - const nameConflict = this.props.allWorkspaces.some(w => _devfile.metadata.name === w.name); - const appendSuffix = policiesCreate === 'perclick' || nameConflict; - - const updatedDevfile = prepareDevfile(_devfile, factoryId, storageType, appendSuffix); - - this.setState({ - devfile: updatedDevfile, - newWorkspaceName: updatedDevfile.metadata.name, - }); + this.updateCurrentDevfile(_devfile); return false; } - await this.createWorkspaceFromDevfile(devfile); + try { + await this.createWorkspaceFromDevfile(devfile); + } catch (e) { + const errorMessage = common.helpers.errors.getMessage(e); + throw new CreateWorkspaceError(errorMessage); + } // wait for the workspace creation to complete try { @@ -206,7 +252,38 @@ class StepApplyDevfile extends AbstractLoaderStep { ); } + private handleCreateWorkspaceError(): void { + const { defaultDevfile } = this.props; + const { devfile } = this.state; + const _devfile = defaultDevfile; + if (_devfile && devfile) { + _devfile.projects = devfile.projects; + _devfile.metadata.name = devfile.metadata.name; + _devfile.metadata.generateName = devfile.metadata.generateName; + this.updateCurrentDevfile(_devfile); + } + this.clearStepError(); + } + private getAlertItem(error: unknown): AlertItem | undefined { + if (error instanceof CreateWorkspaceError) { + return { + key: 'factory-loader-create-workspace-error', + title: 'Warning', + variant: AlertVariant.warning, + children: , + actionCallbacks: [ + { + title: 'Continue with the default devfile', + callback: () => this.handleCreateWorkspaceError(), + }, + { + title: 'Reload', + callback: () => this.clearStepError(), + }, + ], + }; + } if (!error) { return; } @@ -249,6 +326,7 @@ const mapStateToProps = (state: AppState) => ({ defaultNamespace: selectDefaultNamespace(state), factoryResolver: selectFactoryResolver(state), factoryResolverConverted: selectFactoryResolverConverted(state), + defaultDevfile: selectDefaultDevfile(state), }); const connector = connect( diff --git a/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/CheckExistingWorkspaces/index.tsx b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/CheckExistingWorkspaces/index.tsx index 8ba137ae9c..b946a07d70 100644 --- a/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/CheckExistingWorkspaces/index.tsx +++ b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/CheckExistingWorkspaces/index.tsx @@ -147,13 +147,17 @@ class StepCheckExistingWorkspaces extends AbstractLoaderStep { let newWorkspaceName: string; if (prevLoaderStep.id === LoadingStep.CREATE_WORKSPACE__FETCH_DEVFILE) { if ( - factoryResolver?.location !== factoryParams.sourceUrl || - factoryResolverConverted?.devfileV2 === undefined + factoryResolver?.location === factoryParams.sourceUrl && + factoryResolverConverted?.devfileV2 !== undefined ) { + const devfile = factoryResolverConverted.devfileV2; + newWorkspaceName = devfile.metadata.name; + } else { + if (factoryResolver === undefined) { + return true; + } throw new Error('Failed to resolve the devfile.'); } - const devfile = factoryResolverConverted.devfileV2; - newWorkspaceName = devfile.metadata.name; } else { const resources = devWorkspaceResources[factoryParams.sourceUrl]?.resources; if (resources === undefined) { diff --git a/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Fetch/Devfile/index.tsx b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Fetch/Devfile/index.tsx index caf356d25d..48d0b5fe9d 100644 --- a/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Fetch/Devfile/index.tsx +++ b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Fetch/Devfile/index.tsx @@ -13,7 +13,7 @@ import React from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { isEqual } from 'lodash'; -import { helpers } from '@eclipse-che/common'; +import common, { helpers } from '@eclipse-che/common'; import { AlertVariant } from '@patternfly/react-core'; import { AppState } from '../../../../../../store'; import * as FactoryResolverStore from '../../../../../../store/FactoryResolver'; @@ -36,6 +36,14 @@ import buildFactoryParams from '../../../buildFactoryParams'; import { AbstractLoaderStep, LoaderStepProps, LoaderStepState } from '../../../../AbstractStep'; import { AlertItem } from '../../../../../../services/helpers/types'; import OAuthService, { isOAuthResponse } from '../../../../../../services/oauth'; +import ApplyingDevfileErrorItems from '../../../../../../pages/Loader/Factory/ApplyingDevfileErrorItems'; + +export class ApplyingDevfileError extends Error { + constructor(message) { + super(message); + this.name = 'ApplyingDevfileError'; + } +} const RELOADS_LIMIT = 2; type ReloadsInfo = { @@ -49,6 +57,7 @@ export type Props = MappedProps & export type State = LoaderStepState & { factoryParams: FactoryParams; shouldResolve: boolean; + useDefaultDevfile: boolean; }; class StepFetchDevfile extends AbstractLoaderStep { @@ -60,6 +69,7 @@ class StepFetchDevfile extends AbstractLoaderStep { this.state = { factoryParams: buildFactoryParams(props.searchParams), shouldResolve: true, + useDefaultDevfile: false, }; } @@ -106,9 +116,9 @@ class StepFetchDevfile extends AbstractLoaderStep { private init() { const { factoryResolver } = this.props; - const { factoryParams } = this.state; + const { factoryParams, useDefaultDevfile } = this.state; const { sourceUrl } = factoryParams; - if (sourceUrl && sourceUrl === factoryResolver?.location) { + if (sourceUrl && (useDefaultDevfile || sourceUrl === factoryResolver?.location)) { // prevent a resource being fetched one more time this.setState({ shouldResolve: false, @@ -129,7 +139,7 @@ class StepFetchDevfile extends AbstractLoaderStep { protected async runStep(): Promise { await delay(MIN_STEP_DURATION_MS); - const { factoryParams, shouldResolve } = this.state; + const { factoryParams, shouldResolve, useDefaultDevfile } = this.state; const { currentStepIndex, factoryResolver, factoryResolverConverted, loaderSteps } = this.props; const { sourceUrl } = factoryParams; @@ -151,15 +161,30 @@ class StepFetchDevfile extends AbstractLoaderStep { } if (shouldResolve === false) { + if (useDefaultDevfile) { + // go to the next step + return true; + } + if (this.state.lastError instanceof Error) { throw this.state.lastError; } throw new Error('Failed to resolve the devfile.'); } - // start resolving the devfile - const resolveDone = await this.resolveDevfile(sourceUrl); - if (resolveDone === false) { + let resolveDone = false; + try { + // start resolving the devfile + resolveDone = await this.resolveDevfile(sourceUrl); + } catch (e) { + const errorMessage = common.helpers.errors.getMessage(e); + // check if it is a scheme validation error + if (errorMessage.includes('schema validation failed')) { + throw new ApplyingDevfileError(errorMessage); + } + throw e; + } + if (!resolveDone) { return false; } @@ -176,6 +201,13 @@ class StepFetchDevfile extends AbstractLoaderStep { } } + private handleDevfileError(): void { + this.setState({ + useDefaultDevfile: true, + }); + this.clearStepError(); + } + /** * Resolves promise with `true` if devfile resolved successfully. Resolves promise with `false` if the devfile needs to be resolved one more time after authentication. */ @@ -261,6 +293,24 @@ class StepFetchDevfile extends AbstractLoaderStep { } private getAlertItem(error: unknown): AlertItem | undefined { + if (error instanceof ApplyingDevfileError) { + return { + key: 'factory-loader-devfile-error', + title: 'Warning', + variant: AlertVariant.warning, + children: , + actionCallbacks: [ + { + title: 'Continue with the default devfile', + callback: () => this.handleDevfileError(), + }, + { + title: 'Reload', + callback: () => this.clearStepError(), + }, + ], + }; + } if (!error) { return; } diff --git a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/SamplesListGallery.tsx b/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/SamplesListGallery.tsx index c1035b3ba9..398c77a50a 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/SamplesListGallery.tsx +++ b/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/SamplesListGallery.tsx @@ -31,7 +31,10 @@ import { AppState } from '../../../store'; import * as DevfileRegistriesStore from '../../../store/DevfileRegistries'; import { SampleCard } from './SampleCard'; import { AlertItem } from '../../../services/helpers/types'; -import { selectMetadataFiltered } from '../../../store/DevfileRegistries/selectors'; +import { + EMPTY_WORKSPACE_TAG, + selectMetadataFiltered, +} from '../../../store/DevfileRegistries/selectors'; import { selectWorkspacesSettings } from '../../../store/Workspaces/Settings/selectors'; import * as FactoryResolverStore from '../../../store/FactoryResolver'; import { isDevworkspacesEnabled } from '../../../services/helpers/devworkspace'; @@ -60,7 +63,6 @@ type State = { }; export const VISIBLE_TAGS = ['Community', 'Tech-Preview']; -export const EMPTY_WORKSPACE_TAG = 'Empty'; const EXCLUDED_TARGET_EDITOR_NAMES = ['dirigible', 'jupyter', 'eclipseide', 'code-server']; diff --git a/packages/dashboard-frontend/src/pages/Loader/Factory/ApplyingDevfileErrorItems/index.module.css b/packages/dashboard-frontend/src/pages/Loader/Factory/ApplyingDevfileErrorItems/index.module.css new file mode 100644 index 0000000000..13b7d79439 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/Loader/Factory/ApplyingDevfileErrorItems/index.module.css @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2018-2021 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 + */ + +.error { + border-top: 2px solid; + border-color: #c9190b; + background-color: #faeae8; +} + +.error .textMessage { + font-size: small; + overflow: hidden; + text-overflow: ellipsis; +} + +.error button { + font-size: small; +} diff --git a/packages/dashboard-frontend/src/pages/Loader/Factory/ApplyingDevfileErrorItems/index.tsx b/packages/dashboard-frontend/src/pages/Loader/Factory/ApplyingDevfileErrorItems/index.tsx new file mode 100644 index 0000000000..d8e5a6d24c --- /dev/null +++ b/packages/dashboard-frontend/src/pages/Loader/Factory/ApplyingDevfileErrorItems/index.tsx @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2018-2021 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 React from 'react'; +import { helpers } from '@eclipse-che/common'; +import { + Button, + CodeBlock, + CodeBlockAction, + CodeBlockCode, + ExpandableSectionToggle, + TextContent, + Text, + TextVariants, +} from '@patternfly/react-core'; +import { LoaderStepState } from '../../../../containers/Loader/AbstractStep'; +import CopyToClipboard from 'react-copy-to-clipboard'; +import { CopyIcon } from '@patternfly/react-icons/dist/js/icons'; +import { ApplyingDevfileError } from '../../../../containers/Loader/Factory/Steps/Fetch/Devfile'; +import styles from './index.module.css'; + +const ERROR_MESSAGE_ID = 'applying-devfile-error'; + +type Props = { + error: ApplyingDevfileError; +}; +type State = LoaderStepState & { + copied: boolean; + hasExpand: boolean; + isExpanded: boolean; + errorMessage: string; +}; + +class ApplyingDevfileErrorItems extends React.Component { + private readonly checkOverflow: () => void; + private copiedTimer: number | undefined; + constructor(props: Props) { + super(props); + + const errorMessage = helpers.errors.getMessage(this.props.error); + this.state = { + copied: false, + hasExpand: false, + isExpanded: false, + errorMessage, + }; + + this.checkOverflow = () => { + if (this.state.isExpanded) { + this.setState({ + isExpanded: false, + }); + } + const errorMessageElement = document.getElementById(ERROR_MESSAGE_ID); + if (errorMessageElement !== null) { + const { offsetWidth, scrollWidth } = errorMessageElement; + if (scrollWidth > offsetWidth) { + this.setState({ + hasExpand: true, + }); + } else { + this.setState({ + hasExpand: false, + }); + } + } + }; + } + + public componentDidMount(): void { + window.addEventListener('resize', this.checkOverflow, false); + this.checkOverflow(); + } + + public componentDidUpdate(prevProps: Readonly) { + if (prevProps.error === this.props.error) { + return; + } + const errorMessage = helpers.errors.getMessage(this.props.error); + this.setState({ errorMessage }); + this.checkOverflow(); + } + + public componentWillUnmount() { + window.removeEventListener('resize', this.checkOverflow); + } + + private onToggle(isExpanded: boolean) { + this.setState({ isExpanded }); + } + + private onCopyToClipboard() { + this.setState({ copied: true }); + if (this.copiedTimer) { + clearTimeout(this.copiedTimer); + } + this.copiedTimer = window.setTimeout(() => { + this.setState({ copied: false }); + }, 3000); + } + + render(): React.ReactElement { + const { hasExpand, errorMessage, isExpanded, copied } = this.state; + const copyIconTitle = copied ? 'Copied' : 'Copy to clipboard'; + const actions = ( + + this.onCopyToClipboard()}> + + + + ); + + const expandableSectionTitle = isExpanded ? 'Show Less' : 'Show More'; + const whiteSpace = isExpanded ? 'normal' : 'nowrap'; + return ( + <> + + The Devfile in the git repository is invalid: + + + + + {errorMessage} + + + {hasExpand && ( + this.onToggle(isExpanded)} + direction="up" + > + {expandableSectionTitle} + + )} + + + + If you continue it will be ignored and a regular workspace will be created. You will + have have chance to fix the Devfile from the remote IDE once the dev environment is + started. + + + + ); + } +} + +export default ApplyingDevfileErrorItems; diff --git a/packages/dashboard-frontend/src/pages/Loader/Factory/CreateWorkspaceErrorItems/index.module.css b/packages/dashboard-frontend/src/pages/Loader/Factory/CreateWorkspaceErrorItems/index.module.css new file mode 100644 index 0000000000..13b7d79439 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/Loader/Factory/CreateWorkspaceErrorItems/index.module.css @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2018-2021 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 + */ + +.error { + border-top: 2px solid; + border-color: #c9190b; + background-color: #faeae8; +} + +.error .textMessage { + font-size: small; + overflow: hidden; + text-overflow: ellipsis; +} + +.error button { + font-size: small; +} diff --git a/packages/dashboard-frontend/src/pages/Loader/Factory/CreateWorkspaceErrorItems/index.tsx b/packages/dashboard-frontend/src/pages/Loader/Factory/CreateWorkspaceErrorItems/index.tsx new file mode 100644 index 0000000000..55e4de34bf --- /dev/null +++ b/packages/dashboard-frontend/src/pages/Loader/Factory/CreateWorkspaceErrorItems/index.tsx @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2018-2021 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 React from 'react'; +import { helpers } from '@eclipse-che/common'; +import { + Button, + CodeBlock, + CodeBlockAction, + CodeBlockCode, + ExpandableSectionToggle, + TextContent, + Text, + TextVariants, +} from '@patternfly/react-core'; +import { LoaderStepState } from '../../../../containers/Loader/AbstractStep'; +import CopyToClipboard from 'react-copy-to-clipboard'; +import { CopyIcon } from '@patternfly/react-icons/dist/js/icons'; +import { CreateWorkspaceError } from '../../../../containers/Loader/Factory/Steps/Apply/Devfile'; +import styles from './index.module.css'; + +const ERROR_MESSAGE_ID = 'create-workspace-error'; + +type Props = { + error: CreateWorkspaceError; +}; +type State = LoaderStepState & { + copied: boolean; + hasExpand: boolean; + isExpanded: boolean; + errorMessage: string; +}; + +class CreateWorkspaceErrorItems extends React.Component { + private readonly checkOverflow: () => void; + private copiedTimer: number | undefined; + constructor(props: Props) { + super(props); + + const errorMessage = helpers.errors.getMessage(this.props.error); + this.state = { + copied: false, + hasExpand: false, + isExpanded: false, + errorMessage, + }; + + this.checkOverflow = () => { + if (this.state.isExpanded) { + this.setState({ + isExpanded: false, + }); + } + const errorMessageElement = document.getElementById(ERROR_MESSAGE_ID); + if (errorMessageElement !== null) { + const { offsetWidth, scrollWidth } = errorMessageElement; + if (scrollWidth > offsetWidth) { + this.setState({ + hasExpand: true, + }); + } else { + this.setState({ + hasExpand: false, + }); + } + } + }; + } + + public componentDidMount(): void { + window.addEventListener('resize', this.checkOverflow, false); + this.checkOverflow(); + } + + public componentDidUpdate(prevProps: Readonly) { + if (prevProps.error === this.props.error) { + return; + } + const errorMessage = helpers.errors.getMessage(this.props.error); + this.setState({ errorMessage }); + this.checkOverflow(); + } + + public componentWillUnmount() { + window.removeEventListener('resize', this.checkOverflow); + } + + private onToggle(isExpanded: boolean) { + this.setState({ isExpanded }); + } + + private onCopyToClipboard() { + this.setState({ copied: true }); + if (this.copiedTimer) { + clearTimeout(this.copiedTimer); + } + this.copiedTimer = window.setTimeout(() => { + this.setState({ copied: false }); + }, 3000); + } + + render(): React.ReactElement { + const { hasExpand, errorMessage, isExpanded, copied } = this.state; + const copyIconTitle = copied ? 'Copied' : 'Copy to clipboard'; + const actions = ( + + this.onCopyToClipboard()}> + + + + ); + + const expandableSectionTitle = isExpanded ? 'Show Less' : 'Show More'; + const whiteSpace = isExpanded ? 'normal' : 'nowrap'; + return ( + <> + + + The new Workspace couldn't be created from the Devfile in the git repository: + + + + + + {errorMessage} + + + {hasExpand && ( + this.onToggle(isExpanded)} + direction="up" + > + {expandableSectionTitle} + + )} + + + + If you continue it will be ignored and a regular workspace will be created. You will You + You will have a chance to fix the Devfile from the remote IDE once the dev environment + is started. + + + + ); + } +} + +export default CreateWorkspaceErrorItems; diff --git a/packages/dashboard-frontend/src/pages/Loader/Factory/__tests__/__snapshots__/applyingDevfileErrorItems.spec.tsx.snap b/packages/dashboard-frontend/src/pages/Loader/Factory/__tests__/__snapshots__/applyingDevfileErrorItems.spec.tsx.snap new file mode 100644 index 0000000000..ede824dd9b --- /dev/null +++ b/packages/dashboard-frontend/src/pages/Loader/Factory/__tests__/__snapshots__/applyingDevfileErrorItems.spec.tsx.snap @@ -0,0 +1,98 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ApplyingDevfile error items should correctly render the component 1`] = ` +
+ + The Devfile in the git repository is invalid: + + +
+
+
+
+ +
+
+
+
+
+          
+            
+              Failed to request factory resolver.
+            
+          
+        
+
+
+
+ + If you continue it will be ignored and a regular workspace will be created. You will have have chance to fix the Devfile from the remote IDE once the dev environment is started. + +
+`; diff --git a/packages/dashboard-frontend/src/pages/Loader/Factory/__tests__/__snapshots__/createWorkspaceErrorItems.spec.tsx.snap b/packages/dashboard-frontend/src/pages/Loader/Factory/__tests__/__snapshots__/createWorkspaceErrorItems.spec.tsx.snap new file mode 100644 index 0000000000..32e0bc13ec --- /dev/null +++ b/packages/dashboard-frontend/src/pages/Loader/Factory/__tests__/__snapshots__/createWorkspaceErrorItems.spec.tsx.snap @@ -0,0 +1,98 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CreateWorkspace error items should correctly render the component 1`] = ` +
+ + The new Workspace couldn't be created from the Devfile in the git repository: + + +
+
+
+
+ +
+
+
+
+
+          
+            
+              Failed to request factory resolver.
+            
+          
+        
+
+
+
+ + If you continue it will be ignored and a regular workspace will be created. You will You You will have a chance to fix the Devfile from the remote IDE once the dev environment is started. + +
+`; diff --git a/packages/dashboard-frontend/src/pages/Loader/Factory/__tests__/applyingDevfileErrorItems.spec.tsx b/packages/dashboard-frontend/src/pages/Loader/Factory/__tests__/applyingDevfileErrorItems.spec.tsx new file mode 100644 index 0000000000..f1d89fd6be --- /dev/null +++ b/packages/dashboard-frontend/src/pages/Loader/Factory/__tests__/applyingDevfileErrorItems.spec.tsx @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2018-2021 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 { createHashHistory } from 'history'; +import React from 'react'; +import { Provider } from 'react-redux'; +import renderer from 'react-test-renderer'; +import ApplyingDevfileErrorItems from '../ApplyingDevfileErrorItems'; +import { ApplyingDevfileError } from '../../../../containers/Loader/Factory/Steps/Fetch/Devfile'; + +describe('ApplyingDevfile error items', () => { + const getComponent = (error: ApplyingDevfileError): React.ReactElement => { + return ; + }; + + it('should correctly render the component', () => { + const error = new ApplyingDevfileError('Failed to request factory resolver.'); + const component = getComponent(error); + const json = renderer.create(component).toJSON(); + + expect(json).toMatchSnapshot(); + }); +}); diff --git a/packages/dashboard-frontend/src/pages/Loader/Factory/__tests__/createWorkspaceErrorItems.spec.tsx b/packages/dashboard-frontend/src/pages/Loader/Factory/__tests__/createWorkspaceErrorItems.spec.tsx new file mode 100644 index 0000000000..705754ff07 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/Loader/Factory/__tests__/createWorkspaceErrorItems.spec.tsx @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2018-2021 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 React from 'react'; +import renderer from 'react-test-renderer'; +import CreateWorkspaceErrorItems from '../CreateWorkspaceErrorItems'; +import { CreateWorkspaceError } from '../../../../containers/Loader/Factory/Steps/Apply/Devfile'; + +describe('CreateWorkspace error items', () => { + const getComponent = (error: CreateWorkspaceError): React.ReactElement => { + return ; + }; + + it('should correctly render the component', () => { + const error = new CreateWorkspaceError('Failed to request factory resolver.'); + const component = getComponent(error); + const json = renderer.create(component).toJSON(); + + expect(json).toMatchSnapshot(); + }); +}); diff --git a/packages/dashboard-frontend/src/services/bootstrap/index.ts b/packages/dashboard-frontend/src/services/bootstrap/index.ts index b9c82e1b74..df3249b377 100644 --- a/packages/dashboard-frontend/src/services/bootstrap/index.ts +++ b/packages/dashboard-frontend/src/services/bootstrap/index.ts @@ -42,6 +42,7 @@ import { buildDetailsLocation, buildIdeLoaderLocation } from '../helpers/locatio import { Workspace } from '../workspace-adapter'; import { WorkspaceRunningError, WorkspaceStoppedDetector } from './workspaceStoppedDetector'; import { selectOpenVSXUrl } from '../../store/ServerConfig/selectors'; +import { selectEmptyWorkspaceUrl } from '../../store/DevfileRegistries/selectors'; /** * This class executes a few initial instructions @@ -88,7 +89,7 @@ export default class Bootstrap { this.fetchPlugins().then(() => this.fetchDevfileSchema()), this.fetchDwPlugins(), this.fetchDefaultDwPlugins(), - this.fetchRegistriesMetadata(), + this.fetchRegistriesMetadata().then(() => this.fetchEmptyWorkspace()), this.watchNamespaces(), this.updateDevWorkspaceTemplates(), this.fetchWorkspaces().then(() => this.checkWorkspaceStopped()), @@ -287,6 +288,15 @@ export default class Bootstrap { ); } + private async fetchEmptyWorkspace(): Promise { + const { requestDevfile } = DevfileRegistriesStore.actionCreators; + const state = this.store.getState(); + const emptyWorkspaceUrl = selectEmptyWorkspaceUrl(state); + if (emptyWorkspaceUrl) { + await requestDevfile(emptyWorkspaceUrl)(this.store.dispatch, this.store.getState, undefined); + } + } + private async fetchDevfileSchema(): Promise { const { requestJsonSchema } = DevfileRegistriesStore.actionCreators; return requestJsonSchema()(this.store.dispatch, this.store.getState, undefined); diff --git a/packages/dashboard-frontend/src/store/DevfileRegistries/selectors.ts b/packages/dashboard-frontend/src/store/DevfileRegistries/selectors.ts index 58c157ae81..68f47a9da8 100644 --- a/packages/dashboard-frontend/src/store/DevfileRegistries/selectors.ts +++ b/packages/dashboard-frontend/src/store/DevfileRegistries/selectors.ts @@ -15,6 +15,10 @@ import { AppState } from '..'; import match from '../../services/helpers/filter'; import { selectWorkspacesSettingsState } from '../Workspaces/Settings/selectors'; import { isDevworkspacesEnabled } from '../../services/helpers/devworkspace'; +import { load } from 'js-yaml'; +import devfileApi from '../../services/devfileApi'; + +export const EMPTY_WORKSPACE_TAG = 'Empty'; const selectState = (state: AppState) => state.devfileRegistries; @@ -63,6 +67,35 @@ export const selectMetadataFiltered = createSelector( }, ); +export const selectEmptyWorkspaceUrl = createSelector( + selectState, + selectRegistriesMetadata, + (state, metadata) => { + const v2Metadata = filterDevfileV2Metadata(metadata); + const emptyWorkspaceMetadata = v2Metadata.find(meta => meta.tags.includes(EMPTY_WORKSPACE_TAG)); + return emptyWorkspaceMetadata?.links?.v2; + }, +); + +export const selectDefaultDevfile = createSelector( + selectState, + selectEmptyWorkspaceUrl, + (state, devfileLocation) => { + if (!devfileLocation) { + return undefined; + } + const devfileContent = state.devfiles[devfileLocation]?.content; + if (devfileContent) { + try { + return load(devfileContent) as devfileApi.Devfile; + } catch (e) { + console.error(e); + } + } + return undefined; + }, +); + function matches(meta: che.DevfileMetaData, filterValue: string): boolean { return match(meta.displayName, filterValue) || match(meta.description || '', filterValue); } diff --git a/packages/dashboard-frontend/src/store/FactoryResolver/index.ts b/packages/dashboard-frontend/src/store/FactoryResolver/index.ts index da5d33bc5b..93a14a13d9 100644 --- a/packages/dashboard-frontend/src/store/FactoryResolver/index.ts +++ b/packages/dashboard-frontend/src/store/FactoryResolver/index.ts @@ -38,7 +38,7 @@ export type OAuthResponse = { oauth_authentication_url: string; }; errorCode: number; - message: string; + message: string | undefined; }; export interface ResolverState extends FactoryResolver { @@ -72,7 +72,7 @@ interface ReceiveFactoryResolverAction { interface ReceiveFactoryResolverErrorAction { type: 'RECEIVE_FACTORY_RESOLVER_ERROR'; - error: string; + error: string | undefined; } export type KnownAction = diff --git a/yarn.lock b/yarn.lock index 5533e90613..ca85567c6e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10170,9 +10170,9 @@ raw-body@2.5.1: iconv-lite "0.4.24" unpipe "1.0.0" -react-copy-to-clipboard@^5.0.2: +react-copy-to-clipboard@^5.1.0: version "5.1.0" - resolved "https://registry.npmjs.org/react-copy-to-clipboard/-/react-copy-to-clipboard-5.1.0.tgz" + resolved "https://registry.yarnpkg.com/react-copy-to-clipboard/-/react-copy-to-clipboard-5.1.0.tgz#09aae5ec4c62750ccb2e6421a58725eabc41255c" integrity sha512-k61RsNgAayIJNoy9yDsYzDe/yAZAzEbEgcz3DZMhF686LEyukcE1hzurxe85JandPUG+yTfGVFzuEw3xt8WP/A== dependencies: copy-to-clipboard "^3.3.1"