From 6a0fb90d9fce1d66c19427bb28cabadd0904a29e Mon Sep 17 00:00:00 2001 From: Enrique Alcantara Date: Tue, 7 May 2024 16:15:32 +0200 Subject: [PATCH 1/4] Display mapped agents in Workspaces settings In the Workspaces Settings page, display the list of agents that are available to create Workspaces in a given group --- .../workspaces/show/index.js | 3 + .../components/agent_mapping.vue | 60 ++++++++ .../agent_mapping/components/agents_table.vue | 49 ++++++ .../components/get_available_agents_query.vue | 50 ++++++ .../workspaces/settings/init_settings_app.js | 35 +++++ .../workspaces/settings/pages/app.vue | 26 ++++ .../workspaces/show.html.haml | 2 + .../remote_development/workspaces_spec.rb | 29 +++- .../components/agent_mapping_spec.js | 98 ++++++++++++ .../components/agents_table_spec.js | 112 ++++++++++++++ .../get_available_agents_query_spec.js | 145 ++++++++++++++++++ .../workspaces/settings/pages/app_spec.js | 17 ++ locale/gitlab.pot | 12 ++ 13 files changed, 636 insertions(+), 2 deletions(-) create mode 100644 ee/app/assets/javascripts/workspaces/agent_mapping/components/agent_mapping.vue create mode 100644 ee/app/assets/javascripts/workspaces/agent_mapping/components/agents_table.vue create mode 100644 ee/app/assets/javascripts/workspaces/agent_mapping/components/get_available_agents_query.vue create mode 100644 ee/app/assets/javascripts/workspaces/settings/init_settings_app.js create mode 100644 ee/app/assets/javascripts/workspaces/settings/pages/app.vue create mode 100644 ee/spec/frontend/workspaces/agent_mapping/components/agent_mapping_spec.js create mode 100644 ee/spec/frontend/workspaces/agent_mapping/components/agents_table_spec.js create mode 100644 ee/spec/frontend/workspaces/agent_mapping/components/get_available_agents_query_spec.js create mode 100644 ee/spec/frontend/workspaces/settings/pages/app_spec.js 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 e69de29bb2d1d6..2a7aeaab713515 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 00000000000000..43d76bab53d20c --- /dev/null +++ b/ee/app/assets/javascripts/workspaces/agent_mapping/components/agent_mapping.vue @@ -0,0 +1,60 @@ + + 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 00000000000000..1b6d304bd10e7a --- /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 00000000000000..3816d6843bb16e --- /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 00000000000000..c5d39a2a1d8ffc --- /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 00000000000000..82796bd705f725 --- /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 32211907ae713e..6ca6e3c6cbdbfe 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 70f6017ee243e7..71f3dd5ed1dad3 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 allowed 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 00000000000000..be2bd18b280a22 --- /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 allowed agents. Allow at least one agent under the All Agents tab.', + ); + }); + + 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 00000000000000..b4e1f0fd540140 --- /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 00000000000000..045094c12df166 --- /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 transformRemoveDevelopmentClusterAgentGraphQLResultToClusterAgents = ( + 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: transformRemoveDevelopmentClusterAgentGraphQLResultToClusterAgents( + 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 00000000000000..f1cffd64aa6272 --- /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 ed16fca075a634..17085d93482dc0 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 a workspace to your Kubernetes cluster. Any group member with developer access and above can create workspaces using an allowed agent." +msgstr "" + msgid "Workspaces|Cancel" msgstr "" msgid "Workspaces|Cluster agent" msgstr "" +msgid "Workspaces|Could not load available agents." +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 allowed agents. Allow at least one agent under the All Agents tab." +msgstr "" + msgid "Workspaces|To create a workspace, add a devfile to this project. A devfile is a configuration file for your workspace." msgstr "" -- GitLab From 42b4ac2bb77a28e378b148cfdc7dcadcb89ba755 Mon Sep 17 00:00:00 2001 From: Enrique Alcantara Date: Thu, 9 May 2024 17:15:24 +0200 Subject: [PATCH 2/4] UX review feedback Fix right-aligned empty table message --- .../workspaces/agent_mapping/components/agents_table.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 1b6d304bd10e7a..67c1eee2069e9c 100644 --- a/ee/app/assets/javascripts/workspaces/agent_mapping/components/agents_table.vue +++ b/ee/app/assets/javascripts/workspaces/agent_mapping/components/agents_table.vue @@ -39,7 +39,7 @@ export default {