diff --git a/packages/dashboard-frontend/jest.config.js b/packages/dashboard-frontend/jest.config.js index 22ec31b21..2c2429eaf 100644 --- a/packages/dashboard-frontend/jest.config.js +++ b/packages/dashboard-frontend/jest.config.js @@ -50,10 +50,10 @@ module.exports = { ], coverageThreshold: { global: { - statements: 88, - branches: 85, - functions: 81, - lines: 88, + statements: 89, + branches: 86, + functions: 83, + lines: 89, }, }, }; diff --git a/packages/dashboard-frontend/src/Layout/Navigation/index.tsx b/packages/dashboard-frontend/src/Layout/Navigation/index.tsx index d07838e81..8c93c3112 100644 --- a/packages/dashboard-frontend/src/Layout/Navigation/index.tsx +++ b/packages/dashboard-frontend/src/Layout/Navigation/index.tsx @@ -61,7 +61,7 @@ export class Navigation extends React.PureComponent { if (activeLocation.pathname === ROUTE.HOME) { const workspacesNumber = this.props.allWorkspaces.length; if (workspacesNumber === 0) { - newLocation = buildGettingStartedLocation('quick-add'); + newLocation = buildGettingStartedLocation(); } else { newLocation = buildWorkspacesLocation(); } diff --git a/packages/dashboard-frontend/src/Routes/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/Routes/__tests__/index.spec.tsx index e14825c7f..672d9f7b7 100644 --- a/packages/dashboard-frontend/src/Routes/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/Routes/__tests__/index.spec.tsx @@ -33,7 +33,7 @@ import Routes from '..'; jest.mock('@/pages/GetStarted', () => { return function GetStarted() { - return Quick Add; + return Create Workspace; }; }); jest.mock('@/containers/WorkspacesList.tsx', () => { @@ -62,30 +62,21 @@ describe('Routes', () => { jest.clearAllMocks(); }); - describe('Quick Add route', () => { + describe('Create Workspace route', () => { it('should handle "/"', async () => { const path = ROUTE.HOME; render(getComponent(path)); - await waitFor(() => expect(screen.queryByText('Quick Add')).toBeTruthy()); + await waitFor(() => expect(screen.queryByText('Create Workspace')).toBeTruthy()); expect(screen.queryByTestId('fallback-spinner')).not.toBeInTheDocument(); }); - it('should handle "/quick-add"', async () => { + it('should handle "/create-workspace"', async () => { const location = buildGettingStartedLocation(); render(getComponent(location)); - await waitFor(() => expect(screen.queryByText('Quick Add')).toBeTruthy()); - - expect(screen.queryByTestId('fallback-spinner')).not.toBeInTheDocument(); - }); - - it('should handle "/create-workspace?tab=quick-add"', async () => { - const location = buildGettingStartedLocation('quick-add'); - render(getComponent(location)); - - await waitFor(() => expect(screen.queryByText('Quick Add')).toBeTruthy()); + await waitFor(() => expect(screen.queryByText('Create Workspace')).toBeTruthy()); expect(screen.queryByTestId('fallback-spinner')).not.toBeInTheDocument(); }); diff --git a/packages/dashboard-frontend/src/Routes/index.tsx b/packages/dashboard-frontend/src/Routes/index.tsx index 7563706c0..6ed01d77d 100644 --- a/packages/dashboard-frontend/src/Routes/index.tsx +++ b/packages/dashboard-frontend/src/Routes/index.tsx @@ -15,7 +15,7 @@ import { Redirect, Route, RouteComponentProps, Switch } from 'react-router'; import WorkspaceDetailsContainer from '@/containers/WorkspaceDetails'; import WorkspacesListContainer from '@/containers/WorkspacesList'; -import CreateWorkspace from '@/pages/GetStarted'; +import GetStarted from '@/pages/GetStarted'; import UserPreferences from '@/pages/UserPreferences'; import { buildFactoryLoaderPath } from '@/preload/main'; import { ROUTE } from '@/Routes/routes'; @@ -30,8 +30,8 @@ export interface RouteItem { } const items: RouteItem[] = [ - { to: ROUTE.GET_STARTED, component: CreateWorkspace }, - { to: ROUTE.HOME, component: CreateWorkspace }, + { to: ROUTE.GET_STARTED, component: GetStarted }, + { to: ROUTE.HOME, component: GetStarted }, { to: ROUTE.WORKSPACES, component: WorkspacesListContainer }, { to: ROUTE.WORKSPACE_DETAILS, component: WorkspaceDetailsContainer }, { to: ROUTE.IDE_LOADER, component: LoaderContainer }, diff --git a/packages/dashboard-frontend/src/Routes/routes.ts b/packages/dashboard-frontend/src/Routes/routes.ts index 24c8db13b..ef0410887 100644 --- a/packages/dashboard-frontend/src/Routes/routes.ts +++ b/packages/dashboard-frontend/src/Routes/routes.ts @@ -13,7 +13,6 @@ export enum ROUTE { HOME = '/', GET_STARTED = '/create-workspace', - GET_STARTED_TAB = '/create-workspace?tab=:tabId', WORKSPACES = '/workspaces', WORKSPACE_DETAILS = '/workspace/:namespace/:workspaceName', WORKSPACE_DETAILS_TAB = '/workspace/:namespace/:workspaceName?tab=:tabId', diff --git a/packages/dashboard-frontend/src/components/EditorSelector/Definition/DefinitionField/__mocks__/index.tsx b/packages/dashboard-frontend/src/components/EditorSelector/Definition/DefinitionField/__mocks__/index.tsx new file mode 100644 index 000000000..2c6a4f3d4 --- /dev/null +++ b/packages/dashboard-frontend/src/components/EditorSelector/Definition/DefinitionField/__mocks__/index.tsx @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2018-2024 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 { Props } from '@/components/EditorSelector/Definition/DefinitionField'; + +export class EditorDefinitionField extends React.PureComponent { + public render() { + const { onChange } = this.props; + + return ( +
+ + Editor Definition Field +
+ ); + } +} diff --git a/packages/dashboard-frontend/src/components/EditorSelector/Definition/DefinitionField/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/components/EditorSelector/Definition/DefinitionField/__tests__/__snapshots__/index.spec.tsx.snap new file mode 100644 index 000000000..661b6a0c1 --- /dev/null +++ b/packages/dashboard-frontend/src/components/EditorSelector/Definition/DefinitionField/__tests__/__snapshots__/index.spec.tsx.snap @@ -0,0 +1,71 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EditorDefinitionField snapshot 1`] = ` +
+
+ + +
+
+ +
+ + + + + + Default editor will be used if no definition is provided. +
+
+
+`; diff --git a/packages/dashboard-frontend/src/components/EditorSelector/Definition/DefinitionField/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/EditorSelector/Definition/DefinitionField/__tests__/index.spec.tsx new file mode 100644 index 000000000..e267991fd --- /dev/null +++ b/packages/dashboard-frontend/src/components/EditorSelector/Definition/DefinitionField/__tests__/index.spec.tsx @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2018-2024 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 userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { EditorDefinitionField } from '@/components/EditorSelector/Definition/DefinitionField'; +import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; + +const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); + +const mockOnChange = jest.fn(); + +describe('EditorDefinitionField', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('snapshot', () => { + const snapshot = createSnapshot(); + expect(snapshot.toJSON()).toMatchSnapshot(); + }); + + test('helper text, definition change', () => { + renderComponent(); + + const helperText = 'Default editor will be used if no definition is provided.'; + + expect(screen.queryByText(helperText)).not.toBeNull(); + + const input = screen.getByRole('textbox'); + + const editorId = 'some/editor/id'; + userEvent.paste(input, editorId); + + expect(mockOnChange).toHaveBeenNthCalledWith(1, editorId); + expect(screen.queryByText(helperText)).toBeNull(); + + userEvent.clear(input); + + expect(mockOnChange).toHaveBeenNthCalledWith(2, undefined); + expect(screen.queryByText(helperText)).not.toBeNull(); + }); +}); + +function getComponent() { + return ; +} diff --git a/packages/dashboard-frontend/src/components/EditorSelector/Definition/DefinitionField/index.tsx b/packages/dashboard-frontend/src/components/EditorSelector/Definition/DefinitionField/index.tsx new file mode 100644 index 000000000..1d77e664c --- /dev/null +++ b/packages/dashboard-frontend/src/components/EditorSelector/Definition/DefinitionField/index.tsx @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2018-2024 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 { FormGroup, TextInput } from '@patternfly/react-core'; +import { InfoIcon } from '@patternfly/react-icons'; +import React from 'react'; + +export type Props = { + onChange: (definition: string | undefined) => void; +}; +export type State = { + definition: string; +}; + +export class EditorDefinitionField extends React.PureComponent { + constructor(props: Props) { + super(props); + + this.state = { + definition: '', + }; + } + + private handleChange(value: string) { + value = value.trim(); + this.setState({ definition: value }); + this.props.onChange(value !== '' ? value : undefined); + } + + public render() { + const { definition } = this.state; + + const helperText = + definition !== '' ? '' : 'Default editor will be used if no definition is provided.'; + + return ( + }> + this.handleChange(value)} + value={definition} + /> + + ); + } +} diff --git a/packages/dashboard-frontend/src/components/EditorSelector/Definition/ImageField/__mocks__/index.tsx b/packages/dashboard-frontend/src/components/EditorSelector/Definition/ImageField/__mocks__/index.tsx new file mode 100644 index 000000000..968ca7be7 --- /dev/null +++ b/packages/dashboard-frontend/src/components/EditorSelector/Definition/ImageField/__mocks__/index.tsx @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2018-2024 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 { Props } from '@/components/EditorSelector/Definition/ImageField'; + +export class EditorImageField extends React.PureComponent { + public render() { + const { onChange } = this.props; + + return ( +
+ + Editor Definition Field +
+ ); + } +} diff --git a/packages/dashboard-frontend/src/components/EditorSelector/Definition/ImageField/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/components/EditorSelector/Definition/ImageField/__tests__/__snapshots__/index.spec.tsx.snap new file mode 100644 index 000000000..c82ab87ed --- /dev/null +++ b/packages/dashboard-frontend/src/components/EditorSelector/Definition/ImageField/__tests__/__snapshots__/index.spec.tsx.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EditorDefinitionField snapshot 1`] = ` +
+
+ + +
+
+ + +
+
+`; diff --git a/packages/dashboard-frontend/src/components/EditorSelector/Definition/ImageField/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/EditorSelector/Definition/ImageField/__tests__/index.spec.tsx new file mode 100644 index 000000000..c9642d01e --- /dev/null +++ b/packages/dashboard-frontend/src/components/EditorSelector/Definition/ImageField/__tests__/index.spec.tsx @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2018-2024 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 userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { EditorImageField } from '@/components/EditorSelector/Definition/ImageField'; +import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; + +const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); + +const mockOnChange = jest.fn(); + +describe('EditorDefinitionField', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('snapshot', () => { + const snapshot = createSnapshot(); + expect(snapshot.toJSON()).toMatchSnapshot(); + }); + + test('image change', () => { + renderComponent(); + + const input = screen.getByRole('textbox'); + + const editorId = 'editor-image'; + userEvent.paste(input, editorId); + + expect(mockOnChange).toHaveBeenNthCalledWith(1, editorId); + + userEvent.clear(input); + expect(mockOnChange).toHaveBeenNthCalledWith(2, undefined); + }); +}); + +function getComponent() { + return ; +} diff --git a/packages/dashboard-frontend/src/components/EditorSelector/Definition/ImageField/index.tsx b/packages/dashboard-frontend/src/components/EditorSelector/Definition/ImageField/index.tsx new file mode 100644 index 000000000..68d988035 --- /dev/null +++ b/packages/dashboard-frontend/src/components/EditorSelector/Definition/ImageField/index.tsx @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2018-2024 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 { FormGroup, TextInput } from '@patternfly/react-core'; +import React from 'react'; + +export type Props = { + onChange: (image: string | undefined) => void; +}; +export type State = { + image: string; +}; + +export class EditorImageField extends React.PureComponent { + constructor(props: Props) { + super(props); + + this.state = { + image: '', + }; + } + + private handleChange(value: string) { + value = value.trim(); + this.setState({ image: value }); + this.props.onChange(value !== '' ? value : undefined); + } + + public render() { + const { image } = this.state; + + return ( + + this.handleChange(value)} + value={image} + /> + + ); + } +} diff --git a/packages/dashboard-frontend/src/components/EditorSelector/Definition/__mocks__/index.tsx b/packages/dashboard-frontend/src/components/EditorSelector/Definition/__mocks__/index.tsx new file mode 100644 index 000000000..579d62009 --- /dev/null +++ b/packages/dashboard-frontend/src/components/EditorSelector/Definition/__mocks__/index.tsx @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2018-2024 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 { Props } from '@/components/EditorSelector/Definition'; + +export class EditorDefinition extends React.PureComponent { + public render() { + const { editorDefinition, editorImage, onChange } = this.props; + + return ( +
+
Editor Definition
+
{editorDefinition}
+
{editorImage}
+ +
+ ); + } +} diff --git a/packages/dashboard-frontend/src/components/EditorSelector/Definition/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/components/EditorSelector/Definition/__tests__/__snapshots__/index.spec.tsx.snap new file mode 100644 index 000000000..b2248222c --- /dev/null +++ b/packages/dashboard-frontend/src/components/EditorSelector/Definition/__tests__/__snapshots__/index.spec.tsx.snap @@ -0,0 +1,59 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EditorDefinition snapshot w/o initial values 1`] = ` +
+
+ + Editor Definition Field +
+
+ + Editor Definition Field +
+
+`; + +exports[`EditorDefinition snapshot with initial values 1`] = ` +
+
+ + Editor Definition Field +
+
+ + Editor Definition Field +
+
+`; diff --git a/packages/dashboard-frontend/src/components/EditorSelector/Definition/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/EditorSelector/Definition/__tests__/index.spec.tsx new file mode 100644 index 000000000..2b45b9118 --- /dev/null +++ b/packages/dashboard-frontend/src/components/EditorSelector/Definition/__tests__/index.spec.tsx @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2018-2024 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 userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { EditorDefinition } from '@/components/EditorSelector/Definition'; +import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; + +jest.mock('@/components/EditorSelector/Definition/DefinitionField'); +jest.mock('@/components/EditorSelector/Definition/ImageField'); + +const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); + +const mockOnChange = jest.fn(); + +describe('EditorDefinition', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('snapshot w/o initial values', () => { + const snapshot = createSnapshot(undefined, undefined); + expect(snapshot.toJSON()).toMatchSnapshot(); + }); + + test('snapshot with initial values', () => { + const snapshot = createSnapshot('some/editor/id', 'editor-image'); + expect(snapshot.toJSON()).toMatchSnapshot(); + }); + + it('should handle editor definition change', () => { + renderComponent(undefined, undefined); + + const definitionChangeButton = screen.getByRole('button', { name: 'Editor Definition Change' }); + userEvent.click(definitionChangeButton); + + expect(mockOnChange).toHaveBeenCalledWith('some/editor/id', undefined); + }); + + it('should handle editor image change', () => { + renderComponent(undefined, undefined); + + const imageChangeButton = screen.getByRole('button', { name: 'Editor Image Change' }); + userEvent.click(imageChangeButton); + + expect(mockOnChange).toHaveBeenCalledWith(undefined, 'editor-image'); + }); +}); + +function getComponent(editorDefinition: string | undefined, editorImage: string | undefined) { + return ( + + ); +} diff --git a/packages/dashboard-frontend/src/components/EditorSelector/Definition/index.tsx b/packages/dashboard-frontend/src/components/EditorSelector/Definition/index.tsx new file mode 100644 index 000000000..ab461ed27 --- /dev/null +++ b/packages/dashboard-frontend/src/components/EditorSelector/Definition/index.tsx @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2018-2024 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 { Form } from '@patternfly/react-core'; +import React from 'react'; + +import { EditorDefinitionField } from '@/components/EditorSelector/Definition/DefinitionField'; +import { EditorImageField } from '@/components/EditorSelector/Definition/ImageField'; + +export type Props = { + editorDefinition: string | undefined; + editorImage: string | undefined; + onChange: (editorId: string | undefined, editorImage: string | undefined) => void; +}; + +export type State = { + editorDefinition: string | undefined; + editorImage: string | undefined; +}; + +export class EditorDefinition extends React.PureComponent { + constructor(props: Props) { + super(props); + + this.state = { + editorDefinition: props.editorDefinition, + editorImage: props.editorImage, + }; + } + + private handleEditorDefinition(editorDefinition: string | undefined) { + const { editorImage } = this.state; + + this.setState({ editorDefinition }); + this.props.onChange(editorDefinition, editorImage); + } + + private handleEditorImage(editorImage: string | undefined) { + const { editorDefinition } = this.state; + + this.setState({ editorImage }); + this.props.onChange(editorDefinition, editorImage); + } + + public render() { + return ( +
e.preventDefault()}> + this.handleEditorDefinition(editorDefinition)} + /> + this.handleEditorImage(editorImage)} /> + + ); + } +} diff --git a/packages/dashboard-frontend/src/components/EditorSelector/Gallery/Entry/__mocks__/index.tsx b/packages/dashboard-frontend/src/components/EditorSelector/Gallery/Entry/__mocks__/index.tsx new file mode 100644 index 000000000..d65d19246 --- /dev/null +++ b/packages/dashboard-frontend/src/components/EditorSelector/Gallery/Entry/__mocks__/index.tsx @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2018-2024 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 { Props } from '@/components/EditorSelector/Gallery/Entry'; + +export class EditorSelectorEntry extends React.PureComponent { + public render() { + const { selectedId, editorsGroup, groupName, onSelect } = this.props; + return ( +
+
{selectedId}
+
{groupName}
+ {editorsGroup.map(editor => ( +
+ {editor.name} + +
+ ))} +
+ ); + } +} diff --git a/packages/dashboard-frontend/src/components/EditorSelector/Gallery/Entry/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/components/EditorSelector/Gallery/Entry/__tests__/__snapshots__/index.spec.tsx.snap new file mode 100644 index 000000000..5ed83617c --- /dev/null +++ b/packages/dashboard-frontend/src/components/EditorSelector/Gallery/Entry/__tests__/__snapshots__/index.spec.tsx.snap @@ -0,0 +1,93 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Editor Selector Entry snapshot 1`] = ` +[ + , +
+
+ +
+
+ +
+
+
+
+ + VS Code - Open Source + + + + insiders + + +
+
, +] +`; diff --git a/packages/dashboard-frontend/src/components/EditorSelector/Gallery/Entry/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/EditorSelector/Gallery/Entry/__tests__/index.spec.tsx new file mode 100644 index 000000000..342aff518 --- /dev/null +++ b/packages/dashboard-frontend/src/components/EditorSelector/Gallery/Entry/__tests__/index.spec.tsx @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2018-2024 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 { StateMock } from '@react-mock/state'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { EditorSelectorEntry, State } from '@/components/EditorSelector/Gallery/Entry'; +import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; +import { che } from '@/services/models'; + +const mockOnSelect = jest.fn(); + +const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); + +const editorGroup: che.Plugin[] = [ + { + id: 'che-incubator/che-code/insiders', + description: 'Microsoft Visual Studio Code - Open Source IDE for Eclipse Che - Insiders build', + displayName: 'VS Code - Open Source', + links: { + devfile: '/v3/plugins/che-incubator/che-code/insiders/devfile.yaml', + }, + name: 'che-code', + publisher: 'che-incubator', + type: 'Che Editor', + version: 'insiders', + icon: '/v3/images/vscode.svg', + }, + { + id: 'che-incubator/che-code/latest', + description: 'Microsoft Visual Studio Code - Open Source IDE for Eclipse Che', + displayName: 'VS Code - Open Source', + links: { + devfile: '/v3/plugins/che-incubator/che-code/latest/devfile.yaml', + }, + name: 'che-code', + publisher: 'che-incubator', + type: 'Che Editor', + version: 'latest', + icon: '/v3/images/vscode.svg', + }, +]; + +const editorGroupIconSrc = editorGroup[0].icon; +const editorGroupName = editorGroup[0].displayName as string; + +describe('Editor Selector Entry', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('snapshot', () => { + const snapshot = createSnapshot('editor-id', editorGroup); + expect(snapshot.toJSON()).toMatchSnapshot(); + }); + + describe('props change', () => { + test('sibling editor ID provided later', () => { + const { reRenderComponent } = renderComponent(editorGroup[0].id, editorGroup); + + expect(screen.getByRole('checkbox')).toBeChecked(); + + reRenderComponent(editorGroup[1].id, editorGroup); + + expect(screen.getByRole('checkbox')).toBeChecked(); + + expect(mockOnSelect).not.toHaveBeenCalled(); + }); + + test('other editor ID provided later', () => { + const { reRenderComponent } = renderComponent(editorGroup[0].id, editorGroup); + + expect(screen.getByRole('checkbox')).toBeChecked(); + + reRenderComponent('other/editor/id', editorGroup); + + expect(screen.getByRole('checkbox')).not.toBeChecked(); + + expect(mockOnSelect).not.toHaveBeenCalled(); + }); + }); + + describe('card click', () => { + test('card is selected initially', () => { + renderComponent(editorGroup[0].id, editorGroup); + + const card = screen.getByRole('article'); + card.click(); + + expect(mockOnSelect).not.toHaveBeenCalled(); + }); + + test('card is not selected initially', () => { + renderComponent('other/editor/id', editorGroup); + + const card = screen.getByRole('article'); + card.click(); + + expect(mockOnSelect).toHaveBeenCalled(); + }); + }); + + describe('card actions', () => { + test('kebab click', () => { + renderComponent(editorGroup[0].id, editorGroup); + + const kebab = screen.getByRole('button', { name: 'Actions' }); + kebab.click(); + + expect(screen.getByRole('menu')).toBeInTheDocument(); + + const dropdownItems = screen.getAllByRole('menuitem'); + expect(dropdownItems).toHaveLength(2); + }); + + describe('card is selected', () => { + const localState: Partial = { + activeEditor: editorGroup[0], + isKebabOpen: true, + isSelectedGroup: true, // card is selected + }; + + test('select same version', () => { + renderComponent(editorGroup[0].id, editorGroup, localState); + + const activeDropdownItem = screen.getByRole('menuitem', { + name: localState.activeEditor!.version, + }); + + expect(activeDropdownItem).toHaveAttribute('aria-checked', 'true'); + + userEvent.click(activeDropdownItem); + + // should NOT call onSelect + expect(mockOnSelect).not.toHaveBeenCalled(); + }); + + test('select different version', () => { + renderComponent(editorGroup[0].id, editorGroup, localState); + + const dropdownItem = screen.getByRole('menuitem', { name: editorGroup[1].version }); + + expect(dropdownItem).toHaveAttribute('aria-checked', 'false'); + + userEvent.click(dropdownItem); + + // should call onSelect + expect(mockOnSelect).toHaveBeenCalled(); + }); + }); + + describe('card is not selected', () => { + const localState: Partial = { + activeEditor: editorGroup[0], + isKebabOpen: true, + isSelectedGroup: false, // card is not selected + }; + + test('select same version', () => { + renderComponent('other/editor/id', editorGroup, localState); + + const activeDropdownItem = screen.getByRole('menuitem', { + name: localState.activeEditor!.version, + }); + + expect(activeDropdownItem).toHaveAttribute('aria-checked', 'true'); + + userEvent.click(activeDropdownItem); + + // should NOT call onSelect + expect(mockOnSelect).not.toHaveBeenCalled(); + }); + + test('select different version', () => { + renderComponent('other/editor/id', editorGroup, localState); + + const dropdownItem = screen.getByRole('menuitem', { name: editorGroup[1].version }); + + expect(dropdownItem).toHaveAttribute('aria-checked', 'false'); + + userEvent.click(dropdownItem); + + // should NOT call onSelect + expect(mockOnSelect).not.toHaveBeenCalled(); + }); + + test('select different version and select the card', () => { + renderComponent('other/editor/id', editorGroup, localState); + + const dropdownItem = screen.getByRole('menuitem', { name: editorGroup[1].version }); + + expect(dropdownItem).toHaveAttribute('aria-checked', 'false'); + + userEvent.click(dropdownItem); + + // should NOT call onSelect + expect(mockOnSelect).not.toHaveBeenCalled(); + + const card = screen.getByRole('article'); + userEvent.click(card); + + // should call onSelect + expect(mockOnSelect).toHaveBeenCalledWith(editorGroup[1].id); + }); + }); + }); +}); + +function getComponent( + selectedEditorId: string, + editorGroup: che.Plugin[], + localState?: Partial, +) { + const component = ( + + ); + + if (localState) { + return {component}; + } + + return component; +} diff --git a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/DropdownEditors/index.module.css b/packages/dashboard-frontend/src/components/EditorSelector/Gallery/Entry/index.module.css similarity index 70% rename from packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/DropdownEditors/index.module.css rename to packages/dashboard-frontend/src/components/EditorSelector/Gallery/Entry/index.module.css index 94fd18b02..8612cefd1 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/DropdownEditors/index.module.css +++ b/packages/dashboard-frontend/src/components/EditorSelector/Gallery/Entry/index.module.css @@ -10,15 +10,11 @@ * Red Hat, Inc. - initial API and implementation */ -.dropdownEditorGroup { - white-space: nowrap; +.editorIcon { + width: 32px; + height: 32px; } -.editorTitle { - text-transform: capitalize; -} - -.checkIcon { - margin: 0 0 3px 9px; - opacity: 0.6; +.activeCard { + color: #000; } diff --git a/packages/dashboard-frontend/src/components/EditorSelector/Gallery/Entry/index.tsx b/packages/dashboard-frontend/src/components/EditorSelector/Gallery/Entry/index.tsx new file mode 100644 index 000000000..6a4c0a5f8 --- /dev/null +++ b/packages/dashboard-frontend/src/components/EditorSelector/Gallery/Entry/index.tsx @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2018-2024 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 { + Card, + CardActions, + CardBody, + CardHeader, + Dropdown, + DropdownItem, + KebabToggle, +} from '@patternfly/react-core'; +import { CheckIcon } from '@patternfly/react-icons'; +import React from 'react'; + +import styles from '@/components/EditorSelector/Gallery/Entry/index.module.css'; +import TagLabel from '@/components/TagLabel'; +import { che } from '@/services/models'; + +export type Props = { + editorsGroup: che.Plugin[]; + groupIcon: string; + groupName: string; + selectedId: string; + onSelect: (editorId: string) => void; +}; +export type State = { + activeEditor: che.Plugin; + isKebabOpen: boolean; + isSelectedGroup: boolean; +}; + +export class EditorSelectorEntry extends React.PureComponent { + constructor(props: Props) { + super(props); + + // define if this editor group is selected + const selectedEditor = props.editorsGroup.find(editor => editor.id === props.selectedId); + const isSelectedGroup = selectedEditor !== undefined; + + this.state = { + activeEditor: selectedEditor || props.editorsGroup[0], + isKebabOpen: false, + isSelectedGroup, + }; + } + + public componentDidUpdate(prevProps: Props): void { + if (prevProps.selectedId !== this.props.selectedId) { + const selectedEditor = this.props.editorsGroup.find( + editor => editor.id === this.props.selectedId, + ); + + if (selectedEditor === undefined) { + this.setState({ + isSelectedGroup: false, + }); + return; + } + + this.setState({ + activeEditor: selectedEditor, + isSelectedGroup: true, + }); + } + } + + private handleCardClick(event: React.MouseEvent) { + event.preventDefault(); + + const { selectedId, onSelect } = this.props; + const { activeEditor } = this.state; + + if (activeEditor.id === selectedId) { + return; + } + + onSelect(activeEditor.id); + } + + private handleDropdownToggle( + isKebabOpen: boolean, + event: MouseEvent | React.KeyboardEvent | React.MouseEvent, + ) { + event.stopPropagation(); + + this.setState({ isKebabOpen }); + } + + private handleDropdownSelect( + event: MouseEvent | React.MouseEvent | React.KeyboardEvent, + editor: che.Plugin, + ) { + event.stopPropagation(); + event.preventDefault(); + + this.setState({ + activeEditor: editor, + isKebabOpen: false, + }); + + const { selectedId, onSelect } = this.props; + const { activeEditor } = this.state; + if (selectedId === activeEditor.id && selectedId !== editor.id) { + onSelect(editor.id); + } + } + + private buildDropdownItems(): React.ReactNode[] { + const { editorsGroup } = this.props; + const { activeEditor } = this.state; + + return editorsGroup.map(editor => { + const isChecked = editor.version === activeEditor.version; + return ( + this.handleDropdownSelect(event, editor)} + data-testid="editor-card-action" + aria-checked={isChecked} + icon={isChecked ? : <>} + > + {editor.version} + + ); + }); + } + + public render(): React.ReactElement { + const { groupIcon, groupName } = this.props; + const { isKebabOpen, isSelectedGroup, activeEditor } = this.state; + + const dropdownItems = this.buildDropdownItems(); + const areaLabel = `Select ${groupName} ${activeEditor.version} `; + + const titleClassName = isSelectedGroup ? styles.activeCard : ''; + + return ( + this.handleCardClick(event)} + selectableInputAriaLabel={areaLabel} + > + + + + this.handleDropdownToggle(isOpen, event)} + /> + } + isOpen={isKebabOpen} + isPlain + dropdownItems={dropdownItems} + /> + + + + {groupName} + + + + ); + } +} diff --git a/packages/dashboard-frontend/src/components/EditorSelector/Gallery/__mocks__/index.tsx b/packages/dashboard-frontend/src/components/EditorSelector/Gallery/__mocks__/index.tsx new file mode 100644 index 000000000..479b768fa --- /dev/null +++ b/packages/dashboard-frontend/src/components/EditorSelector/Gallery/__mocks__/index.tsx @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2018-2024 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 { Props } from '@/components/EditorSelector/Gallery'; + +export class EditorGallery extends React.PureComponent { + public render() { + const { defaultEditorId, selectedEditorId, onSelect } = this.props; + + return ( +
+
Editor Gallery
+
{defaultEditorId}
+
{selectedEditorId}
+ +
+ ); + } +} diff --git a/packages/dashboard-frontend/src/components/EditorSelector/Gallery/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/components/EditorSelector/Gallery/__tests__/__snapshots__/index.spec.tsx.snap new file mode 100644 index 000000000..1cb580cf3 --- /dev/null +++ b/packages/dashboard-frontend/src/components/EditorSelector/Gallery/__tests__/__snapshots__/index.spec.tsx.snap @@ -0,0 +1,102 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EditorGallery snapshot 1`] = ` +
+
+
+ che-incubator/che-code/latest +
+
+ VS Code - Open Source +
+
+ + che-code + + +
+
+ + che-code + + +
+
+
+
+ che-incubator/che-code/latest +
+
+ che-idea-server +
+
+ + che-idea-server + + +
+
+ + che-idea-server + + +
+
+
+`; diff --git a/packages/dashboard-frontend/src/components/EditorSelector/Gallery/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/EditorSelector/Gallery/__tests__/index.spec.tsx new file mode 100644 index 000000000..5b811f839 --- /dev/null +++ b/packages/dashboard-frontend/src/components/EditorSelector/Gallery/__tests__/index.spec.tsx @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2018-2024 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 { EditorGallery, sortEditors } from '@/components/EditorSelector/Gallery'; +import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; +import { che } from '@/services/models'; + +jest.mock('@/components/EditorSelector/Gallery/Entry'); + +const mockOnSelect = jest.fn(); + +const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); + +const defaultEditorId = 'che-incubator/che-code/insiders'; +const selectedEditorId = 'che-incubator/che-code/latest'; + +describe('EditorGallery', () => { + let editors: che.Plugin[]; + + beforeEach(() => { + editors = [ + { + id: 'che-incubator/che-code/insiders', + description: + 'Microsoft Visual Studio Code - Open Source IDE for Eclipse Che - Insiders build', + displayName: 'VS Code - Open Source', + links: { + devfile: '/v3/plugins/che-incubator/che-code/insiders/devfile.yaml', + }, + name: 'che-code', + publisher: 'che-incubator', + type: 'Che Editor', + version: 'insiders', + icon: '/v3/images/vscode.svg', + }, + { + id: 'che-incubator/che-code/latest', + description: 'Microsoft Visual Studio Code - Open Source IDE for Eclipse Che', + displayName: 'VS Code - Open Source', + links: { + devfile: '/v3/plugins/che-incubator/che-code/latest/devfile.yaml', + }, + name: 'che-code', + publisher: 'che-incubator', + type: 'Che Editor', + version: 'latest', + icon: '/v3/images/vscode.svg', + }, + { + id: 'che-incubator/che-idea-server/latest', + description: 'JetBrains IntelliJ IDEA Ultimate dev server for Eclipse Che - latest', + // the displayName is missing - increase the test coverage + // displayName: 'IntelliJ IDEA Ultimate (desktop)', + links: { + devfile: '/v3/plugins/che-incubator/che-idea-server/latest/devfile.yaml', + }, + name: 'che-idea-server', + publisher: 'che-incubator', + type: 'Che Editor', + version: 'latest', + icon: '/v3/images/intllij-idea.svg', + }, + { + id: 'che-incubator/che-idea-server/next', + description: 'JetBrains IntelliJ IDEA Ultimate dev server for Eclipse Che - next', + // the displayName is missing - increase the test coverage + // displayName: 'IntelliJ IDEA Ultimate (desktop)', + links: { + devfile: '/v3/plugins/che-incubator/che-idea-server/next/devfile.yaml', + }, + name: 'che-idea-server', + publisher: 'che-incubator', + type: 'Che Editor', + version: 'next', + icon: '/v3/images/intllij-idea.svg', + }, + ]; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('snapshot', () => { + const snapshot = createSnapshot(editors, defaultEditorId, selectedEditorId); + expect(snapshot.toJSON()).toMatchSnapshot(); + }); + + describe('sortEditors function', () => { + test('insiders <=> latest', () => { + const editorA = editors[0]; // insiders + const editorB = editors[1]; // latest + + const sortedEditors = sortEditors([editorA, editorB]); + + expect(sortedEditors[0].version).toEqual('insiders'); + expect(sortedEditors[1].version).toEqual('latest'); + }); + + test('latest <=> insiders', () => { + const editorA = editors[1]; // latest + const editorB = editors[0]; // insiders + + const sortedEditors = sortEditors([editorA, editorB]); + + expect(sortedEditors[0].version).toEqual('insiders'); + expect(sortedEditors[1].version).toEqual('latest'); + }); + + test('1.0.0 <=> latest', () => { + const editorA = editors[0]; + editorA.version = '1.0.0'; + editorA.id = `${editorA.publisher}/${editorA.name}/${editorA.version}`; + const editorB = editors[1]; // latest + + const sortedEditors = sortEditors([editorA, editorB]); + + expect(sortedEditors[0].version).toEqual('latest'); + expect(sortedEditors[1].version).toEqual('1.0.0'); + }); + + test('insiders <=> 1.0.0', () => { + const editorA = editors[0]; // insiders + const editorB = editors[1]; + editorB.version = '1.0.0'; + editorA.id = `${editorA.publisher}/${editorA.name}/${editorA.version}`; + + const sortedEditors = sortEditors([editorA, editorB]); + + expect(sortedEditors[0].version).toEqual('insiders'); + expect(sortedEditors[1].version).toEqual('1.0.0'); + }); + }); + + describe('select editor', () => { + test('default and selected IDs are the same', () => { + renderComponent(editors, defaultEditorId, defaultEditorId); + + expect(mockOnSelect).toHaveBeenCalledTimes(0); + + const nextEditor = editors[3]; + + const button = screen.getByRole('button', { + name: `Select ${nextEditor.name} ${nextEditor.version}`, + }); + button.click(); + + expect(mockOnSelect).toHaveBeenCalledTimes(1); + expect(mockOnSelect).toHaveBeenNthCalledWith(1, nextEditor.id); + }); + + test('selected ID is different from the default ID', () => { + renderComponent(editors, defaultEditorId, selectedEditorId); + + expect(mockOnSelect).toHaveBeenCalledTimes(0); + + const nextEditor = editors[3]; + + const button = screen.getByRole('button', { + name: `Select ${nextEditor.name} ${nextEditor.version}`, + }); + button.click(); + + expect(mockOnSelect).toHaveBeenCalledTimes(1); + expect(mockOnSelect).toHaveBeenNthCalledWith(1, nextEditor.id); + }); + + test('selected ID is undefined', () => { + renderComponent(editors, defaultEditorId, undefined); + + expect(mockOnSelect).toHaveBeenCalledTimes(1); + expect(mockOnSelect).toHaveBeenNthCalledWith(1, defaultEditorId); + + const nextEditor = editors[3]; + + const button = screen.getByRole('button', { + name: `Select ${nextEditor.name} ${nextEditor.version}`, + }); + button.click(); + + expect(mockOnSelect).toHaveBeenCalledTimes(2); + expect(mockOnSelect).toHaveBeenNthCalledWith(2, nextEditor.id); + }); + + test(`default ID doesn't match any editor and selected ID is undefined`, () => { + renderComponent(editors, 'wrong/editor/id', undefined); + + expect(mockOnSelect).toHaveBeenCalledTimes(1); + expect(mockOnSelect).toHaveBeenNthCalledWith(1, editors[0].id); + + const nextEditor = editors[3]; + + const button = screen.getByRole('button', { + name: `Select ${nextEditor.name} ${nextEditor.version}`, + }); + button.click(); + + expect(mockOnSelect).toHaveBeenCalledTimes(2); + expect(mockOnSelect).toHaveBeenNthCalledWith(2, nextEditor.id); + }); + + test('should rerender when the selected editor changes', () => { + const { reRenderComponent } = renderComponent(editors, defaultEditorId, undefined); + + const selectedEditor = screen.getAllByTestId('selected-editor-id')[0]; + expect(selectedEditor).toHaveTextContent(defaultEditorId); + + reRenderComponent(editors, defaultEditorId, selectedEditorId); + + const nextSelectedEditor = screen.getAllByTestId('selected-editor-id')[0]; + expect(nextSelectedEditor).toHaveTextContent(selectedEditorId); + }); + }); +}); + +function getComponent( + editors: che.Plugin[], + defaultId: string, + selectedId: string | undefined = undefined, +) { + return ( + + ); +} diff --git a/packages/dashboard-frontend/src/components/EditorSelector/Gallery/index.tsx b/packages/dashboard-frontend/src/components/EditorSelector/Gallery/index.tsx new file mode 100644 index 000000000..4eb3f83c1 --- /dev/null +++ b/packages/dashboard-frontend/src/components/EditorSelector/Gallery/index.tsx @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2018-2024 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 { Gallery } from '@patternfly/react-core'; +import React from 'react'; + +import { EditorSelectorEntry } from '@/components/EditorSelector/Gallery/Entry'; +import { che } from '@/services/models'; + +export type Props = { + defaultEditorId: string; + editors: che.Plugin[]; + selectedEditorId: string | undefined; + onSelect: (editorId: string) => void; +}; +export type State = { + selectedId: string; + sortedEditorsByName: Map; +}; + +export class EditorGallery extends React.PureComponent { + constructor(props: Props) { + super(props); + + this.state = { + selectedId: '', // will be set on component mount + sortedEditorsByName: new Map(), + }; + } + + public componentDidMount(): void { + this.init(); + } + + public componentDidUpdate(prevProps: Readonly): void { + if (prevProps.selectedEditorId !== this.props.selectedEditorId) { + this.init(); + } + } + + private init(): void { + const { defaultEditorId, editors, selectedEditorId, onSelect } = this.props; + + const sortedEditors = sortEditors(editors); + + const sortedEditorsByName = new Map(); + + let defaultEditor: che.Plugin | undefined; + let selectedEditor: che.Plugin | undefined; + sortedEditors.forEach(editor => { + const name = editor.name; + if (!sortedEditorsByName.has(name)) { + sortedEditorsByName.set(name, []); + } + sortedEditorsByName.get(name)?.push(editor); + + // find the default editor + if (editor.id === defaultEditorId) { + defaultEditor = editor; + } + // find the selected editor + if (editor.id === selectedEditorId) { + selectedEditor = editor; + } + }); + + let selectedId: string; + if (selectedEditor !== undefined) { + selectedId = selectedEditor.id; + } else { + if (defaultEditor !== undefined) { + selectedId = defaultEditor.id; + } else { + selectedId = sortedEditors[0].id; + } + onSelect(selectedId); + } + + this.setState({ + selectedId, + sortedEditorsByName, + }); + } + + private handleEditorSelect(editorId: string): void { + this.props.onSelect(editorId); + } + + private buildEditorCards(): React.ReactNode[] { + const { selectedId, sortedEditorsByName } = this.state; + + return Array.from(sortedEditorsByName.keys()).map(editorName => { + // editors same name, different version + const editorsGroup = sortedEditorsByName.get(editorName); + + /* c8 ignore start */ + if (editorsGroup === undefined) { + return; + } + /* c8 ignore end */ + + const groupIcon = editorsGroup[0].icon; + const groupName = editorsGroup[0].displayName || editorsGroup[0].name; + + return ( + this.handleEditorSelect(editorId)} + /> + ); + }); + } + + public render() { + return ( + + {this.buildEditorCards()} + + ); + } +} + +const VERSION_PRIORITY: ReadonlyArray = ['insiders', 'next', 'latest']; +export function sortEditors(editors: che.Plugin[]) { + const sorted = editors.sort((a, b) => { + if (a.name === b.name) { + const aPriority = VERSION_PRIORITY.indexOf(a.version); + const bPriority = VERSION_PRIORITY.indexOf(b.version); + + if (aPriority !== -1 && bPriority !== -1) { + return aPriority - bPriority; + } else if (aPriority !== -1) { + return -1; + } else if (bPriority !== -1) { + return 1; + } + } + + return a.id.localeCompare(b.id); + }); + + return sorted; +} diff --git a/packages/dashboard-frontend/src/components/EditorSelector/__mocks__/index.tsx b/packages/dashboard-frontend/src/components/EditorSelector/__mocks__/index.tsx new file mode 100644 index 000000000..0a8064947 --- /dev/null +++ b/packages/dashboard-frontend/src/components/EditorSelector/__mocks__/index.tsx @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2018-2024 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 { Props } from '@/components/EditorSelector'; + +export default class EditorSelector extends React.PureComponent { + render() { + const { onSelect } = this.props; + return ( +
+ Editor Selector + +
+ ); + } +} diff --git a/packages/dashboard-frontend/src/components/EditorSelector/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/components/EditorSelector/__tests__/__snapshots__/index.spec.tsx.snap new file mode 100644 index 000000000..7db150492 --- /dev/null +++ b/packages/dashboard-frontend/src/components/EditorSelector/__tests__/__snapshots__/index.spec.tsx.snap @@ -0,0 +1,231 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Editor Selector snapshot 1`] = ` +
+
+

+ Editor Selector +

+
+
+
+
+

+ +

+ +

+ +

+ +
+
+`; diff --git a/packages/dashboard-frontend/src/components/EditorSelector/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/EditorSelector/__tests__/index.spec.tsx new file mode 100644 index 000000000..fa9aa80ae --- /dev/null +++ b/packages/dashboard-frontend/src/components/EditorSelector/__tests__/index.spec.tsx @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2018-2024 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 { api } from '@eclipse-che/common'; +import { StateMock } from '@react-mock/state'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { Provider } from 'react-redux'; + +import EditorSelector, { State } from '@/components/EditorSelector'; +import mockPlugins from '@/pages/GetStarted/__tests__/plugins.json'; +import getComponentRenderer, { screen, within } from '@/services/__mocks__/getComponentRenderer'; +import { che } from '@/services/models'; +import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; + +jest.mock('@/components/EditorSelector/Definition'); +jest.mock('@/components/EditorSelector/Gallery'); + +const plugins = mockPlugins as che.Plugin[]; + +const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); + +const mockOnSelect = jest.fn(); + +describe('Editor Selector', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('snapshot', () => { + const snapshot = createSnapshot(); + expect(snapshot.toJSON()).toMatchSnapshot(); + }); + + test('accordion content toggling', async () => { + renderComponent(); + + const editorGalleryButton = screen.getByRole('button', { name: 'Choose an Editor' }); + const editorDefinitionButton = screen.getByRole('button', { name: 'Use an Editor Definition' }); + + // initially the gallery is visible and the definition is not + await expect(screen.findByTestId('editor-gallery-content')).resolves.toBeInTheDocument(); + + userEvent.click(editorDefinitionButton); + + // now the gallery is not visible and the definition is + await expect(screen.findByTestId('editor-definition-content')).resolves.toBeInTheDocument(); + + userEvent.click(editorGalleryButton); + + // now the gallery is visible and the definition is not + await expect(screen.findByTestId('editor-gallery-content')).resolves.toBeInTheDocument(); + }); + + test('select editor from gallery', () => { + renderComponent(); + + const editorGallery = screen.queryByTestId('editor-gallery-content')!; + expect(editorGallery).not.toBeNull(); + + // initial default editor ID + const defaultEditor = within(editorGallery).getByTestId('default-editor-id'); + expect(defaultEditor).toHaveTextContent('default/editor/id'); + + // initial selected editor ID + const selectedEditor = within(editorGallery).getByTestId('selected-editor-id'); + expect(selectedEditor).toHaveTextContent(''); + + const selectEditorButton = within(editorGallery).getByRole('button', { + name: 'Select Editor', + }); + + userEvent.click(selectEditorButton); + + expect(mockOnSelect).toHaveBeenCalledWith('che-incubator/che-code/latest', undefined); + + // next selected editor ID + expect(screen.queryByTestId('selected-editor-id')).toHaveTextContent( + 'che-incubator/che-code/latest', + ); + }); + + test('define editor by ID and editor image', () => { + renderComponent({ + expandedId: 'definition', + selectorEditorValue: 'some/editor/id', + definitionEditorValue: undefined, + definitionImageValue: undefined, + }); + + const editorDefinitionPanel = screen.queryByTestId('editor-definition-content')!; + expect(editorDefinitionPanel).not.toBeNull(); + + // initial editor definition state + const editorDefinition = within(editorDefinitionPanel).getByTestId('editor-definition'); + expect(editorDefinition).toHaveTextContent(''); + + // initial editor image + const editorImage = within(editorDefinitionPanel).getByTestId('editor-image'); + expect(editorImage).toHaveTextContent(''); + + const changeDefinitionButton = within(editorDefinitionPanel).getByRole('button', { + name: 'Editor Definition Change', + }); + + userEvent.click(changeDefinitionButton); + + expect(mockOnSelect).toHaveBeenCalledWith('some/editor/id', 'editor-image'); + + // next editor definition + expect(screen.getByTestId('editor-definition')).toHaveTextContent('some/editor/id'); + // next editor image + expect(screen.getByTestId('editor-image')).toHaveTextContent('editor-image'); + }); +}); + +function getComponent(localState?: State) { + const store = new FakeStoreBuilder() + .withPlugins(plugins) + .withDwServerConfig({ + defaults: { + editor: 'che-incubator/che-code/insiders', + } as api.IServerConfig['defaults'], + }) + .build(); + + const component = ; + + if (localState) { + return ( + + {component} + + ); + } + + return {component}; +} diff --git a/packages/dashboard-frontend/src/components/EditorSelector/index.tsx b/packages/dashboard-frontend/src/components/EditorSelector/index.tsx new file mode 100644 index 000000000..8efbb5347 --- /dev/null +++ b/packages/dashboard-frontend/src/components/EditorSelector/index.tsx @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2018-2024 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 { + Accordion, + AccordionContent, + AccordionItem, + AccordionToggle, + Button, + Flex, + FlexItem, + Panel, + PanelHeader, + PanelMain, + PanelMainBody, + Title, +} from '@patternfly/react-core'; +import React from 'react'; +import { connect, ConnectedProps } from 'react-redux'; + +import { EditorDefinition } from '@/components/EditorSelector/Definition'; +import { EditorGallery } from '@/components/EditorSelector/Gallery'; +import { AppState } from '@/store'; +import { selectEditors } from '@/store/Plugins/chePlugins/selectors'; + +const DOCS_DEFINING_A_COMMON_IDE = + 'https://eclipse.dev/che/docs/stable/end-user-guide/defining-a-common-ide/'; + +type AccordionId = 'selector' | 'definition'; + +export type Props = MappedProps & { + defaultEditorId: string; + onSelect: (editorDefinition: string | undefined, editorImage: string | undefined) => void; +}; +export type State = { + definitionEditorValue: string | undefined; + definitionImageValue: string | undefined; + + selectorEditorValue: string; + + expandedId: AccordionId | undefined; +}; + +class EditorSelector extends React.PureComponent { + constructor(props: Props) { + super(props); + + this.state = { + definitionEditorValue: undefined, + definitionImageValue: undefined, + + selectorEditorValue: '', + + expandedId: 'selector', + }; + } + + private handleSelectorValueChange(editorId: string): void { + this.setState({ selectorEditorValue: editorId }); + this.props.onSelect(editorId, undefined); + } + + private handleDefinitionValueChange( + editorDefinition: string | undefined, + editorImage: string | undefined, + ): void { + this.setState({ + definitionEditorValue: editorDefinition, + definitionImageValue: editorImage, + }); + this.props.onSelect(editorDefinition, editorImage); + } + + private handleToggle(expandedId: AccordionId): void { + const { onSelect } = this.props; + + this.setState({ + expandedId: this.state.expandedId === expandedId ? this.state.expandedId : expandedId, + }); + + const { definitionEditorValue, definitionImageValue, selectorEditorValue } = this.state; + + if (expandedId === 'selector') { + onSelect(selectorEditorValue, undefined); + } else { + onSelect(definitionEditorValue, definitionImageValue); + } + } + + render(): React.ReactElement { + const { defaultEditorId, editors } = this.props; + const { definitionEditorValue, definitionImageValue, selectorEditorValue, expandedId } = + this.state; + + return ( + + + Editor Selector + + + + + + { + this.handleToggle('selector'); + }} + isExpanded={expandedId === 'selector'} + id="accordion-item-selector" + > + Choose an Editor + + + + + + + this.handleSelectorValueChange(editorId)} + /> + + + + + + + + { + this.handleToggle('definition'); + }} + isExpanded={expandedId === 'definition'} + id="accordion-item-definition" + > + Use an Editor Definition + + + + + + + + this.handleDefinitionValueChange(editorDefinition, editorImage) + } + /> + + + + + + + + + + + + + + + ); + } +} + +const mapStateToProps = (state: AppState) => ({ + editors: selectEditors(state), +}); + +const connector = connect(mapStateToProps, null, null, { + // forwardRef is mandatory for using `@react-mock/state` in unit tests + forwardRef: true, +}); + +type MappedProps = ConnectedProps; +export default connector(EditorSelector); diff --git a/packages/dashboard-frontend/src/components/Spacer/index.module.css b/packages/dashboard-frontend/src/components/Spacer/index.module.css new file mode 100644 index 000000000..fbecea327 --- /dev/null +++ b/packages/dashboard-frontend/src/components/Spacer/index.module.css @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2018-2024 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 + */ + +.spacer { + margin-top: var(--pf-c-page__main-section--PaddingTop); +} diff --git a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/__mocks__/index.tsx b/packages/dashboard-frontend/src/components/Spacer/index.tsx similarity index 57% rename from packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/__mocks__/index.tsx rename to packages/dashboard-frontend/src/components/Spacer/index.tsx index cb1735eab..2893b98e5 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/__mocks__/index.tsx +++ b/packages/dashboard-frontend/src/components/Spacer/index.tsx @@ -12,15 +12,10 @@ import React from 'react'; -import { Props } from '@/pages/GetStarted/GetStartedTab/ImportFromGit'; +import styles from '@/components/Spacer/index.module.css'; -export class ImportFromGit extends React.PureComponent { +export class Spacer extends React.PureComponent { public render() { - const { hasSshKeys } = this.props; - return ( -
- {hasSshKeys ? 'true' : 'false'} -
- ); + return
; } } diff --git a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/DropdownEditors/index.tsx b/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/DropdownEditors/index.tsx deleted file mode 100644 index 703311ee8..000000000 --- a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/DropdownEditors/index.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright (c) 2018-2024 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 { DropdownGroup, DropdownItem } from '@patternfly/react-core'; -import { CheckIcon } from '@patternfly/react-icons'; -import React from 'react'; - -import TagLabel from '@/components/TagLabel'; -import styles from '@/pages/GetStarted/GetStartedTab/DropdownEditors/index.module.css'; -import { TargetEditor } from '@/pages/GetStarted/GetStartedTab/SamplesListGallery'; - -type Props = { - targetEditors: TargetEditor[]; - onClick: (editorId: string) => void; -}; - -class DropdownEditors extends React.PureComponent { - public render(): React.ReactElement { - const { targetEditors, onClick } = this.props; - - return ( - - {targetEditors.map(editor => { - return ( - onClick(editor.id)} - > - {editor.name} - - {editor.isDefault && ( - - )} - - ); - })} - - ); - } -} - -export default DropdownEditors; diff --git a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/__tests__/__snapshots__/index.spec.tsx.snap deleted file mode 100644 index 5c9d3242c..000000000 --- a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/__tests__/__snapshots__/index.spec.tsx.snap +++ /dev/null @@ -1,109 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`GitRepoLocationInput snapshot 1`] = ` -
-
-

- Import from Git -

-
-
- - -
-
-
-
- -
-
- -
-
-
- Import from a Git repository to create your first workspace. -
-
-
-
-
-`; diff --git a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/__tests__/index.spec.tsx deleted file mode 100644 index 73c38a0ee..000000000 --- a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/__tests__/index.spec.tsx +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Copyright (c) 2018-2024 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 userEvent from '@testing-library/user-event'; -import { createMemoryHistory } from 'history'; -import React from 'react'; -import { Provider } from 'react-redux'; - -import { ImportFromGit } from '@/pages/GetStarted/GetStartedTab/ImportFromGit'; -import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; - -const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); - -const history = createMemoryHistory({ - initialEntries: ['/'], -}); - -global.window.open = jest.fn(); - -describe('GitRepoLocationInput', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - test('snapshot', () => { - const component = createSnapshot(true); - expect(component).toMatchSnapshot(); - }); - - test('valid http:// location', () => { - renderComponent(true); - - const input = screen.getByRole('textbox'); - expect(input).toBeValid(); - - userEvent.paste(input, 'http://test-location/'); - - expect(input).toHaveValue('http://test-location/'); - expect(input).toBeValid(); - - const button = screen.getByRole('button'); - expect(button).toBeEnabled(); - - userEvent.click(button); - expect(window.open).toHaveBeenLastCalledWith( - 'http://localhost/#http://test-location/', - '_blank', - ); - expect(window.open).toHaveBeenCalledTimes(1); - - userEvent.type(input, '{enter}'); - expect(window.open).toHaveBeenCalledTimes(2); - }); - - test('invalid location', () => { - renderComponent(true); - - const input = screen.getByRole('textbox'); - expect(input).toBeValid(); - - userEvent.paste(input, 'invalid-test-location'); - - expect(input).toHaveValue('invalid-test-location'); - expect(input).toBeInvalid(); - - const button = screen.getByRole('button'); - expect(button).toBeDisabled(); - - userEvent.type(input, '{enter}'); - expect(window.open).not.toHaveBeenCalled(); - }); - - test('valid Git+SSH location with SSH keys', () => { - renderComponent(true); - - const input = screen.getByRole('textbox'); - expect(input).toBeValid(); - - userEvent.paste(input, 'git@github.com:user/repo.git'); - - expect(input).toHaveValue('git@github.com:user/repo.git'); - expect(input).toBeValid(); - - const buttonCreate = screen.getByRole('button', { name: 'Create & Open' }); - expect(buttonCreate).toBeEnabled(); - }); - - test('valid Git+SSH location w/o SSH keys', () => { - renderComponent(false); - - const input = screen.getByRole('textbox'); - expect(input).toBeValid(); - - userEvent.paste(input, 'git@github.com:user/repo.git'); - - expect(input).toHaveValue('git@github.com:user/repo.git'); - expect(input).toBeInvalid(); - - const buttonCreate = screen.getByRole('button', { name: 'Create & Open' }); - expect(buttonCreate).toBeDisabled(); - - const buttonUserPreferences = screen.getByRole('button', { name: 'here' }); - - userEvent.click(buttonUserPreferences); - expect(history.location.pathname).toBe('/user-preferences'); - expect(history.location.search).toBe('?tab=SshKeys'); - }); -}); - -function getComponent(hasSshKeys: boolean) { - const store = new FakeStoreBuilder().build(); - return ( - - - - ); -} diff --git a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/SampleCard/index.tsx b/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/SampleCard/index.tsx deleted file mode 100644 index d70ab63fc..000000000 --- a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/SampleCard/index.tsx +++ /dev/null @@ -1,168 +0,0 @@ -/* - * Copyright (c) 2018-2024 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 { - Badge, - Brand, - Card, - CardActions, - CardBody, - CardHeader, - CardHeaderMain, - Dropdown, - DropdownPosition, - KebabToggle, -} from '@patternfly/react-core'; -import { CubesIcon } from '@patternfly/react-icons'; -import React from 'react'; - -import DropdownEditors from '@/pages/GetStarted/GetStartedTab/DropdownEditors'; -import styles from '@/pages/GetStarted/GetStartedTab/SampleCard/index.module.css'; -import { TargetEditor, VISIBLE_TAGS } from '@/pages/GetStarted/GetStartedTab/SamplesListGallery'; -import { che } from '@/services/models'; -import { convertIconToSrc } from '@/services/registry/devfiles'; - -type Props = { - metadata: che.DevfileMetaData; - targetEditors: TargetEditor[]; - onClick: (editorId: string | undefined) => void; -}; -type State = { - isExpanded: boolean; -}; - -export class SampleCard extends React.PureComponent { - constructor(props: Props) { - super(props); - - this.state = { - isExpanded: false, - }; - } - - private getTags(): JSX.Element[] { - const { - metadata: { tags }, - } = this.props; - - const createTag = (text: string, key: number): React.ReactElement => { - return ( - - {text.trim()} - - ); - }; - - return tags - .filter(tag => VISIBLE_TAGS.indexOf(tag) !== -1) - .map((item: string, index: number) => createTag(item, index)); - } - - private getEditors(): TargetEditor[] { - const editors: TargetEditor[] = []; - this.props.targetEditors.forEach((editor: TargetEditor) => { - const isAdded = editors.find(e => e.name === editor.name); - if (!isAdded) { - editors.push(editor); - return; - } - if (isAdded.isDefault || isAdded.version === 'next') { - return; - } - if (editor.isDefault || editor.version === 'next' || editor.version === 'latest') { - const existingEditorIndex = editors.indexOf(isAdded); - editors[existingEditorIndex] = editor; - } - }); - return editors; - } - - private getDropdownItems(): React.ReactNode[] { - const targetEditors = this.getEditors(); - - return [ - { - this.setState({ isExpanded: false }); - this.props.onClick(editorId); - }} - />, - ]; - } - - render(): React.ReactElement { - const { metadata } = this.props; - const { isExpanded } = this.state; - const tags = this.getTags(); - const devfileIcon = this.buildIcon(metadata); - const dropdownItems = this.getDropdownItems(); - const onClickHandler = () => this.props.onClick(undefined); - - return ( - - - {devfileIcon} - - {tags} - e.stopPropagation()} - toggle={ - { - this.setState({ isExpanded }); - }} - /> - } - isOpen={isExpanded} - position={DropdownPosition.right} - dropdownItems={dropdownItems} - isPlain - /> - - - {metadata.displayName} - {metadata.description} - - ); - } - - private buildIcon(metadata: che.DevfileMetaData): React.ReactElement { - const props = { - className: styles.sampleCardIcon, - alt: metadata.displayName, - 'aria-label': metadata.displayName, - 'data-testid': 'sample-card-icon', - }; - - return metadata.icon ? ( - - ) : ( - - ); - } -} diff --git a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/SamplesListGallery.tsx b/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/SamplesListGallery.tsx deleted file mode 100644 index 90ea4394a..000000000 --- a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/SamplesListGallery.tsx +++ /dev/null @@ -1,259 +0,0 @@ -/* - * Copyright (c) 2018-2024 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 { - Alert, - AlertActionCloseButton, - AlertGroup, - AlertVariant, - Button, - EmptyState, - EmptyStateBody, - EmptyStateIcon, - EmptyStatePrimary, - EmptyStateVariant, - Gallery, - Title, -} from '@patternfly/react-core'; -import { SearchIcon } from '@patternfly/react-icons'; -import React from 'react'; -import { connect, ConnectedProps } from 'react-redux'; - -import { SampleCard } from '@/pages/GetStarted/GetStartedTab/SampleCard'; -import { AlertItem } from '@/services/helpers/types'; -import { che } from '@/services/models'; -import { AppState } from '@/store'; -import * as DevfileRegistriesStore from '@/store/DevfileRegistries'; -import { EMPTY_WORKSPACE_TAG, selectMetadataFiltered } from '@/store/DevfileRegistries/selectors'; -import * as FactoryResolverStore from '@/store/FactoryResolver'; -import { selectEditors } from '@/store/Plugins/chePlugins/selectors'; -import { selectDefaultEditor } from '@/store/Plugins/devWorkspacePlugins/selectors'; - -export type TargetEditor = { - id: string; - name: string; - tooltip: string | undefined; - version: string; - isDefault: boolean; -}; -type Props = MappedProps & { - onCardClick: ( - devfileContent: string, - stackName: string, - optionalFilesContent?: { - [fileName: string]: string; - }, - ) => void; - storageType: che.WorkspaceStorageType; -}; -type State = { - alerts: AlertItem[]; -}; - -export const VISIBLE_TAGS = ['Community', 'Tech-Preview', 'Devfile.io']; - -const EXCLUDED_TARGET_EDITOR_NAMES = ['dirigible', 'jupyter', 'eclipseide', 'code-server']; - -export class SamplesListGallery extends React.PureComponent { - private static sortByName(a: TargetEditor, b: TargetEditor): number { - if (a.name < b.name) { - return -1; - } - if (a.name > b.name) { - return 1; - } - return 0; - } - private static sortByVisibleTag(a: che.DevfileMetaData, b: che.DevfileMetaData): number { - const getVisibleTag = (metadata: che.DevfileMetaData) => - metadata.tags.filter(tag => VISIBLE_TAGS.includes(tag))[0]; - const tagA = getVisibleTag(a); - const tagB = getVisibleTag(b); - if (tagA === tagB) { - return 0; - } - if (tagA === undefined || tagA < tagB) { - return -1; - } - if (tagB === undefined || tagA > tagB) { - return 1; - } - return 0; - } - private static sortByEmptyWorkspaceTag(a: che.DevfileMetaData, b: che.DevfileMetaData): number { - if (a.tags.includes(EMPTY_WORKSPACE_TAG) > b.tags.includes(EMPTY_WORKSPACE_TAG)) { - return -1; - } - if (a.tags.includes(EMPTY_WORKSPACE_TAG) < b.tags.includes(EMPTY_WORKSPACE_TAG)) { - return 1; - } - return 0; - } - private static sortByDisplayName(a: che.DevfileMetaData, b: che.DevfileMetaData): number { - if (a.displayName < b.displayName) { - return -1; - } - if (a.displayName > b.displayName) { - return 1; - } - return 0; - } - - private isLoading: boolean; - - constructor(props: Props) { - super(props); - - this.state = { - alerts: [], - }; - this.isLoading = false; - } - - private removeAlert(key: string): void { - this.setState({ alerts: [...this.state.alerts.filter(al => al.key !== key)] }); - } - - render(): React.ReactElement { - const metadata = this.props.metadataFiltered; - const cards = this.buildCardsList(metadata); - - if (cards.length) { - return ( - - - {this.state.alerts.map(({ title, variant, key }) => ( - this.removeAlert(key)} />} - /> - ))} - - {cards} - - ); - } - - return this.buildEmptyState(); - } - - private async fetchDevfile(meta: che.DevfileMetaData, editor: string | undefined): Promise { - if (this.isLoading) { - return; - } - this.isLoading = true; - try { - if (meta.links.v2) { - const link = encodeURIComponent(meta.links.v2); - let devWorkspace = ''; - if (!editor && this.props.defaultEditor) { - editor = this.props.defaultEditor; - } - if (editor) { - const prebuiltDevWorkspace = meta.links.devWorkspaces?.[editor]; - const storageType = this.props.storageType; - devWorkspace = prebuiltDevWorkspace - ? `&devWorkspace=${encodeURIComponent(prebuiltDevWorkspace)}&storageType=${storageType}` - : `&storageType=${storageType}`; - } - // use factory workflow to load the getting started samples - let factoryUrl = `${window.location.origin}${window.location.pathname}#/load-factory?url=${link}${devWorkspace}`; - if (editor !== this.props.defaultEditor) { - factoryUrl += `&che-editor=${editor}`; - } - // open a new page to handle that - window.open(factoryUrl, '_blank'); - } else if (meta.links.self) { - const devfileContent = (await this.props.requestDevfile(meta.links.self)) as string; - this.props.onCardClick(devfileContent, meta.displayName); - } - } catch (e) { - console.warn('Failed to load devfile.', e); - const key = meta.links.self ? meta.links.self : meta.links.v2 || meta.displayName; - const alerts = [ - ...this.state.alerts, - { - key, - title: `Failed to load devfile "${meta.displayName}"`, - variant: AlertVariant.warning, - }, - ]; - this.setState({ alerts }); - } - this.isLoading = false; - } - - private buildCardsList(metadata: che.DevfileMetaData[] = []): React.ReactElement[] { - const { editors, defaultEditor } = this.props; - const targetEditors = editors - .filter(editor => !EXCLUDED_TARGET_EDITOR_NAMES.includes(editor.name)) - .map(editor => { - return { - id: editor.id, - version: editor.version, - name: editor.displayName, - tooltip: editor.description, - isDefault: defaultEditor === editor.id, - } as TargetEditor; - }); - targetEditors.sort(SamplesListGallery.sortByName); - - return metadata - .sort(SamplesListGallery.sortByDisplayName) - .sort(SamplesListGallery.sortByVisibleTag) - .sort(SamplesListGallery.sortByEmptyWorkspaceTag) - .map(meta => ( - => - this.fetchDevfile(meta, editorId) - } - /> - )); - } - - private buildEmptyState(): React.ReactElement { - return ( - - - No results found - - No results match the filter criteria. Clear filter to show results. - - - - - - ); - } -} - -const mapStateToProps = (state: AppState) => ({ - metadataFiltered: selectMetadataFiltered(state), - factoryResolver: state.factoryResolver, - editors: selectEditors(state), - defaultEditor: selectDefaultEditor(state), -}); - -const connector = connect(mapStateToProps, { - ...DevfileRegistriesStore.actionCreators, - ...FactoryResolverStore.actionCreators, -}); - -type MappedProps = ConnectedProps; -export default connector(SamplesListGallery); diff --git a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/SamplesListHeader.tsx b/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/SamplesListHeader.tsx deleted file mode 100644 index 9775d5bb4..000000000 --- a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/SamplesListHeader.tsx +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright (c) 2018-2024 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 { Text, TextContent, TextVariants } from '@patternfly/react-core'; -import React from 'react'; - -const TITLE = 'Select a Sample'; -const DESCRIPTION = 'Select a sample to create your first workspace.'; - -export class SamplesListHeader extends React.PureComponent { - constructor(props) { - super(props); - } - - render(): React.ReactElement { - return ( - - {TITLE} - {DESCRIPTION} - - ); - } -} diff --git a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/SamplesListToolbar.tsx b/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/SamplesListToolbar.tsx deleted file mode 100644 index 2c408dec8..000000000 --- a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/SamplesListToolbar.tsx +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright (c) 2018-2024 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 { - Flex, - FlexItem, - Text, - TextContent, - TextInput, - TextInputProps, -} from '@patternfly/react-core'; -import React from 'react'; -import Pluralize from 'react-pluralize'; -import { connect, ConnectedProps } from 'react-redux'; - -import TemporaryStorageSwitch from '@/pages/GetStarted/GetStartedTab/TemporaryStorageSwitch'; -import { AppState } from '@/store'; -import * as DevfileRegistriesStore from '@/store/DevfileRegistries'; -import { selectFilterValue, selectMetadataFiltered } from '@/store/DevfileRegistries/selectors'; - -type Props = MappedProps & { - persistVolumesDefault: string; - onTemporaryStorageChange: (temporary: boolean) => void; -}; -type State = { - filterValue: string; -}; - -export class SamplesListToolbar extends React.PureComponent { - handleTextInputChange: TextInputProps['onChange']; - buildSearchBox: (searchValue: string) => React.ReactElement; - - constructor(props: Props) { - super(props); - - this.state = { - filterValue: '', - }; - - this.handleTextInputChange = (searchValue): void => { - this.setState({ filterValue: searchValue }); - this.props.setFilter(searchValue); - }; - this.buildSearchBox = (filterValue: string): React.ReactElement => ( - - ); - } - - componentWillUnmount(): void { - this.props.clearFilter(); - } - - render(): React.ReactElement { - const filterValue = this.props.filterValue || ''; - const foundCount = this.props.metadataFiltered.length; - - return ( - - {this.buildSearchBox(filterValue)} - - - {this.buildCount(foundCount, filterValue)} - - - - - - - ); - } - - private buildCount(foundCount: number, searchValue: string): React.ReactElement { - return searchValue === '' ? ( - - ) : ( - - ); - } -} - -const mapStateToProps = (state: AppState) => ({ - filterValue: selectFilterValue(state), - metadataFiltered: selectMetadataFiltered(state), -}); - -const connector = connect(mapStateToProps, DevfileRegistriesStore.actionCreators); - -type MappedProps = ConnectedProps; -export default connector(SamplesListToolbar); diff --git a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/__tests__/DropdownEditors.spec.tsx b/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/__tests__/DropdownEditors.spec.tsx deleted file mode 100644 index 9b247a3aa..000000000 --- a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/__tests__/DropdownEditors.spec.tsx +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright (c) 2018-2024 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 { Dropdown, KebabToggle } from '@patternfly/react-core'; -import { fireEvent, render, RenderResult, screen, within } from '@testing-library/react'; -import React from 'react'; - -import DropdownEditors from '@/pages/GetStarted/GetStartedTab/DropdownEditors'; -import { TargetEditor } from '@/pages/GetStarted/GetStartedTab/SamplesListGallery'; - -const onItemClick = jest.fn(); - -describe('DropdownEditors component', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should have editor items', () => { - const editors: TargetEditor[] = [ - { - id: 'che-incubator/che-code/insiders', - name: 'che-code', - tooltip: 'Code OSS integration in Eclipse Che.', - version: 'insiders', - isDefault: true, - }, - { - id: 'eclipse/che-theia/next', - name: 'theia-ide', - tooltip: 'Eclipse Theia.', - version: 'next', - isDefault: false, - }, - ]; - - const component = ( - } - dropdownItems={[ - , - ]} - /> - ); - renderComponent(component); - - const menuitems = screen.getAllByRole('menuitem'); - - expect(menuitems.length).toEqual(2); - - const codeItem = menuitems[0]; - expect(codeItem.textContent).toContain('che-code'); - - const codeItemCheckMark = within(codeItem).queryByTestId('checkmark'); - expect(codeItemCheckMark).not.toBeNull(); - - const theiaItem = menuitems[1]; - expect(theiaItem.textContent).toContain('theia-ide'); - - const theiaItemCheckMark = within(theiaItem).queryByTestId('checkmark'); - expect(theiaItemCheckMark).toBeNull(); - }); - - it('should handle "onClick" events', () => { - const editors: TargetEditor[] = [ - { - id: 'che-incubator/che-code/insiders', - name: 'che-code', - tooltip: 'Code OSS integration in Eclipse Che.', - version: 'insiders', - isDefault: true, - }, - { - id: 'eclipse/che-theia/next', - name: 'theia-ide', - tooltip: 'Eclipse Theia.', - version: 'next', - isDefault: false, - }, - ]; - - const component = ( - } - dropdownItems={[ - , - ]} - /> - ); - renderComponent(component); - - const theiaItem = screen.getByText('theia-ide'); - fireEvent.click(theiaItem); - - expect(onItemClick).toHaveBeenCalledWith('eclipse/che-theia/next'); - - const codeItem = screen.getByText('che-code'); - fireEvent.click(codeItem); - - expect(onItemClick).toHaveBeenCalledWith('che-incubator/che-code/insiders'); - }); -}); - -function renderComponent(component: React.ReactElement): RenderResult { - return render(component); -} diff --git a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/__tests__/GetStartedTab.spec.tsx b/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/__tests__/GetStartedTab.spec.tsx deleted file mode 100644 index 02b1c884d..000000000 --- a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/__tests__/GetStartedTab.spec.tsx +++ /dev/null @@ -1,232 +0,0 @@ -/* - * Copyright (c) 2018-2024 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 { api } from '@eclipse-che/common'; -import { render, RenderResult, screen } from '@testing-library/react'; -import { createMemoryHistory } from 'history'; -import React from 'react'; -import { Provider } from 'react-redux'; - -import { BrandingData } from '@/services/bootstrap/branding.constant'; -import devfileApi from '@/services/devfileApi'; -import { che } from '@/services/models'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { selectPvcStrategy } from '@/store/ServerConfig/selectors'; - -import { SamplesListTab } from '..'; - -jest.mock('@/pages/GetStarted/GetStartedTab/ImportFromGit'); -const history = createMemoryHistory({ - initialEntries: ['/'], -}); - -const onDevfileMock: ( - devfileContent: string, - stackName: string, - optionalFilesContent?: { [fileName: string]: string }, -) => Promise = jest.fn().mockResolvedValue(true); - -const testStackName = 'http://test-location/'; -const testDevfileName = 'Custom Devfile'; -const testDevfile = { - schemaVersion: '2.2.0', - metadata: { name: testDevfileName }, -} as devfileApi.Devfile; - -jest.mock('../SamplesListGallery', () => { - const FakeSamplesListGallery = (props: { - onCardClick: (devfileContent: string, stackName: string) => void; - }) => ( -
- -
- ); - FakeSamplesListGallery.displayName = 'SamplesListGallery'; - return FakeSamplesListGallery; -}); - -describe('Samples list tab', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - const renderComponent = (preferredStorageType: che.WorkspaceStorageType): RenderResult => { - const brandingData = { - name: 'Product Name', - docs: { - storageTypes: 'https://dummy.location', - }, - } as BrandingData; - const store = new FakeStoreBuilder() - .withDwServerConfig({ - defaults: { - pvcStrategy: preferredStorageType, - }, - } as api.IServerConfig) - .withDevWorkspaces({ - workspaces: [], - }) - .withBranding(brandingData) - .build(); - - const state = store.getState(); - - return render( - - - , - ); - }; - - it('should correctly render the samples list component', () => { - renderComponent('persistent'); - - const switchInput = screen.queryByRole('checkbox'); - expect(switchInput).toBeDefined(); - - const sampleItem = screen.queryByRole('sample-item-id'); - expect(sampleItem).toBeDefined(); - }); - - it('should prevent double-clicking during the creation of a new workspace', () => { - const preferredStorageType = 'persistent'; - renderComponent(preferredStorageType); - - const switchInput = screen.getByRole('checkbox') as HTMLInputElement; - expect(switchInput.checked).toBeFalsy(); - - const sampleItem = screen.getByTestId('sample-item-id'); - expect(onDevfileMock).not.toBeCalled(); - - sampleItem.click(); - sampleItem.click(); - expect(onDevfileMock).toBeCalledTimes(1); - expect(onDevfileMock).toHaveBeenCalledWith( - expect.not.stringContaining('controller.devfile.io/storage-type:'), - testStackName, - undefined, - ); - (onDevfileMock as jest.Mock).mockClear(); - }); - - it('should correctly apply the preferred storage type "persistent"', () => { - const preferredStorageType = 'persistent'; - renderComponent(preferredStorageType); - - const switchInput = screen.getByRole('checkbox') as HTMLInputElement; - expect(switchInput.checked).toBeFalsy(); - - const sampleItem = screen.getByTestId('sample-item-id'); - expect(onDevfileMock).not.toBeCalled(); - - sampleItem.click(); - expect(onDevfileMock).toHaveBeenCalledWith( - expect.not.stringContaining('controller.devfile.io/storage-type:'), - testStackName, - undefined, - ); - }); - - it('should correctly apply the storage type "ephemeral"', () => { - const preferredStorageType = 'persistent'; - renderComponent(preferredStorageType); - - const switchInput = screen.getByRole('checkbox') as HTMLInputElement; - expect(switchInput.checked).toBeFalsy(); - - const sampleItem = screen.getByTestId('sample-item-id'); - expect(onDevfileMock).not.toBeCalled(); - - switchInput.click(); - expect(switchInput.checked).toBeTruthy(); - - sampleItem.click(); - expect(onDevfileMock).toHaveBeenCalledWith( - expect.stringContaining('controller.devfile.io/storage-type: ephemeral'), - testStackName, - undefined, - ); - }); - - it('should correctly apply the preferred storage type "async"', () => { - const preferredStorageType = 'async'; - renderComponent(preferredStorageType); - - const switchInput = screen.getByRole('checkbox') as HTMLInputElement; - expect(switchInput.checked).toBeFalsy(); - - const sampleItem = screen.getByTestId('sample-item-id'); - expect(onDevfileMock).not.toBeCalled(); - - sampleItem.click(); - expect(onDevfileMock).toHaveBeenCalledWith( - expect.stringContaining('controller.devfile.io/storage-type: async'), - testStackName, - undefined, - ); - }); - - it('should correctly apply the storage type "ephemeral"', () => { - const preferredStorageType = 'persistent'; - renderComponent(preferredStorageType); - - const switchInput = screen.getByRole('checkbox') as HTMLInputElement; - expect(switchInput.checked).toBeFalsy(); - - const sampleItem = screen.getByTestId('sample-item-id'); - expect(onDevfileMock).not.toBeCalled(); - - switchInput.click(); - expect(switchInput.checked).toBeTruthy(); - - sampleItem.click(); - expect(onDevfileMock).toHaveBeenCalledWith( - expect.stringContaining('controller.devfile.io/storage-type: ephemeral'), - testStackName, - undefined, - ); - }); - - it('should correctly apply the preferred storage type "persistent"', () => { - const preferredStorageType = 'ephemeral'; - renderComponent(preferredStorageType); - - const switchInput = screen.getByRole('checkbox') as HTMLInputElement; - expect(switchInput.checked).toBeTruthy(); - - const sampleItem = screen.getByTestId('sample-item-id'); - expect(onDevfileMock).not.toBeCalled(); - - switchInput.click(); - expect(switchInput.checked).toBeFalsy(); - - sampleItem.click(); - expect(onDevfileMock).toHaveBeenCalledWith( - expect.not.stringContaining('controller.devfile.io/storage-type:'), - testStackName, - undefined, - ); - }); -}); diff --git a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/__tests__/SamplesListGallery.spec.tsx b/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/__tests__/SamplesListGallery.spec.tsx deleted file mode 100644 index 9dbc8ed56..000000000 --- a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/__tests__/SamplesListGallery.spec.tsx +++ /dev/null @@ -1,158 +0,0 @@ -/* - * Copyright (c) 2018-2024 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 { fireEvent, render, RenderResult, screen } from '@testing-library/react'; -import mockAxios from 'axios'; -import React from 'react'; -import { Provider } from 'react-redux'; -import { Store } from 'redux'; - -import mockMetadata from '@/pages/GetStarted/__tests__/devfileMetadata.json'; -import SamplesListGallery from '@/pages/GetStarted/GetStartedTab/SamplesListGallery'; -import { BrandingData } from '@/services/bootstrap/branding.constant'; -import devfileApi from '@/services/devfileApi'; -import { che } from '@/services/models'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import { ConvertedState } from '@/store/FactoryResolver'; - -const requestFactoryResolverMock = jest.fn().mockResolvedValue(undefined); - -jest.mock('@/store/FactoryResolver', () => { - return { - actionCreators: { - requestFactoryResolver: - ( - location: string, - overrideParams?: { - [params: string]: string; - }, - ) => - async (): Promise => { - if (!overrideParams) { - requestFactoryResolverMock(location); - } else { - requestFactoryResolverMock(location, overrideParams); - } - }, - }, - }; -}); - -describe('Samples List Gallery', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.runOnlyPendingTimers(); - jest.useRealTimers(); - }); - - function renderGallery( - store: Store, - onCardClicked: () => void = (): void => undefined, - ): RenderResult { - return render( - - - , - ); - } - - it('should render cards with v2 metadata only', () => { - // eslint-disable-next-line - const store = createFakeStoreWithMetadata(); - renderGallery(store); - - const cards = screen.getAllByRole('article'); - // only one link is with devfile v2 format - expect(cards.length).toEqual(18); - }); - - it('should handle "onCardClick" event for v2 metadata', async () => { - let resolveFn: { - (value?: unknown): void; - }; - const onCardClicked = jest.fn(() => resolveFn()); - - // eslint-disable-next-line - const store = createFakeStoreWithMetadata(); - renderGallery(store, onCardClicked); - - (mockAxios.get as any).mockResolvedValueOnce({ - data: {}, - }); - const windowSpy = jest.spyOn(window, 'open').mockImplementationOnce(() => null); - - const cardHeader = screen.getByText('Java with Spring Boot and MongoDB'); - fireEvent.click(cardHeader); - jest.runOnlyPendingTimers(); - expect(windowSpy).toHaveBeenCalledWith( - 'http://localhost/#/load-factory?url=https%3A%2F%2Fgithub.com%2Fche-samples%2Fjava-guestbook%2Ftree%2Fdevfilev2', - '_blank', - ); - }); - - it('should render empty state', () => { - // eslint-disable-next-line - const store = createFakeStoreWithoutMetadata(); - renderGallery(store); - - const emptyStateTitle = screen.getByRole('heading', { name: 'No results found' }); - expect(emptyStateTitle).toBeTruthy(); - }); -}); - -function createFakeStore(metadata?: che.DevfileMetaData[]): Store { - const registries = {} as { - [location: string]: { - metadata?: che.DevfileMetaData[]; - error?: string; - }; - }; - if (metadata) { - registries['registry-location'] = { - metadata, - }; - } - return new FakeStoreBuilder() - .withBranding({ - docs: { - storageTypes: 'https://docs.location', - }, - } as BrandingData) - .withFactoryResolver({ - resolver: { - source: 'devfile.yaml', - devfile: {} as devfileApi.Devfile, - location: 'http://fake-location', - scm_info: { - clone_url: 'http://github.com/clone-url', - scm_provider: 'github', - }, - links: [], - }, - converted: { - isConverted: false, - } as ConvertedState, - }) - .withDevfileRegistries({ registries }) - .build(); -} - -function createFakeStoreWithoutMetadata(): Store { - return createFakeStore(); -} - -function createFakeStoreWithMetadata(): Store { - return createFakeStore(mockMetadata); -} diff --git a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/__tests__/SamplesListToolbar.spec.tsx b/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/__tests__/SamplesListToolbar.spec.tsx deleted file mode 100644 index 54b8406e6..000000000 --- a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/__tests__/SamplesListToolbar.spec.tsx +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright (c) 2018-2024 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 { fireEvent, render, RenderResult, screen } from '@testing-library/react'; -import React from 'react'; -import { Provider } from 'react-redux'; -import { Store } from 'redux'; - -import mockMetadata from '@/pages/GetStarted/__tests__/devfileMetadata.json'; -import SamplesListToolbar from '@/pages/GetStarted/GetStartedTab/SamplesListToolbar'; -import { BrandingData } from '@/services/bootstrap/branding.constant'; -import { che } from '@/services/models'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; -import * as DevfileRegistriesStore from '@/store/DevfileRegistries'; - -describe('Samples List Toolbar', () => { - function renderToolbar(): RenderResult { - // eslint-disable-next-line - const store = createFakeStore(mockMetadata); - return render( - - - , - ); - } - - it('should initially have empty filter value', () => { - renderToolbar(); - const filterInput = screen.getByPlaceholderText('Filter by') as HTMLInputElement; - expect(filterInput.value).toEqual(''); - }); - - it('should not initially show the results counter', () => { - renderToolbar(); - const resultsCount = screen.queryByTestId('toolbar-results-count'); - expect(resultsCount).toBeNull(); - }); - - it('should call "setFilter" action', () => { - // mock "setFilter" action - const setFilter = DevfileRegistriesStore.actionCreators.setFilter; - DevfileRegistriesStore.actionCreators.setFilter = jest.fn(arg => setFilter(arg)); - - renderToolbar(); - - const filterInput = screen.getByLabelText('Filter samples list') as HTMLInputElement; - fireEvent.change(filterInput, { target: { value: 'NodeJS Angular Web Application' } }); - - expect(DevfileRegistriesStore.actionCreators.setFilter).toHaveBeenCalledTimes(1); - expect(DevfileRegistriesStore.actionCreators.setFilter).toHaveBeenCalledWith( - 'NodeJS Angular Web Application', - ); - }); -}); - -function createFakeStore(metadata?: che.DevfileMetaData[]): Store { - const registries = {}; - if (metadata) { - registries['registry-location'] = { - metadata, - }; - } - return new FakeStoreBuilder() - .withBranding({ - docs: { - storageTypes: 'https://docs.location', - }, - } as BrandingData) - .withDevfileRegistries({ registries }) - .build(); -} diff --git a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/index.tsx b/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/index.tsx deleted file mode 100644 index 1ae68eca2..000000000 --- a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/index.tsx +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright (c) 2018-2024 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 { Flex, FlexItem, PageSection, PageSectionVariants } from '@patternfly/react-core'; -import { History } from 'history'; -import { load } from 'js-yaml'; -import React from 'react'; -import { connect, ConnectedProps } from 'react-redux'; - -import { ImportFromGit } from '@/pages/GetStarted/GetStartedTab/ImportFromGit'; -import SamplesListGallery from '@/pages/GetStarted/GetStartedTab/SamplesListGallery'; -import { SamplesListHeader } from '@/pages/GetStarted/GetStartedTab/SamplesListHeader'; -import SamplesListToolbar from '@/pages/GetStarted/GetStartedTab/SamplesListToolbar'; -import { DevfileAdapter } from '@/services/devfile/adapter'; -import devfileApi from '@/services/devfileApi'; -import stringify from '@/services/helpers/editor'; -import { che } from '@/services/models'; -import { AppState } from '@/store'; -import { selectPvcStrategy } from '@/store/ServerConfig/selectors'; -import { selectSshKeys } from '@/store/SshKeys/selectors'; - -// At runtime, Redux will merge together... -type Props = { - history: History; - onDevfile: ( - devfileContent: string, - stackName: string, - optionalFilesContent?: { - [fileName: string]: string; - }, - ) => Promise; -} & MappedProps; -type State = { - temporary?: boolean; - persistVolumesDefault: string; -}; - -export class SamplesListTab extends React.PureComponent { - private isLoading: boolean; - - constructor(props: Props) { - super(props); - - const persistVolumesDefault = - this.props.preferredStorageType === 'ephemeral' ? 'false' : 'true'; - - this.state = { - persistVolumesDefault, - }; - this.isLoading = false; - } - - private handleTemporaryStorageChange(temporary: boolean): void { - this.setState({ temporary }); - } - - private getStorageType(): che.WorkspaceStorageType { - if (this.state.temporary === undefined) { - return this.props.preferredStorageType as che.WorkspaceStorageType; - } - if (this.props.preferredStorageType === 'async') { - return this.state.temporary ? 'ephemeral' : this.props.preferredStorageType; - } - return this.state.temporary ? 'ephemeral' : 'persistent'; - } - - private async handleSampleCardClick( - devfileContent: string, - stackName: string, - optionalFilesContent?: { [fileName: string]: string }, - ): Promise { - if (this.isLoading) { - return; - } - const devfileAdapter = new DevfileAdapter(load(devfileContent) as devfileApi.Devfile); - devfileAdapter.storageType = this.getStorageType(); - this.isLoading = true; - try { - await this.props.onDevfile( - stringify(devfileAdapter.devfile), - stackName, - optionalFilesContent, - ); - } catch (e) { - console.warn(e); - } - this.isLoading = false; - } - - public render(): React.ReactElement { - const { history, sshKeys } = this.props; - - const storageType = this.getStorageType(); - const hasSshKeys = sshKeys.length !== 0; - - return ( - - - - - - - - - - - this.handleTemporaryStorageChange(temporary)} - /> - - - - this.handleSampleCardClick(devfileContent, stackName, optionalFilesContent) - } - storageType={storageType} - /> - - - ); - } -} - -const mapStateToProps = (state: AppState) => ({ - preferredStorageType: selectPvcStrategy(state), - sshKeys: selectSshKeys(state), -}); - -const connector = connect(mapStateToProps); -type MappedProps = ConnectedProps; -export default connector(SamplesListTab); diff --git a/packages/dashboard-frontend/src/pages/GetStarted/ImportFromGit/__mocks__/index.tsx b/packages/dashboard-frontend/src/pages/GetStarted/ImportFromGit/__mocks__/index.tsx new file mode 100644 index 000000000..00d0fc00b --- /dev/null +++ b/packages/dashboard-frontend/src/pages/GetStarted/ImportFromGit/__mocks__/index.tsx @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2018-2024 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 { Props } from '@/pages/GetStarted/ImportFromGit'; + +export default class ImportFromGit extends React.PureComponent { + public render() { + return ( +
+
Import from Git
+
+ {this.props.editorDefinition ? this.props.editorDefinition : ''} +
+
{this.props.editorImage ? this.props.editorImage : ''}
+
+ ); + } +} diff --git a/packages/dashboard-frontend/src/pages/GetStarted/ImportFromGit/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/pages/GetStarted/ImportFromGit/__tests__/__snapshots__/index.spec.tsx.snap new file mode 100644 index 000000000..62d34dea8 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/GetStarted/ImportFromGit/__tests__/__snapshots__/index.spec.tsx.snap @@ -0,0 +1,126 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GitRepoLocationInput snapshot 1`] = ` +
+
+

+ Import from Git +

+
+
+
+
+
+
+ + +
+
+
+
+ +
+
+ +
+
+
+ Import from a Git repository to create your first workspace. +
+
+
+
+
+
+
+`; diff --git a/packages/dashboard-frontend/src/pages/GetStarted/ImportFromGit/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/GetStarted/ImportFromGit/__tests__/index.spec.tsx new file mode 100644 index 000000000..3cb9f2f20 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/GetStarted/ImportFromGit/__tests__/index.spec.tsx @@ -0,0 +1,327 @@ +/* + * Copyright (c) 2018-2024 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 userEvent from '@testing-library/user-event'; +import { createMemoryHistory } from 'history'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { Store } from 'redux'; + +import ImportFromGit from '@/pages/GetStarted/ImportFromGit'; +import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; +import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; + +const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); + +const history = createMemoryHistory({ + initialEntries: ['/'], +}); + +global.window.open = jest.fn(); + +const defaultEditorId = 'che-incubator/che-code/next'; +const editorId = 'che-incubator/che-code/insiders'; +const editorImage = 'custom-editor-image'; + +describe('GitRepoLocationInput', () => { + let store: Store; + + beforeEach(() => { + store = new FakeStoreBuilder() + .withDwServerConfig({ + defaults: { + editor: defaultEditorId, + components: [], + plugins: [], + pvcStrategy: 'per-workspace', + }, + }) + .build(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('snapshot', () => { + const component = createSnapshot(store, editorId, editorImage); + expect(component).toMatchSnapshot(); + }); + + test('invalid location', () => { + renderComponent(store); + + const input = screen.getByRole('textbox'); + expect(input).toBeValid(); + + userEvent.paste(input, 'invalid-test-location'); + + expect(input).toHaveValue('invalid-test-location'); + expect(input).toBeInvalid(); + + const button = screen.getByRole('button'); + expect(button).toBeDisabled(); + + userEvent.type(input, '{enter}'); + expect(window.open).not.toHaveBeenCalled(); + }); + + describe('valid HTTP location', () => { + describe('factory URL w/o other parameters', () => { + test('editor definition and image are empty', () => { + renderComponent(store); + + const input = screen.getByRole('textbox'); + expect(input).toBeValid(); + + userEvent.paste(input, 'http://test-location/'); + + expect(input).toHaveValue('http://test-location/'); + expect(input).toBeValid(); + + const button = screen.getByRole('button'); + expect(button).toBeEnabled(); + + userEvent.click(button); + // the selected editor ID should be added to the URL + expect(window.open).toHaveBeenLastCalledWith( + 'http://localhost/#http://test-location/', + '_blank', + ); + expect(window.open).toHaveBeenCalledTimes(1); + + userEvent.type(input, '{enter}'); + expect(window.open).toHaveBeenCalledTimes(2); + }); + + test('editor definition is defined, editor image is empty', () => { + renderComponent(store, editorId); + + const input = screen.getByRole('textbox'); + expect(input).toBeValid(); + + userEvent.paste(input, 'http://test-location/'); + + expect(input).toHaveValue('http://test-location/'); + expect(input).toBeValid(); + + const button = screen.getByRole('button'); + expect(button).toBeEnabled(); + + userEvent.click(button); + // the selected editor ID should be added to the URL + expect(window.open).toHaveBeenLastCalledWith( + 'http://localhost/#http://test-location/?che-editor=che-incubator%2Fche-code%2Finsiders', + '_blank', + ); + expect(window.open).toHaveBeenCalledTimes(1); + + userEvent.type(input, '{enter}'); + expect(window.open).toHaveBeenCalledTimes(2); + }); + + test('editor definition is empty, editor image is defined', () => { + renderComponent(store, undefined, editorImage); + + const input = screen.getByRole('textbox'); + expect(input).toBeValid(); + + userEvent.paste(input, 'http://test-location/'); + + expect(input).toHaveValue('http://test-location/'); + expect(input).toBeValid(); + + const button = screen.getByRole('button'); + expect(button).toBeEnabled(); + + userEvent.click(button); + // the selected editor ID should be added to the URL + expect(window.open).toHaveBeenLastCalledWith( + 'http://localhost/#http://test-location/?editor-image=custom-editor-image', + '_blank', + ); + expect(window.open).toHaveBeenCalledTimes(1); + + userEvent.type(input, '{enter}'); + expect(window.open).toHaveBeenCalledTimes(2); + }); + + test('editor definition and editor image are defined', () => { + renderComponent(store, editorId, editorImage); + + const input = screen.getByRole('textbox'); + expect(input).toBeValid(); + + userEvent.paste(input, 'http://test-location/'); + + expect(input).toHaveValue('http://test-location/'); + expect(input).toBeValid(); + + const button = screen.getByRole('button'); + expect(button).toBeEnabled(); + + userEvent.click(button); + // the selected editor ID should be added to the URL + expect(window.open).toHaveBeenLastCalledWith( + 'http://localhost/#http://test-location/?che-editor=che-incubator%2Fche-code%2Finsiders&editor-image=custom-editor-image', + '_blank', + ); + expect(window.open).toHaveBeenCalledTimes(1); + + userEvent.type(input, '{enter}'); + expect(window.open).toHaveBeenCalledTimes(2); + }); + }); + + describe('factory URL with `che-editor` and/or `editor-image` parameters', () => { + test('editor definition and editor image are defined, and `che-editor` is provided', () => { + renderComponent(store, editorId, editorImage); + + const input = screen.getByRole('textbox'); + expect(input).toBeValid(); + + userEvent.paste(input, 'http://test-location/?che-editor=other-editor-id'); + + expect(input).toHaveValue('http://test-location/?che-editor=other-editor-id'); + expect(input).toBeValid(); + + const button = screen.getByRole('button'); + expect(button).toBeEnabled(); + + userEvent.click(button); + // the selected editor ID should NOT be added to the URL, as the URL parameter has higher priority + expect(window.open).toHaveBeenLastCalledWith( + 'http://localhost/#http://test-location/?che-editor=other-editor-id', + '_blank', + ); + expect(window.open).toHaveBeenCalledTimes(1); + + userEvent.type(input, '{enter}'); + expect(window.open).toHaveBeenCalledTimes(2); + }); + + test('editor definition and editor image are defined, and `editor-image` is provided', () => { + renderComponent(store, editorId, editorImage); + + const input = screen.getByRole('textbox'); + expect(input).toBeValid(); + + userEvent.paste(input, 'http://test-location/?editor-image=custom-editor-image'); + + expect(input).toHaveValue('http://test-location/?editor-image=custom-editor-image'); + expect(input).toBeValid(); + + const button = screen.getByRole('button'); + expect(button).toBeEnabled(); + + userEvent.click(button); + // the selected editor ID should be added to the URL + expect(window.open).toHaveBeenLastCalledWith( + 'http://localhost/#http://test-location/?editor-image=custom-editor-image', + '_blank', + ); + expect(window.open).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('valid Git+SSH location', () => { + test('w/o SSH keys', () => { + renderComponent(store, editorId, editorImage); + + const input = screen.getByRole('textbox'); + expect(input).toBeValid(); + + userEvent.paste(input, 'git@github.com:user/repo.git'); + + expect(input).toHaveValue('git@github.com:user/repo.git'); + expect(input).toBeInvalid(); + + const buttonCreate = screen.getByRole('button', { name: 'Create & Open' }); + expect(buttonCreate).toBeDisabled(); + + const buttonUserPreferences = screen.getByRole('button', { name: 'here' }); + + userEvent.click(buttonUserPreferences); + expect(history.location.pathname).toBe('/user-preferences'); + expect(history.location.search).toBe('?tab=SshKeys'); + }); + + test('with SSH keys, the `che-editor` parameter is omitted', () => { + const store = new FakeStoreBuilder() + .withSshKeys({ keys: [{ name: 'key1', keyPub: 'publicKey' }] }) + .build(); + renderComponent(store, editorId, editorImage); + + const input = screen.getByRole('textbox'); + expect(input).toBeValid(); + + userEvent.paste(input, 'git@github.com:user/repo.git'); + + expect(input).toHaveValue('git@github.com:user/repo.git'); + expect(input).toBeValid(); + + const buttonCreate = screen.getByRole('button', { name: 'Create & Open' }); + expect(buttonCreate).toBeEnabled(); + + userEvent.click(buttonCreate); + + expect(window.open).toHaveBeenCalledTimes(1); + expect(window.open).toHaveBeenLastCalledWith( + 'http://localhost/#git@github.com:user/repo.git?che-editor=che-incubator%2Fche-code%2Finsiders&editor-image=custom-editor-image', + '_blank', + ); + }); + + test('with SSH keys, the `che-editor` parameter is set', () => { + const store = new FakeStoreBuilder() + .withSshKeys({ keys: [{ name: 'key1', keyPub: 'publicKey' }] }) + .build(); + renderComponent(store, editorId, editorImage); + + const input = screen.getByRole('textbox'); + expect(input).toBeValid(); + + userEvent.paste(input, 'git@github.com:user/repo.git?che-editor=other-editor-id'); + + expect(input).toHaveValue('git@github.com:user/repo.git?che-editor=other-editor-id'); + expect(input).toBeValid(); + + const buttonCreate = screen.getByRole('button', { name: 'Create & Open' }); + expect(buttonCreate).toBeEnabled(); + + userEvent.click(buttonCreate); + + expect(window.open).toHaveBeenCalledTimes(1); + expect(window.open).toHaveBeenLastCalledWith( + 'http://localhost/#git@github.com:user/repo.git?che-editor=other-editor-id', + '_blank', + ); + }); + }); +}); + +function getComponent( + store: Store, + editorDefinition: string | undefined = undefined, + editorImage: string | undefined = undefined, +) { + return ( + + + + ); +} diff --git a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/index.tsx b/packages/dashboard-frontend/src/pages/GetStarted/ImportFromGit/index.tsx similarity index 50% rename from packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/index.tsx rename to packages/dashboard-frontend/src/pages/GetStarted/ImportFromGit/index.tsx index 3eb01eb06..32ab7488b 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/ImportFromGit/index.tsx +++ b/packages/dashboard-frontend/src/pages/GetStarted/ImportFromGit/index.tsx @@ -18,39 +18,69 @@ import { Form, FormGroup, FormHelperText, - FormSection, + Panel, + PanelHeader, + PanelMain, + PanelMainBody, TextInput, + Title, ValidatedOptions, } from '@patternfly/react-core'; import { ExclamationCircleIcon } from '@patternfly/react-icons'; import { History } from 'history'; import React from 'react'; +import { connect, ConnectedProps } from 'react-redux'; import { FactoryLocationAdapter } from '@/services/factory-location-adapter'; +import { EDITOR_ATTR, EDITOR_IMAGE_ATTR } from '@/services/helpers/factoryFlow/buildFactoryParams'; import { buildUserPreferencesLocation } from '@/services/helpers/location'; import { UserPreferencesTab } from '@/services/helpers/types'; +import { AppState } from '@/store'; +import { selectSshKeys } from '@/store/SshKeys/selectors'; +import * as WorkspacesStore from '@/store/Workspaces'; -export type Props = { - hasSshKeys: boolean; +export type Props = MappedProps & { history: History; + editorDefinition: string | undefined; + editorImage: string | undefined; }; export type State = { + hasSshKeys: boolean; location: string; validated: ValidatedOptions; }; -export class ImportFromGit extends React.PureComponent { +class ImportFromGit extends React.PureComponent { constructor(props: Props) { super(props); this.state = { + hasSshKeys: this.props.sshKeys.length > 0, validated: ValidatedOptions.default, location: '', }; } private handleCreate(): void { - const factory = new FactoryLocationAdapter(this.state.location); + const { editorDefinition, editorImage } = this.props; + const { location } = this.state; + + const factory = new FactoryLocationAdapter(location); + + // add the editor definition and editor image to the URL + // if they are not already there + if ( + factory.searchParams.has(EDITOR_ATTR) === false && + factory.searchParams.has(EDITOR_IMAGE_ATTR) === false + ) { + if (editorDefinition !== undefined) { + factory.searchParams.set(EDITOR_ATTR, editorDefinition); + } + if (editorImage !== undefined) { + factory.searchParams.set(EDITOR_IMAGE_ATTR, editorImage); + } + } + // open a new page to handle that window.open(`${window.location.origin}/#${factory.toString()}`, '_blank'); } @@ -71,7 +101,7 @@ export class ImportFromGit extends React.PureComponent { return ValidatedOptions.success; } - if (isValidGitSsh === true && this.props.hasSshKeys === true) { + if (isValidGitSsh === true && this.state.hasSshKeys === true) { return ValidatedOptions.success; } @@ -81,7 +111,7 @@ export class ImportFromGit extends React.PureComponent { private getErrorMessage(location: string): string | React.ReactNode { const isValidGitSsh = FactoryLocationAdapter.isSshLocation(location); - if (isValidGitSsh === true && this.props.hasSshKeys === false) { + if (isValidGitSsh === true && this.state.hasSshKeys === false) { return ( } isHidden={false} isError={true}> No SSH keys found. Please add your SSH keys{' '} @@ -101,7 +131,7 @@ export class ImportFromGit extends React.PureComponent { this.props.history.push(location); } - public render() { + public buildForm() { const { location, validated } = this.state; const fieldId = 'git-repo-url'; @@ -119,41 +149,61 @@ export class ImportFromGit extends React.PureComponent { this.handleCreate(); }} > - - } - helperText="Import from a Git repository to create your first workspace." - > - - - this.handleChange(value)} - value={location} - /> - - - - - - - + } + helperText="Import from a Git repository to create your first workspace." + > + + + this.handleChange(value)} + value={location} + /> + + + + + + ); } + + public render() { + return ( + + + Import from Git + + + {this.buildForm()} + + + ); + } } + +const mapStateToProps = (state: AppState) => ({ + sshKeys: selectSshKeys(state), +}); + +const connector = connect(mapStateToProps, WorkspacesStore.actionCreators); + +type MappedProps = ConnectedProps; +export default connector(ImportFromGit); diff --git a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/Card/__mocks__/index.tsx b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/Card/__mocks__/index.tsx new file mode 100644 index 000000000..0963ce366 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/Card/__mocks__/index.tsx @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2018-2024 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 { Props } from '@/pages/GetStarted/SamplesList/Gallery/Card'; + +export class SampleCard extends React.PureComponent { + render(): React.ReactElement { + const { metadata, onClick } = this.props; + + return ( +
+
{metadata.displayName}
+ +
+ ); + } +} diff --git a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/Card/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/Card/__tests__/__snapshots__/index.spec.tsx.snap new file mode 100644 index 000000000..c6f041c27 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/Card/__tests__/__snapshots__/index.spec.tsx.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Devfile Metadata Card snapshot 1`] = ` +
+
+
+ Go +
+
+
+
+ Go +
+
+ Stack with Go 1.12.10 +
+
+`; diff --git a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/__tests__/SampleCard.spec.tsx b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/Card/__tests__/index.spec.tsx similarity index 58% rename from packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/__tests__/SampleCard.spec.tsx rename to packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/Card/__tests__/index.spec.tsx index f662f4deb..2bdb55dad 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/__tests__/SampleCard.spec.tsx +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/Card/__tests__/index.spec.tsx @@ -10,15 +10,19 @@ * Red Hat, Inc. - initial API and implementation */ -import { fireEvent, render, RenderResult, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; -import { SampleCard } from '@/pages/GetStarted/GetStartedTab/SampleCard'; -import { che } from '@/services/models'; +import { SampleCard } from '@/pages/GetStarted/SamplesList/Gallery/Card'; +import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; +import { DevfileRegistryMetadata } from '@/store/DevfileRegistries/selectors'; + +const onCardClick = jest.fn(); + +const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); describe('Devfile Metadata Card', () => { - const onCardClick = jest.fn(); - let metadata: che.DevfileMetaData; + let metadata: DevfileRegistryMetadata; beforeEach(() => { metadata = { @@ -28,59 +32,46 @@ describe('Devfile Metadata Card', () => { icon: '/images/go.svg', globalMemoryLimit: '1686Mi', links: { - self: '/devfiles/go/devfile.yaml', + v2: '/devfiles/go/devfile.yaml', }, }; + }); + afterEach(() => { jest.clearAllMocks(); }); - function renderCard(): RenderResult { - return render( - , - ); - } + test('snapshot', () => { + const snapshot = createSnapshot(metadata); + expect(snapshot.toJSON()).toMatchSnapshot(); + }); it('should have a correct title in header', () => { - renderCard(); + renderComponent(metadata); const cardHeader = screen.getByText(metadata.displayName); expect(cardHeader).toBeTruthy(); }); it('should have an icon', () => { - renderCard(); + renderComponent(metadata); const cardIcon = screen.queryByTestId('sample-card-icon'); expect(cardIcon).toBeTruthy(); }); it('should be able to provide the default icon', () => { metadata.icon = ''; - const { container } = renderCard(); + renderComponent(metadata); - const cardIcon = screen.queryByAltText(metadata.displayName); - expect(cardIcon).toBeFalsy(); + const sampleIcon = screen.queryByTestId('sample-card-icon'); + expect(sampleIcon).toBeFalsy(); - const blankIcon = container.querySelector('.sampleCardIcon'); + const blankIcon = screen.getByTestId('default-card-icon'); expect(blankIcon).toBeTruthy(); }); - it('should handle "onClick" event', () => { - renderCard(); - - const card = screen.getByRole('article'); - fireEvent.click(card); - - expect(onCardClick).toHaveBeenCalledWith(undefined); - }); - it('should not have visible tags', () => { metadata.tags = ['Debian', 'Go']; - renderCard(); + renderComponent(metadata); const badge = screen.queryAllByTestId('card-badge'); expect(badge.length).toEqual(0); @@ -88,7 +79,7 @@ describe('Devfile Metadata Card', () => { it('should have "Community" tag', () => { metadata.tags = ['Community', 'Debian', 'Go']; - renderCard(); + renderComponent(metadata); const badge = screen.queryAllByTestId('card-badge'); expect(badge.length).toEqual(1); @@ -97,10 +88,23 @@ describe('Devfile Metadata Card', () => { it('should have "tech-preview" tag', () => { metadata.tags = ['Tech-Preview', 'Debian', 'Go']; - renderCard(); + renderComponent(metadata); const badge = screen.queryAllByTestId('card-badge'); expect(badge.length).toEqual(1); expect(screen.queryByText('Tech-Preview')).toBeTruthy(); }); + + it('should handle card click', () => { + renderComponent(metadata); + + const card = screen.getByRole('article'); + userEvent.click(card); + + expect(onCardClick).toHaveBeenCalledWith(); + }); }); + +function getComponent(metadata: DevfileRegistryMetadata): React.ReactElement { + return ; +} diff --git a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/SampleCard/index.module.css b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/Card/index.module.css similarity index 100% rename from packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/SampleCard/index.module.css rename to packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/Card/index.module.css diff --git a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/Card/index.tsx b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/Card/index.tsx new file mode 100644 index 000000000..602d3805a --- /dev/null +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/Card/index.tsx @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2018-2024 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 { + Badge, + Brand, + Card, + CardActions, + CardBody, + CardHeader, + CardHeaderMain, +} from '@patternfly/react-core'; +import { CubesIcon } from '@patternfly/react-icons'; +import React from 'react'; + +import { VISIBLE_TAGS } from '@/pages/GetStarted/SamplesList/Gallery'; +import styles from '@/pages/GetStarted/SamplesList/Gallery/Card/index.module.css'; +import { che } from '@/services/models'; +import { convertIconToSrc } from '@/services/registry/devfiles'; +import { DevfileRegistryMetadata } from '@/store/DevfileRegistries/selectors'; + +export type Props = { + metadata: DevfileRegistryMetadata; + onClick: () => void; +}; + +export class SampleCard extends React.PureComponent { + private getTags(): JSX.Element[] { + const { + metadata: { tags }, + } = this.props; + + const createTag = (text: string, key: number): React.ReactElement => { + return ( + + {text.trim()} + + ); + }; + + return tags + .filter(tag => VISIBLE_TAGS.indexOf(tag) !== -1) + .map((item: string, index: number) => createTag(item, index)); + } + + private handleCardClick(): void { + this.props.onClick(); + } + + private buildIcon(metadata: che.DevfileMetaData): React.ReactElement { + const props = { + className: styles.sampleCardIcon, + alt: metadata.displayName, + 'aria-label': metadata.displayName, + }; + + return metadata.icon ? ( + + ) : ( + + ); + } + + render(): React.ReactElement { + const { metadata } = this.props; + + const tags = this.getTags(); + const devfileIcon = this.buildIcon(metadata); + + return ( + this.handleCardClick()} + className={'sample-card'} + data-testid="sample-card" + > + + {devfileIcon} + {tags} + + {metadata.displayName} + {metadata.description} + + ); + } +} diff --git a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/__mocks__/index.tsx b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/__mocks__/index.tsx new file mode 100644 index 000000000..e37d9c219 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/__mocks__/index.tsx @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2018-2024 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 { Props } from '@/pages/GetStarted/SamplesList/Gallery'; + +export default class SamplesListGallery extends React.PureComponent { + render() { + const { onCardClick, metadataFiltered = [] } = this.props; + return ( +
+
Samples List Gallery
+ {metadataFiltered.map(metadata => ( +
+ {metadata.displayName} + + +
+ ))} +
+ ); + } +} diff --git a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/__tests__/__snapshots__/index.spec.tsx.snap new file mode 100644 index 000000000..c14fd9481 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/__tests__/__snapshots__/index.spec.tsx.snap @@ -0,0 +1,349 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Samples List Gallery snapshot w/ metadata 1`] = ` +
+
+
+ Empty Workspace +
+ +
+
+
+ ASP.NET Core Web Application +
+ +
+
+
+ Apache Camel K +
+ +
+
+
+ Apache Camel based on Spring Boot +
+ +
+
+
+ Bash +
+ +
+
+
+ C/C++ +
+ +
+
+
+ Java Lombok +
+ +
+
+
+ Java Spring Boot +
+ +
+
+
+ Java with Spring Boot and MongoDB +
+ +
+
+
+ Node.js Angular Web Application +
+ +
+
+
+ Node.js Express Web Application +
+ +
+
+
+ Node.js MongoDB Web Application +
+ +
+
+
+ Node.js React Web Application +
+ +
+
+
+ Node.js Web Application based on Yarn +
+ +
+
+
+ PHP Symfony +
+ +
+
+
+ Python Django +
+ +
+
+
+ Quarkus REST API +
+ +
+
+
+ Scala +
+ +
+
+
+ .NET 5.0 +
+ +
+
+
+ Rust +
+ +
+
+`; + +exports[`Samples List Gallery snapshot w/o metadata 1`] = ` +
+
+ +

+ No results found +

+
+ No results match the filter criteria. Clear filter to show results. +
+
+ +
+
+
+`; diff --git a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/__tests__/filterEditors.spec.ts b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/__tests__/filterEditors.spec.ts new file mode 100644 index 000000000..e700b9259 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/__tests__/filterEditors.spec.ts @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2018-2024 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 { PluginEditor } from '@/pages/GetStarted/SamplesList/Gallery'; +import { + filterMostPrioritized, + sortByPriority, +} from '@/pages/GetStarted/SamplesList/Gallery/filterEditors'; + +describe('Filter Editors', () => { + let editors: PluginEditor[]; + + beforeEach(() => { + editors = [ + { + id: 'che-incubator/che-code/insiders', + description: + 'Microsoft Visual Studio Code - Open Source IDE for Eclipse Che - Insiders build', + displayName: 'VS Code - Open Source', + links: { + devfile: '/v3/plugins/che-incubator/che-code/insiders/devfile.yaml', + }, + name: 'che-code', + publisher: 'che-incubator', + type: 'Che Editor', + version: 'insiders', + icon: '/v3/images/vscode.svg', + isDefault: true, + }, + { + id: 'che-incubator/che-code/latest', + description: 'Microsoft Visual Studio Code - Open Source IDE for Eclipse Che', + displayName: 'VS Code - Open Source', + links: { + devfile: '/v3/plugins/che-incubator/che-code/latest/devfile.yaml', + }, + name: 'che-code', + publisher: 'che-incubator', + type: 'Che Editor', + version: 'latest', + icon: '/v3/images/vscode.svg', + isDefault: false, + }, + { + id: 'che-incubator/che-idea/latest', + description: 'JetBrains IntelliJ IDEA Community IDE for Eclipse Che', + displayName: 'IntelliJ IDEA Community', + links: { + devfile: '/v3/plugins/che-incubator/che-idea/latest/devfile.yaml', + }, + name: 'che-idea', + publisher: 'che-incubator', + type: 'Che Editor', + version: 'latest', + icon: '/v3/images/intllij-idea.svg', + isDefault: false, + }, + { + id: 'che-incubator/che-idea/next', + description: 'JetBrains IntelliJ IDEA Community IDE for Eclipse Che - next', + displayName: 'IntelliJ IDEA Community', + links: { + devfile: '/v3/plugins/che-incubator/che-idea/next/devfile.yaml', + }, + name: 'che-idea', + publisher: 'che-incubator', + type: 'Che Editor', + version: 'next', + icon: '/v3/images/intllij-idea.svg', + isDefault: false, + }, + ]; + }); + + it('should sort editors by priority', () => { + const sortedEditors = sortByPriority(editors); + expect(sortedEditors).toEqual([ + expect.objectContaining({ + id: 'che-incubator/che-code/insiders', + }), + expect.objectContaining({ + id: 'che-incubator/che-code/latest', + }), + expect.objectContaining({ + id: 'che-incubator/che-idea/next', + }), + expect.objectContaining({ + id: 'che-incubator/che-idea/latest', + }), + ]); + }); + + it('should filter editors to leave the first occurrence of each editor name', () => { + const sortedEditors = sortByPriority(editors); + const filteredEditors = filterMostPrioritized(sortedEditors); + + expect(filteredEditors).toEqual([ + expect.objectContaining({ + id: 'che-incubator/che-code/insiders', + }), + expect.objectContaining({ + id: 'che-incubator/che-idea/next', + }), + ]); + }); +}); diff --git a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/__tests__/index.spec.tsx new file mode 100644 index 000000000..f9047fd81 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/__tests__/index.spec.tsx @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2018-2024 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 userEvent from '@testing-library/user-event'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { Store } from 'redux'; + +import mockMetadata from '@/pages/GetStarted/__tests__/devfileMetadata.json'; +import mockPlugins from '@/pages/GetStarted/__tests__/plugins.json'; +import SamplesListGallery from '@/pages/GetStarted/SamplesList/Gallery'; +import getComponentRenderer, { screen, within } from '@/services/__mocks__/getComponentRenderer'; +import { BrandingData } from '@/services/bootstrap/branding.constant'; +import { che } from '@/services/models'; +import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import { DevfileRegistryMetadata } from '@/store/DevfileRegistries/selectors'; + +jest.mock('@/pages/GetStarted/SamplesList/Gallery/Card'); + +const mockOnCardClick = jest.fn(); + +const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); + +const metadata = mockMetadata as DevfileRegistryMetadata[]; +const plugins = mockPlugins as che.Plugin[]; + +describe('Samples List Gallery', () => { + let store: Store; + + beforeEach(() => { + store = new FakeStoreBuilder() + .withBranding({ + docs: { + storageTypes: 'https://docs.location', + }, + } as BrandingData) + .withPlugins(plugins) + .build(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('snapshot w/ metadata', () => { + const snapshot = createSnapshot(store, metadata); + expect(snapshot.toJSON()).toMatchSnapshot(); + }); + + test('snapshot w/o metadata', () => { + const snapshot = createSnapshot(store, []); + expect(snapshot.toJSON()).toMatchSnapshot(); + }); + + test('empty state', () => { + renderComponent(store, []); + + const cards = screen.queryAllByTestId('sample-card'); + expect(cards.length).toEqual(0); + }); + + test('gallery with cards', () => { + renderComponent(store, metadata); + + const cards = screen.queryAllByTestId('sample-card'); + expect(cards.length).toEqual(20); + }); + + it('should handle click on card', () => { + renderComponent(store, metadata); + + const cards = screen.getAllByTestId('sample-card'); + + const button = within(cards[0]).getByRole('button'); + userEvent.click(button); + + expect(mockOnCardClick).toHaveBeenCalledTimes(1); + expect(mockOnCardClick).toHaveBeenCalledWith(metadata[0]); + }); +}); + +function getComponent(store: Store, metadata: DevfileRegistryMetadata[]) { + return ( + + + + ); +} diff --git a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/filterEditors.ts b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/filterEditors.ts new file mode 100644 index 000000000..faf32b78c --- /dev/null +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/filterEditors.ts @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2018-2024 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 { PluginEditor } from '@/pages/GetStarted/SamplesList/Gallery'; + +/** + * Filter the editors to leave the first occurrence of each editor name. + */ +export function filterMostPrioritized(editors: PluginEditor[]): PluginEditor[] { + const editorNames = new Set(); + const filteredEditors = editors.filter(editor => { + if (!editorNames.has(editor.name)) { + editorNames.add(editor.name); + return true; + } + return false; + }); + + return filteredEditors; +} + +/** + * Sort editors by name and version. The priority is defined as follows: + * 1. Default editor + * 2. Version insiders + * 3. Version next + * 4. Version latest + * The rest of the editors are sorted by id. + */ +export function sortByPriority(editors: PluginEditor[]) { + function sortFn(editorA: PluginEditor, editorB: PluginEditor) { + const priority = ['isDefault', 'insiders', 'next', 'latest']; + + if (editorA.name === editorB.name) { + for (const prop of priority) { + if (editorA.version === prop) return -1; + if (editorB.version === prop) return 1; + } + } + return editorA.id.localeCompare(editorB.id); + } + + return editors.sort(sortFn); +} diff --git a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/index.tsx b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/index.tsx new file mode 100644 index 000000000..13d5410cd --- /dev/null +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Gallery/index.tsx @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2018-2024 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 { + Button, + EmptyState, + EmptyStateBody, + EmptyStateIcon, + EmptyStatePrimary, + EmptyStateVariant, + Gallery, + Title, +} from '@patternfly/react-core'; +import { SearchIcon } from '@patternfly/react-icons'; +import React from 'react'; +import { connect, ConnectedProps } from 'react-redux'; + +import { SampleCard } from '@/pages/GetStarted/SamplesList/Gallery/Card'; +import { che } from '@/services/models'; +import * as DevfileRegistriesStore from '@/store/DevfileRegistries'; +import { DevfileRegistryMetadata, EMPTY_WORKSPACE_TAG } from '@/store/DevfileRegistries/selectors'; + +export type PluginEditor = che.Plugin & { + isDefault: boolean; +}; + +export const VISIBLE_TAGS = ['Community', 'Tech-Preview', 'Devfile.io']; + +export type Props = MappedProps & { + metadataFiltered: DevfileRegistryMetadata[]; + onCardClick: (metadata: DevfileRegistryMetadata) => void; +}; + +export class SamplesListGallery extends React.PureComponent { + private handleCardClick(metadata: DevfileRegistryMetadata): void { + this.props.onCardClick(metadata); + } + + private prepareMetadata(): DevfileRegistryMetadata[] { + function sortByVisibleTag(a: DevfileRegistryMetadata, b: DevfileRegistryMetadata): -1 | 0 | 1 { + const getVisibleTag = (metadata: DevfileRegistryMetadata) => + metadata.tags.filter(tag => VISIBLE_TAGS.includes(tag))[0]; + const tagA = getVisibleTag(a); + const tagB = getVisibleTag(b); + if (tagA === tagB) { + return 0; + } + if (tagA === undefined || tagA < tagB) { + return -1; + } + if (tagB === undefined || tagA > tagB) { + return 1; + } + return 0; + } + + function sortByEmptyWorkspaceTag( + a: DevfileRegistryMetadata, + b: DevfileRegistryMetadata, + ): -1 | 0 | 1 { + if (a.tags.includes(EMPTY_WORKSPACE_TAG) > b.tags.includes(EMPTY_WORKSPACE_TAG)) { + return -1; + } + if (a.tags.includes(EMPTY_WORKSPACE_TAG) < b.tags.includes(EMPTY_WORKSPACE_TAG)) { + return 1; + } + return 0; + } + + function sortByDisplayName(a: DevfileRegistryMetadata, b: DevfileRegistryMetadata): -1 | 0 | 1 { + if (a.displayName < b.displayName) { + return -1; + } + if (a.displayName > b.displayName) { + return 1; + } + return 0; + } + + return this.props.metadataFiltered + .sort(sortByDisplayName) + .sort(sortByVisibleTag) + .sort(sortByEmptyWorkspaceTag); + } + + private buildCardsList(): React.ReactElement[] { + const metadata = this.prepareMetadata(); + + return metadata.map(meta => ( + this.handleCardClick(meta)} /> + )); + } + + render(): React.ReactElement { + const cards = this.buildCardsList(); + + if (cards.length === 0) { + return ( + + + No results found + + No results match the filter criteria. Clear filter to show results. + + + + + + ); + } + + return {cards}; + } +} + +const connector = connect(null, { + ...DevfileRegistriesStore.actionCreators, +}); + +type MappedProps = ConnectedProps; +export default connector(SamplesListGallery); diff --git a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/TemporaryStorageSwitch/__mocks__/index.tsx b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/TemporaryStorageSwitch/__mocks__/index.tsx new file mode 100644 index 000000000..2945b8114 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/TemporaryStorageSwitch/__mocks__/index.tsx @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2018-2024 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 { Props } from '..'; + +export default class TemporaryStorageSwitch extends React.PureComponent { + render(): React.ReactNode { + const { isTemporary } = this.props; + return ( +
+ {isTemporary ? 'Temporary Storage On' : 'Temporary Storage Off'} + this.props.onChange(!isTemporary)} + name="Toggle Temporary Storage" + /> +
+ ); + } +} diff --git a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/TemporaryStorageSwitch/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/TemporaryStorageSwitch/__tests__/__snapshots__/index.spec.tsx.snap new file mode 100644 index 000000000..7f372b021 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/TemporaryStorageSwitch/__tests__/__snapshots__/index.spec.tsx.snap @@ -0,0 +1,101 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Temporary Storage Switch snapshot 1`] = ` +[ + , + +
+ + {"id":"temporary-storage-tooltip","isContentLeftAligned":true,"position":"top"} + +
+ Temporary Storage allows for faster I/O but may have limited storage and is not persistent. +

+ + Open documentation page + +

+
+
+ + + +
+
+
, +] +`; diff --git a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/__tests__/TemporaryStorageSwitch.spec.tsx b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/TemporaryStorageSwitch/__tests__/index.spec.tsx similarity index 50% rename from packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/__tests__/TemporaryStorageSwitch.spec.tsx rename to packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/TemporaryStorageSwitch/__tests__/index.spec.tsx index 98270d4b5..c3a1fc8a6 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/__tests__/TemporaryStorageSwitch.spec.tsx +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/TemporaryStorageSwitch/__tests__/index.spec.tsx @@ -10,38 +10,45 @@ * Red Hat, Inc. - initial API and implementation */ -import { render, RenderResult, screen } from '@testing-library/react'; +import { screen } from '@testing-library/react'; import React from 'react'; import { Provider } from 'react-redux'; import { Store } from 'redux'; -import mockMetadata from '@/pages/GetStarted/__tests__/devfileMetadata.json'; -import TemporaryStorageSwitch from '@/pages/GetStarted/GetStartedTab/TemporaryStorageSwitch'; +import TemporaryStorageSwitch from '@/pages/GetStarted/SamplesList/Toolbar/TemporaryStorageSwitch'; +import getComponentRenderer from '@/services/__mocks__/getComponentRenderer'; import { BrandingData } from '@/services/bootstrap/branding.constant'; -import { che } from '@/services/models'; import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +const { renderComponent, createSnapshot } = getComponentRenderer(getComponent); + +const mockOnChange = jest.fn(); + describe('Temporary Storage Switch', () => { - const mockOnChange = jest.fn(); + let store: Store; - function renderSwitch(store: Store, persistVolumesDefault: 'true' | 'false'): RenderResult { - return render( - - - , - ); - } + beforeEach(() => { + store = new FakeStoreBuilder() + .withBranding({ + docs: { + storageTypes: 'https://docs.location', + }, + } as BrandingData) + .build(); + }); afterEach(() => { jest.clearAllMocks(); }); + test('snapshot', () => { + const snapshot = createSnapshot(store, false); + expect(snapshot.toJSON()).toMatchSnapshot(); + }); + it('should be initially switched on', () => { - const store = createFakeStoreWithMetadata(); - renderSwitch(store, 'false'); + renderComponent(store, true); + const switchInput = screen.getByRole('checkbox') as HTMLInputElement; expect(switchInput.checked).toBeTruthy(); @@ -51,8 +58,7 @@ describe('Temporary Storage Switch', () => { }); it('should be initially switched off', () => { - const store = createFakeStoreWithMetadata(); - renderSwitch(store, 'true'); + renderComponent(store, false); const switchInput = screen.getByRole('checkbox') as HTMLInputElement; expect(switchInput.checked).toBeFalsy(); @@ -62,23 +68,10 @@ describe('Temporary Storage Switch', () => { }); }); -function createFakeStore(metadata?: che.DevfileMetaData[]): Store { - const registries = {}; - if (metadata) { - registries['registry-location'] = { - metadata, - }; - } - return new FakeStoreBuilder() - .withBranding({ - docs: { - storageTypes: 'https://docs.location', - }, - } as BrandingData) - .withDevfileRegistries({ registries }) - .build(); -} - -function createFakeStoreWithMetadata(): Store { - return createFakeStore(mockMetadata); +function getComponent(store: Store, isTemporary: boolean) { + return ( + + + + ); } diff --git a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/TemporaryStorageSwitch.tsx b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/TemporaryStorageSwitch/index.tsx similarity index 73% rename from packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/TemporaryStorageSwitch.tsx rename to packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/TemporaryStorageSwitch/index.tsx index 1499be532..2a32fe5eb 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/GetStartedTab/TemporaryStorageSwitch.tsx +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/TemporaryStorageSwitch/index.tsx @@ -12,39 +12,38 @@ import { Switch, Text, Tooltip, TooltipPosition } from '@patternfly/react-core'; import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons'; -import React, { FormEvent } from 'react'; +import React from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { AppState } from '@/store'; import { selectBranding } from '@/store/Branding/selectors'; -type Props = MappedProps & { - persistVolumesDefault: string; - onChange: (temporary: boolean) => void; +export type Props = MappedProps & { + isTemporary: boolean; + onChange: (isTemporary: boolean) => void; }; type State = { isChecked: boolean; }; -export class TemporaryStorageSwitch extends React.PureComponent { - private readonly handleChange: (checked: boolean, event: FormEvent) => void; - +class TemporaryStorageSwitch extends React.PureComponent { constructor(props: Props) { super(props); this.state = { - isChecked: this.props.persistVolumesDefault === 'false', + isChecked: this.props.isTemporary, }; + } - this.handleChange = (isChecked: boolean): void => { - this.setState({ isChecked }); - this.props.onChange(isChecked); - }; + private handleChange(isChecked: boolean): void { + this.setState({ isChecked }); + this.props.onChange(isChecked); } render(): React.ReactElement { - const href = this.props.branding.docs.storageTypes; + const storageTypesDocLink = this.props.branding.docs.storageTypes; const isChecked = this.state.isChecked; + return ( { label="Temporary Storage On" labelOff="Temporary Storage Off" isChecked={isChecked} - onChange={this.handleChange} + onChange={isChecked => this.handleChange(isChecked)} aria-describedby="temporary-storage-tooltip" /> { Temporary Storage allows for faster I/O but may have limited storage and is not persistent. - + Open documentation page diff --git a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/__mocks__/index.tsx b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/__mocks__/index.tsx new file mode 100644 index 000000000..557006231 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/__mocks__/index.tsx @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2018-2024 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 { Props } from '@/pages/GetStarted/SamplesList/Toolbar'; + +export default class SamplesListToolbar extends React.PureComponent { + render(): React.ReactNode { + const { isTemporary, onTemporaryStorageChange } = this.props; + return ( +
+ Samples List Toolbar + {isTemporary ? 'true' : 'false'} + +
+ ); + } +} diff --git a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/__tests__/__snapshots__/index.spec.tsx.snap new file mode 100644 index 000000000..5c313b640 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/__tests__/__snapshots__/index.spec.tsx.snap @@ -0,0 +1,55 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Samples List Toolbar snapshot 1`] = ` +
+
+ +
+
+
+

+

+
+
+
+ Temporary Storage On + +
+
+
+`; diff --git a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/__tests__/index.spec.tsx new file mode 100644 index 000000000..baad1c14c --- /dev/null +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/__tests__/index.spec.tsx @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2018-2024 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 userEvent from '@testing-library/user-event'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { MockStoreEnhanced } from 'redux-mock-store'; + +import mockMetadata from '@/pages/GetStarted/__tests__/devfileMetadata.json'; +import SamplesListToolbar from '@/pages/GetStarted/SamplesList/Toolbar'; +import getComponentRenderer, { screen, waitFor } from '@/services/__mocks__/getComponentRenderer'; +import { BrandingData } from '@/services/bootstrap/branding.constant'; +import { che } from '@/services/models'; +import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; +import * as DevfileRegistriesStore from '@/store/DevfileRegistries'; + +jest.mock('@/pages/GetStarted/SamplesList/Toolbar/TemporaryStorageSwitch'); + +const { renderComponent, createSnapshot } = getComponentRenderer(getComponent); + +describe('Samples List Toolbar', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('snapshot', () => { + const snapshot = createSnapshot(); + expect(snapshot.toJSON()).toMatchSnapshot(); + }); + + it('should initially have empty filter value', () => { + renderComponent(); + const filterInput = screen.getByPlaceholderText('Filter by') as HTMLInputElement; + expect(filterInput.value).toEqual(''); + }); + + it('should not initially show the results counter', () => { + renderComponent(); + const resultsCount = screen.queryByTestId('toolbar-results-count'); + expect(resultsCount).toBeNull(); + }); + + it('should call "setFilter" action', async () => { + // mock "setFilter" action + const setFilter = DevfileRegistriesStore.actionCreators.setFilter; + DevfileRegistriesStore.actionCreators.setFilter = jest.fn(arg => setFilter(arg)); + + renderComponent(); + + const filterInput = screen.getByLabelText('Filter samples list') as HTMLInputElement; + userEvent.paste(filterInput, 'bash'); + + await waitFor(() => + expect(DevfileRegistriesStore.actionCreators.setFilter).toHaveBeenCalledTimes(1), + ); + await waitFor(() => + expect(DevfileRegistriesStore.actionCreators.setFilter).toHaveBeenCalledWith('bash'), + ); + }); + + it('should show the results counter', async () => { + const store = createFakeStore(mockMetadata); + const storeNext = new FakeStoreBuilder(store) + .withDevfileRegistries({ + filter: 'bash', + }) + .build(); + renderComponent(storeNext); + const filterInput = screen.getByPlaceholderText('Filter by') as HTMLInputElement; + userEvent.paste(filterInput, 'bash'); + + await waitFor(() => screen.findByText('1 item')); + }); + + test('switch temporary storage toggle', () => { + renderComponent(); + const switchInput = screen.getByRole('checkbox') as HTMLInputElement; + + expect(switchInput.checked).toBeFalsy(); + + userEvent.click(switchInput); + + expect(switchInput.checked).toBeTruthy(); + }); +}); + +function createFakeStore(metadata?: che.DevfileMetaData[]) { + const registries = {}; + if (metadata) { + registries['registry-location'] = { + metadata, + }; + } + return new FakeStoreBuilder() + .withBranding({ + docs: { + storageTypes: 'https://docs.location', + }, + } as BrandingData) + .withDevfileRegistries({ registries }) + .build(); +} + +function getComponent(store?: MockStoreEnhanced) { + store ||= createFakeStore(mockMetadata); + return ( + + + + ); +} diff --git a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/index.tsx b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/index.tsx new file mode 100644 index 000000000..8c6a0196c --- /dev/null +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/Toolbar/index.tsx @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2018-2024 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 { Flex, FlexItem, Text, TextContent, TextInput } from '@patternfly/react-core'; +import React from 'react'; +import Pluralize from 'react-pluralize'; +import { connect, ConnectedProps } from 'react-redux'; + +import TemporaryStorageSwitch from '@/pages/GetStarted/SamplesList/Toolbar/TemporaryStorageSwitch'; +import { AppState } from '@/store'; +import * as DevfileRegistriesStore from '@/store/DevfileRegistries'; +import { selectFilterValue, selectMetadataFiltered } from '@/store/DevfileRegistries/selectors'; + +export type Props = MappedProps & { + isTemporary: boolean; + onTemporaryStorageChange: (isTemporary: boolean) => void; +}; + +class SamplesListToolbar extends React.PureComponent { + componentWillUnmount(): void { + this.props.clearFilter(); + } + + private handleTextInputChange(searchValue: string): void { + this.props.setFilter(searchValue); + } + + private buildCount(foundCount: number, searchValue: string): React.ReactElement { + return searchValue === '' ? ( + <> + ) : ( + + ); + } + + render(): React.ReactElement { + const { filterValue, isTemporary, metadataFiltered } = this.props; + + const foundCount = metadataFiltered.length; + + return ( + + + this.handleTextInputChange(value)} + aria-label="Filter samples list" + placeholder="Filter by" + /> + + + + {this.buildCount(foundCount, filterValue)} + + + + this.props.onTemporaryStorageChange(isTemporary)} + /> + + + ); + } +} + +const mapStateToProps = (state: AppState) => ({ + filterValue: selectFilterValue(state), + metadataFiltered: selectMetadataFiltered(state), +}); + +const connector = connect(mapStateToProps, DevfileRegistriesStore.actionCreators); + +type MappedProps = ConnectedProps; +export default connector(SamplesListToolbar); diff --git a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/__mocks__/index.tsx b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/__mocks__/index.tsx new file mode 100644 index 000000000..bfd83115f --- /dev/null +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/__mocks__/index.tsx @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2018-2024 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 { Props } from '@/pages/GetStarted/SamplesList'; + +export default class SamplesList extends React.PureComponent { + render() { + const { editorDefinition, editorImage } = this.props; + return ( +
+
{editorDefinition ? editorDefinition : ''}
+
{editorImage ? editorImage : ''}
+ Samples List +
+ ); + } +} diff --git a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/__tests__/__snapshots__/index.spec.tsx.snap new file mode 100644 index 000000000..e7c90ccab --- /dev/null +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/__tests__/__snapshots__/index.spec.tsx.snap @@ -0,0 +1,163 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Samples List preferred storage: ephemeral snapshot 1`] = ` +
+
+

+ Select a Sample +

+
+ + Select a sample to create your first workspace. + +
+
+
+
+
+ + Samples List Toolbar + + + true + + +
+
+
+
+
+ Samples List Gallery +
+
+ Quarkus REST API + +
+
+
+
+
+`; + +exports[`Samples List preferred storage: non-ephemeral snapshot 1`] = ` +
+
+

+ Select a Sample +

+
+ + Select a sample to create your first workspace. + +
+
+
+
+
+ + Samples List Toolbar + + + false + + +
+
+
+
+
+ Samples List Gallery +
+
+ Quarkus REST API + +
+
+
+
+
+`; diff --git a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/__tests__/index.spec.tsx new file mode 100644 index 000000000..c95621cd2 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/__tests__/index.spec.tsx @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2018-2024 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 { api } from '@eclipse-che/common'; +import userEvent from '@testing-library/user-event'; +import { createMemoryHistory } from 'history'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { Store } from 'redux'; + +import SamplesList from '@/pages/GetStarted/SamplesList'; +import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; +import { BrandingData } from '@/services/bootstrap/branding.constant'; +import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; + +jest.mock('@/pages/GetStarted/SamplesList/Gallery'); +jest.mock('@/pages/GetStarted/SamplesList/Toolbar'); + +const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); + +const editorId = 'che-incubator/che-code/insiders'; +const editorImage = 'custom-editor-image'; + +describe('Samples List', () => { + const sampleUrl = 'https://github.com/che-samples/quarkus-quickstarts/tree/devfilev2'; + let storeBuilder: FakeStoreBuilder; + + beforeEach(() => { + storeBuilder = new FakeStoreBuilder() + .withBranding({ + docs: { + storageTypes: 'storage-types-docs', + }, + } as BrandingData) + .withDevfileRegistries({ + registries: { + ['registry-url']: { + metadata: [ + { + displayName: 'Quarkus REST API', + description: 'Quarkus stack with a default REST endpoint application sample', + tags: ['Community', 'Java', 'Quarkus', 'OpenJDK', 'Maven', 'Debian'], + icon: '/images/quarkus.svg', + links: { + v2: sampleUrl, + devWorkspaces: { + 'che-incubator/che-code/insiders': + 'registry-url/devfile-registry/devfiles/quarkus/devworkspace-che-code-insiders.yaml', + 'che-incubator/che-code/latest': + 'registry-url/devfile-registry/devfiles/quarkus/devworkspace-che-code-latest.yaml', + 'che-incubator/che-idea/next': + 'registry-url/devfile-registry/devfiles/quarkus/devworkspace-che-idea-next.yaml', + }, + }, + }, + ], + }, + }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('preferred storage: non-ephemeral', () => { + const preferredPvcStrategy = 'per-workspace'; + + let store: Store; + let mockWindowOpen: jest.Mock; + + beforeEach(() => { + store = storeBuilder + .withDwServerConfig({ + defaults: { + pvcStrategy: preferredPvcStrategy, + } as api.IServerConfig['defaults'], + }) + .build(); + + mockWindowOpen = jest.fn(); + window.open = mockWindowOpen; + }); + + test('snapshot', () => { + const snapshot = createSnapshot(store); + expect(snapshot.toJSON()).toMatchSnapshot(); + }); + + test('default storage type', () => { + renderComponent(store); + + const isTemporary = screen.getByTestId('isTemporary'); + expect(isTemporary).toHaveTextContent('false'); + + const sampleCardButton = screen.getByRole('button', { name: 'Select Sample' }); + userEvent.click(sampleCardButton); + + expect(mockWindowOpen).toHaveBeenCalledWith( + `/load-factory?url=${encodeURIComponent( + sampleUrl, + )}&che-editor=che-incubator%2Fche-code%2Finsiders&devWorkspace=registry-url%2Fdevfile-registry%2Fdevfiles%2Fquarkus%2Fdevworkspace-che-code-insiders.yaml&editor-image=custom-editor-image&storageType=${preferredPvcStrategy}`, + '_blank', + ); + }); + + test('toggled storage type', () => { + renderComponent(store); + + const toggleIsTemporaryButton = screen.getByRole('button', { name: 'Toggle isTemporary' }); + userEvent.click(toggleIsTemporaryButton); + + const isTemporary = screen.getByTestId('isTemporary'); + expect(isTemporary).toHaveTextContent('true'); + + const sampleCardButton = screen.getByRole('button', { name: 'Select Sample' }); + userEvent.click(sampleCardButton); + + expect(mockWindowOpen).toHaveBeenCalledWith( + `/load-factory?url=${encodeURIComponent( + sampleUrl, + )}&che-editor=che-incubator%2Fche-code%2Finsiders&devWorkspace=registry-url%2Fdevfile-registry%2Fdevfiles%2Fquarkus%2Fdevworkspace-che-code-insiders.yaml&editor-image=custom-editor-image&storageType=ephemeral`, + '_blank', + ); + expect(mockWindowOpen).toHaveBeenCalledTimes(1); + }); + }); + + describe('preferred storage: ephemeral', () => { + const preferredPvcStrategy = 'ephemeral'; + + let store: Store; + let mockWindowOpen: jest.Mock; + + beforeEach(() => { + store = storeBuilder + .withDwServerConfig({ + defaults: { + pvcStrategy: preferredPvcStrategy, + } as api.IServerConfig['defaults'], + }) + .build(); + + mockWindowOpen = jest.fn(); + window.open = mockWindowOpen; + }); + + test('snapshot', () => { + const snapshot = createSnapshot(store); + expect(snapshot.toJSON()).toMatchSnapshot(); + }); + + test('default storage type', () => { + renderComponent(store); + + const isTemporary = screen.getByTestId('isTemporary'); + expect(isTemporary).toHaveTextContent('true'); + + const sampleCardButton = screen.getByRole('button', { name: 'Select Sample' }); + userEvent.click(sampleCardButton); + + expect(mockWindowOpen).toHaveBeenCalledWith( + `/load-factory?url=${encodeURIComponent( + sampleUrl, + )}&che-editor=che-incubator%2Fche-code%2Finsiders&devWorkspace=registry-url%2Fdevfile-registry%2Fdevfiles%2Fquarkus%2Fdevworkspace-che-code-insiders.yaml&editor-image=custom-editor-image&storageType=${preferredPvcStrategy}`, + '_blank', + ); + }); + + test('toggled storage type', () => { + renderComponent(store); + + const toggleIsTemporaryButton = screen.getByRole('button', { name: 'Toggle isTemporary' }); + userEvent.click(toggleIsTemporaryButton); + + const isTemporary = screen.getByTestId('isTemporary'); + expect(isTemporary).toHaveTextContent('false'); + + const sampleCardButton = screen.getByRole('button', { name: 'Select Sample' }); + userEvent.click(sampleCardButton); + + expect(mockWindowOpen).toHaveBeenCalledWith( + `/load-factory?url=${encodeURIComponent( + sampleUrl, + )}&che-editor=che-incubator%2Fche-code%2Finsiders&devWorkspace=registry-url%2Fdevfile-registry%2Fdevfiles%2Fquarkus%2Fdevworkspace-che-code-insiders.yaml&editor-image=custom-editor-image&storageType=persistent`, + '_blank', + ); + expect(mockWindowOpen).toHaveBeenCalledTimes(1); + }); + }); +}); + +function getComponent(store: Store) { + const history = createMemoryHistory(); + + return ( + + + + ); +} diff --git a/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/index.tsx b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/index.tsx new file mode 100644 index 000000000..a5aa1a112 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/GetStarted/SamplesList/index.tsx @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2018-2024 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 { + Panel, + PanelHeader, + PanelMain, + PanelMainBody, + Text, + TextContent, + Title, +} from '@patternfly/react-core'; +import { History } from 'history'; +import React from 'react'; +import { connect, ConnectedProps } from 'react-redux'; + +import SamplesListGallery from '@/pages/GetStarted/SamplesList/Gallery'; +import SamplesListToolbar from '@/pages/GetStarted/SamplesList/Toolbar'; +import { EDITOR_ATTR, EDITOR_IMAGE_ATTR } from '@/services/helpers/factoryFlow/buildFactoryParams'; +import { buildFactoryLocation, toHref } from '@/services/helpers/location'; +import { che } from '@/services/models'; +import { AppState } from '@/store'; +import { + DevfileRegistryMetadata, + selectMetadataFiltered, +} from '@/store/DevfileRegistries/selectors'; +import { selectPvcStrategy } from '@/store/ServerConfig/selectors'; + +export type Props = { + history: History; + editorDefinition: string | undefined; + editorImage: string | undefined; +} & MappedProps; + +type State = { + isTemporary: boolean; +}; + +class SamplesList extends React.PureComponent { + constructor(props: Props) { + super(props); + + this.state = { + isTemporary: this.props.preferredStorageType === 'ephemeral' ? true : false, + }; + } + + private handleTemporaryStorageChange(isTemporary: boolean): void { + this.setState({ isTemporary }); + } + + private getStorageType(): che.WorkspaceStorageType { + const { preferredStorageType } = this.props; + const { isTemporary } = this.state; + + if (isTemporary) { + return 'ephemeral'; + } + + return preferredStorageType === 'ephemeral' ? 'persistent' : preferredStorageType; + } + + private async handleSampleCardClick(metadata: DevfileRegistryMetadata): Promise { + const { editorDefinition, editorImage } = this.props; + + const factoryUrlParams = new URLSearchParams({ url: metadata.links.v2 }); + + if (editorDefinition !== undefined) { + factoryUrlParams.append(EDITOR_ATTR, editorDefinition); + + const prebuiltDevWorkspace = metadata.links.devWorkspaces?.[editorDefinition]; + if (prebuiltDevWorkspace !== undefined) { + factoryUrlParams.append('devWorkspace', prebuiltDevWorkspace); + } + } + + if (editorImage !== undefined) { + factoryUrlParams.append(EDITOR_IMAGE_ATTR, editorImage); + } + + const storageType = this.getStorageType(); + factoryUrlParams.append('storageType', storageType); + + const factoryLocation = buildFactoryLocation(); + factoryLocation.search = factoryUrlParams.toString(); + + const factoryLink = toHref(this.props.history, factoryLocation); + + window.open(factoryLink, '_blank'); + } + + public render(): React.ReactElement { + const { metadataFiltered } = this.props; + + return ( + + + Select a Sample + + Select a sample to create your first workspace. + + + + + + this.handleTemporaryStorageChange(isTemporary) + } + /> + + + this.handleSampleCardClick(metadata)} + /> + + + + ); + } +} + +const mapStateToProps = (state: AppState) => ({ + metadataFiltered: selectMetadataFiltered(state), + preferredStorageType: selectPvcStrategy(state), +}); + +const connector = connect(mapStateToProps); +type MappedProps = ConnectedProps; +export default connector(SamplesList); diff --git a/packages/dashboard-frontend/src/pages/GetStarted/__tests__/GetStarted.spec.tsx b/packages/dashboard-frontend/src/pages/GetStarted/__tests__/GetStarted.spec.tsx deleted file mode 100644 index 19972d61c..000000000 --- a/packages/dashboard-frontend/src/pages/GetStarted/__tests__/GetStarted.spec.tsx +++ /dev/null @@ -1,137 +0,0 @@ -/* - * Copyright (c) 2018-2024 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 { render, screen, waitFor } from '@testing-library/react'; -import { createHashHistory } from 'history'; -import React from 'react'; -import { Provider } from 'react-redux'; -import { Store } from 'redux'; - -import { BrandingData } from '@/services/bootstrap/branding.constant'; -import devfileApi from '@/services/devfileApi'; -import { constructWorkspace, Workspace } from '@/services/workspace-adapter'; -import { devfileToDevWorkspace } from '@/services/workspace-client/devworkspace/converters'; -import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; -import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; - -import GetStarted from '..'; - -const setWorkspaceQualifiedName = jest.fn(); -const createWorkspaceFromDevfileMock = jest.fn().mockResolvedValue(undefined); -const startWorkspaceMock = jest.fn().mockResolvedValue(undefined); - -const namespace = 'che'; -const workspaceName = 'wksp-test'; -const dummyDevfile = { - schemaVersion: '2.2.0', - metadata: { - name: workspaceName, - namespace, - }, -} as devfileApi.Devfile; -const workspace = new DevWorkspaceBuilder() - .withName(workspaceName) - .withNamespace(namespace) - .build(); - -jest.mock('@/store/Workspaces/index', () => { - return { - actionCreators: { - createWorkspaceFromDevfile: (devfile, attributes) => async (): Promise => { - createWorkspaceFromDevfileMock(devfile, attributes); - const devWorkspace = devfileToDevWorkspace(devfile, 'che', false); - return constructWorkspace(devWorkspace); - }, - startWorkspace: workspace => async (): Promise => { - startWorkspaceMock(workspace); - }, - setWorkspaceQualifiedName: - (namespace: string, workspaceName: string) => async (): Promise => { - setWorkspaceQualifiedName(namespace, workspaceName); - }, - }, - }; -}); - -jest.mock('../GetStartedTab', () => { - return function DummyTab(props: { - onDevfile: (devfileContent: string, stackName: string) => Promise; - }): React.ReactElement { - return ( - - Samples List Tab Content - - - ); - }; -}); - -describe('Quick Add page', () => { - it('should create and start a new workspace', async () => { - renderGetStartedPage(); - - const quickAddTabButton = screen.getByRole('tab', { name: 'Quick Add' }); - quickAddTabButton.click(); - - await waitFor(() => expect(screen.getByRole('button', { name: 'Dummy Devfile' })).toBeTruthy()); - - const devfileButton = await screen.findByRole('button', { name: 'Dummy Devfile' }); - expect(devfileButton).toBeTruthy(); - devfileButton.click(); - - expect(createWorkspaceFromDevfileMock).toHaveBeenCalledWith(dummyDevfile, { - factoryId: 'dummyStackName', - }); - }); - - it('should have correct masthead when Quick Add tab is active', () => { - renderGetStartedPage(); - const masthead = screen.getByRole('heading'); - - const quickAddTabButton = screen.getByRole('tab', { name: 'Quick Add' }); - quickAddTabButton.click(); - - expect(masthead.textContent?.startsWith('Getting Started with')); - }); -}); - -function renderGetStartedPage(): void { - const store = createFakeStore(); - const history = createHashHistory(); - render( - - - , - ); -} - -function createFakeStore(): Store { - return new FakeStoreBuilder() - .withBranding({ - name: 'test', - } as BrandingData) - .withDevWorkspaces({ - workspaces: [workspace], - }) - .withWorkspaces({ - namespace: workspace.metadata.namespace, - workspaceName: workspace.metadata.name, - }) - .withInfrastructureNamespace([{ name: namespace, attributes: { phase: 'Active' } }], false) - .build(); -} diff --git a/packages/dashboard-frontend/src/pages/GetStarted/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/pages/GetStarted/__tests__/__snapshots__/index.spec.tsx.snap new file mode 100644 index 000000000..59197e8ef --- /dev/null +++ b/packages/dashboard-frontend/src/pages/GetStarted/__tests__/__snapshots__/index.spec.tsx.snap @@ -0,0 +1,75 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GetStarted snapshot 1`] = ` +[ +
+

+ Create Workspace +

+
, +
, +
+
+ Editor Selector + +
+
+
+
+ Import from Git +
+
+ +
+
+ +
+
+
+
+
+ +
+
+ +
+ Samples List +
+
, +] +`; diff --git a/packages/dashboard-frontend/src/pages/GetStarted/__tests__/devfileMetadata.json b/packages/dashboard-frontend/src/pages/GetStarted/__tests__/devfileMetadata.json index a770f86e6..b886f9fbf 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/__tests__/devfileMetadata.json +++ b/packages/dashboard-frontend/src/pages/GetStarted/__tests__/devfileMetadata.json @@ -155,7 +155,7 @@ { "displayName": "Rust", "description": "Rust Stack with Rust 1.57", - "tags": ["Community", "Rust"], + "tags": ["Tech-Preview", "Rust"], "icon": "/images/rust.svg", "links": { "v2": "https://github.com/che-samples/helloworld-rust/tree/devfilev2" @@ -171,23 +171,38 @@ } }, { - "displayName": "Python", - "description": "Python Stack with Python 3.7", - "tags": ["Centos", "Python", "pip"], - "icon": "/images/python.svg", - "globalMemoryLimit": "1686Mi", + "displayName": "Empty Workspace", + "description": "Start an empty remote development environment and create files or clone a git repository afterwards", + "tags": [ + "Empty" + ], + "icon": "/images/empty.svg", "links": { - "self": "/devfiles/python/devfile.yaml" + "v2": "/devfiles/empty.yaml" } }, { - "displayName": "Rust", - "description": "Rust Stack with Rust 1.39", - "tags": ["Rust"], - "icon": "/images/rust.svg", - "globalMemoryLimit": "1686Mi", - "links": { - "self": "/devfiles/rust/devfile.yaml" - } + "name": "dotnet50", + "version": "1.0.3", + "displayName": ".NET 5.0", + "description": ".NET 5.0 application", + "type": "stack", + "tags": [ + ".NET", + ".NET 5.0", + "Devfile.io" + ], + "icon": "https://github.com/dotnet/brand/raw/main/logo/dotnet-logo.png", + "projectType": "dotnet", + "language": ".NET", + "links": { + "v2": "devfile-catalog/dotnet50:1.0.3" + }, + "resources": [ + "devfile.yaml" + ], + "starterProjects": [ + "dotnet50-example" + ] } ] diff --git a/packages/dashboard-frontend/src/pages/GetStarted/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/GetStarted/__tests__/index.spec.tsx new file mode 100644 index 000000000..a92bdcc0e --- /dev/null +++ b/packages/dashboard-frontend/src/pages/GetStarted/__tests__/index.spec.tsx @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2018-2024 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 userEvent from '@testing-library/user-event'; +import { createMemoryHistory } from 'history'; +import React from 'react'; +import { Provider } from 'react-redux'; + +import GetStarted from '@/pages/GetStarted'; +import getComponentRenderer, { screen, within } from '@/services/__mocks__/getComponentRenderer'; +import { FakeStoreBuilder } from '@/store/__mocks__/storeBuilder'; + +jest.mock('@/components/EditorSelector'); +jest.mock('@/pages/GetStarted/SamplesList'); +jest.mock('@/pages/GetStarted/ImportFromGit'); + +const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); + +describe('GetStarted', () => { + test('snapshot', () => { + const snapshot = createSnapshot(); + expect(snapshot.toJSON()).toMatchSnapshot(); + }); + + test('editor definition change', () => { + renderComponent(); + + // initial state of import from git + { + const importFromGit = screen.getByTestId('import-from-git'); + const ifgEditorDefinition = within(importFromGit).getByTestId('editor-definition'); + expect(ifgEditorDefinition).toHaveTextContent(''); + const ifgEditorImage = within(importFromGit).getByTestId('editor-image'); + expect(ifgEditorImage).toHaveTextContent(''); + } + + // initial state of samples list + { + const samplesList = screen.getByTestId('samples-list'); + const slEditorDefinition = within(samplesList).getByTestId('editor-definition'); + expect(slEditorDefinition).toHaveTextContent(''); + const slEditorImage = within(samplesList).getByTestId('editor-image'); + expect(slEditorImage).toHaveTextContent(''); + } + + const button = screen.getByRole('button', { name: 'Select Editor' }); + userEvent.click(button); + + // next state of import from git + { + const importFromGit = screen.getByTestId('import-from-git'); + const ifgEditorDefinition = within(importFromGit).getByTestId('editor-definition'); + expect(ifgEditorDefinition).toHaveTextContent('some/editor/id'); + const ifgEditorImage = within(importFromGit).getByTestId('editor-image'); + expect(ifgEditorImage).toHaveTextContent('custom-editor-image'); + } + + // next state of samples list + { + const samplesList = screen.getByTestId('samples-list'); + const slEditorDefinition = within(samplesList).getByTestId('editor-definition'); + expect(slEditorDefinition).toHaveTextContent('some/editor/id'); + const slEditorImage = within(samplesList).getByTestId('editor-image'); + expect(slEditorImage).toHaveTextContent('custom-editor-image'); + } + }); +}); + +function getComponent() { + const store = new FakeStoreBuilder().build(); + const history = createMemoryHistory(); + return ( + + + + ); +} diff --git a/packages/dashboard-frontend/src/pages/GetStarted/__tests__/plugins.json b/packages/dashboard-frontend/src/pages/GetStarted/__tests__/plugins.json new file mode 100644 index 000000000..0e5028c4c --- /dev/null +++ b/packages/dashboard-frontend/src/pages/GetStarted/__tests__/plugins.json @@ -0,0 +1,158 @@ +[ + { + "id": "cdr/code-server/latest", + "description": "An open source distribution of Visual Studio Code (code-server) for Eclipse Che", + "displayName": "VS Code via code-server", + "links": { + "devfile": "/v3/plugins/cdr/code-server/latest/devfile.yaml" + }, + "name": "code-server", + "publisher": "cdr", + "type": "Che Editor", + "version": "latest", + "icon": "/v3/images/vscode.svg" + }, + { + "id": "che-incubator/che-code/insiders", + "description": "Microsoft Visual Studio Code - Open Source IDE for Eclipse Che - Insiders build", + "displayName": "VS Code - Open Source", + "links": { + "devfile": "/v3/plugins/che-incubator/che-code/insiders/devfile.yaml" + }, + "name": "che-code", + "publisher": "che-incubator", + "type": "Che Editor", + "version": "insiders", + "icon": "/v3/images/vscode.svg" + }, + { + "id": "che-incubator/che-code/latest", + "description": "Microsoft Visual Studio Code - Open Source IDE for Eclipse Che", + "displayName": "VS Code - Open Source", + "links": { + "devfile": "/v3/plugins/che-incubator/che-code/latest/devfile.yaml" + }, + "name": "che-code", + "publisher": "che-incubator", + "type": "Che Editor", + "version": "latest", + "icon": "/v3/images/vscode.svg" + }, + { + "id": "che-incubator/che-idea-server/latest", + "description": "JetBrains IntelliJ IDEA Ultimate dev server for Eclipse Che - latest", + "displayName": "IntelliJ IDEA Ultimate (desktop)", + "links": { + "devfile": "/v3/plugins/che-incubator/che-idea-server/latest/devfile.yaml" + }, + "name": "che-idea-server", + "publisher": "che-incubator", + "type": "Che Editor", + "version": "latest", + "icon": "/v3/images/intllij-idea.svg" + }, + { + "id": "che-incubator/che-idea-server/next", + "description": "JetBrains IntelliJ IDEA Ultimate dev server for Eclipse Che - next", + "displayName": "IntelliJ IDEA Ultimate (desktop)", + "links": { + "devfile": "/v3/plugins/che-incubator/che-idea-server/next/devfile.yaml" + }, + "name": "che-idea-server", + "publisher": "che-incubator", + "type": "Che Editor", + "version": "next", + "icon": "/v3/images/intllij-idea.svg" + }, + { + "id": "che-incubator/che-idea/latest", + "description": "JetBrains IntelliJ IDEA Community IDE for Eclipse Che", + "displayName": "IntelliJ IDEA Community", + "links": { + "devfile": "/v3/plugins/che-incubator/che-idea/latest/devfile.yaml" + }, + "name": "che-idea", + "publisher": "che-incubator", + "type": "Che Editor", + "version": "latest", + "icon": "/v3/images/intllij-idea.svg" + }, + { + "id": "che-incubator/che-idea/next", + "description": "JetBrains IntelliJ IDEA Community IDE for Eclipse Che - next", + "displayName": "IntelliJ IDEA Community", + "links": { + "devfile": "/v3/plugins/che-incubator/che-idea/next/devfile.yaml" + }, + "name": "che-idea", + "publisher": "che-incubator", + "type": "Che Editor", + "version": "next", + "icon": "/v3/images/intllij-idea.svg" + }, + { + "id": "che-incubator/che-pycharm/latest", + "description": "JetBrains PyCharm Community IDE for Eclipse Che", + "displayName": "JetBrains PyCharm Community", + "links": { + "devfile": "/v3/plugins/che-incubator/che-pycharm/latest/devfile.yaml" + }, + "name": "che-pycharm", + "publisher": "che-incubator", + "type": "Che Editor", + "version": "latest", + "icon": "/v3/images/pycharm.svg" + }, + { + "id": "che-incubator/che-pycharm/next", + "description": "JetBrains PyCharm Community IDE for Eclipse Che - next", + "displayName": "JetBrains PyCharm Community", + "links": { + "devfile": "/v3/plugins/che-incubator/che-pycharm/next/devfile.yaml" + }, + "name": "che-pycharm", + "publisher": "che-incubator", + "type": "Che Editor", + "version": "next", + "icon": "/v3/images/pycharm.svg" + }, + { + "id": "dirigiblelabs/dirigible/latest", + "description": "Eclipse Dirigible for Eclipse Che", + "displayName": "Eclipse Dirigible", + "links": { + "devfile": "/v3/plugins/dirigiblelabs/dirigible/latest/devfile.yaml" + }, + "name": "dirigible", + "publisher": "dirigiblelabs", + "type": "Che Editor", + "version": "latest", + "icon": "/v3/images/dirigible.svg" + }, + { + "id": "ws-skeleton/eclipseide/latest", + "description": "Eclipse IDE (in browser using Broadway) for Eclipse Che", + "displayName": "Eclipse IDE with Broadway", + "links": { + "devfile": "/v3/plugins/ws-skeleton/eclipseide/latest/devfile.yaml" + }, + "name": "eclipseide", + "publisher": "ws-skeleton", + "type": "Che Editor", + "version": "latest", + "icon": "/v3/images/eclipse-ide.svg" + }, + { + "id": "ws-skeleton/jupyter/latest", + "description": "Jupyter Notebook for Eclipse Che", + "displayName": "Jupyter Notebook", + "links": { + "devfile": "/v3/plugins/ws-skeleton/jupyter/latest/devfile.yaml" + }, + "name": "jupyter", + "publisher": "ws-skeleton", + "type": "Che Editor", + "version": "latest", + "icon": "/v3/images/notebook.svg" + } +] \ No newline at end of file diff --git a/packages/dashboard-frontend/src/pages/GetStarted/index.tsx b/packages/dashboard-frontend/src/pages/GetStarted/index.tsx index 04662d718..5c9483a9b 100644 --- a/packages/dashboard-frontend/src/pages/GetStarted/index.tsx +++ b/packages/dashboard-frontend/src/pages/GetStarted/index.tsx @@ -10,237 +10,86 @@ * Red Hat, Inc. - initial API and implementation */ -import common from '@eclipse-che/common'; -import { - AlertVariant, - PageSection, - PageSectionVariants, - Tab, - Tabs, - Title, -} from '@patternfly/react-core'; +import { Divider, PageSection, PageSectionVariants, Title } from '@patternfly/react-core'; import { History } from 'history'; -import { load } from 'js-yaml'; -import React, { Suspense } from 'react'; +import React from 'react'; import { connect, ConnectedProps } from 'react-redux'; -import Fallback from '@/components/Fallback'; +import EditorSelector from '@/components/EditorSelector'; import Head from '@/components/Head'; -import { lazyInject } from '@/inversify.config'; -import { ROUTE } from '@/Routes/routes'; -import { AppAlerts } from '@/services/alerts/appAlerts'; -import devfileApi from '@/services/devfileApi'; -import { FactoryParams } from '@/services/helpers/factoryFlow/buildFactoryParams'; -import getRandomString from '@/services/helpers/random'; -import { AlertItem, CreateWorkspaceTab } from '@/services/helpers/types'; -import { isCheDevfile, Workspace } from '@/services/workspace-adapter'; +import { Spacer } from '@/components/Spacer'; +import ImportFromGit from '@/pages/GetStarted/ImportFromGit'; +import SamplesList from '@/pages/GetStarted/SamplesList'; import { AppState } from '@/store'; -import { selectRegistriesErrors } from '@/store/DevfileRegistries/selectors'; -import { selectDefaultNamespace } from '@/store/InfrastructureNamespaces/selectors'; -import * as WorkspaceStore from '@/store/Workspaces'; -import { selectWorkspaceByQualifiedName } from '@/store/Workspaces/selectors'; - -const SamplesListTab = React.lazy(() => import('./GetStartedTab')); +import { selectDefaultEditor } from '@/store/ServerConfig/selectors'; type Props = MappedProps & { history: History; }; - type State = { - activeTabKey: CreateWorkspaceTab; + editorDefinition: string | undefined; + editorImage: string | undefined; }; export class GetStarted extends React.PureComponent { - @lazyInject(AppAlerts) - private readonly appAlerts: AppAlerts; - constructor(props: Props) { super(props); - const activeTabKey = this.getActiveTabKey(); - this.state = { - activeTabKey, + editorDefinition: undefined, + editorImage: undefined, }; } - public componentDidMount(): void { - if (this.props.registriesErrors.length) { - this.showErrors(); - } - } - - public componentDidUpdate(): void { - const activeTabKey = this.getActiveTabKey(); - if (this.state.activeTabKey !== activeTabKey) { - this.setState({ activeTabKey }); - } - - if (this.props.registriesErrors.length) { - this.showErrors(); - } - } - - private showErrors(): void { - const { registriesErrors } = this.props; - registriesErrors.forEach(error => { - const key = 'registry-error-' + error.url; - this.appAlerts.removeAlert(key); - this.appAlerts.showAlert({ - key, - title: error.errorMessage, - variant: AlertVariant.danger, - }); + private handleSelectEditor( + editorDefinition: string | undefined, + editorImage: string | undefined, + ): void { + this.setState({ + editorDefinition, + editorImage, }); } - private getActiveTabKey(): CreateWorkspaceTab { - const { pathname, search } = this.props.history.location; - - if (search) { - const searchParam = new URLSearchParams(search.substring(1)); - if ( - pathname === ROUTE.GET_STARTED && - (searchParam.get('tab') as CreateWorkspaceTab) === 'custom-workspace' - ) { - return 'custom-workspace'; - } - } - - return 'quick-add'; - } - - private async createWorkspace( - devfile: devfileApi.Devfile, - stackName: string | undefined, - infrastructureNamespace: string | undefined, - optionalFilesContent?: { - [fileName: string]: string; - }, - ): Promise { - const attr: Partial = {}; - if (stackName) { - attr.factoryId = stackName; - } - if (isCheDevfile(devfile) && !devfile.metadata.name && devfile.metadata.generateName) { - const name = devfile.metadata.generateName + getRandomString(4).toLowerCase(); - delete devfile.metadata.generateName; - devfile.metadata.name = name; - } - const namespace = infrastructureNamespace - ? infrastructureNamespace - : this.props.defaultNamespace.name; - let workspace: Workspace | undefined; - try { - await this.props.createWorkspaceFromDevfile(devfile, attr, optionalFilesContent); - this.props.setWorkspaceQualifiedName(namespace, devfile.metadata.name); - workspace = this.props.activeWorkspace; - } catch (e) { - const errorMessage = common.helpers.errors.getMessage(e); - this.showAlert({ - key: 'new-workspace-failed', - variant: AlertVariant.danger, - title: errorMessage, - }); - throw e; - } - - if (!workspace) { - const errorMessage = `Workspace "${namespace}/${devfile.metadata.name}" not found.`; - this.showAlert({ - key: 'find-workspace-failed', - variant: AlertVariant.danger, - title: errorMessage, - }); - throw errorMessage; - } - - const workspaceName = workspace.name; - this.showAlert({ - key: 'new-workspace-success', - variant: AlertVariant.success, - title: `Workspace ${workspaceName} has been created.`, - }); - - // force start for the new workspace - try { - this.props.history.push(`/ide/${workspace.namespace}/${workspaceName}`); - } catch (error) { - const errorMessage = common.helpers.errors.getMessage(error); - this.showAlert({ - key: 'start-workspace-failed', - variant: AlertVariant.warning, - title: `Workspace ${workspaceName} failed to start. ${errorMessage}`, - }); - throw new Error(errorMessage); - } - } - - private handleDevfileContent( - devfileContent: string, - attrs: { stackName?: string; infrastructureNamespace?: string }, - optionalFilesContent?: { - [fileName: string]: string; - }, - ): Promise { - try { - const devfile = load(devfileContent) as devfileApi.Devfile; - return this.createWorkspace( - devfile, - attrs.stackName, - attrs.infrastructureNamespace, - optionalFilesContent, - ); - } catch (e) { - const errorMessage = 'Failed to parse the devfile'; - this.showAlert({ - key: 'parse-devfile-failed', - variant: AlertVariant.danger, - title: errorMessage + '.', - }); - throw new Error(errorMessage + ', \n' + e); - } - } - - private showAlert(alert: AlertItem): void { - this.appAlerts.showAlert(alert); - } - render(): React.ReactNode { - const { activeTabKey } = this.state; + const { defaultEditor, history } = this.props; + const { editorDefinition, editorImage } = this.state; + const title = 'Create Workspace'; - const quickAddTab: CreateWorkspaceTab = 'quick-add'; return ( + {title} - - - - - { - return this.handleDevfileContent( - devfileContent, - { stackName }, - optionalFilesContent, - ); - }} - /> - - - + + + + + + this.handleSelectEditor(editorDefinition, editorImage) + } + /> + + + + + + + + ); @@ -248,12 +97,10 @@ export class GetStarted extends React.PureComponent { } const mapStateToProps = (state: AppState) => ({ - registriesErrors: selectRegistriesErrors(state), - activeWorkspace: selectWorkspaceByQualifiedName(state), - defaultNamespace: selectDefaultNamespace(state), + defaultEditor: selectDefaultEditor(state), }); -const connector = connect(mapStateToProps, WorkspaceStore.actionCreators); +const connector = connect(mapStateToProps); type MappedProps = ConnectedProps; export default connector(GetStarted); diff --git a/packages/dashboard-frontend/src/pages/WorkspacesList/index.tsx b/packages/dashboard-frontend/src/pages/WorkspacesList/index.tsx index 6a049a5ab..b2cab020f 100644 --- a/packages/dashboard-frontend/src/pages/WorkspacesList/index.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspacesList/index.tsx @@ -385,7 +385,7 @@ export default class WorkspacesList extends React.PureComponent { } private handleAddWorkspace(): void { - const location = buildGettingStartedLocation('quick-add'); + const location = buildGettingStartedLocation(); this.props.history.push(location); } diff --git a/packages/dashboard-frontend/src/services/helpers/location.ts b/packages/dashboard-frontend/src/services/helpers/location.ts index 8d8a15d95..cdc0ceaa4 100644 --- a/packages/dashboard-frontend/src/services/helpers/location.ts +++ b/packages/dashboard-frontend/src/services/helpers/location.ts @@ -13,7 +13,7 @@ import { History, Location } from 'history'; import { ROUTE } from '@/Routes/routes'; -import { CreateWorkspaceTab, LoaderTab, WorkspaceDetailsTab } from '@/services/helpers/types'; +import { LoaderTab, WorkspaceDetailsTab } from '@/services/helpers/types'; import { UserPreferencesTab } from '@/services/helpers/types'; import { Workspace } from '@/services/workspace-adapter'; @@ -53,14 +53,8 @@ export function buildUserPreferencesLocation(tab?: UserPreferencesTab): Location return _buildLocationObject(pathAndQuery); } -export function buildGettingStartedLocation(tab?: CreateWorkspaceTab): Location { - let pathAndQuery: string; - if (!tab) { - pathAndQuery = ROUTE.GET_STARTED; - } else { - pathAndQuery = ROUTE.GET_STARTED_TAB.replace(':tabId', tab); - } - return _buildLocationObject(pathAndQuery); +export function buildGettingStartedLocation(): Location { + return _buildLocationObject(ROUTE.GET_STARTED); } export function buildDetailsLocation( @@ -96,6 +90,10 @@ export function buildDetailsLocation( return _buildLocationObject(pathAndQuery); } +export function buildFactoryLocation(): Location { + return _buildLocationObject(ROUTE.FACTORY_LOADER); +} + function _buildLocationObject(pathAndQuery: string): Location { const tmpUrl = new URL(pathAndQuery, window.location.origin); return { diff --git a/packages/dashboard-frontend/src/services/helpers/types.ts b/packages/dashboard-frontend/src/services/helpers/types.ts index 1e8983a04..78aea96af 100644 --- a/packages/dashboard-frontend/src/services/helpers/types.ts +++ b/packages/dashboard-frontend/src/services/helpers/types.ts @@ -75,8 +75,6 @@ export function isDevWorkspaceStatus(status: unknown): status is DevWorkspaceSta return Object.values(DevWorkspaceStatus).includes(status as DevWorkspaceStatus); } -export type CreateWorkspaceTab = 'quick-add' | 'custom-workspace'; - export enum LoaderTab { Progress = 'Progress', Logs = 'Logs', diff --git a/packages/dashboard-frontend/src/services/models/che.ts b/packages/dashboard-frontend/src/services/models/che.ts index c381cd37d..78c590e97 100755 --- a/packages/dashboard-frontend/src/services/models/che.ts +++ b/packages/dashboard-frontend/src/services/models/che.ts @@ -25,15 +25,14 @@ export interface Plugin { id: string; name: string; publisher: string; - deprecate?: { - automigrate: boolean; - migrateTo: string; - }; - displayName: string; + displayName?: string; type: string; - version?: string; + version: string; description?: string; - isEnabled?: boolean; + links: { + devfile: string; + }; + icon: string; } export interface WorkspaceDevfileAttributes { diff --git a/packages/dashboard-frontend/src/store/DevfileRegistries/selectors.ts b/packages/dashboard-frontend/src/store/DevfileRegistries/selectors.ts index 302eb3fd0..c72bd1c22 100644 --- a/packages/dashboard-frontend/src/store/DevfileRegistries/selectors.ts +++ b/packages/dashboard-frontend/src/store/DevfileRegistries/selectors.ts @@ -107,8 +107,15 @@ function mergeRegistriesMetadata( }, []); } -function filterDevfileV2Metadata(metadata: Array): Array { - return metadata.filter(metadata => metadata.links?.v2); +export type DevfileRegistryMetadata = che.DevfileMetaData & { + links: { + v2: string; + }; +}; +function filterDevfileV2Metadata( + metadata: Array, +): Array { + return metadata.filter(metadata => metadata.links?.v2) as DevfileRegistryMetadata[]; } export const selectDevWorkspaceResources = createSelector( diff --git a/packages/dashboard-frontend/src/store/Plugins/chePlugins/index.ts b/packages/dashboard-frontend/src/store/Plugins/chePlugins/index.ts index 0dad52597..4394c32ed 100644 --- a/packages/dashboard-frontend/src/store/Plugins/chePlugins/index.ts +++ b/packages/dashboard-frontend/src/store/Plugins/chePlugins/index.ts @@ -21,6 +21,8 @@ import { SanityCheckAction } from '@/store/sanityCheckMiddleware'; const axiosInstance = getAxiosInstance(); +export const EXCLUDED_TARGET_EDITOR_NAMES = ['dirigible', 'jupyter', 'eclipseide', 'code-server']; + export interface State { isLoading: boolean; plugins: che.Plugin[]; @@ -44,22 +46,30 @@ interface ReceivePluginsErrorAction { type KnownAction = RequestPluginsAction | ReceivePluginsAction | ReceivePluginsErrorAction; export type ActionCreators = { - requestPlugins: (registryUrl: string) => AppThunk>; + requestPlugins: (registryUrl: string) => AppThunk>; }; export const actionCreators: ActionCreators = { requestPlugins: - (registryUrl: string): AppThunk> => - async (dispatch): Promise => { + (registryUrl: string): AppThunk> => + async (dispatch): Promise => { try { const response = await axiosInstance.get(`${registryUrl}/plugins/index.json`); - const plugins = response.data; + + const plugins = response.data.map(plugin => { + return { + ...plugin, + icon: resolveLink(registryUrl, plugin.icon), + links: { + devfile: resolveLink(registryUrl, plugin.links.devfile), + }, + }; + }); dispatch({ type: 'RECEIVE_PLUGINS', plugins, }); - return plugins; } catch (e) { const errorMessage = `Failed to fetch plugins from registry URL: ${registryUrl}, reason: ` + @@ -107,3 +117,20 @@ export const reducer: Reducer = ( return state; } }; + +/** + * Because the `baseUrl` ends with '/v3/' and the `link` starts with '/v3/', + * the `resolveLink` function will remove the duplicate '/v3/' from the `resolved` URL. + */ +export function resolveLink(baseUrl: string, link: string): string { + const resolved = baseUrl + link; + + const regexSingle = /(\/v\d)/i; + const regexDuplicate = new RegExp(regexSingle.source + '\\1', regexSingle.flags); + + if (regexDuplicate.test(resolved)) { + return resolved.replace(regexSingle, ''); + } + + return resolved; +} diff --git a/packages/dashboard-frontend/src/store/Plugins/chePlugins/selectors.ts b/packages/dashboard-frontend/src/store/Plugins/chePlugins/selectors.ts index a3a8f7226..8900baf8b 100644 --- a/packages/dashboard-frontend/src/store/Plugins/chePlugins/selectors.ts +++ b/packages/dashboard-frontend/src/store/Plugins/chePlugins/selectors.ts @@ -13,6 +13,7 @@ import { createSelector } from 'reselect'; import { AppState } from '@/store'; +import { EXCLUDED_TARGET_EDITOR_NAMES } from '@/store/Plugins/chePlugins'; const CHE_EDITOR = 'Che Editor'; @@ -24,7 +25,9 @@ export const selectPlugins = createSelector(selectState, state => ); export const selectEditors = createSelector(selectState, state => - state.plugins.filter(item => item.type === CHE_EDITOR), + state.plugins + .filter(item => item.type === CHE_EDITOR) + .filter(item => !EXCLUDED_TARGET_EDITOR_NAMES.includes(item.name)), ); export const selectPluginsError = createSelector(selectState, state => state.error); diff --git a/packages/dashboard-frontend/src/store/__mocks__/storeBuilder.ts b/packages/dashboard-frontend/src/store/__mocks__/storeBuilder.ts index caa802b22..f36c8e294 100644 --- a/packages/dashboard-frontend/src/store/__mocks__/storeBuilder.ts +++ b/packages/dashboard-frontend/src/store/__mocks__/storeBuilder.ts @@ -312,7 +312,7 @@ export class FakeStoreBuilder { devWorkspaceResources?: { [location: string]: { resources?: DevWorkspaceResources; error?: string }; }; - schema?: any; + filter?: string; }, isLoading = false, ): FakeStoreBuilder { @@ -328,6 +328,9 @@ export class FakeStoreBuilder { options.devWorkspaceResources, ); } + if (options.filter) { + this.state.devfileRegistries.filter = options.filter; + } this.state.devfileRegistries.isLoading = isLoading; return this; }