+
+
+
+ {{ $options.i18n.form.gitReference }}
+
+
+
+
+
+
+
+
+
+ {{ $options.i18n.form.devfileLocation.label }}
+
+
+
+
+
{{ $options.i18n.form.devfileLocation.contentParagraph1 }}
+
{{ $options.i18n.form.devfileLocation.contentParagraph2 }}
+
+
+ {{ $options.i18n.form.devfileLocation.linkText }}
+
+
+
+
+
+ {{ selectedProjectFullPathDisplay }}
+
+
+
+
+
+
+
+
+
+ {{ $options.i18n.form.maxHoursSuffix }}
+
+
+
+
+
+
+
{{ $options.i18n.submitButton.create }}
-
+
{{ $options.i18n.cancelButton }}
diff --git a/ee/app/assets/javascripts/remote_development/pages/list.vue b/ee/app/assets/javascripts/remote_development/pages/list.vue
index 7df809c4c4e7e4e1c3dcac8438a3230979dc9eaa..54b182bcbb2319dee8789bc80904f511de4cff89 100644
--- a/ee/app/assets/javascripts/remote_development/pages/list.vue
+++ b/ee/app/assets/javascripts/remote_development/pages/list.vue
@@ -11,7 +11,7 @@ import {
} from '../constants';
import userWorkspacesListQuery from '../graphql/queries/user_workspaces_list.query.graphql';
import WorkspacesList from '../components/common/workspaces_list.vue';
-import { fetchProjectNames, populateWorkspacesWithProjectNames } from '../services/utils';
+import { fetchProjectsDetails, populateWorkspacesWithProjectDetails } from '../services/utils';
export const i18n = {
newWorkspaceButton: s__('Workspaces|New workspace'),
@@ -44,7 +44,7 @@ export default {
return;
}
const workspaces = data.currentUser.workspaces.nodes;
- const result = await fetchProjectNames(this.$apollo, workspaces);
+ const result = await fetchProjectsDetails(this.$apollo, workspaces);
if (result.error) {
this.error = i18n.loadingWorkspacesFailed;
@@ -53,7 +53,7 @@ export default {
return;
}
- this.workspaces = populateWorkspacesWithProjectNames(workspaces, result.projects);
+ this.workspaces = populateWorkspacesWithProjectDetails(workspaces, result.projects);
this.pageInfo = data.currentUser.workspaces.pageInfo;
},
},
diff --git a/ee/app/assets/javascripts/remote_development/services/utils.js b/ee/app/assets/javascripts/remote_development/services/utils.js
index 18a1789c2223aa6c3649eab05281c2360f916876..7bbb9873cf94073fc234deb92be831a98d8a92a8 100644
--- a/ee/app/assets/javascripts/remote_development/services/utils.js
+++ b/ee/app/assets/javascripts/remote_development/services/utils.js
@@ -1,6 +1,6 @@
-import userWorkspacesProjectsNamesQuery from '../graphql/queries/user_workspaces_projects_names.query.graphql';
+import getProjectsDetailsQuery from '../graphql/queries/get_projects_details.query.graphql';
-export const populateWorkspacesWithProjectNames = (workspaces, projects) => {
+export const populateWorkspacesWithProjectDetails = (workspaces, projects) => {
return workspaces.map((workspace) => {
const project = projects.find((p) => p.id === workspace.projectId);
@@ -10,14 +10,14 @@ export const populateWorkspacesWithProjectNames = (workspaces, projects) => {
};
});
};
-export const fetchProjectNames = async (apollo, workspaces) => {
+export const fetchProjectsDetails = async (apollo, workspaces) => {
const projectIds = workspaces.map(({ projectId }) => projectId);
try {
const {
data: { projects },
} = await apollo.query({
- query: userWorkspacesProjectsNamesQuery,
+ query: getProjectsDetailsQuery,
variables: { ids: projectIds },
});
diff --git a/ee/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/ee/app/assets/javascripts/vue_shared/components/web_ide_link.vue
index a709d561c3ebd0fe92bd7a6c76efd7ba2fb69121..fb9a3bf688dd09a2272b9763f9dbebab8362be76 100644
--- a/ee/app/assets/javascripts/vue_shared/components/web_ide_link.vue
+++ b/ee/app/assets/javascripts/vue_shared/components/web_ide_link.vue
@@ -59,9 +59,9 @@ export default {
onDropdownHidden() {
this.isDropdownVisible = false;
},
- onProjectDetailsResult({ hasDevFile, clusterAgents }) {
+ onProjectDetailsResult({ clusterAgents }) {
this.projectDetailsLoaded = true;
- this.supportsWorkspaces = hasDevFile && clusterAgents.length > 0;
+ this.supportsWorkspaces = clusterAgents.length > 0;
},
onProjectDetailsError() {
this.projectDetailsLoaded = true;
diff --git a/ee/app/assets/stylesheets/page_bundles/remote_development.scss b/ee/app/assets/stylesheets/page_bundles/remote_development.scss
index 043d04b2872c8f96fbb320e3af32471a9fd5b79d..be1a5659e908055cd6e703242cc9e35dad788034 100644
--- a/ee/app/assets/stylesheets/page_bundles/remote_development.scss
+++ b/ee/app/assets/stylesheets/page_bundles/remote_development.scss
@@ -1,6 +1,6 @@
@import 'page_bundles/mixins_and_variables_and_functions';
-.workspace-preview-link {
+.workspace-list-link {
white-space: normal;
overflow: hidden;
// This property is necessary to implement line truncation.
diff --git a/ee/app/graphql/types/remote_development/workspace_type.rb b/ee/app/graphql/types/remote_development/workspace_type.rb
index 496205ba097a461531ce66aa52cfa888f84a69c3..730295f15eda0140ecba902af08b9e67fdfdaf9e 100644
--- a/ee/app/graphql/types/remote_development/workspace_type.rb
+++ b/ee/app/graphql/types/remote_development/workspace_type.rb
@@ -14,10 +14,10 @@ class WorkspaceType < ::Types::BaseObject
field :cluster_agent, ::Types::Clusters::AgentType,
null: false,
method: :agent,
- description: 'Kubernetes Agent associated with the workspace.'
+ description: 'Kubernetes agent associated with the workspace.'
field :project_id, GraphQL::Types::ID,
- null: false, description: 'ID of the Project providing the Devfile for the workspace.'
+ null: false, description: 'ID of the project that contains the devfile for the workspace.'
field :user, ::Types::UserType,
null: false, description: 'Owner of the workspace.'
@@ -33,14 +33,15 @@ class WorkspaceType < ::Types::BaseObject
null: false, description: 'Desired state of the workspace.'
field :desired_state_updated_at, Types::TimeType,
- null: false, description: 'Timestamp of last update to desired state.'
+ null: false, description: 'Timestamp of the last update to the desired state.'
# TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/409772 - Make this a type:enum
field :actual_state, GraphQL::Types::String,
null: false, description: 'Actual state of the workspace.'
field :responded_to_agent_at, Types::TimeType,
- null: true, description: 'Timestamp of last response sent to GA4K for the workspace.'
+ null: true,
+ description: 'Timestamp of the last response sent to the GitLab agent for Kubernetes for the workspace.'
field :url, GraphQL::Types::String,
null: false, description: 'URL of the workspace.'
@@ -49,13 +50,16 @@ class WorkspaceType < ::Types::BaseObject
null: false, description: 'Editor used to configure the workspace. Must match a configured template.'
field :max_hours_before_termination, GraphQL::Types::Int,
- null: false, description: 'Maximum hours the workspace can exist before it is automatically terminated.'
+ null: false, description: 'Number of hours until the workspace automatically terminates.'
field :devfile_ref, GraphQL::Types::String,
- null: false, description: 'Project repo git ref containing the devfile used to configure the workspace.'
+ null: false, description: 'Git reference that contains the devfile used to configure the workspace.'
field :devfile_path, GraphQL::Types::String,
- null: false, description: 'Project repo git path containing the devfile used to configure the workspace.'
+ null: false, description: 'Path to the devfile used to configure the workspace.'
+
+ field :devfile_web_url, GraphQL::Types::String,
+ null: false, description: 'Web URL of the devfile used to configure the workspace.'
field :devfile, GraphQL::Types::String,
null: false, description: 'Source YAML of the devfile used to configure the workspace.'
@@ -64,13 +68,13 @@ class WorkspaceType < ::Types::BaseObject
null: false, description: 'Processed YAML of the devfile used to configure the workspace.'
field :deployment_resource_version, GraphQL::Types::Int,
- null: true, description: 'ResourceVersion of the Deployment resource for the workspace.'
+ null: true, description: 'Version of the deployment resource for the workspace.'
field :created_at, Types::TimeType,
- null: false, description: 'Timestamp of workspace creation.'
+ null: false, description: 'Timestamp of when the workspace was created.'
field :updated_at, Types::TimeType,
- null: false, description: 'Timestamp of last update to any mutable workspace property.'
+ null: false, description: 'Timestamp of the last update to any mutable workspace property.'
def project_id
"gid://gitlab/Project/#{object.project_id}"
diff --git a/ee/app/models/remote_development/workspace.rb b/ee/app/models/remote_development/workspace.rb
index 7d05999cf05b7e2f72b63f18daacb40e222fed35..f2bed27057f75853bdf5acde4b1e58110fd4ebd2 100644
--- a/ee/app/models/remote_development/workspace.rb
+++ b/ee/app/models/remote_development/workspace.rb
@@ -114,6 +114,10 @@ def url
URI::HTTPS.build(host: "#{url_prefix}.#{dns_zone}", query: url_query_string).to_s
end
+ def devfile_web_url
+ project.http_url_to_repo.gsub(/\.git$/, "/-/blob/#{devfile_ref}/#{devfile_path}")
+ end
+
private
def max_hours_before_termination_limit
diff --git a/ee/lib/remote_development/workspaces/create/devfile_fetcher.rb b/ee/lib/remote_development/workspaces/create/devfile_fetcher.rb
index 171509720561987b9d7964c023b41bf99b6fb72e..fb998b564291b08eca03ec5da894ed9ab9bfd825 100644
--- a/ee/lib/remote_development/workspaces/create/devfile_fetcher.rb
+++ b/ee/lib/remote_development/workspaces/create/devfile_fetcher.rb
@@ -27,9 +27,18 @@ def self.fetch(value)
end
repository = project.repository
- devfile_yaml = repository.blob_at_branch(devfile_ref, devfile_path)&.data
- unless devfile_yaml
+ devfile_blob = repository.blob_at_branch(devfile_ref, devfile_path)
+
+ unless devfile_blob
+ return Result.err(WorkspaceCreateDevfileLoadFailed.new(
+ details: "Devfile path '#{devfile_path}' at ref '#{devfile_ref}' does not exist in project repository"
+ ))
+ end
+
+ devfile_yaml = devfile_blob.data
+
+ unless devfile_yaml.present?
return Result.err(WorkspaceCreateDevfileLoadFailed.new(details: "Devfile could not be loaded from project"))
end
diff --git a/ee/lib/remote_development/workspaces/create/project_cloner_component_injector.rb b/ee/lib/remote_development/workspaces/create/project_cloner_component_injector.rb
index e5aeb122c4be758298513f27cac754cf6ac8d52d..a6a62b6cdf77759b0e8c72844be9e14bffdb1dc7 100644
--- a/ee/lib/remote_development/workspaces/create/project_cloner_component_injector.rb
+++ b/ee/lib/remote_development/workspaces/create/project_cloner_component_injector.rb
@@ -17,7 +17,10 @@ def self.inject(value)
}
volume_mounts => { data_volume: Hash => data_volume }
data_volume => { path: String => volume_path }
- params => { project: Project => project }
+ params => {
+ project: Project => project,
+ devfile_ref: String => devfile_ref,
+ }
settings => {
project_cloner_image: String => image,
}
@@ -27,7 +30,6 @@ def self.inject(value)
# reasons
clone_dir = "#{volume_path}/#{project.path}"
project_url = project.http_url_to_repo
- project_ref = project.default_branch
# The project is cloned only if one doesn't exist already.
# This done to avoid resetting user's modifications to the workspace.
@@ -39,7 +41,7 @@ def self.inject(value)
container_args = <<~SH.chomp
if [ ! -d '#{clone_dir}' ];
then
- git clone --branch #{Shellwords.shellescape(project_ref)} #{Shellwords.shellescape(project_url)} #{Shellwords.shellescape(clone_dir)};
+ git clone --branch #{Shellwords.shellescape(devfile_ref)} #{Shellwords.shellescape(project_url)} #{Shellwords.shellescape(clone_dir)};
fi
SH
diff --git a/ee/spec/features/remote_development/workspaces_spec.rb b/ee/spec/features/remote_development/workspaces_spec.rb
index 7548665b8ff5dbee7a56d94c707cfc6f3e0a8af7..8f86a4cbb1d681f4d99cc6ece7210b38c5f08a4c 100644
--- a/ee/spec/features/remote_development/workspaces_spec.rb
+++ b/ee/spec/features/remote_development/workspaces_spec.rb
@@ -57,8 +57,8 @@
find_by_testid("listbox-item-#{project.full_path}").click
wait_for_requests
# noinspection RubyMismatchedArgumentType - TODO: Try suggestions in https://youtrack.jetbrains.com/issue/RUBY-25400/Programmatically-defined-constants-always-produce-Unresolved-reference-error#focus=Comments-27-8161148.0-0
- select agent.name, from: 'Select cluster agent'
- fill_in 'Time before automatic termination', with: '20'
+ select agent.name, from: 'Cluster agent'
+ fill_in 'Workspace automatically terminates after', with: '20'
click_button 'Create workspace'
# We look for the project GID because that's all we know about the workspace at this point. For the new UI,
diff --git a/ee/spec/frontend/remote_development/components/agent_admin_ui/agent_workspaces_list_spec.js b/ee/spec/frontend/remote_development/components/agent_admin_ui/agent_workspaces_list_spec.js
index 95ecb008e04c5093bae31548bc7129a3a587965c..67e1ff3b8c36d5e7781ba28babc4e3bf47a2c516 100644
--- a/ee/spec/frontend/remote_development/components/agent_admin_ui/agent_workspaces_list_spec.js
+++ b/ee/spec/frontend/remote_development/components/agent_admin_ui/agent_workspaces_list_spec.js
@@ -10,12 +10,12 @@ import WorkspaceEmptyState from 'ee/remote_development/components/list/empty_sta
import WorkspacesTable from 'ee/remote_development/components/list/workspaces_table.vue';
import WorkspacesListPagination from 'ee/remote_development/components/list/workspaces_list_pagination.vue';
import agentWorkspacesListQuery from 'ee/remote_development/graphql/queries/agent_workspaces_list.query.graphql';
-import userWorkspacesProjectsNamesQuery from 'ee/remote_development/graphql/queries/user_workspaces_projects_names.query.graphql';
-import { populateWorkspacesWithProjectNames } from 'ee/remote_development/services/utils';
+import getProjectsDetailsQuery from 'ee/remote_development/graphql/queries/get_projects_details.query.graphql';
+import { populateWorkspacesWithProjectDetails } from 'ee/remote_development/services/utils';
import {
- AGENT_WORKSPACES_LIST_QUERY_RESULT,
AGENT_WORKSPACES_LIST_QUERY_EMPTY_RESULT,
- WORKSPACES_PROJECT_NAMES_QUERY_RESULT,
+ AGENT_WORKSPACES_LIST_QUERY_RESULT,
+ GET_PROJECTS_DETAILS_QUERY_RESULT,
} from '../../mock_data';
jest.mock('~/lib/logger');
@@ -30,19 +30,19 @@ describe('remote_development/components/agent_admin_ui/agent_workspaces_list.vue
let wrapper;
let mockApollo;
let agentWorkspacesListQueryHandler;
- let userWorkspacesProjectNamesQueryHandler;
+ let getProjectsDetailsQueryHandler;
const buildMockApollo = () => {
agentWorkspacesListQueryHandler = jest
.fn()
.mockResolvedValueOnce(AGENT_WORKSPACES_LIST_QUERY_RESULT);
- userWorkspacesProjectNamesQueryHandler = jest
+ getProjectsDetailsQueryHandler = jest
.fn()
- .mockResolvedValueOnce(WORKSPACES_PROJECT_NAMES_QUERY_RESULT);
+ .mockResolvedValueOnce(GET_PROJECTS_DETAILS_QUERY_RESULT);
mockApollo = createMockApollo([
[agentWorkspacesListQuery, agentWorkspacesListQueryHandler],
- [userWorkspacesProjectsNamesQuery, userWorkspacesProjectNamesQueryHandler],
+ [getProjectsDetailsQuery, getProjectsDetailsQueryHandler],
]);
};
const createWrapper = () => {
@@ -118,9 +118,9 @@ describe('remote_development/components/agent_admin_ui/agent_workspaces_list.vue
it('provides workspaces data to the workspaces table', () => {
expect(findTable(wrapper).props('workspaces')).toEqual(
- populateWorkspacesWithProjectNames(
+ populateWorkspacesWithProjectDetails(
AGENT_WORKSPACES_LIST_QUERY_RESULT.data.project.clusterAgent.workspaces.nodes,
- WORKSPACES_PROJECT_NAMES_QUERY_RESULT.data.projects.nodes,
+ GET_PROJECTS_DETAILS_QUERY_RESULT.data.projects.nodes,
),
);
});
@@ -188,9 +188,9 @@ describe('remote_development/components/agent_admin_ui/agent_workspaces_list.vue
});
describe.each`
- query | queryHandlerFactory
- ${'userWorkspaces'} | ${() => agentWorkspacesListQueryHandler}
- ${'userWorkspacesProjectsNames'} | ${() => userWorkspacesProjectNamesQueryHandler}
+ query | queryHandlerFactory
+ ${'userWorkspaces'} | ${() => agentWorkspacesListQueryHandler}
+ ${'projectsDetails'} | ${() => getProjectsDetailsQueryHandler}
`('when $query query fails', ({ queryHandlerFactory }) => {
const ERROR = new Error('Something bad!');
diff --git a/ee/spec/frontend/remote_development/components/common/get_project_details_query_spec.js b/ee/spec/frontend/remote_development/components/common/get_project_details_query_spec.js
index fe6f437da14fd45d56f0ead08a70ff18d1187791..ea87a6556abd764e6140efe06fc113015c3e21e7 100644
--- a/ee/spec/frontend/remote_development/components/common/get_project_details_query_spec.js
+++ b/ee/spec/frontend/remote_development/components/common/get_project_details_query_spec.js
@@ -5,7 +5,6 @@ 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/common/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';
@@ -75,7 +74,6 @@ describe('remote_development/components/create/get_project_details_query', () =>
expect(getProjectDetailsQueryHandler).toHaveBeenCalledWith({
projectFullPath: projectFullPathFixture,
- devFilePath: DEFAULT_DEVFILE_PATH,
});
});
});
@@ -125,7 +123,6 @@ describe('remote_development/components/create/get_project_details_query', () =>
rootRef: GET_PROJECT_DETAILS_QUERY_RESULT.data.project.repository.rootRef,
nameWithNamespace: GET_PROJECT_DETAILS_QUERY_RESULT.data.project.nameWithNamespace,
fullPath: projectFullPathFixture,
- hasDevFile: false,
});
});
});
@@ -165,7 +162,6 @@ describe('remote_development/components/create/get_project_details_query', () =>
rootRef: GET_PROJECT_DETAILS_QUERY_RESULT.data.project.repository.rootRef,
nameWithNamespace: GET_PROJECT_DETAILS_QUERY_RESULT.data.project.nameWithNamespace,
fullPath: projectFullPathFixture,
- hasDevFile: false,
});
});
});
@@ -191,7 +187,6 @@ describe('remote_development/components/create/get_project_details_query', () =>
},
],
fullPath: 'gitlab-org/gitlab',
- hasDevFile: false,
id: 'gid://gitlab/Project/1',
nameWithNamespace: 'GitLab Org / Subgroup / GitLab',
rootRef: 'main',
@@ -242,7 +237,6 @@ describe('remote_development/components/create/get_project_details_query', () =>
rootRef: GET_PROJECT_DETAILS_QUERY_RESULT.data.project.repository.rootRef,
nameWithNamespace: GET_PROJECT_DETAILS_QUERY_RESULT.data.project.nameWithNamespace,
fullPath: projectFullPathFixture,
- hasDevFile: false,
});
});
@@ -262,49 +256,6 @@ describe('remote_development/components/create/get_project_details_query', () =>
expect(wrapper.emitted('error')).toEqual([[]]);
});
});
-
- describe('when the project repository has .devfile in the root repository', () => {
- beforeEach(() => {
- const customMockData = cloneDeep(GET_PROJECT_DETAILS_QUERY_RESULT);
-
- customMockData.data.project.repository.blobs.nodes.push({
- id: DEFAULT_DEVFILE_PATH,
- path: DEFAULT_DEVFILE_PATH,
- });
-
- getProjectDetailsQueryHandler.mockReset();
- getProjectDetailsQueryHandler.mockResolvedValueOnce(customMockData);
- });
-
- it('emits result event with hasDevFile property that equals true', async () => {
- await buildWrapper();
-
- expect(wrapper.emitted('result')[0][0]).toMatchObject({
- hasDevFile: true,
- });
- });
- });
-
- describe('when the project repository does not have .devfile in the root repository', () => {
- beforeEach(() => {
- const customMockData = cloneDeep(GET_PROJECT_DETAILS_QUERY_RESULT);
-
- customMockData.data.project.repository.blobs.nodes = customMockData.data.project.repository.blobs.nodes.filter(
- (blob) => blob.path !== DEFAULT_DEVFILE_PATH,
- );
-
- getProjectDetailsQueryHandler.mockReset();
- getProjectDetailsQueryHandler.mockResolvedValueOnce(customMockData);
- });
-
- it('emits result event with hasDevFile property that equals false', async () => {
- await buildWrapper();
-
- expect(wrapper.emitted('result')[0][0]).toMatchObject({
- hasDevFile: false,
- });
- });
- });
});
describe('when the project does not have a repository', () => {
@@ -323,11 +274,10 @@ describe('remote_development/components/create/get_project_details_query', () =>
getProjectDetailsQueryHandler.mockResolvedValueOnce(customMockData);
});
- it('emits result event with hasDevFile property that equals false and rootRef null', async () => {
+ it('emits result event with rootRef null', async () => {
await buildWrapper();
expect(wrapper.emitted('result')[0][0]).toMatchObject({
- hasDevFile: false,
rootRef: null,
});
});
@@ -363,7 +313,6 @@ describe('remote_development/components/create/get_project_details_query', () =>
expect(wrapper.emitted('result')[0][0]).toEqual({
clusterAgents: [],
id: GET_PROJECT_DETAILS_QUERY_RESULT.data.project.id,
- hasDevFile: false,
rootRef: GET_PROJECT_DETAILS_QUERY_RESULT.data.project.repository.rootRef,
nameWithNamespace: GET_PROJECT_DETAILS_QUERY_RESULT.data.project.nameWithNamespace,
fullPath: projectFullPathFixture,
@@ -427,7 +376,6 @@ describe('remote_development/components/create/get_project_details_query', () =>
expect(wrapper.emitted('result')[1]).toEqual([
{
clusterAgents: [],
- hasDevFile: false,
id: GET_PROJECT_DETAILS_QUERY_RESULT.data.project.id,
rootRef: GET_PROJECT_DETAILS_QUERY_RESULT.data.project.repository.rootRef,
fullPath: projectFullPath,
diff --git a/ee/spec/frontend/remote_development/components/list/workspaces_table_spec.js b/ee/spec/frontend/remote_development/components/list/workspaces_table_spec.js
index 50e61dd11a9185af7e7d8df3acfda6862deeb0f8..b6c48ed7e89a3e7db1f927c7e987c60a2dc7d851 100644
--- a/ee/spec/frontend/remote_development/components/list/workspaces_table_spec.js
+++ b/ee/spec/frontend/remote_development/components/list/workspaces_table_spec.js
@@ -7,11 +7,11 @@ import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import WorkspacesTable from 'ee/remote_development/components/list/workspaces_table.vue';
import WorkspaceActions from 'ee/remote_development/components/common/workspace_actions.vue';
import WorkspaceStateIndicator from 'ee/remote_development/components/common/workspace_state_indicator.vue';
-import { populateWorkspacesWithProjectNames } from 'ee/remote_development/services/utils';
+import { populateWorkspacesWithProjectDetails } from 'ee/remote_development/services/utils';
import { WORKSPACE_STATES, WORKSPACE_DESIRED_STATES } from 'ee/remote_development/constants';
import {
USER_WORKSPACES_LIST_QUERY_RESULT,
- WORKSPACES_PROJECT_NAMES_QUERY_RESULT,
+ GET_PROJECTS_DETAILS_QUERY_RESULT,
} from '../../mock_data';
jest.mock('~/lib/logger');
@@ -29,12 +29,21 @@ const findTableRowsAsData = (wrapper) =>
workspaceState: tds.at(0).findComponent(WorkspaceStateIndicator).props('workspaceState'),
nameText: tds.at(1).text(),
createdAt: tds.at(2).findComponent(TimeAgoTooltip).props().time,
- actionsProps: tds.at(4).findComponent(WorkspaceActions).props(),
+ actionsProps: tds.at(5).findComponent(WorkspaceActions).props(),
};
- if (tds.at(3).findComponent(GlLink).exists()) {
- rowData.previewText = tds.at(3).text();
- rowData.previewHref = tds.at(3).findComponent(GlLink).attributes('href');
+ const td3 = tds.at(3);
+ const devfileLink = td3.findComponent(GlLink);
+ if (devfileLink.exists()) {
+ rowData.devfileText = td3.text();
+ rowData.devfileHref = devfileLink.attributes('href');
+ rowData.devfileTooltipTitle = devfileLink.attributes('title');
+ rowData.devfileTooltipAriaLabel = devfileLink.attributes('aria-label');
+ }
+
+ if (tds.at(4).findComponent(GlLink).exists()) {
+ rowData.previewText = tds.at(4).text();
+ rowData.previewHref = tds.at(4).findComponent(GlLink).attributes('href');
}
return rowData;
@@ -51,9 +60,9 @@ describe('remote_development/components/list/workspaces_table.vue', () => {
};
const createWrapper = ({
- workspaces = populateWorkspacesWithProjectNames(
+ workspaces = populateWorkspacesWithProjectDetails(
USER_WORKSPACES_LIST_QUERY_RESULT.data.currentUser.workspaces.nodes,
- WORKSPACES_PROJECT_NAMES_QUERY_RESULT.data.projects.nodes,
+ GET_PROJECTS_DETAILS_QUERY_RESULT.data.projects.nodes,
),
} = {}) => {
updateWorkspaceMutationMock = jest.fn();
@@ -97,9 +106,9 @@ describe('remote_development/components/list/workspaces_table.vue', () => {
it('displays user workspaces correctly', () => {
expect(findTableRowsAsData(wrapper)).toEqual(
- populateWorkspacesWithProjectNames(
+ populateWorkspacesWithProjectDetails(
USER_WORKSPACES_LIST_QUERY_RESULT.data.currentUser.workspaces.nodes,
- WORKSPACES_PROJECT_NAMES_QUERY_RESULT.data.projects.nodes,
+ GET_PROJECTS_DETAILS_QUERY_RESULT.data.projects.nodes,
).map((x) => {
return {
nameText: `${x.projectName} ${x.name}`,
@@ -110,6 +119,9 @@ describe('remote_development/components/list/workspaces_table.vue', () => {
desiredState: x.desiredState,
compact: false,
},
+ devfileText: `${x.devfilePath} on ${x.devfileRef}`,
+ devfileHref: x.devfileWebUrl,
+ devfileTooltipTitle: x.devfileWebUrl,
...(x.actualState === WORKSPACE_STATES.running
? {
previewText: x.url,
diff --git a/ee/spec/frontend/remote_development/mock_data/index.js b/ee/spec/frontend/remote_development/mock_data/index.js
index cc34435dce8d131bf0dd3fc2a8715b538490161e..974b16ad9e46dc785e236d7e64e03dac4627bc65 100644
--- a/ee/spec/frontend/remote_development/mock_data/index.js
+++ b/ee/spec/frontend/remote_development/mock_data/index.js
@@ -12,6 +12,7 @@ export const WORKSPACE = {
url: `${TEST_HOST}/workspace/1`,
devfileRef: 'main',
devfilePath: '.devfile.yaml',
+ devfileWebUrl: 'http://gdk.test:3000/gitlab-org/gitlab-shell/-/blob/main/.devfile.yaml',
createdAt: '2023-05-01T18:24:34Z',
};
@@ -39,6 +40,7 @@ export const USER_WORKSPACES_LIST_QUERY_RESULT = {
url: 'https://8000-workspace-1-1-idmi02.workspaces.localdev.me?tkn=password',
devfileRef: 'main',
devfilePath: '.devfile.yaml',
+ devfileWebUrl: 'http://gdk.test:3000/gitlab-org/gitlab-shell/-/blob/main/.devfile.yaml',
projectId: 'gid://gitlab/Project/1',
createdAt: '2023-04-29T18:24:34Z',
},
@@ -51,6 +53,7 @@ export const USER_WORKSPACES_LIST_QUERY_RESULT = {
url: 'https://8000-workspace-1-1-rfu27q.workspaces.localdev.me?tkn=password',
devfileRef: 'main',
devfilePath: '.devfile.yaml',
+ devfileWebUrl: 'http://gdk.test:3000/gitlab-org/gitlab-shell/-/blob/main/.devfile.yaml',
projectId: 'gid://gitlab/Project/1',
createdAt: '2023-05-01T18:24:34Z',
},
@@ -100,6 +103,8 @@ export const AGENT_WORKSPACES_LIST_QUERY_RESULT = {
url: 'https://8000-workspace-1-1-idmi02.workspaces.localdev.me?tkn=password',
devfileRef: 'main',
devfilePath: '.devfile.yaml',
+ devfileWebUrl:
+ 'http://gdk.test:3000/gitlab-org/gitlab-shell/-/blob/main/.devfile.yaml',
projectId: 'gid://gitlab/Project/1',
createdAt: '2023-04-29T18:24:34Z',
},
@@ -112,6 +117,8 @@ export const AGENT_WORKSPACES_LIST_QUERY_RESULT = {
url: 'https://8000-workspace-1-1-rfu27q.workspaces.localdev.me?tkn=password',
devfileRef: 'main',
devfilePath: '.devfile.yaml',
+ devfileWebUrl:
+ 'http://gdk.test:3000/gitlab-org/gitlab-shell/-/blob/main/.devfile.yaml',
projectId: 'gid://gitlab/Project/1',
createdAt: '2023-05-01T18:24:34Z',
},
@@ -176,12 +183,6 @@ export const GET_PROJECT_DETAILS_QUERY_RESULT = {
nameWithNamespace: 'GitLab Org / Subgroup / GitLab',
repository: {
rootRef: 'main',
- blobs: {
- nodes: [
- { id: '.editorconfig', path: '.editorconfig' },
- { id: '.eslintrc.js', path: '.eslintrc.js' },
- ],
- },
},
group: {
id: 'gid://gitlab/Group/80',
@@ -191,6 +192,21 @@ export const GET_PROJECT_DETAILS_QUERY_RESULT = {
},
};
+export const GET_PROJECTS_DETAILS_QUERY_RESULT = {
+ data: {
+ projects: {
+ nodes: [
+ {
+ id: 'gid://gitlab/Project/1',
+ nameWithNamespace: 'Gitlab Org / Gitlab Shell',
+ __typename: 'Project',
+ },
+ ],
+ __typename: 'ProjectConnection',
+ },
+ },
+};
+
export const GET_GROUP_CLUSTER_AGENTS_QUERY_RESULT_ROOTGROUP_NO_AGENT = {
data: {
group: {
@@ -298,18 +314,3 @@ export const WORKSPACE_UPDATE_MUTATION_RESULT = {
},
},
};
-
-export const WORKSPACES_PROJECT_NAMES_QUERY_RESULT = {
- data: {
- projects: {
- nodes: [
- {
- id: 'gid://gitlab/Project/1',
- nameWithNamespace: 'Gitlab Org / Gitlab Shell',
- __typename: 'Project',
- },
- ],
- __typename: 'ProjectConnection',
- },
- },
-};
diff --git a/ee/spec/frontend/remote_development/pages/create_spec.js b/ee/spec/frontend/remote_development/pages/create_spec.js
index e65e39eeef566df2bb936ec2243a5f1774ef09d8..06ff28d8fdc914c49c2f6f79f4b3b6bc57c9d373 100644
--- a/ee/spec/frontend/remote_development/pages/create_spec.js
+++ b/ee/spec/frontend/remote_development/pages/create_spec.js
@@ -1,30 +1,41 @@
import VueApollo from 'vue-apollo';
import Vue, { nextTick } from 'vue';
import { cloneDeep } from 'lodash';
-import { GlFormSelect, GlForm, GlFormInput, GlLink, GlSprintf } from '@gitlab/ui';
+import {
+ GlForm,
+ GlFormSelect,
+ GlIcon,
+ GlLink,
+ GlSprintf,
+ GlFormInput,
+ GlFormInputGroup,
+ GlPopover,
+} from '@gitlab/ui';
+import RefSelector from '~/ref/components/ref_selector.vue';
import SearchProjectsListbox from 'ee/remote_development/components/create/search_projects_listbox.vue';
import GetProjectDetailsQuery from 'ee/remote_development/components/common/get_project_details_query.vue';
-import WorkspaceCreate, { i18n } from 'ee/remote_development/pages/create.vue';
+import WorkspaceCreate, { devfileHelpPath, i18n } from 'ee/remote_development/pages/create.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { stubComponent } from 'helpers/stub_component';
import createMockApollo from 'helpers/mock_apollo_helper';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import {
- DEFAULT_EDITOR,
DEFAULT_DESIRED_STATE,
- DEFAULT_DEVFILE_PATH,
+ DEFAULT_EDITOR,
ROUTES,
WORKSPACES_LIST_PAGE_SIZE,
} from 'ee/remote_development/constants';
import waitForPromises from 'helpers/wait_for_promises';
import { logError } from '~/lib/logger';
import { createAlert } from '~/alert';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import workspaceCreateMutation from 'ee/remote_development/graphql/mutations/workspace_create.mutation.graphql';
import userWorkspacesListQuery from 'ee/remote_development/graphql/queries/user_workspaces_list.query.graphql';
import {
GET_PROJECT_DETAILS_QUERY_RESULT,
USER_WORKSPACES_LIST_QUERY_RESULT,
- WORKSPACE_QUERY_RESULT,
WORKSPACE_CREATE_MUTATION_RESULT,
+ WORKSPACE_QUERY_RESULT,
} from '../mock_data';
Vue.use(VueApollo);
@@ -102,17 +113,22 @@ describe('remote_development/pages/create.vue', () => {
},
stubs: {
GlFormSelect: GlFormSelectStub,
+ GlFormInputGroup,
GlSprintf,
},
mocks: {
$router: mockRouter,
},
+ directives: {
+ GlTooltip: createMockDirective('gl-tooltip'),
+ },
});
};
+ const projectGid = GET_PROJECT_DETAILS_QUERY_RESULT.data.project.id;
+ const projectId = String(getIdFromGraphQLId(projectGid));
const findSearchProjectsListbox = () => wrapper.findComponent(SearchProjectsListbox);
const findNoAgentsGlAlert = () => wrapper.findByTestId('no-agents-alert');
- const findNoDevFileGlAlert = () => wrapper.findByTestId('no-dev-file-alert');
const findCreateWorkspaceErrorGlAlert = () =>
wrapper.findByTestId('create-workspace-error-alert');
const findClusterAgentsFormGroup = () =>
@@ -120,19 +136,63 @@ describe('remote_development/pages/create.vue', () => {
const findGetProjectDetailsQuery = () => wrapper.findComponent(GetProjectDetailsQuery);
const findCreateWorkspaceButton = () => wrapper.findByTestId('create-workspace');
const findClusterAgentsFormSelect = () => wrapper.findComponent(GlFormSelectStub);
- const findMaxHoursBeforeTerminationField = () => wrapper.findComponent(GlFormInput);
+
+ const findDevfileRefField = () => wrapper.findByTestId('devfile-ref');
+ const findDevfileRefRefSelector = () => findDevfileRefField().findComponent(RefSelector);
+ const findDevfileRefFieldParts = () => {
+ const field = findDevfileRefField();
+ const icon = field.findComponent(GlIcon);
+
+ return {
+ label: field.find('label').text(),
+ icon: icon.attributes('name'),
+ iconTooltip: getBinding(icon.element, 'gl-tooltip').value,
+ };
+ };
+
+ const findDevfilePathField = () => wrapper.findByTestId('devfile-path');
+ const findDevfilePathInputGroup = () => findDevfilePathField().findComponent(GlFormInputGroup);
+ const findDevfilePathInput = () => findDevfilePathInputGroup().findComponent(GlFormInput);
+ const findDevfilePathFieldParts = () => {
+ const field = findDevfilePathField();
+ const popover = field.findComponent(GlPopover);
+
+ return {
+ label: field.find('label').text(),
+ inputPrepend: findDevfilePathInputGroup().text(),
+ inputPlaceholder: findDevfilePathInput().attributes('placeholder'),
+ popoverText: popover.text(),
+ popoverLinkHref: popover.findComponent(GlLink).attributes('href'),
+ popoverLinkText: popover.findComponent(GlLink).text(),
+ };
+ };
+
+ const findMaxHoursBeforeTerminationField = () =>
+ wrapper.findByTestId('max-hours-before-termination');
+ const findMaxHoursBeforeTerminationInputGroup = () =>
+ findMaxHoursBeforeTerminationField().findComponent(GlFormInputGroup);
+ const findMaxHoursBeforeTerminationInput = () =>
+ findMaxHoursBeforeTerminationInputGroup().findComponent(GlFormInput);
+ const findMaxHoursBeforeTerminationFieldParts = () => {
+ const field = findMaxHoursBeforeTerminationField();
+ const inputAppendText = findMaxHoursBeforeTerminationInputGroup().text();
+
+ return {
+ label: field.attributes('label'),
+ inputAppendText,
+ };
+ };
+
const emitGetProjectDetailsQueryResult = ({
clusterAgents = [],
- hasDevFile = false,
groupPath = GET_PROJECT_DETAILS_QUERY_RESULT.data.project.group.fullPath,
- id = GET_PROJECT_DETAILS_QUERY_RESULT.data.project.id,
+ id = projectGid,
rootRef = rootRefFixture,
nameWithNamespace,
fullPath,
}) =>
findGetProjectDetailsQuery().vm.$emit('result', {
clusterAgents,
- hasDevFile,
groupPath,
rootRef,
id,
@@ -190,21 +250,16 @@ describe('remote_development/pages/create.vue', () => {
expect(findClusterAgentsFormGroup().exists()).toBe(false);
});
- it('does not display max hours before termination field', () => {
- expect(findMaxHoursBeforeTerminationField().exists()).toBe(false);
+ it('does not display devfile ref field', () => {
+ expect(findDevfileRefField().exists()).toBe(false);
});
- describe('when a project does not have a .devfile file', () => {
- it('does not display a devfile alert', async () => {
- createWrapper();
-
- await selectProject();
- await emitGetProjectDetailsQueryResult({
- hasDevFile: false,
- });
+ it('does not display devfile path field', () => {
+ expect(findDevfilePathField().exists()).toBe(false);
+ });
- expect(findNoDevFileGlAlert().exists()).toBe(false);
- });
+ it('does not display max hours before termination field', () => {
+ expect(findMaxHoursBeforeTerminationField().exists()).toBe(false);
});
});
@@ -227,52 +282,15 @@ describe('remote_development/pages/create.vue', () => {
it('populates cluster agents form select with cluster agents', () => {
expect(findClusterAgentsFormSelect().props().options).toBe(clusterAgentsFixture);
});
-
- describe('when a project have a .devfile file', () => {
- it('does not display a devfile alert', async () => {
- createWrapper();
-
- await selectProject();
- await emitGetProjectDetailsQueryResult({
- hasDevFile: true,
- clusterAgents: clusterAgentsFixture,
- });
-
- expect(findNoDevFileGlAlert().exists()).toBe(false);
- });
- });
-
- describe('when a project does not have a .devfile file', () => {
- it('displays a devfile alert', async () => {
- createWrapper();
-
- await selectProject();
- await emitGetProjectDetailsQueryResult({
- hasDevFile: false,
- clusterAgents: clusterAgentsFixture,
- });
-
- expect(findNoDevFileGlAlert().props()).toMatchObject({
- 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 and the project has a devfile', () => {
+ describe('when a project and a cluster agent are selected', () => {
beforeEach(async () => {
createWrapper();
await selectProject();
await emitGetProjectDetailsQueryResult({
clusterAgents: clusterAgentsFixture,
- hasDevFile: true,
});
await selectClusterAgent();
});
@@ -281,6 +299,44 @@ describe('remote_development/pages/create.vue', () => {
expect(findCreateWorkspaceButton().props().disabled).toBe(false);
});
+ it('populates devfile ref selector with project ID', () => {
+ expect(findDevfileRefRefSelector().props().projectId).toBe(projectId);
+ });
+
+ describe('devfile ref field', () => {
+ it('renders parts', () => {
+ expect(findDevfileRefFieldParts()).toEqual({
+ label: 'Git reference',
+ icon: 'information-o',
+ iconTooltip: 'The branch, tag, or commit hash GitLab uses to create your workspace.',
+ });
+ });
+ });
+
+ describe('devfile path field', () => {
+ it('renders parts', () => {
+ expect(findDevfilePathFieldParts()).toEqual({
+ inputPrepend: 'gitlab-org / gitlab /',
+ inputPlaceholder: 'Path to devfile',
+ label: 'Devfile location',
+ popoverLinkHref: devfileHelpPath,
+ popoverLinkText: 'Learn more.',
+ popoverText: expect.stringMatching(
+ `${i18n.form.devfileLocation.contentParagraph1} ${i18n.form.devfileLocation.contentParagraph2}`,
+ ),
+ });
+ });
+ });
+
+ describe('max hours before termination field', () => {
+ it('renders parts', () => {
+ expect(findMaxHoursBeforeTerminationFieldParts()).toEqual({
+ label: 'Workspace automatically terminates after',
+ inputAppendText: 'hours',
+ });
+ });
+ });
+
describe('when selecting a project again', () => {
beforeEach(async () => {
await selectProject({ nameWithNamespace: 'New Project', fullPath: 'new-project' });
@@ -294,24 +350,29 @@ describe('remote_development/pages/create.vue', () => {
describe('when clicking Create Workspace button', () => {
it('submits workspaceCreate mutation', async () => {
const maxHoursBeforeTermination = 10;
-
- findMaxHoursBeforeTerminationField().vm.$emit(
+ findMaxHoursBeforeTerminationInput().vm.$emit(
'input',
maxHoursBeforeTermination.toString(),
);
+ const devfileRef = 'mybranch';
+ findDevfileRefRefSelector().vm.$emit('input', devfileRef);
+
+ const devfilePath = 'path/to/mydevfile.yaml';
+ findDevfilePathInput().vm.$emit('input', devfilePath);
+
await nextTick();
await submitCreateWorkspaceForm();
expect(workspaceCreateMutationHandler).toHaveBeenCalledWith({
input: {
clusterAgentId: selectedClusterAgentIDFixture,
- projectId: GET_PROJECT_DETAILS_QUERY_RESULT.data.project.id,
+ projectId: projectGid,
editor: DEFAULT_EDITOR,
desiredState: DEFAULT_DESIRED_STATE,
- devfilePath: DEFAULT_DEVFILE_PATH,
+ devfilePath,
maxHoursBeforeTermination,
- devfileRef: rootRefFixture,
+ devfileRef,
},
});
});
diff --git a/ee/spec/frontend/remote_development/pages/list_spec.js b/ee/spec/frontend/remote_development/pages/list_spec.js
index f2d159da6145869a112ac1fe6d4f8d386d909f0d..b355c483ad018285c6d64a69ee638c821c27dd7f 100644
--- a/ee/spec/frontend/remote_development/pages/list_spec.js
+++ b/ee/spec/frontend/remote_development/pages/list_spec.js
@@ -10,13 +10,13 @@ import WorkspaceEmptyState from 'ee/remote_development/components/list/empty_sta
import WorkspacesTable from 'ee/remote_development/components/list/workspaces_table.vue';
import WorkspacesListPagination from 'ee/remote_development/components/list/workspaces_list_pagination.vue';
import userWorkspacesListQuery from 'ee/remote_development/graphql/queries/user_workspaces_list.query.graphql';
-import userWorkspacesProjectsNamesQuery from 'ee/remote_development/graphql/queries/user_workspaces_projects_names.query.graphql';
+import getProjectsDetailsQuery from 'ee/remote_development/graphql/queries/get_projects_details.query.graphql';
import { ROUTES } from 'ee/remote_development/constants';
-import { populateWorkspacesWithProjectNames } from 'ee/remote_development/services/utils';
+import { populateWorkspacesWithProjectDetails } from 'ee/remote_development/services/utils';
import {
USER_WORKSPACES_LIST_QUERY_RESULT,
USER_WORKSPACES_LIST_QUERY_EMPTY_RESULT,
- WORKSPACES_PROJECT_NAMES_QUERY_RESULT,
+ GET_PROJECTS_DETAILS_QUERY_RESULT,
} from '../mock_data';
jest.mock('~/lib/logger');
@@ -29,19 +29,19 @@ describe('remote_development/pages/list.vue', () => {
let wrapper;
let mockApollo;
let userWorkspacesListQueryHandler;
- let userWorkspacesProjectNamesQueryHandler;
+ let getProjectsDetailsQueryHandler;
const buildMockApollo = () => {
userWorkspacesListQueryHandler = jest
.fn()
.mockResolvedValueOnce(USER_WORKSPACES_LIST_QUERY_RESULT);
- userWorkspacesProjectNamesQueryHandler = jest
+ getProjectsDetailsQueryHandler = jest
.fn()
- .mockResolvedValueOnce(WORKSPACES_PROJECT_NAMES_QUERY_RESULT);
+ .mockResolvedValueOnce(GET_PROJECTS_DETAILS_QUERY_RESULT);
mockApollo = createMockApollo([
[userWorkspacesListQuery, userWorkspacesListQueryHandler],
- [userWorkspacesProjectsNamesQuery, userWorkspacesProjectNamesQueryHandler],
+ [getProjectsDetailsQuery, getProjectsDetailsQueryHandler],
]);
};
const createWrapper = () => {
@@ -112,9 +112,9 @@ describe('remote_development/pages/list.vue', () => {
it('provides workspaces data to the workspaces table', () => {
expect(findTable(wrapper).props('workspaces')).toEqual(
- populateWorkspacesWithProjectNames(
+ populateWorkspacesWithProjectDetails(
USER_WORKSPACES_LIST_QUERY_RESULT.data.currentUser.workspaces.nodes,
- WORKSPACES_PROJECT_NAMES_QUERY_RESULT.data.projects.nodes,
+ GET_PROJECTS_DETAILS_QUERY_RESULT.data.projects.nodes,
),
);
});
@@ -177,9 +177,9 @@ describe('remote_development/pages/list.vue', () => {
});
describe.each`
- query | queryHandlerFactory
- ${'userWorkspaces'} | ${() => userWorkspacesListQueryHandler}
- ${'userWorkspacesProjectsNames'} | ${() => userWorkspacesProjectNamesQueryHandler}
+ query | queryHandlerFactory
+ ${'userWorkspaces'} | ${() => userWorkspacesListQueryHandler}
+ ${'projectsDetails'} | ${() => getProjectsDetailsQueryHandler}
`('when $query query fails', ({ queryHandlerFactory }) => {
const ERROR = new Error('Something bad!');
diff --git a/ee/spec/frontend/remote_development/router/index_spec.js b/ee/spec/frontend/remote_development/router/index_spec.js
index 752a7028d52fe1d1213fb5a36acd30b7af6348d5..96c926fee5ff54d8f65428857924c137b3a3d6e0 100644
--- a/ee/spec/frontend/remote_development/router/index_spec.js
+++ b/ee/spec/frontend/remote_development/router/index_spec.js
@@ -6,12 +6,12 @@ import App from 'ee/remote_development/pages/app.vue';
import WorkspacesList from 'ee/remote_development/pages/list.vue';
import createRouter from 'ee/remote_development/router/index';
import CreateWorkspace from 'ee/remote_development/pages/create.vue';
-import userWorkspacesProjectsNamesQuery from 'ee/remote_development/graphql/queries/user_workspaces_projects_names.query.graphql';
+import getProjectsDetailsQuery from 'ee/remote_development/graphql/queries/get_projects_details.query.graphql';
import { ROUTES } from 'ee/remote_development/constants';
import createMockApollo from 'helpers/mock_apollo_helper';
import userWorkspacesListQuery from 'ee/remote_development/graphql/queries/user_workspaces_list.query.graphql';
import {
- WORKSPACES_PROJECT_NAMES_QUERY_RESULT,
+ GET_PROJECTS_DETAILS_QUERY_RESULT,
USER_WORKSPACES_LIST_QUERY_EMPTY_RESULT,
} from '../mock_data';
@@ -46,8 +46,8 @@ describe('remote_development/router/index.js', () => {
jest.fn().mockResolvedValue(USER_WORKSPACES_LIST_QUERY_EMPTY_RESULT),
],
[
- userWorkspacesProjectsNamesQuery,
- jest.fn().mockResolvedValueOnce(WORKSPACES_PROJECT_NAMES_QUERY_RESULT),
+ getProjectsDetailsQuery,
+ jest.fn().mockResolvedValueOnce(GET_PROJECTS_DETAILS_QUERY_RESULT),
],
]),
provide: {
diff --git a/ee/spec/frontend/remote_development/services/utils_spec.js b/ee/spec/frontend/remote_development/services/utils_spec.js
index 4fe0d628782bfc5dbbf9219a246d827e01772abb..eaa68499537fff3f5906a45f5024c942e940fa92 100644
--- a/ee/spec/frontend/remote_development/services/utils_spec.js
+++ b/ee/spec/frontend/remote_development/services/utils_spec.js
@@ -1,7 +1,7 @@
-import { populateWorkspacesWithProjectNames } from 'ee/remote_development/services/utils';
+import { populateWorkspacesWithProjectDetails } from 'ee/remote_development/services/utils';
describe('ee/remote_development/services/utils', () => {
- describe('populateWorkspacesWithProjectNames', () => {
+ describe('populateWorkspacesWithProjectDetails', () => {
describe('when the workspace references an existing project', () => {
it('sets the projectName property to the project nameWithNamespace property', () => {
const workspaces = [
@@ -16,7 +16,7 @@ describe('ee/remote_development/services/utils', () => {
},
];
- expect(populateWorkspacesWithProjectNames(workspaces, projects)).toEqual([
+ expect(populateWorkspacesWithProjectDetails(workspaces, projects)).toEqual([
{
projectId: 'foo',
projectName: 'Foo',
@@ -39,7 +39,7 @@ describe('ee/remote_development/services/utils', () => {
},
];
- expect(populateWorkspacesWithProjectNames(workspaces, projects)).toEqual([
+ expect(populateWorkspacesWithProjectDetails(workspaces, projects)).toEqual([
{
projectId: 'bar',
projectName: 'bar',
diff --git a/ee/spec/frontend/vue_shared/components/web_ide_link_spec.js b/ee/spec/frontend/vue_shared/components/web_ide_link_spec.js
index 2cbd812fdc376e641a7e0e831e94eab21c35150e..d8477fbd28f3b9f4fb59d07828a08dde988c1be6 100644
--- a/ee/spec/frontend/vue_shared/components/web_ide_link_spec.js
+++ b/ee/spec/frontend/vue_shared/components/web_ide_link_spec.js
@@ -109,10 +109,9 @@ describe('ee_component/vue_shared/components/web_ide_link', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
- describe('when project hasDevFile and cluster agents', () => {
+ describe('when project has cluster agents', () => {
beforeEach(async () => {
findGetProjectDetailsQuery().vm.$emit('result', {
- hasDevFile: true,
clusterAgents: [{}],
});
@@ -134,12 +133,10 @@ describe('ee_component/vue_shared/components/web_ide_link', () => {
});
});
- describe.each([
- { hasDevFile: false, clusterAgents: [{}] },
- { hasDevFile: true, clusterAgents: [] },
- ])('when does not have devfile or cluster agents', (result) => {
+ // TODO: This doesn't need to be a describe.each anymore...
+ describe('when does not have cluster agents', () => {
beforeEach(async () => {
- findGetProjectDetailsQuery().vm.$emit('result', result);
+ findGetProjectDetailsQuery().vm.$emit('result', { clusterAgents: [] });
await nextTick();
});
diff --git a/ee/spec/graphql/types/remote_development/workspace_type_spec.rb b/ee/spec/graphql/types/remote_development/workspace_type_spec.rb
index 4e1ff9f775be4c0d09c6ee433ba9e51f5ebb9c8d..02d9063b721164e91ababe751b1af992f83a2a58 100644
--- a/ee/spec/graphql/types/remote_development/workspace_type_spec.rb
+++ b/ee/spec/graphql/types/remote_development/workspace_type_spec.rb
@@ -7,7 +7,8 @@
%i[
id cluster_agent project_id user name namespace max_hours_before_termination
desired_state desired_state_updated_at actual_state responded_to_agent_at
- url editor devfile_ref devfile_path devfile processed_devfile deployment_resource_version created_at updated_at
+ url editor devfile_ref devfile_path devfile_web_url devfile processed_devfile
+ deployment_resource_version created_at updated_at
]
end
diff --git a/ee/spec/lib/remote_development/workspaces/create/devfile_fetcher_spec.rb b/ee/spec/lib/remote_development/workspaces/create/devfile_fetcher_spec.rb
index eef2ebfc9fc54636b1342eaedf1f8721889cd26d..e5ff259fad500b5c3dc337cfa4f8c04229d32b84 100644
--- a/ee/spec/lib/remote_development/workspaces/create/devfile_fetcher_spec.rb
+++ b/ee/spec/lib/remote_development/workspaces/create/devfile_fetcher_spec.rb
@@ -15,6 +15,7 @@
let_it_be(:project) { create(:project, :in_group, :repository) }
let_it_be(:agent) { create(:ee_cluster_agent, :with_remote_development_agent_config) }
let(:random_string) { 'abcdef' }
+ let(:devfile_ref) { 'main' }
let(:devfile_path) { '.devfile.yaml' }
let(:devfile_fixture_name) { 'example.devfile.yaml' }
let(:devfile_yaml) { read_devfile(devfile_fixture_name) }
@@ -28,7 +29,7 @@
editor: editor,
max_hours_before_termination: 24,
desired_state: RemoteDevelopment::Workspaces::States::RUNNING,
- devfile_ref: 'main',
+ devfile_ref: devfile_ref,
devfile_path: devfile_path
}
end
@@ -69,13 +70,28 @@
end
end
- context 'when devfile is not found' do
+ context 'when devfile_path does not exist' do
let(:devfile_path) { 'not-found.yaml' }
before do
allow(project.repository).to receive(:blob_at_branch).and_return(nil)
end
+ it 'returns an err Result containing error details' do
+ expect(result).to be_err_result do |message|
+ expect(message).to be_a(RemoteDevelopment::Messages::WorkspaceCreateDevfileLoadFailed)
+ message.context => { details: String => error_details }
+ expect(error_details)
+ .to eq("Devfile path '#{devfile_path}' at ref '#{devfile_ref}' does not exist in project repository")
+ end
+ end
+ end
+
+ context 'when devfile blob data could not be loaded' do
+ before do
+ allow(project.repository).to receive_message_chain(:blob_at_branch, :data) { '' }
+ end
+
it 'returns an err Result containing error details' do
expect(result).to be_err_result do |message|
expect(message).to be_a(RemoteDevelopment::Messages::WorkspaceCreateDevfileLoadFailed)
diff --git a/ee/spec/lib/remote_development/workspaces/create/main_integration_spec.rb b/ee/spec/lib/remote_development/workspaces/create/main_integration_spec.rb
index 90e7bb0624040f199fee6e7665f466b2580fc1b7..41ee4ad9e499a657c9ed84e0a912fcf096dacbb4 100644
--- a/ee/spec/lib/remote_development/workspaces/create/main_integration_spec.rb
+++ b/ee/spec/lib/remote_development/workspaces/create/main_integration_spec.rb
@@ -14,6 +14,7 @@
let(:group) { create(:group, name: 'test-group').tap { |group| group.add_developer(user) } }
let(:current_user) { user }
let(:random_string) { 'abcdef' }
+ let(:devfile_ref) { 'master' }
let(:devfile_path) { '.devfile.yaml' }
let(:devfile_fixture_name) { 'example.devfile.yaml' }
let(:devfile_yaml) { read_devfile(devfile_fixture_name) }
@@ -38,7 +39,7 @@
editor: editor,
max_hours_before_termination: 24,
desired_state: RemoteDevelopment::Workspaces::States::RUNNING,
- devfile_ref: 'main',
+ devfile_ref: devfile_ref,
devfile_path: devfile_path
}
end
@@ -56,14 +57,16 @@
end
context 'when params are valid' do
+ before do
+ allow(project.repository).to receive_message_chain(:blob_at_branch, :data) { devfile_yaml }
+ end
+
context 'when devfile is valid' do
before do
allow(SecureRandom).to receive(:alphanumeric) { random_string }
end
it 'creates a new workspace and returns success', :aggregate_failures do
- allow(project.repository).to receive_message_chain(:blob_at_branch, :data) { devfile_yaml }
-
# NOTE: This example is structured and ordered to give useful and informative error messages in case of failures
expect { response }.to change { RemoteDevelopment::Workspace.count }.by(1)
@@ -100,8 +103,6 @@
let(:devfile_fixture_name) { 'example.invalid-components-entry-missing-devfile.yaml' }
it 'does not create the workspace and returns error' do
- allow(project.repository).to receive_message_chain(:blob_at_branch, :data) { devfile_yaml }
-
expect { response }.not_to change { RemoteDevelopment::Workspace.count }
expect(response).to eq({
@@ -117,14 +118,18 @@
context 'when devfile is not found' do
let(:devfile_path) { 'not-found.yaml' }
- it 'does not create the workspace and returns error', :aggregate_failures do
+ before do
allow(project.repository).to receive(:blob_at_branch).and_return(nil)
+ end
+ it 'does not create the workspace and returns error', :aggregate_failures do
expect { response }.not_to change { RemoteDevelopment::Workspace.count }
expect(response).to eq({
status: :error,
- message: "Workspace create devfile load failed: Devfile could not be loaded from project",
+ message:
+ "Workspace create devfile load failed: Devfile path '#{devfile_path}' at ref '#{devfile_ref}' " \
+ "does not exist in project repository", # rubocop:disable Layout/LineEndStringConcatenationIndentation -- RubyMine formatting conflict. See https://gitlab.com/gitlab-org/gitlab/-/issues/442626
reason: :bad_request
})
end
diff --git a/ee/spec/lib/remote_development/workspaces/create/project_cloner_component_injector_spec.rb b/ee/spec/lib/remote_development/workspaces/create/project_cloner_component_injector_spec.rb
index 0e59e31165f8fca2b25c01893cd61a3178e601aa..5cba632fe4cf381298941aa93c10d62a5e55a49a 100644
--- a/ee/spec/lib/remote_development/workspaces/create/project_cloner_component_injector_spec.rb
+++ b/ee/spec/lib/remote_development/workspaces/create/project_cloner_component_injector_spec.rb
@@ -18,7 +18,8 @@
let(:value) do
{
params: {
- project: project
+ project: project,
+ devfile_ref: "master"
},
processed_devfile: input_processed_devfile,
volume_mounts: {
diff --git a/ee/spec/models/remote_development/workspace_spec.rb b/ee/spec/models/remote_development/workspace_spec.rb
index 47b9332497d8c5c1742f42964cd55fa23329281d..34af0e778ef5ef980c8036d0c99bed3f908788d2 100644
--- a/ee/spec/models/remote_development/workspace_spec.rb
+++ b/ee/spec/models/remote_development/workspace_spec.rb
@@ -62,6 +62,15 @@
end
end
+ describe '#devfile_web_url' do
+ subject(:workspace) { build(:workspace) }
+
+ it 'returns web url to devfile' do
+ # noinspection HttpUrlsUsage - suppress RubyMine warning for insecure http link.
+ expect(workspace.devfile_web_url).to eq("http://#{Gitlab.config.gitlab.host}/#{workspace.project.path_with_namespace}/-/blob/main/.devfile.yaml")
+ end
+ end
+
describe '.before_save' do
describe 'when creating new record', :freeze_time do
# NOTE: The workspaces factory overrides the desired_state_updated_at to be earlier than
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 2cff33f14f59fb7df345d79828b33c326d8cd8a9..66858a1a03b8cca2553144455e3fb8266083cfbb 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -1002,6 +1002,9 @@ msgstr ""
msgid "%{over_limit_message} To get more seats, %{link_start}upgrade to a paid tier%{link_end}."
msgstr ""
+msgid "%{path} on %{ref}"
+msgstr ""
+
msgid "%{percentageUsed}%% used"
msgstr ""
@@ -17721,6 +17724,9 @@ msgstr ""
msgid "Developer"
msgstr ""
+msgid "Devfile"
+msgstr ""
+
msgid "Device name"
msgstr ""
@@ -57017,12 +57023,18 @@ msgstr ""
msgid "Workspaces"
msgstr ""
+msgid "Workspaces|A devfile defines the development environment for a GitLab project. A workspace must have a valid devfile in the Git reference you use."
+msgstr ""
+
msgid "Workspaces|A workspace is a virtual sandbox environment for your code in GitLab."
msgstr ""
msgid "Workspaces|Cancel"
msgstr ""
+msgid "Workspaces|Cluster agent"
+msgstr ""
+
msgid "Workspaces|Could not load workspaces"
msgstr ""
@@ -57041,6 +57053,9 @@ msgstr ""
msgid "Workspaces|Develop anywhere"
msgstr ""
+msgid "Workspaces|Devfile location"
+msgstr ""
+
msgid "Workspaces|Error"
msgstr ""
@@ -57053,28 +57068,34 @@ msgstr ""
msgid "Workspaces|Failed to update workspace"
msgstr ""
+msgid "Workspaces|Git reference"
+msgstr ""
+
msgid "Workspaces|GitLab Workspaces is a powerful collaborative platform that provides a comprehensive set of tools for software development teams to manage their entire development lifecycle."
msgstr ""
-msgid "Workspaces|New workspace"
+msgid "Workspaces|If your devfile is not in the root directory of your project, specify a relative path."
msgstr ""
-msgid "Workspaces|Restart"
+msgid "Workspaces|Learn more."
msgstr ""
-msgid "Workspaces|Restarting"
+msgid "Workspaces|New workspace"
msgstr ""
-msgid "Workspaces|Running"
+msgid "Workspaces|Path to devfile"
msgstr ""
-msgid "Workspaces|Select cluster agent"
+msgid "Workspaces|Project"
msgstr ""
-msgid "Workspaces|Select default editor"
+msgid "Workspaces|Restart"
msgstr ""
-msgid "Workspaces|Select project"
+msgid "Workspaces|Restarting"
+msgstr ""
+
+msgid "Workspaces|Running"
msgstr ""
msgid "Workspaces|Start"
@@ -57101,7 +57122,7 @@ msgstr ""
msgid "Workspaces|Terminating"
msgstr ""
-msgid "Workspaces|Time before automatic termination"
+msgid "Workspaces|The branch, tag, or commit hash GitLab uses to create your workspace."
msgstr ""
msgid "Workspaces|To create a workspace for this project, an administrator must %{linkStart}configure a cluster agent%{linkEnd} for the project's group."
@@ -57116,6 +57137,12 @@ msgstr ""
msgid "Workspaces|Unknown state"
msgstr ""
+msgid "Workspaces|What is a devfile?"
+msgstr ""
+
+msgid "Workspaces|Workspace automatically terminates after"
+msgstr ""
+
msgid "Workspaces|Workspaces"
msgstr ""