@@ -94,7 +95,9 @@ export default {
{{ $options.i18n.heading }}
- {{ $options.i18n.newWorkspaceButton }}
+ {{
+ $options.i18n.newWorkspaceButton
+ }}
diff --git a/ee/app/assets/javascripts/remote_development/router/index.js b/ee/app/assets/javascripts/remote_development/router/index.js
index 6989fdafb5d319125de49eddee8326d9d0892a36..6553d29294b5ecef5400ac85f37be875d9b1c3a3 100644
--- a/ee/app/assets/javascripts/remote_development/router/index.js
+++ b/ee/app/assets/javascripts/remote_development/router/index.js
@@ -2,6 +2,7 @@ import Vue from 'vue';
import VueRouter from 'vue-router';
import WorkspacesList from '../pages/list.vue';
import CreateWorkspace from '../pages/create.vue';
+import { ROUTES } from '../constants';
Vue.use(VueRouter);
@@ -9,12 +10,12 @@ export default function createRouter({ base }) {
const routes = [
{
path: '/',
- name: 'index',
+ name: ROUTES.index,
component: WorkspacesList,
},
{
path: '/create',
- name: 'create',
+ name: ROUTES.create,
component: CreateWorkspace,
},
{
diff --git a/ee/spec/frontend/remote_development/components/create/get_project_details_query_spec.js b/ee/spec/frontend/remote_development/components/create/get_project_details_query_spec.js
index 13debad64f5d16e7614671ce9586724d686c0d6a..2e55f6798e29d105e61d68ee27ceab6cb9dcfb80 100644
--- a/ee/spec/frontend/remote_development/components/create/get_project_details_query_spec.js
+++ b/ee/spec/frontend/remote_development/components/create/get_project_details_query_spec.js
@@ -3,12 +3,16 @@ import Vue from 'vue';
import { cloneDeep } from 'lodash';
import { logError } from '~/lib/logger';
import getProjectDetailsQuery from 'ee/remote_development/graphql/queries/get_project_details.query.graphql';
+import getGroupClusterAgentsQuery from 'ee/remote_development/graphql/queries/get_group_cluster_agents.query.graphql';
import GetProjectDetailsQuery from 'ee/remote_development/components/create/get_project_details_query.vue';
import { DEFAULT_DEVFILE_PATH } from 'ee/remote_development/constants';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import { GET_PROJECT_DETAILS_QUERY_RESULT } from '../../mock_data';
+import {
+ GET_PROJECT_DETAILS_QUERY_RESULT,
+ GET_GROUP_CLUSTER_AGENTS_QUERY_RESULT,
+} from '../../mock_data';
Vue.use(VueApollo);
@@ -16,17 +20,19 @@ jest.mock('~/lib/logger');
describe('remote_development/components/create/get_project_details_query', () => {
let mockApollo;
- let getProjectAgentsAndRootFilesQueryHandler;
+ let getProjectDetailsQueryHandler;
+ let getGroupClusterAgentsQueryHandler;
let wrapper;
const projectFullPathFixture = 'gitlab-org/gitlab';
const buildMockApollo = () => {
- getProjectAgentsAndRootFilesQueryHandler = jest.fn();
- getProjectAgentsAndRootFilesQueryHandler.mockResolvedValueOnce(
- GET_PROJECT_DETAILS_QUERY_RESULT,
- );
+ getProjectDetailsQueryHandler = jest.fn();
+ getProjectDetailsQueryHandler.mockResolvedValueOnce(GET_PROJECT_DETAILS_QUERY_RESULT);
+ getGroupClusterAgentsQueryHandler = jest.fn();
+ getGroupClusterAgentsQueryHandler.mockResolvedValueOnce(GET_GROUP_CLUSTER_AGENTS_QUERY_RESULT);
mockApollo = createMockApollo([
- [getProjectDetailsQuery, getProjectAgentsAndRootFilesQueryHandler],
+ [getProjectDetailsQuery, getProjectDetailsQueryHandler],
+ [getGroupClusterAgentsQuery, getGroupClusterAgentsQueryHandler],
]);
};
const buildWrapper = async ({ projectFullPath = projectFullPathFixture } = {}) => {
@@ -48,12 +54,20 @@ describe('remote_development/components/create/get_project_details_query', () =>
it('executes get_project_details query', async () => {
await buildWrapper();
- expect(getProjectAgentsAndRootFilesQueryHandler).toHaveBeenCalledWith({
+ expect(getProjectDetailsQueryHandler).toHaveBeenCalledWith({
projectFullPath: projectFullPathFixture,
devFilePath: DEFAULT_DEVFILE_PATH,
});
});
+ it('executes get_group_cluster_agents query', async () => {
+ await buildWrapper();
+
+ expect(getGroupClusterAgentsQueryHandler).toHaveBeenCalledWith({
+ groupPath: GET_PROJECT_DETAILS_QUERY_RESULT.data.project.group.fullPath,
+ });
+ });
+
it('emits result event with fetched cluster agents, project id, project group, and root files', async () => {
await buildWrapper();
@@ -66,6 +80,8 @@ describe('remote_development/components/create/get_project_details_query', () =>
],
id: GET_PROJECT_DETAILS_QUERY_RESULT.data.project.id,
groupPath: GET_PROJECT_DETAILS_QUERY_RESULT.data.project.group.fullPath,
+ rootRef: GET_PROJECT_DETAILS_QUERY_RESULT.data.project.repository.rootRef,
+ hasDevFile: false,
});
});
@@ -78,8 +94,8 @@ describe('remote_development/components/create/get_project_details_query', () =>
path: DEFAULT_DEVFILE_PATH,
});
- getProjectAgentsAndRootFilesQueryHandler.mockReset();
- getProjectAgentsAndRootFilesQueryHandler.mockResolvedValueOnce(customMockData);
+ getProjectDetailsQueryHandler.mockReset();
+ getProjectDetailsQueryHandler.mockResolvedValueOnce(customMockData);
});
it('emits result event with hasDevFile property that equals true', async () => {
@@ -99,8 +115,8 @@ describe('remote_development/components/create/get_project_details_query', () =>
(blob) => blob.path !== DEFAULT_DEVFILE_PATH,
);
- getProjectAgentsAndRootFilesQueryHandler.mockReset();
- getProjectAgentsAndRootFilesQueryHandler.mockResolvedValueOnce(customMockData);
+ getProjectDetailsQueryHandler.mockReset();
+ getProjectDetailsQueryHandler.mockResolvedValueOnce(customMockData);
});
it('emits result event with hasDevFile property that equals false', async () => {
@@ -113,21 +129,126 @@ describe('remote_development/components/create/get_project_details_query', () =>
});
});
+ describe('when the project does not have a repository', () => {
+ beforeEach(() => {
+ const customMockData = cloneDeep(GET_PROJECT_DETAILS_QUERY_RESULT);
+
+ customMockData.data.project.repository = null;
+
+ getProjectDetailsQueryHandler.mockReset();
+ getProjectDetailsQueryHandler.mockResolvedValueOnce(customMockData);
+ });
+
+ it('emits result event with hasDevFile property that equals false and rootRef null', async () => {
+ await buildWrapper();
+
+ expect(wrapper.emitted('result')[0][0]).toMatchObject({
+ hasDevFile: false,
+ rootRef: null,
+ });
+ });
+ });
+
describe('when project full path is not provided', () => {
it('does not execute get_project_details query', async () => {
await buildWrapper({ projectFullPath: null });
- expect(getProjectAgentsAndRootFilesQueryHandler).not.toHaveBeenCalled();
+ expect(getProjectDetailsQueryHandler).not.toHaveBeenCalled();
});
});
- describe('when a graphql query error occurs', () => {
+ describe('when a project does not belong to a group', () => {
+ beforeEach(async () => {
+ const customMockData = cloneDeep(GET_PROJECT_DETAILS_QUERY_RESULT);
+
+ customMockData.data.project.group = null;
+
+ getProjectDetailsQueryHandler.mockReset();
+ getProjectDetailsQueryHandler.mockResolvedValueOnce(customMockData);
+
+ await buildWrapper();
+ });
+
+ it('does not execute the getGroupClusterAgents query', () => {
+ expect(getProjectDetailsQueryHandler).toHaveBeenCalled();
+ expect(getGroupClusterAgentsQueryHandler).not.toHaveBeenCalled();
+ });
+
+ it('emits result event with the project data', () => {
+ expect(wrapper.emitted('result')[0][0]).toMatchObject({
+ clusterAgents: [],
+ id: GET_PROJECT_DETAILS_QUERY_RESULT.data.project.id,
+ groupPath: null,
+ hasDevFile: false,
+ rootRef: GET_PROJECT_DETAILS_QUERY_RESULT.data.project.repository.rootRef,
+ });
+ });
+ });
+
+ describe('when the project full path changes', () => {
+ it('fetches the project root group', async () => {
+ const customMockData = cloneDeep(GET_PROJECT_DETAILS_QUERY_RESULT);
+
+ await buildWrapper();
+
+ expect(getGroupClusterAgentsQueryHandler).toHaveBeenCalledTimes(1);
+
+ customMockData.data.project.group.fullPath = 'new';
+
+ getProjectDetailsQueryHandler.mockResolvedValueOnce(customMockData);
+
+ await wrapper.setProps({ projectFullPath: 'new/path' });
+
+ await waitForPromises();
+
+ expect(getGroupClusterAgentsQueryHandler).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('when the project full path changes from group to not group', () => {
+ it('emits empty clusters', async () => {
+ await buildWrapper();
+
+ expect(getGroupClusterAgentsQueryHandler).toHaveBeenCalledTimes(1);
+
+ const projectWithoutGroup = cloneDeep(GET_PROJECT_DETAILS_QUERY_RESULT);
+ projectWithoutGroup.data.project.group = null;
+ getProjectDetailsQueryHandler.mockResolvedValueOnce(projectWithoutGroup);
+
+ // assert that we've only emitted once
+ expect(wrapper.emitted('result')).toHaveLength(1);
+ await wrapper.setProps({ projectFullPath: 'new/path' });
+
+ await waitForPromises();
+
+ // assert against the last emitted result
+ expect(wrapper.emitted('result')).toHaveLength(2);
+ expect(wrapper.emitted('result')[1]).toEqual([
+ {
+ clusterAgents: [],
+ groupPath: null,
+ hasDevFile: false,
+ id: GET_PROJECT_DETAILS_QUERY_RESULT.data.project.id,
+ rootRef: GET_PROJECT_DETAILS_QUERY_RESULT.data.project.repository.rootRef,
+ },
+ ]);
+ });
+ });
+
+ describe.each`
+ queryName | queryHandlerFactory
+ ${'getProjectDetailsQuery'} | ${() => getProjectDetailsQueryHandler}
+ ${'getGroupClusterAgentsQuery'} | ${() => getGroupClusterAgentsQueryHandler}
+ `('when the $queryName query fails', ({ queryHandlerFactory }) => {
const error = new Error();
beforeEach(() => {
- getProjectAgentsAndRootFilesQueryHandler.mockReset();
- getProjectAgentsAndRootFilesQueryHandler.mockRejectedValueOnce(error);
+ const queryHandler = queryHandlerFactory();
+
+ queryHandler.mockReset();
+ queryHandler.mockRejectedValueOnce(error);
});
+
it('logs the error', async () => {
expect(logError).not.toHaveBeenCalled();
@@ -136,6 +257,12 @@ describe('remote_development/components/create/get_project_details_query', () =>
expect(logError).toHaveBeenCalledWith(error);
});
+ it('does not emit result event', async () => {
+ await buildWrapper();
+
+ expect(wrapper.emitted('result')).toBe(undefined);
+ });
+
it('emits error event', async () => {
await buildWrapper();
diff --git a/ee/spec/frontend/remote_development/components/list/empty_state_spec.js b/ee/spec/frontend/remote_development/components/list/empty_state_spec.js
index 1a8bc0fce7805c4f9e7606049b2a7cafb4b4a483..7623df60791f6e95b20eef50342db762beccad18 100644
--- a/ee/spec/frontend/remote_development/components/list/empty_state_spec.js
+++ b/ee/spec/frontend/remote_development/components/list/empty_state_spec.js
@@ -1,6 +1,7 @@
import { GlEmptyState, GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import EmptyState, { i18n } from 'ee/remote_development/components/list/empty_state.vue';
+import { ROUTES } from 'ee/remote_development/constants';
const SVG_PATH = '/assets/illustrations/empty_states/empty_workspaces.svg';
@@ -40,7 +41,7 @@ describe('remote_development/components/list/empty_state.vue', () => {
variant: 'confirm',
});
expect(button.attributes()).toMatchObject({
- to: 'create',
+ to: ROUTES.create,
});
});
});
diff --git a/ee/spec/frontend/remote_development/mock_data/index.js b/ee/spec/frontend/remote_development/mock_data/index.js
index 7e482489c0a4902917ed6d43c7777331470aab5c..e02e79830617a57ee3e941d54a9dbc3f6df51855 100644
--- a/ee/spec/frontend/remote_development/mock_data/index.js
+++ b/ee/spec/frontend/remote_development/mock_data/index.js
@@ -99,6 +99,7 @@ export const GET_PROJECT_DETAILS_QUERY_RESULT = {
project: {
id: 'gid://gitlab/Project/79',
repository: {
+ rootRef: 'main',
blobs: {
nodes: [
{ id: '.editorconfig', path: '.editorconfig' },
@@ -109,16 +110,24 @@ export const GET_PROJECT_DETAILS_QUERY_RESULT = {
group: {
id: 'gid://gitlab/Group/80',
fullPath: 'gitlab-org',
- clusterAgents: { nodes: [{ name: 'default-agent', id: 'agents/1' }] },
},
},
},
};
+export const GET_GROUP_CLUSTER_AGENTS_QUERY_RESULT = {
+ data: {
+ group: {
+ id: 'gid://gitlab/Group/80',
+ fullPath: 'gitlab-org',
+ clusterAgents: { nodes: [{ name: 'default-agent', id: 'agents/1' }] },
+ },
+ },
+};
+
export const WORKSPACE_CREATE_MUTATION_RESULT = {
data: {
workspaceCreate: {
- workspace: cloneDeep(WORKSPACE),
errors: [],
},
},
diff --git a/ee/spec/frontend/remote_development/pages/create_spec.js b/ee/spec/frontend/remote_development/pages/create_spec.js
index e9e8a01d99b7d081d1f7896ac4b34169f50b677c..5f63f77466efc9cb25cc605e21c22a81eb7c545c 100644
--- a/ee/spec/frontend/remote_development/pages/create_spec.js
+++ b/ee/spec/frontend/remote_development/pages/create_spec.js
@@ -7,8 +7,12 @@ import GetProjectDetailsQuery from 'ee/remote_development/components/create/get_
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { stubComponent } from 'helpers/stub_component';
import createMockApollo from 'helpers/mock_apollo_helper';
-import { DEFAULT_EDITOR } from 'ee/remote_development/constants';
-import { visitUrl } from '~/lib/utils/url_utility';
+import {
+ DEFAULT_EDITOR,
+ DEFAULT_DESIRED_STATE,
+ DEFAULT_DEVFILE_PATH,
+ ROUTES,
+} from 'ee/remote_development/constants';
import waitForPromises from 'helpers/wait_for_promises';
import { logError } from '~/lib/logger';
import { createAlert } from '~/alert';
@@ -16,7 +20,6 @@ import { GET_PROJECT_DETAILS_QUERY_RESULT, WORKSPACE_CREATE_MUTATION_RESULT } fr
Vue.use(VueApollo);
-jest.mock('~/lib/utils/url_utility');
jest.mock('~/lib/logger');
jest.mock('~/alert');
@@ -27,17 +30,22 @@ describe('remote_development/pages/create.vue', () => {
};
const selectedClusterAgentIDFixture = 'agents/1';
const clusterAgentsFixture = [{ text: 'Agent', value: 'agents/1' }];
+ const rootRefFixture = 'main';
const GlFormSelectStub = stubComponent(GlFormSelect, {
props: ['options'],
});
+ const mockRouter = {
+ push: jest.fn(),
+ };
let wrapper;
let workspaceCreateMutationHandler;
let mockApollo;
const buildMockApollo = () => {
- workspaceCreateMutationHandler = jest
- .fn()
- .mockResolvedValueOnce(WORKSPACE_CREATE_MUTATION_RESULT.data.workspaceCreate);
+ workspaceCreateMutationHandler = jest.fn();
+ workspaceCreateMutationHandler.mockResolvedValueOnce(
+ WORKSPACE_CREATE_MUTATION_RESULT.data.workspaceCreate,
+ );
mockApollo = createMockApollo([], {
Mutation: {
workspaceCreate: workspaceCreateMutationHandler,
@@ -51,6 +59,9 @@ describe('remote_development/pages/create.vue', () => {
stubs: {
GlFormSelect: GlFormSelectStub,
},
+ mocks: {
+ $router: mockRouter,
+ },
});
};
@@ -67,11 +78,13 @@ describe('remote_development/pages/create.vue', () => {
hasDevFile = false,
groupPath = GET_PROJECT_DETAILS_QUERY_RESULT.data.project.group.fullPath,
id = GET_PROJECT_DETAILS_QUERY_RESULT.data.project.id,
+ rootRef = rootRefFixture,
}) =>
findGetProjectDetailsQuery().vm.$emit('result', {
clusterAgents,
hasDevFile,
groupPath,
+ rootRef,
id,
});
const selectProject = (project = selectedProjectFixture) =>
@@ -91,7 +104,7 @@ describe('remote_development/pages/create.vue', () => {
});
it('displays a cancel button that allows navigating to the workspaces list', () => {
- expect(wrapper.findByTestId('cancel-workspace').attributes().to).toBe('root');
+ expect(wrapper.findByTestId('cancel-workspace').attributes().to).toBe(ROUTES.index);
});
it('disables create workspace button', () => {
@@ -108,7 +121,7 @@ describe('remote_development/pages/create.vue', () => {
});
it('displays danger alert indicating it', () => {
expect(findNoAgentsGlAlert().props()).toMatchObject({
- title: i18n.alerts.noAgents.title,
+ title: i18n.invalidProjectAlert.title,
variant: 'danger',
dismissible: false,
});
@@ -177,20 +190,27 @@ describe('remote_development/pages/create.vue', () => {
});
expect(findNoDevFileGlAlert().props()).toMatchObject({
- title: i18n.alerts.noDevFile.title,
- variant: 'info',
+ title: i18n.invalidProjectAlert.title,
+ variant: 'danger',
dismissible: false,
});
});
+
+ it('disables the "Create Workspace" button', () => {
+ expect(findCreateWorkspaceButton().props().disabled).toBe(true);
+ });
});
});
- describe('when a project and a cluster agent are selected', () => {
+ describe('when a project and a cluster agent are selected and the project has a devfile', () => {
beforeEach(async () => {
createWrapper();
await selectProject();
- await emitGetProjectDetailsQueryResult({ clusterAgents: clusterAgentsFixture });
+ await emitGetProjectDetailsQueryResult({
+ clusterAgents: clusterAgentsFixture,
+ hasDevFile: true,
+ });
await selectClusterAgent();
});
@@ -220,6 +240,9 @@ describe('remote_development/pages/create.vue', () => {
projectId: GET_PROJECT_DETAILS_QUERY_RESULT.data.project.id,
groupPath: GET_PROJECT_DETAILS_QUERY_RESULT.data.project.group.fullPath,
editor: DEFAULT_EDITOR,
+ desiredState: DEFAULT_DESIRED_STATE,
+ devfilePath: DEFAULT_DEVFILE_PATH,
+ devfileRef: rootRefFixture,
},
},
expect.any(Object),
@@ -234,13 +257,11 @@ describe('remote_development/pages/create.vue', () => {
});
describe('when the workspaceCreate mutation succeeds', () => {
- it('redirects the user to the workspace editor', async () => {
+ it('redirects the user to the workspaces list', async () => {
await submitCreateWorkspaceForm();
await waitForPromises();
- expect(visitUrl).toHaveBeenCalledWith(
- WORKSPACE_CREATE_MUTATION_RESULT.data.workspaceCreate.workspace.url,
- );
+ expect(mockRouter.push).toHaveBeenCalledWith(ROUTES.index);
});
});
diff --git a/ee/spec/frontend/remote_development/pages/list_spec.js b/ee/spec/frontend/remote_development/pages/list_spec.js
index 2809be0a77632a591e5fad6f03a2ace0825ac25b..a7a8f258f19b8d945e157c9e188b5543b3a298b1 100644
--- a/ee/spec/frontend/remote_development/pages/list_spec.js
+++ b/ee/spec/frontend/remote_development/pages/list_spec.js
@@ -8,7 +8,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import WorkspaceList from 'ee/remote_development/pages/list.vue';
import WorkspaceEmptyState from 'ee/remote_development/components/list/empty_state.vue';
import userWorkspacesListQuery from 'ee/remote_development/graphql/queries/user_workspaces_list.query.graphql';
-import { WORKSPACE_STATES } from 'ee/remote_development/constants';
+import { WORKSPACE_STATES, ROUTES } from 'ee/remote_development/constants';
import {
CURRENT_USERNAME,
USER_WORKSPACES_QUERY_RESULT,
@@ -145,7 +145,7 @@ describe('remote_development/pages/list.vue', () => {
await waitForPromises();
- expect(findNewWorkspaceButton(wrapper).attributes().to).toBe('create');
+ expect(findNewWorkspaceButton(wrapper).attributes().to).toBe(ROUTES.create);
expect(findNewWorkspaceButton(wrapper).text()).toMatch(/New workspace/);
});
});
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index a2affaeb776aab8c360165ae1eb2d602e1aeb634..5aeba7df5fc29d5ce39b7cf0ba1aba074402c2dc 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -50383,9 +50383,6 @@ msgstr ""
msgid "Workspaces"
msgstr ""
-msgid "Workspaces|A devfile is a configuration file for your workspace. Without a devfile, a default workspace is created for this project. You can change that workspace at any time."
-msgstr ""
-
msgid "Workspaces|A workspace is a virtual sandbox environment for your code in GitLab. You can create a workspace on its own or as part of a project."
msgstr ""
@@ -50419,10 +50416,10 @@ msgstr ""
msgid "Workspaces|Select project"
msgstr ""
-msgid "Workspaces|This project doesn't have a devfile"
+msgid "Workspaces|To create a workspace for this project, an administrator must configure an agent for the project's group."
msgstr ""
-msgid "Workspaces|To create a workspace for this project, an administrator must configure an agent for the project's group."
+msgid "Workspaces|To create a workspace, add a devfile to this project. A devfile is a configuration file for your workspace."
msgstr ""
msgid "Workspaces|Workspaces"