diff --git a/lerna.json b/lerna.json index 92f7c5686..a5a1685ad 100644 --- a/lerna.json +++ b/lerna.json @@ -4,6 +4,6 @@ ], "npmClient": "yarn", "stream": true, - "version": "7.58.0-next", + "version": "7.58.1-next", "useWorkspaces": true } diff --git a/package.json b/package.json index 9b116a931..0778b27e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@eclipse-che/dashboard", - "version": "7.58.0-next", + "version": "7.58.1-next", "description": "Dashboard for Eclipse Che", "private": true, "workspaces": [ diff --git a/packages/common/package.json b/packages/common/package.json index 1102ed745..d986283cd 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@eclipse-che/common", - "version": "7.58.0-next", + "version": "7.58.1-next", "repository": "https://github.com/eclipse-che/che-dashboard", "license": "EPL-2.0", "private": true, diff --git a/packages/dashboard-backend/package.json b/packages/dashboard-backend/package.json index b182724bd..1f394ef9d 100644 --- a/packages/dashboard-backend/package.json +++ b/packages/dashboard-backend/package.json @@ -1,6 +1,6 @@ { "name": "@eclipse-che/dashboard-backend", - "version": "7.58.0-next", + "version": "7.58.1-next", "description": "Dashboard backend for Eclipse Che", "scripts": { "build": "webpack --color --config webpack.config.prod.js", diff --git a/packages/dashboard-frontend/package.json b/packages/dashboard-frontend/package.json index 24c55c331..bf0fc7199 100644 --- a/packages/dashboard-frontend/package.json +++ b/packages/dashboard-frontend/package.json @@ -1,6 +1,6 @@ { "name": "@eclipse-che/dashboard-frontend", - "version": "7.58.0-next", + "version": "7.58.1-next", "description": "Dashboard frontend for Eclipse Che", "private": true, "repository": { diff --git a/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Apply/Devfile/__tests__/getGitRemotes.spec.ts b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Apply/Devfile/__tests__/getGitRemotes.spec.ts new file mode 100644 index 000000000..15b1fc685 --- /dev/null +++ b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Apply/Devfile/__tests__/getGitRemotes.spec.ts @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2018-2021 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { getGitRemotes, GitRemote, sanitizeValue } from '../getGitRemotes'; + +describe('getGitRemotes functions', () => { + describe('getGitRemotes()', () => { + it('should return remotes when one remote is provided', () => { + const input = '{https://github.com/test1/che-dashboard}'; + const expected: GitRemote[] = [ + { name: 'origin', url: 'https://github.com/test1/che-dashboard' }, + ]; + expect(getGitRemotes(input)).toMatchObject(expected); + }); + it('should return remotes when two remotes are provided', () => { + const input = + '{https://github.com/test1/che-dashboard, https://github.com/test2/che-dashboard}'; + const expected: GitRemote[] = [ + { name: 'origin', url: 'https://github.com/test1/che-dashboard' }, + { name: 'upstream', url: 'https://github.com/test2/che-dashboard' }, + ]; + expect(getGitRemotes(input)).toMatchObject(expected); + }); + + it('should return remotes when three remotes are provided', () => { + const input = + '{https://github.com/test1/che-dashboard, https://github.com/test2/che-dashboard, https://github.com/test3/che-dashboard}'; + const expected: GitRemote[] = [ + { name: 'origin', url: 'https://github.com/test1/che-dashboard' }, + { name: 'upstream', url: 'https://github.com/test2/che-dashboard' }, + { name: 'fork1', url: 'https://github.com/test3/che-dashboard' }, + ]; + expect(getGitRemotes(input)).toMatchObject(expected); + }); + + it('should return remotes when two remotes with names are provided', () => { + const input = + '{{test1,https://github.com/test1/che-dashboard},{test2,https://github.com/test2/che-dashboard}}'; + const expected: GitRemote[] = [ + { name: 'test1', url: 'https://github.com/test1/che-dashboard' }, + { name: 'test2', url: 'https://github.com/test2/che-dashboard' }, + ]; + expect(getGitRemotes(input)).toMatchObject(expected); + }); + + it('should return remotes when one remote with name is provided', () => { + const input = '{{test1,https://github.com/test1/che-dashboard}}'; + const expected: GitRemote[] = [ + { name: 'test1', url: 'https://github.com/test1/che-dashboard' }, + ]; + expect(getGitRemotes(input)).toMatchObject(expected); + }); + + it('should return remotes when multiple remotes with names are provided', () => { + const input = + '{{test1,https://github.com/test1/che-dashboard},{test2,https://github.com/test2/che-dashboard},{test3,https://github.com/test3/che-dashboard},{test4,https://github.com/test4/che-dashboard}}'; + const expected: GitRemote[] = [ + { name: 'test1', url: 'https://github.com/test1/che-dashboard' }, + { name: 'test2', url: 'https://github.com/test2/che-dashboard' }, + { name: 'test3', url: 'https://github.com/test3/che-dashboard' }, + { name: 'test4', url: 'https://github.com/test4/che-dashboard' }, + ]; + expect(getGitRemotes(input)).toMatchObject(expected); + }); + + it('should throw error when cannot parse remotes input', () => { + const input = + '{{https://github.com/test1/che-dashboard,https://github.com/test2/che-dashboard}'; + expect(() => { + getGitRemotes(input); + }).toThrow(); + }); + }); + + describe('sanitizeValue()', () => { + it('should remove all whitespaces', () => { + const input = + '[ https://github.com/test1/che-dashboard, https://github.com/test2/che-dashboard ] '; + const expected = + '["https://github.com/test1/che-dashboard","https://github.com/test2/che-dashboard"]'; + expect(sanitizeValue(input)).toBe(expected); + }); + it('should convert left braces', () => { + const input = + '{{test,https://github.com/test1/che-dashboard],{test2,https://github.com/test2/che-dashboard]]'; + const expected = + '[["test","https://github.com/test1/che-dashboard"],["test2","https://github.com/test2/che-dashboard"]]'; + expect(sanitizeValue(input)).toBe(expected); + }); + it('should convert right braces', () => { + const input = + '[[test,https://github.com/test1/che-dashboard},[test2,https://github.com/test2/che-dashboard}}'; + const expected = + '[["test","https://github.com/test1/che-dashboard"],["test2","https://github.com/test2/che-dashboard"]]'; + expect(sanitizeValue(input)).toBe(expected); + }); + it('should add quotations beside left square brackets', () => { + const input = + '[[test","https://github.com/test1/che-dashboard"],[test2","https://github.com/test2/che-dashboard"]]'; + const expected = + '[["test","https://github.com/test1/che-dashboard"],["test2","https://github.com/test2/che-dashboard"]]'; + expect(sanitizeValue(input)).toBe(expected); + }); + it('should add quotations beside right square brackets', () => { + const input = + '[["test","https://github.com/test1/che-dashboard],["test2","https://github.com/test2/che-dashboard]]'; + const expected = + '[["test","https://github.com/test1/che-dashboard"],["test2","https://github.com/test2/che-dashboard"]]'; + expect(sanitizeValue(input)).toBe(expected); + }); + it('should add quotations when in between two strings', () => { + const input = + '[["test,https://github.com/test1/che-dashboard"],["test2,https://github.com/test2/che-dashboard"]]'; + const expected = + '[["test","https://github.com/test1/che-dashboard"],["test2","https://github.com/test2/che-dashboard"]]'; + expect(sanitizeValue(input)).toBe(expected); + }); + }); +}); diff --git a/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Apply/Devfile/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Apply/Devfile/__tests__/index.spec.tsx index e2d883fad..ec307e352 100644 --- a/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Apply/Devfile/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Apply/Devfile/__tests__/index.spec.tsx @@ -33,11 +33,14 @@ import { MIN_STEP_DURATION_MS, POLICIES_CREATE_ATTR, TIMEOUT_TO_CREATE_SEC, + REMOTES_ATTR, } from '../../../../../const'; import userEvent from '@testing-library/user-event'; import { StateMock } from '@react-mock/state'; import buildFactoryParams from '../../../../buildFactoryParams'; import { prepareDevfile } from '../prepareDevfile'; +import { dump } from 'js-yaml'; +import { api } from '@eclipse-che/common'; jest.mock('../prepareDevfile.ts'); jest.mock('../../../../../../../pages/Loader/Factory'); @@ -109,6 +112,7 @@ describe('Factory Loader container, step CREATE_WORKSPACE__APPLYING_DEVFILE', () }; const store = getStoreBuilder() .withFactoryResolver({ + resolver: {}, converted: { devfileV2: devfile, }, @@ -151,6 +155,216 @@ describe('Factory Loader container, step CREATE_WORKSPACE__APPLYING_DEVFILE', () expect(mockOnNextStep).not.toHaveBeenCalled(); }); + describe('configure remotes', () => { + test('remotes configured with urls', async () => { + const store = getStoreBuilder() + .withFactoryResolver({ + resolver: {}, + converted: { + devfileV2: devfile, + }, + }) + .build(); + + const remotesAttr = '{http://git-test-1.git,http://git-test-2.git,http://git-test-3.git}'; + searchParams.append(REMOTES_ATTR, remotesAttr); + + renderComponent(store, loaderSteps, searchParams); + jest.advanceTimersByTime(MIN_STEP_DURATION_MS); + + factoryId = `${REMOTES_ATTR}=${remotesAttr}&` + factoryId; + + await waitFor(() => + expect(prepareDevfile).toHaveBeenCalledWith(devfile, factoryId, undefined, false), + ); + + expect(devfile.projects).not.toBe(undefined); + expect(devfile.projects?.length).toBe(1); + expect(devfile.projects?.[0]).toMatchObject({ + git: { + checkoutFrom: { + remote: 'origin', + }, + remotes: { + origin: 'http://git-test-1.git', + upstream: 'http://git-test-2.git', + fork1: 'http://git-test-3.git', + }, + }, + }); + }); + + test('remotes configured with urls and names', async () => { + const store = getStoreBuilder() + .withFactoryResolver({ + resolver: {}, + converted: { + devfileV2: devfile, + }, + }) + .build(); + + const remotesAttr = + '{{test1,http://git-test-1.git},{test2,http://git-test-2.git},{test3,http://git-test-3.git}}'; + searchParams.append(REMOTES_ATTR, remotesAttr); + + renderComponent(store, loaderSteps, searchParams); + jest.advanceTimersByTime(MIN_STEP_DURATION_MS); + + factoryId = `${REMOTES_ATTR}=${remotesAttr}&` + factoryId; + + await waitFor(() => + expect(prepareDevfile).toHaveBeenCalledWith(devfile, factoryId, undefined, false), + ); + + expect(devfile.projects).not.toBe(undefined); + expect(devfile.projects!.length).toBe(1); + expect(devfile.projects![0]).toMatchObject({ + git: { + checkoutFrom: { + remote: 'test1', + }, + remotes: { + test1: 'http://git-test-1.git', + test2: 'http://git-test-2.git', + test3: 'http://git-test-3.git', + }, + }, + }); + }); + + test('checkoutFrom set to first remote', async () => { + const store = getStoreBuilder() + .withFactoryResolver({ + resolver: {}, + converted: { + devfileV2: devfile, + }, + }) + .build(); + + const remotesAttr = + '{{test2,http://git-test-2.git},{test1,http://git-test-1.git},{test3,http://git-test-3.git}}'; + searchParams.append(REMOTES_ATTR, remotesAttr); + + renderComponent(store, loaderSteps, searchParams); + jest.advanceTimersByTime(MIN_STEP_DURATION_MS); + + factoryId = `${REMOTES_ATTR}=${remotesAttr}&` + factoryId; + + await waitFor(() => + expect(prepareDevfile).toHaveBeenCalledWith(devfile, factoryId, undefined, false), + ); + + expect(devfile.projects).not.toBe(undefined); + expect(devfile.projects!.length).toBe(1); + expect(devfile.projects![0]).toMatchObject({ + git: { + checkoutFrom: { + remote: 'test2', + }, + remotes: { + test1: 'http://git-test-1.git', + test2: 'http://git-test-2.git', + test3: 'http://git-test-3.git', + }, + }, + }); + }); + + test('use default devfile when there is no project url, but remotes exist', async () => { + const registryUrl = 'https://registry-url'; + const sampleResourceUrl = 'https://resources-url'; + const registryMetadata = { + displayName: 'Empty Workspace', + description: 'Start an empty remote development environment', + tags: ['Empty'], + icon: '/images/empty.svg', + links: { + v2: sampleResourceUrl, + }, + } as che.DevfileMetaData; + const sampleContent = dump({ + schemaVersion: '2.1.0', + metadata: { + generateName: 'empty', + }, + } as devfileApi.Devfile); + const defaultComponents = [ + { + name: 'universal-developer-image', + container: { + image: 'quay.io/devfile/universal-developer-image:ubi8-latest', + }, + }, + ]; + + const store = getStoreBuilder() + .withFactoryResolver({ resolver: undefined, converted: undefined }) + .withDevfileRegistries({ + registries: { + [registryUrl]: { + metadata: [registryMetadata], + }, + }, + devfiles: { + [sampleResourceUrl]: { + content: sampleContent, + }, + }, + }) + .withDwServerConfig({ + defaults: { + components: defaultComponents, + }, + } as api.IServerConfig) + .build(); + + const remotesAttr = '{https://github.com/eclipse-che/che-dashboard.git}'; + searchParams.append(REMOTES_ATTR, remotesAttr); + searchParams.delete(FACTORY_URL_ATTR); + + renderComponent(store, loaderSteps, searchParams); + jest.advanceTimersByTime(MIN_STEP_DURATION_MS); + + const expectedDevfile = { + schemaVersion: '2.1.0', + metadata: { + generateName: 'che-dashboard', + name: 'che-dashboard', + }, + components: [ + { + container: { + image: 'quay.io/devfile/universal-developer-image:ubi8-latest', + }, + name: 'universal-developer-image', + }, + ], + projects: [ + { + git: { + checkoutFrom: { remote: 'origin' }, + remotes: { + origin: 'https://github.com/eclipse-che/che-dashboard.git', + }, + }, + name: 'che-dashboard', + }, + ], + }; + + await waitFor(() => + expect(prepareDevfile).toHaveBeenCalledWith( + expectedDevfile, + `${REMOTES_ATTR}=${remotesAttr}`, + undefined, + false, + ), + ); + }); + }); + describe('handle name conflicts', () => { test('name conflict', async () => { const store = getStoreBuilder() @@ -158,6 +372,7 @@ describe('Factory Loader container, step CREATE_WORKSPACE__APPLYING_DEVFILE', () workspaces: [new DevWorkspaceBuilder().withName(devfileName).build()], }) .withFactoryResolver({ + resolver: {}, converted: { devfileV2: devfile, }, @@ -178,6 +393,7 @@ describe('Factory Loader container, step CREATE_WORKSPACE__APPLYING_DEVFILE', () workspaces: [new DevWorkspaceBuilder().withName('unique-name').build()], }) .withFactoryResolver({ + resolver: {}, converted: { devfileV2: devfile, }, @@ -201,6 +417,7 @@ describe('Factory Loader container, step CREATE_WORKSPACE__APPLYING_DEVFILE', () workspaces: [new DevWorkspaceBuilder().withName('unique-name').build()], }) .withFactoryResolver({ + resolver: {}, converted: { devfileV2: devfile, }, @@ -219,6 +436,7 @@ describe('Factory Loader container, step CREATE_WORKSPACE__APPLYING_DEVFILE', () test('the workspace took more than TIMEOUT_TO_CREATE_SEC to create', async () => { const store = getStoreBuilder() .withFactoryResolver({ + resolver: {}, converted: { devfileV2: devfile, }, @@ -264,6 +482,7 @@ describe('Factory Loader container, step CREATE_WORKSPACE__APPLYING_DEVFILE', () test('the workspace created successfully', async () => { const store = getStoreBuilder() .withFactoryResolver({ + resolver: {}, converted: { devfileV2: devfile, }, @@ -299,6 +518,7 @@ describe('Factory Loader container, step CREATE_WORKSPACE__APPLYING_DEVFILE', () // build next store const nextStore = getStoreBuilder() .withFactoryResolver({ + resolver: {}, converted: { devfileV2: devfile, }, diff --git a/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Apply/Devfile/getGitRemotes.ts b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Apply/Devfile/getGitRemotes.ts new file mode 100644 index 000000000..6daade874 --- /dev/null +++ b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Apply/Devfile/getGitRemotes.ts @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2018-2021 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import common from '@eclipse-che/common'; + +export interface GitRemote { + name: string; + url: string; +} + +/** + * Returns parsed Git remotes given a string in one of the following + * formats: + * + * 1. Explicit name and URL + * {{origin,https://git...},{upstream,https://git...},...} + * + * 2. URLs only, name is implicit: first is origin, second is upstream, subsequent are fork1, fork2 + * {https://git...,https://git...,...} + * + * @param remotes input string to parse + * @returns parsed array of Git remotes + */ +export function getGitRemotes(remotes: string): GitRemote[] { + if (remotes.length === 0) { + return []; + } + const remotesArray = parseRemotes(remotes); + if (Array.isArray(remotesArray[0])) { + return parseNameAndUrls(remotesArray, remotes); + } + return parseUrls(remotesArray, remotes); +} + +function parseRemotes(remotes: string): string[] { + try { + return JSON.parse(sanitizeValue(remotes)); + } catch (e) { + throw `Unable to parse remotes attribute. ${common.helpers.errors.getMessage(e)}`; + } +} + +/** + * Replaces braces ({}) with square brackets ([]) and adds + * quotation marks (") around strings + * @param str + * @returns string representing an array + */ +export function sanitizeValue(str: string): string { + return ( + str + .replace(/\s/g, '') + .replace(/{/g, '[') + .replace(/}/g, ']') + /* eslint-disable no-useless-escape */ + // replace '[' with '["' + .replace(/(\[([^\["]))/g, '["$2') + // replace ']' with '"]' + .replace(/(([^\]"])\])/g, '$2"]') + // replace ',' with '","' + .replace(/(([^\]"]),([^\["]))/g, '$2","$3') + /* eslint-enable no-useless-escape */ + ); +} + +function parseNameAndUrls(remotesArray, remotesString): GitRemote[] { + const remotes: GitRemote[] = []; + remotesArray.forEach(value => { + if (!Array.isArray(value) || value.length !== 2) { + throw `Malformed remotes provided: ${remotesString}`; + } + remotes.push({ name: value[0], url: value[1] }); + }); + return remotes; +} + +function parseUrls(remotesArray, remotesString): GitRemote[] { + const remotes: GitRemote[] = []; + + remotesArray.forEach((value, i) => { + if (typeof value !== 'string') { + throw `Malformed remotes provided: ${remotesString}`; + } + + let name; + if (i === 0) { + name = 'origin'; + } else if (i === 1) { + name = 'upstream'; + } else { + name = `fork${i - 1}`; + } + remotes.push({ name, url: value }); + }); + return remotes; +} diff --git a/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Apply/Devfile/index.tsx b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Apply/Devfile/index.tsx index e4793d3fa..523522167 100644 --- a/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Apply/Devfile/index.tsx +++ b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Apply/Devfile/index.tsx @@ -39,6 +39,9 @@ import { AlertItem } from '../../../../../../services/helpers/types'; import { selectDefaultDevfile } from '../../../../../../store/DevfileRegistries/selectors'; import ExpandableWarning from '../../../../../../components/ExpandableWarning'; import { getProjectFromUrl } from './getProjectFromUrl'; +import { getGitRemotes } from './getGitRemotes'; +import { V220DevfileProjects } from '@devfile/api'; +import { getProjectName } from '../../../../../../services/helpers/getProjectName'; export class CreateWorkspaceError extends Error { constructor(message: string) { @@ -139,7 +142,8 @@ class StepApplyDevfile extends AbstractLoaderStep { private updateCurrentDevfile(devfile: devfileApi.Devfile): void { const { factoryResolver, allWorkspaces, defaultDevfile } = this.props; const { factoryParams } = this.state; - const { factoryId, policiesCreate, storageType } = factoryParams; + const { factoryId, policiesCreate, sourceUrl, storageType, remotes } = factoryParams; + // when using the default devfile instead of a user devfile if (factoryResolver === undefined && isEqual(devfile, defaultDevfile)) { if (devfile.projects === undefined) { @@ -147,13 +151,20 @@ class StepApplyDevfile extends AbstractLoaderStep { } if (devfile.projects.length === 0) { // adds a default project from the source URL - const project = getProjectFromUrl(factoryParams.sourceUrl); - devfile.projects[0] = project; - // change default name - devfile.metadata.name = project.name; - devfile.metadata.generateName = project.name; + if (sourceUrl) { + const project = getProjectFromUrl(factoryParams.sourceUrl); + devfile.projects[0] = project; + // change default name + devfile.metadata.name = project.name; + devfile.metadata.generateName = project.name; + } } } + + if (remotes) { + this.configureProjectRemotes(devfile, remotes, isEqual(devfile, defaultDevfile)); + } + // test the devfile name to decide if we need to append a suffix to is const nameConflict = allWorkspaces.some(w => devfile.metadata.name === w.name); const appendSuffix = policiesCreate === 'perclick' || nameConflict; @@ -304,6 +315,64 @@ class StepApplyDevfile extends AbstractLoaderStep { }; } + private configureProjectRemotes( + devfile: devfileApi.Devfile, + remotes: string, + isDefaultDevfile: boolean, + ) { + const parsedRemotes = getGitRemotes(remotes); + const gitRemotes = parsedRemotes.reduce((map, remote) => { + map[remote.name] = remote.url; + return map; + }, {}); + + const projectName = getProjectName(parsedRemotes[0].url); + + const gitProject = this.getGitProjectForRemotes(devfile.projects); + if (gitProject) { + // edit existing Git project remote + gitProject.remotes = gitRemotes; + gitProject.checkoutFrom = { remote: parsedRemotes[0].name }; + } else { + devfile.projects = [ + { + git: { + remotes: gitRemotes, + checkoutFrom: { remote: parsedRemotes[0].name }, + }, + name: projectName, + }, + ]; + } + + if (isDefaultDevfile) { + devfile.metadata.name = projectName; + devfile.metadata.generateName = projectName; + } + } + + /** + * Returns the Git project to replace remotes for + */ + private getGitProjectForRemotes(projects: V220DevfileProjects[] | undefined) { + if (!projects) { + return undefined; + } + + const gitProjects = projects.filter(project => project.git); + if (gitProjects.length > 1) { + throw new Error( + 'Configuring remotes is not supported when multiple Git projects found in Devfile.', + ); + } + + if (gitProjects.length === 1) { + return gitProjects[0].git; + } + + return undefined; + } + render(): React.ReactElement { const { currentStepIndex, loaderSteps, tabParam } = this.props; const { lastError } = this.state; diff --git a/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Fetch/Devfile/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Fetch/Devfile/__tests__/index.spec.tsx index 8b7d210bd..3ec22449e 100644 --- a/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Fetch/Devfile/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Fetch/Devfile/__tests__/index.spec.tsx @@ -32,6 +32,7 @@ import { MIN_STEP_DURATION_MS, OVERRIDE_ATTR_PREFIX, TIMEOUT_TO_RESOLVE_SEC, + REMOTES_ATTR, } from '../../../../../const'; import { StateMock } from '@react-mock/state'; import buildFactoryParams from '../../../../buildFactoryParams'; @@ -132,6 +133,21 @@ describe('Factory Loader container, step CREATE_WORKSPACE__FETCH_DEVFILE', () => expect(mockOnRestart).toHaveBeenCalled(); }); + test('no project url, remotes exist', async () => { + const store = new FakeStoreBuilder().build(); + + const remotesAttr = + '{{test-1,http://git-test-1.git},{test-2,http://git-test-2.git},{test-3,http://git-test-3.git}}'; + searchParams.append(REMOTES_ATTR, remotesAttr); + searchParams.delete(FACTORY_URL_ATTR); + + renderComponent(store, loaderSteps, searchParams); + + jest.advanceTimersByTime(MIN_STEP_DURATION_MS); + + await waitFor(() => expect(mockOnNextStep).toHaveBeenCalled()); + }); + describe('step title', () => { test('direct link to devfile', async () => { const store = new FakeStoreBuilder() diff --git a/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Fetch/Devfile/index.tsx b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Fetch/Devfile/index.tsx index b367842c1..8d32910ae 100644 --- a/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Fetch/Devfile/index.tsx +++ b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Fetch/Devfile/index.tsx @@ -117,7 +117,7 @@ class StepFetchDevfile extends AbstractLoaderStep { private init() { const { factoryResolver } = this.props; const { factoryParams, useDefaultDevfile } = this.state; - const { sourceUrl } = factoryParams; + const { sourceUrl, remotes } = factoryParams; if (sourceUrl && (useDefaultDevfile || sourceUrl === factoryResolver?.location)) { // prevent a resource being fetched one more time this.setState({ @@ -125,6 +125,15 @@ class StepFetchDevfile extends AbstractLoaderStep { }); } + // make it possible to start a workspace without a sourceUrl as long as + // remotes are specified + if (!sourceUrl && remotes) { + this.setState({ + shouldResolve: false, + useDefaultDevfile: true, + }); + } + this.prepareAndRun(); } diff --git a/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Initialize/index.tsx b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Initialize/index.tsx index de12fd0ef..7a31ffaa1 100644 --- a/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Initialize/index.tsx +++ b/packages/dashboard-frontend/src/containers/Loader/Factory/Steps/Initialize/index.tsx @@ -82,12 +82,12 @@ class StepInitialize extends AbstractLoaderStep { protected async runStep(): Promise { await delay(MIN_STEP_DURATION_MS); - const { useDevworkspaceResources, sourceUrl, errorCode, policiesCreate } = + const { useDevworkspaceResources, sourceUrl, errorCode, policiesCreate, remotes } = this.state.factoryParams; if (useDevworkspaceResources === true && sourceUrl === '') { throw new Error('Devworkspace resources URL is missing.'); - } else if (useDevworkspaceResources === false && sourceUrl === '') { + } else if (useDevworkspaceResources === false && sourceUrl === '' && !remotes) { const factoryPath = generatePath(ROUTE.FACTORY_LOADER_URL, { url: 'your-repository-url', }); diff --git a/packages/dashboard-frontend/src/containers/Loader/Factory/buildFactoryParams.ts b/packages/dashboard-frontend/src/containers/Loader/Factory/buildFactoryParams.ts index 6ca8887d1..1a19f6868 100644 --- a/packages/dashboard-frontend/src/containers/Loader/Factory/buildFactoryParams.ts +++ b/packages/dashboard-frontend/src/containers/Loader/Factory/buildFactoryParams.ts @@ -19,6 +19,7 @@ import { OVERRIDE_ATTR_PREFIX, POLICIES_CREATE_ATTR, STORAGE_TYPE_ATTR, + REMOTES_ATTR, } from '../const'; import { ErrorCode, FactoryParams, PoliciesCreate } from './types'; @@ -32,6 +33,7 @@ export default function (searchParams: URLSearchParams): FactoryParams { policiesCreate: getPoliciesCreate(searchParams), sourceUrl: getSourceUrl(searchParams), storageType: getStorageType(searchParams), + remotes: getRemotes(searchParams), useDevworkspaceResources: getDevworkspaceResourcesUrl(searchParams) !== undefined, }; } @@ -74,6 +76,10 @@ function getErrorCode(searchParams: URLSearchParams): ErrorCode | undefined { return (searchParams.get(ERROR_CODE_ATTR) as ErrorCode) || undefined; } +function getRemotes(searchParams: URLSearchParams): string | undefined { + return searchParams.get(REMOTES_ATTR) || undefined; +} + function buildFactoryId(searchParams: URLSearchParams): string { searchParams.sort(); const factoryParams = new window.URLSearchParams(); diff --git a/packages/dashboard-frontend/src/containers/Loader/Factory/types.tsx b/packages/dashboard-frontend/src/containers/Loader/Factory/types.tsx index edb5d0679..6828a2da0 100644 --- a/packages/dashboard-frontend/src/containers/Loader/Factory/types.tsx +++ b/packages/dashboard-frontend/src/containers/Loader/Factory/types.tsx @@ -20,6 +20,7 @@ export type FactoryParams = { errorCode: ErrorCode | undefined; storageType: che.WorkspaceStorageType | undefined; cheEditor: string | undefined; + remotes: string | undefined; }; export type PoliciesCreate = 'perclick' | 'peruser'; diff --git a/packages/dashboard-frontend/src/containers/Loader/const.tsx b/packages/dashboard-frontend/src/containers/Loader/const.tsx index 0e9dd187d..9bf729b4a 100644 --- a/packages/dashboard-frontend/src/containers/Loader/const.tsx +++ b/packages/dashboard-frontend/src/containers/Loader/const.tsx @@ -16,6 +16,7 @@ export const ERROR_CODE_ATTR = 'error_code'; export const FACTORY_URL_ATTR = 'url'; export const POLICIES_CREATE_ATTR = 'policies.create'; export const STORAGE_TYPE_ATTR = 'storageType'; +export const REMOTES_ATTR = 'remotes'; export const PROPAGATE_FACTORY_ATTRS = [ 'workspaceDeploymentAnnotations', 'workspaceDeploymentLabels', @@ -24,6 +25,7 @@ export const PROPAGATE_FACTORY_ATTRS = [ FACTORY_URL_ATTR, POLICIES_CREATE_ATTR, STORAGE_TYPE_ATTR, + REMOTES_ATTR, ]; export const OVERRIDE_ATTR_PREFIX = 'override.'; diff --git a/packages/dashboard-frontend/src/preload/index.ts b/packages/dashboard-frontend/src/preload/index.ts index 57fbad694..657800c23 100644 --- a/packages/dashboard-frontend/src/preload/index.ts +++ b/packages/dashboard-frontend/src/preload/index.ts @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -import { PROPAGATE_FACTORY_ATTRS } from '../containers/Loader/const'; +import { PROPAGATE_FACTORY_ATTRS, REMOTES_ATTR } from '../containers/Loader/const'; import SessionStorageService, { SessionStorageKey } from '../services/session-storage'; (function acceptNewFactoryLink(): void { @@ -28,6 +28,13 @@ import SessionStorageService, { SessionStorageKey } from '../services/session-st } window.location.href = window.location.origin + '/dashboard' + buildFactoryLoaderPath(factoryUrl); + } else if ( + window.location.search.startsWith(`?${REMOTES_ATTR}=`) || + window.location.search.includes(`&${REMOTES_ATTR}=`) + ) { + // allow starting workspaces when no project url, but remotes are provided + window.location.href = + window.origin + '/dashboard' + buildFactoryLoaderPath(window.location.href, false); } else { window.location.href = window.location.origin + '/dashboard/'; } @@ -39,7 +46,7 @@ export function storePathIfNeeded(path: string) { } } -export function buildFactoryLoaderPath(url: string): string { +export function buildFactoryLoaderPath(url: string, appendUrl = true): string { const fullUrl = new window.URL(url); const initParams = PROPAGATE_FACTORY_ATTRS.map(paramName => { @@ -57,7 +64,10 @@ export function buildFactoryLoaderPath(url: string): string { } const searchParams = new URLSearchParams(initParams); - searchParams.append('url', fullUrl.toString()); + + if (appendUrl) { + searchParams.append('url', fullUrl.toString()); + } return '/f?' + searchParams.toString(); } diff --git a/packages/dashboard-frontend/src/services/workspace-client/devworkspace/devWorkspaceClient.ts b/packages/dashboard-frontend/src/services/workspace-client/devworkspace/devWorkspaceClient.ts index e57b0be5c..01d7a5345 100644 --- a/packages/dashboard-frontend/src/services/workspace-client/devworkspace/devWorkspaceClient.ts +++ b/packages/dashboard-frontend/src/services/workspace-client/devworkspace/devWorkspaceClient.ts @@ -532,7 +532,6 @@ export class DevWorkspaceClient extends WorkspaceClient { }, ]; return DwApi.patchWorkspace(namespace, name, patch); - await delay(); } /** diff --git a/packages/dashboard-frontend/src/store/__mocks__/storeBuilder.ts b/packages/dashboard-frontend/src/store/__mocks__/storeBuilder.ts index bd1944e08..6be8434a0 100644 --- a/packages/dashboard-frontend/src/store/__mocks__/storeBuilder.ts +++ b/packages/dashboard-frontend/src/store/__mocks__/storeBuilder.ts @@ -223,9 +223,13 @@ export class FakeStoreBuilder { ): FakeStoreBuilder { if (options.resolver) { this.state.factoryResolver.resolver = Object.assign({}, options.resolver as ResolverState); + } else { + delete this.state.factoryResolver.resolver; } if (options.converted) { this.state.factoryResolver.converted = Object.assign({}, options.converted as ConvertedState); + } else { + delete this.state.factoryResolver.converted; } this.state.factoryResolver.isLoading = isLoading; return this;