+ {{
+ s__(
+ 'Workspaces|Agents connect workspaces to your Kubernetes cluster. To create a workspace with an allowed agent, group members must have at least the Developer role.',
+ )
+ }}
+
+
+
+
diff --git a/ee/app/views/groups/settings/remote_development/workspaces/show.html.haml b/ee/app/views/groups/settings/remote_development/workspaces/show.html.haml
index 32211907ae713ec8a84d630e5391dba8ab2502c3..6ca6e3c6cbdbfeca777dd9349dc6673fad79effc 100644
--- a/ee/app/views/groups/settings/remote_development/workspaces/show.html.haml
+++ b/ee/app/views/groups/settings/remote_development/workspaces/show.html.haml
@@ -1,2 +1,4 @@
- page_title s_('Workspaces|Workspaces Settings')
- breadcrumb_title s_("Workspaces|Workspaces Settings")
+
+#js-workspaces-settings{ data: { namespace: @group.full_path } }
diff --git a/ee/spec/features/groups/settings/remote_development/workspaces_spec.rb b/ee/spec/features/groups/settings/remote_development/workspaces_spec.rb
index 70f6017ee243e741991a05e9436a756c41c9997f..0104628d78c8c1ea205d041d0a711dfe82fa171b 100644
--- a/ee/spec/features/groups/settings/remote_development/workspaces_spec.rb
+++ b/ee/spec/features/groups/settings/remote_development/workspaces_spec.rb
@@ -7,6 +7,13 @@
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
+ let_it_be(:project) do
+ create(:project, :public, :in_group, :custom_repo, path: 'test-project', namespace: group)
+ end
+
+ let_it_be(:agent) do
+ create(:ee_cluster_agent, :with_remote_development_agent_config, project: project, created_by_user: user)
+ end
before_all do
group.add_developer(user)
@@ -21,7 +28,25 @@
wait_for_requests
end
- it 'renders workspaces settings page' do
- expect(page).to have_content 'Workspaces Settings'
+ describe 'Group agents' do
+ context 'when there are not available agents' do
+ it 'displays available agents table with empty state message' do
+ expect(page).to have_content 'This group has no available agents.'
+ end
+ end
+
+ context 'when there are available agents' do
+ let_it_be(:cluster_agent_mapping) do
+ create(
+ :remote_development_namespace_cluster_agent_mapping,
+ user: user, agent: agent,
+ namespace: group
+ )
+ end
+
+ it 'displays agent in the agents table' do
+ expect(page).to have_content agent.name
+ end
+ end
end
end
diff --git a/ee/spec/frontend/workspaces/agent_mapping/components/agent_mapping_spec.js b/ee/spec/frontend/workspaces/agent_mapping/components/agent_mapping_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..2c3e88ef9550b013cc125a78cace19f04dc1425a
--- /dev/null
+++ b/ee/spec/frontend/workspaces/agent_mapping/components/agent_mapping_spec.js
@@ -0,0 +1,98 @@
+import { GlAlert } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import AgentMapping from 'ee_component/workspaces/agent_mapping/components/agent_mapping.vue';
+import AgentsTable from 'ee_component/workspaces/agent_mapping/components/agents_table.vue';
+import GetAvailableAgentsQuery from 'ee_component/workspaces/agent_mapping/components/get_available_agents_query.vue';
+import { stubComponent } from 'helpers/stub_component';
+
+describe('workspaces/agent_mapping/components/agent_mapping.vue', () => {
+ let wrapper;
+ const NAMESPACE = 'foo/bar';
+
+ const buildWrapper = ({ mappedAgentsQueryState = {} } = {}) => {
+ wrapper = shallowMount(AgentMapping, {
+ provide: {
+ namespace: NAMESPACE,
+ },
+ stubs: {
+ GetAvailableAgentsQuery: stubComponent(GetAvailableAgentsQuery, {
+ render() {
+ return this.$scopedSlots.default?.(mappedAgentsQueryState);
+ },
+ }),
+ },
+ });
+ };
+ const findGetAvailableAgentsQuery = () => wrapper.findComponent(GetAvailableAgentsQuery);
+ const findAgentsTable = () => wrapper.findComponent(AgentsTable);
+ const findErrorAlert = () => wrapper.findComponent(GlAlert);
+
+ describe('default', () => {
+ beforeEach(() => {
+ buildWrapper();
+ });
+
+ it('does not display an error alert', () => {
+ expect(findErrorAlert().exists()).toBe(false);
+ });
+ });
+
+ describe('available agents table', () => {
+ it('renders GetAvailableAgentsQuery component and passes namespace path', () => {
+ buildWrapper();
+
+ expect(findGetAvailableAgentsQuery().props('namespace')).toBe(NAMESPACE);
+ });
+
+ describe('when GetAvailableAgentsQuery component emits results event', () => {
+ let agents;
+
+ beforeEach(() => {
+ buildWrapper();
+
+ agents = [{}];
+ findGetAvailableAgentsQuery().vm.$emit('result', { agents });
+ });
+
+ it('passes query result to the AgentsTable component', () => {
+ expect(findAgentsTable().props('agents')).toBe(agents);
+ });
+ });
+
+ describe('when GetAvailableAgentsQuery component emits error event', () => {
+ beforeEach(() => {
+ buildWrapper();
+
+ findGetAvailableAgentsQuery().vm.$emit('error');
+ });
+
+ it('displays error as a danger alert', () => {
+ expect(findErrorAlert().text()).toContain('Could not load available agents');
+ });
+
+ it('does not render AgentsTable component', () => {
+ expect(findAgentsTable().exists()).toBe(false);
+ });
+ });
+
+ it('renders AgentsTable component', () => {
+ buildWrapper();
+
+ expect(findAgentsTable().exists()).toBe(true);
+ });
+
+ it('provides empty state message to the AgentsTable component', () => {
+ buildWrapper();
+
+ expect(findAgentsTable().props('emptyStateMessage')).toBe(
+ 'This group has no available agents. Select the All agents tab and allow at least one agent.',
+ );
+ });
+
+ it('provides loading state from the GetAvailableAgentsQuery to the AgentsTable component', () => {
+ buildWrapper({ mappedAgentsQueryState: { loading: true } });
+
+ expect(findAgentsTable().props('isLoading')).toBe(true);
+ });
+ });
+});
diff --git a/ee/spec/frontend/workspaces/agent_mapping/components/agents_table_spec.js b/ee/spec/frontend/workspaces/agent_mapping/components/agents_table_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..b4e1f0fd5401408aa4fb1ada1327101c6f214fd7
--- /dev/null
+++ b/ee/spec/frontend/workspaces/agent_mapping/components/agents_table_spec.js
@@ -0,0 +1,112 @@
+import { GlTable, GlSkeletonLoader } from '@gitlab/ui';
+import { shallowMount, mount } from '@vue/test-utils';
+import AgentsTable from 'ee_component/workspaces/agent_mapping/components/agents_table.vue';
+
+describe('workspaces/agent_mapping/components/agents_table.vue', () => {
+ let wrapper;
+ const EMPTY_STATE_MESSAGE = 'No agents found';
+
+ const buildWrapper = ({ propsData = {} } = {}, mountFn = shallowMount) => {
+ wrapper = mountFn(AgentsTable, {
+ propsData: {
+ agents: [],
+ emptyStateMessage: EMPTY_STATE_MESSAGE,
+ isLoading: false,
+ ...propsData,
+ },
+ });
+ };
+ const findAgentsTable = () => wrapper.findComponent(GlTable);
+ const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
+
+ describe('when loading', () => {
+ beforeEach(() => {
+ buildWrapper({
+ propsData: {
+ isLoading: true,
+ },
+ });
+ });
+
+ it('displays skeleton loader', () => {
+ expect(findSkeletonLoader().exists()).toBe(true);
+ });
+
+ it('does not display agents table', () => {
+ expect(findAgentsTable().exists()).toBe(false);
+ });
+ });
+
+ describe('when is not loading and agents are available', () => {
+ describe('with agents', () => {
+ beforeEach(() => {
+ buildWrapper({
+ propsData: {
+ isLoading: false,
+ agents: [{}],
+ },
+ });
+ });
+
+ it('does not display skeleton loader', () => {
+ expect(findSkeletonLoader().exists()).toBe(false);
+ });
+
+ it('displays agents table', () => {
+ expect(findAgentsTable().exists()).toBe(true);
+ });
+ });
+
+ describe('with no agents', () => {
+ beforeEach(() => {
+ buildWrapper(
+ {
+ propsData: {
+ isLoading: false,
+ agents: [],
+ },
+ },
+ mount,
+ );
+ });
+
+ it('does not display skeleton loader', () => {
+ expect(findSkeletonLoader().exists()).toBe(false);
+ });
+
+ it('displays agents table', () => {
+ expect(findAgentsTable().exists()).toBe(true);
+ });
+
+ it('displays empty message in agents table', () => {
+ expect(findAgentsTable().text()).toContain(EMPTY_STATE_MESSAGE);
+ });
+ });
+ });
+
+ describe('with agents', () => {
+ beforeEach(() => {
+ buildWrapper(
+ {
+ propsData: {
+ isLoading: false,
+ agents: [{ name: 'agent-1' }],
+ },
+ },
+ mount,
+ );
+ });
+
+ it('does not display skeleton loader', () => {
+ expect(findSkeletonLoader().exists()).toBe(false);
+ });
+
+ it('displays agents table', () => {
+ expect(findAgentsTable().exists()).toBe(true);
+ });
+
+ it('displays agents list', () => {
+ expect(findAgentsTable().text()).toContain('agent-1');
+ });
+ });
+});
diff --git a/ee/spec/frontend/workspaces/agent_mapping/components/get_available_agents_query_spec.js b/ee/spec/frontend/workspaces/agent_mapping/components/get_available_agents_query_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..c9325158efc761a9031d357967343889737b5ee9
--- /dev/null
+++ b/ee/spec/frontend/workspaces/agent_mapping/components/get_available_agents_query_spec.js
@@ -0,0 +1,145 @@
+import { shallowMount } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
+import Vue from 'vue';
+import { logError } from '~/lib/logger';
+import getRemoteDevelopmentClusterAgentsQuery from 'ee/workspaces/common/graphql/queries/get_remote_development_cluster_agents.query.graphql';
+import GetAvailableAgentsQuery from 'ee/workspaces/agent_mapping/components/get_available_agents_query.vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { GET_REMOTE_DEVELOPMENT_CLUSTER_AGENTS_QUERY_RESULT_TWO_AGENTS } from '../../mock_data';
+
+Vue.use(VueApollo);
+
+jest.mock('~/lib/logger');
+
+describe('workspaces/agent_mapping/components/get_available_agents_query.vue', () => {
+ let getRemoteDevelopmentClusterAgentsQueryHandler;
+ let wrapper;
+ const NAMESPACE = 'gitlab-org/gitlab';
+
+ const buildWrapper = async ({ propsData = {}, scopedSlots = {} } = {}) => {
+ const apolloProvider = createMockApollo([
+ [getRemoteDevelopmentClusterAgentsQuery, getRemoteDevelopmentClusterAgentsQueryHandler],
+ ]);
+
+ wrapper = shallowMount(GetAvailableAgentsQuery, {
+ apolloProvider,
+ propsData: {
+ ...propsData,
+ },
+ scopedSlots: {
+ ...scopedSlots,
+ },
+ });
+
+ await waitForPromises();
+ };
+ const buildWrapperWithNamespace = () => buildWrapper({ propsData: { namespace: NAMESPACE } });
+
+ const setupRemoteDevelopmentClusterAgentsQueryHandler = (responses) => {
+ getRemoteDevelopmentClusterAgentsQueryHandler.mockResolvedValueOnce(responses);
+ };
+
+ const transformRemoteDevelopmentClusterAgentGraphQLResultToClusterAgents = (
+ clusterAgentsGraphQLResult,
+ ) =>
+ clusterAgentsGraphQLResult.data.namespace.remoteDevelopmentClusterAgents.nodes.map(
+ ({ id, name }) => ({
+ name,
+ id,
+ }),
+ );
+
+ beforeEach(() => {
+ getRemoteDevelopmentClusterAgentsQueryHandler = jest.fn();
+ logError.mockReset();
+ });
+
+ it('exposes apollo loading state in the default slot', async () => {
+ let loadingState = null;
+
+ await buildWrapper({
+ propsData: { namespace: NAMESPACE },
+ scopedSlots: {
+ default: (props) => {
+ loadingState = props.loading;
+ return null;
+ },
+ },
+ });
+
+ expect(loadingState).toBe(false);
+ });
+
+ describe('when namespace path is provided', () => {
+ it('executes getRemoteDevelopmentClusterAgentsQuery query', async () => {
+ await buildWrapperWithNamespace();
+
+ expect(getRemoteDevelopmentClusterAgentsQueryHandler).toHaveBeenCalledWith({
+ namespace: NAMESPACE,
+ });
+ });
+
+ describe('when the query is successful', () => {
+ beforeEach(() => {
+ setupRemoteDevelopmentClusterAgentsQueryHandler(
+ GET_REMOTE_DEVELOPMENT_CLUSTER_AGENTS_QUERY_RESULT_TWO_AGENTS,
+ );
+ });
+
+ it('triggers result event with the agents list', async () => {
+ await buildWrapperWithNamespace();
+
+ expect(wrapper.emitted('result')).toEqual([
+ [
+ {
+ agents: transformRemoteDevelopmentClusterAgentGraphQLResultToClusterAgents(
+ GET_REMOTE_DEVELOPMENT_CLUSTER_AGENTS_QUERY_RESULT_TWO_AGENTS,
+ ),
+ },
+ ],
+ ]);
+ });
+ });
+
+ describe('when the query fails', () => {
+ const error = new Error();
+
+ beforeEach(() => {
+ getRemoteDevelopmentClusterAgentsQueryHandler.mockReset();
+ getRemoteDevelopmentClusterAgentsQueryHandler.mockRejectedValueOnce(error);
+ });
+
+ it('logs the error', async () => {
+ expect(logError).not.toHaveBeenCalled();
+
+ await buildWrapperWithNamespace();
+
+ expect(logError).toHaveBeenCalledWith(error);
+ });
+
+ it('does not emit result event', async () => {
+ await buildWrapperWithNamespace();
+
+ expect(wrapper.emitted('result')).toBe(undefined);
+ });
+
+ it('emits error event', async () => {
+ await buildWrapperWithNamespace();
+
+ expect(wrapper.emitted('error')).toEqual([[{ error }]]);
+ });
+ });
+ });
+
+ describe('when namespace path is not provided', () => {
+ it('does not getRemoteDevelopmentClusterAgentsQuery query', async () => {
+ setupRemoteDevelopmentClusterAgentsQueryHandler(
+ GET_REMOTE_DEVELOPMENT_CLUSTER_AGENTS_QUERY_RESULT_TWO_AGENTS,
+ );
+ await buildWrapper();
+
+ expect(getRemoteDevelopmentClusterAgentsQueryHandler).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/ee/spec/frontend/workspaces/settings/pages/app_spec.js b/ee/spec/frontend/workspaces/settings/pages/app_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..f1cffd64aa627295f0bb31a0a475747825fc36f5
--- /dev/null
+++ b/ee/spec/frontend/workspaces/settings/pages/app_spec.js
@@ -0,0 +1,17 @@
+import { shallowMount } from '@vue/test-utils';
+import AgentMapping from 'ee_component/workspaces/agent_mapping/components/agent_mapping.vue';
+import App from 'ee_component/workspaces/settings/pages/app.vue';
+
+describe('workspaces/settings/pages/app.vue', () => {
+ let wrapper;
+
+ const buildWrapper = () => {
+ wrapper = shallowMount(App);
+ };
+
+ it('renders AgentMapping component', () => {
+ buildWrapper();
+
+ expect(wrapper.findComponent(AgentMapping).exists()).toBe(true);
+ });
+});
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index ed16fca075a6340a9f8f775d8f91fe7e4fbbc3c3..895bd8a596dc3f73ca1826f0d0f27a5122cde89c 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -59044,12 +59044,18 @@ msgstr ""
msgid "Workspaces|A workspace is a virtual sandbox environment for your code in GitLab."
msgstr ""
+msgid "Workspaces|Agents connect workspaces to your Kubernetes cluster. To create a workspace with an allowed agent, group members must have at least the Developer role."
+msgstr ""
+
msgid "Workspaces|Cancel"
msgstr ""
msgid "Workspaces|Cluster agent"
msgstr ""
+msgid "Workspaces|Could not load available agents. Refresh the page to try again."
+msgstr ""
+
msgid "Workspaces|Could not load workspaces"
msgstr ""
@@ -59089,6 +59095,9 @@ 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|Group agents"
+msgstr ""
+
msgid "Workspaces|If your devfile is not in the root directory of your project, specify a relative path."
msgstr ""
@@ -59140,6 +59149,9 @@ msgstr ""
msgid "Workspaces|The branch, tag, or commit hash GitLab uses to create your workspace."
msgstr ""
+msgid "Workspaces|This group has no available agents. Select the All agents tab and allow at least one agent."
+msgstr ""
+
msgid "Workspaces|To create a workspace, add a devfile to this project. A devfile is a configuration file for your workspace."
msgstr ""