diff --git a/ee/app/assets/javascripts/pages/groups/settings/remote_development/workspaces/show/index.js b/ee/app/assets/javascripts/pages/groups/settings/remote_development/workspaces/show/index.js index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..2a7aeaab71351558135c65661bad1984a560fb62 100644 --- a/ee/app/assets/javascripts/pages/groups/settings/remote_development/workspaces/show/index.js +++ b/ee/app/assets/javascripts/pages/groups/settings/remote_development/workspaces/show/index.js @@ -0,0 +1,3 @@ +import { initWorkspacesSettingsApp } from 'ee/workspaces/settings/init_settings_app'; + +initWorkspacesSettingsApp(); diff --git a/ee/app/assets/javascripts/workspaces/agent_mapping/components/agent_mapping.vue b/ee/app/assets/javascripts/workspaces/agent_mapping/components/agent_mapping.vue new file mode 100644 index 0000000000000000000000000000000000000000..36ff1d9d4ffb90cb7fa9252d627da42fda5a549b --- /dev/null +++ b/ee/app/assets/javascripts/workspaces/agent_mapping/components/agent_mapping.vue @@ -0,0 +1,62 @@ + + diff --git a/ee/app/assets/javascripts/workspaces/agent_mapping/components/agents_table.vue b/ee/app/assets/javascripts/workspaces/agent_mapping/components/agents_table.vue new file mode 100644 index 0000000000000000000000000000000000000000..67c1eee2069e9cb81afe68d82ed353417958eb1d --- /dev/null +++ b/ee/app/assets/javascripts/workspaces/agent_mapping/components/agents_table.vue @@ -0,0 +1,49 @@ + + diff --git a/ee/app/assets/javascripts/workspaces/agent_mapping/components/get_available_agents_query.vue b/ee/app/assets/javascripts/workspaces/agent_mapping/components/get_available_agents_query.vue new file mode 100644 index 0000000000000000000000000000000000000000..3816d6843bb16e4821c8587a02a1ad1be141b758 --- /dev/null +++ b/ee/app/assets/javascripts/workspaces/agent_mapping/components/get_available_agents_query.vue @@ -0,0 +1,50 @@ + diff --git a/ee/app/assets/javascripts/workspaces/settings/init_settings_app.js b/ee/app/assets/javascripts/workspaces/settings/init_settings_app.js new file mode 100644 index 0000000000000000000000000000000000000000..c5d39a2a1d8ffc825ae6ce0c9a3a3325cbafa38d --- /dev/null +++ b/ee/app/assets/javascripts/workspaces/settings/init_settings_app.js @@ -0,0 +1,35 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import App from './pages/app.vue'; + +Vue.use(VueApollo); + +const createApolloProvider = () => { + const defaultClient = createDefaultClient(); + + return new VueApollo({ defaultClient }); +}; + +const initWorkspacesSettingsApp = () => { + const el = document.querySelector('#js-workspaces-settings'); + + if (!el) { + return null; + } + + const { namespace } = convertObjectPropsToCamelCase(el.dataset); + + return new Vue({ + el, + name: 'WorkspacesSettingsRoot', + apolloProvider: createApolloProvider(), + provide: { + namespace, + }, + render: (createElement) => createElement(App), + }); +}; + +export { initWorkspacesSettingsApp }; diff --git a/ee/app/assets/javascripts/workspaces/settings/pages/app.vue b/ee/app/assets/javascripts/workspaces/settings/pages/app.vue new file mode 100644 index 0000000000000000000000000000000000000000..2ac375520235b5b9acdd6bb1fc0f3fe366b67460 --- /dev/null +++ b/ee/app/assets/javascripts/workspaces/settings/pages/app.vue @@ -0,0 +1,26 @@ + + 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 ""