diff --git a/ee/app/assets/javascripts/workspaces/common/components/monitor_terminating_workspace.vue b/ee/app/assets/javascripts/workspaces/common/components/monitor_terminating_workspace.vue new file mode 100644 index 0000000000000000000000000000000000000000..5be75f7a28db4d6f938e699dd156f2a9da60ba62 --- /dev/null +++ b/ee/app/assets/javascripts/workspaces/common/components/monitor_terminating_workspace.vue @@ -0,0 +1,38 @@ + diff --git a/ee/app/assets/javascripts/workspaces/common/components/workspace_tab.vue b/ee/app/assets/javascripts/workspaces/common/components/workspace_tab.vue new file mode 100644 index 0000000000000000000000000000000000000000..bfbab4580c62842faebb714c739764621cf7fa9c --- /dev/null +++ b/ee/app/assets/javascripts/workspaces/common/components/workspace_tab.vue @@ -0,0 +1,128 @@ + + + + + + + + + {{ $options.getEmptyStateText(tabName) }} + + + + + + + + + diff --git a/ee/app/assets/javascripts/workspaces/common/components/workspaces_list/base_workspaces_list.vue b/ee/app/assets/javascripts/workspaces/common/components/workspaces_list/base_workspaces_list.vue new file mode 100644 index 0000000000000000000000000000000000000000..f63c0406778ae4e99779b7d27f9679090f8e3a39 --- /dev/null +++ b/ee/app/assets/javascripts/workspaces/common/components/workspaces_list/base_workspaces_list.vue @@ -0,0 +1,78 @@ + + + + + {{ error }} + + + + + + {{ $options.i18n.heading }} + + + {{ $options.i18n.learnMoreHelpLink }} + + + + + + + diff --git a/ee/app/assets/javascripts/workspaces/common/components/workspaces_list/workspaces_table.vue b/ee/app/assets/javascripts/workspaces/common/components/workspaces_list/workspaces_table.vue index b0a594f8ad0d4e5a96aee303acbb0276f0c6a78f..57cc8882aaf0114c048bb5ac38ad703c0a9f96b4 100644 --- a/ee/app/assets/javascripts/workspaces/common/components/workspaces_list/workspaces_table.vue +++ b/ee/app/assets/javascripts/workspaces/common/components/workspaces_list/workspaces_table.vue @@ -18,6 +18,51 @@ export const i18n = { }, }; +const tableFields = [ + { + key: 'status', + /* + * The status and action columns in this table + * do not have a label in the table header. We + * use this zero-width unicode character because + * using an empty string breaks the table alignment + * in mobile views. + */ + label: '\u200b', + thClass: 'gl-w-2/20', + }, + { + key: 'name', + label: i18n.tableColumnHeaders.name, + thClass: 'gl-w-4/20', + }, + { + key: 'created', + label: i18n.tableColumnHeaders.created, + thClass: 'gl-w-2/20', + }, + { + key: 'terminates', + label: i18n.tableColumnHeaders.terminates, + thClass: 'gl-w-2/20', + }, + { + key: 'devfile', + label: i18n.tableColumnHeaders.devfile, + thClass: 'gl-w-3/20', + }, + { + key: 'preview', + label: i18n.tableColumnHeaders.preview, + thClass: 'gl-w-4/20', + }, + { + key: 'actions', + label: '\u200b', + thClass: 'gl-w-3/20', + }, +]; + export default { components: { GlTableLite, @@ -35,20 +80,33 @@ export default { type: Array, required: true, }, - }, - data() { - return { - transitionProps: { - name: 'fade', - delay: 200, - duration: 300, - }, - }; + tabsMode: { + type: Boolean, + required: false, + default: false, + }, + transitionProps: { + type: Object, + required: false, + default: undefined, + }, }, computed: { sortedWorkspaces() { return [...this.workspaces].sort(this.sortWorkspacesByTerminatedState); }, + tableFields() { + return this.tabsMode + ? tableFields.map((tableField) => ({ + ...tableField, + // eslint-disable-next-line @gitlab/require-i18n-strings + thClass: `${tableField.thClass} gl-border-t-0!`, + })) + : tableFields; + }, + tableClasses() { + return this.tabsMode ? 'gl-mb-5' : 'gl-my-5'; + }, }, methods: { devfileRefAndPathDisplay(ref, path) { @@ -84,50 +142,6 @@ export default { return workspace.actualState === WORKSPACE_STATES.terminated; }, }, - fields: [ - { - key: 'status', - /* - * The status and action columns in this table - * do not have a label in the table header. We - * use this zero-width unicode character because - * using an empty string breaks the table alignment - * in mobile views. - */ - label: '\u200b', - thClass: 'gl-w-1/20', - }, - { - key: 'name', - label: i18n.tableColumnHeaders.name, - thClass: 'gl-w-4/20', - }, - { - key: 'created', - label: i18n.tableColumnHeaders.created, - thClass: 'gl-w-2/20', - }, - { - key: 'terminates', - label: i18n.tableColumnHeaders.terminates, - thClass: 'gl-w-2/20', - }, - { - key: 'devfile', - label: i18n.tableColumnHeaders.devfile, - thClass: 'gl-w-4/20', - }, - { - key: 'preview', - label: i18n.tableColumnHeaders.preview, - thClass: 'gl-w-4/20', - }, - { - key: 'actions', - label: '\u200b', - thClass: 'gl-w-4/20', - }, - ], i18n, WORKSPACE_STATES, }; @@ -139,9 +153,11 @@ export default { > { }; }); }; + export const fetchProjectsDetails = async (apollo, workspaces) => { const projectIds = workspaces.map(({ projectId }) => projectId); diff --git a/ee/app/assets/javascripts/workspaces/user/init_user_workspaces_app.js b/ee/app/assets/javascripts/workspaces/user/init_user_workspaces_app.js index 942d5e56d3b23e1a7c33c23ced50538de11e5903..9443ab2258883baf693975f2c1d4a0c8e80a2119 100644 --- a/ee/app/assets/javascripts/workspaces/user/init_user_workspaces_app.js +++ b/ee/app/assets/javascripts/workspaces/user/init_user_workspaces_app.js @@ -1,3 +1,4 @@ +import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; @@ -8,6 +9,7 @@ import App from './pages/app.vue'; import createRouter from './router/index'; Vue.use(VueApollo); +Vue.use(GlToast); const createApolloProvider = () => { const defaultClient = createDefaultClient(); diff --git a/ee/app/assets/javascripts/workspaces/user/pages/list.vue b/ee/app/assets/javascripts/workspaces/user/pages/list.vue index ef7039f41dedb87e402af57da4cee066b21b71ad..712525bee19823211f7a07cecf9831954a8cbf6b 100644 --- a/ee/app/assets/javascripts/workspaces/user/pages/list.vue +++ b/ee/app/assets/javascripts/workspaces/user/pages/list.vue @@ -1,42 +1,79 @@ - - + {{ $options.i18n.newWorkspaceButton }} - + + + + + + + + + + diff --git a/ee/app/assets/javascripts/workspaces/user/services/apollo_cache_mutators.js b/ee/app/assets/javascripts/workspaces/user/services/apollo_cache_mutators.js index 1baa97ef0ec923cb4da1995c96368f39e9de37b9..20972cc8fafdf2e594936aec97fa37dfa6c25c09 100644 --- a/ee/app/assets/javascripts/workspaces/user/services/apollo_cache_mutators.js +++ b/ee/app/assets/javascripts/workspaces/user/services/apollo_cache_mutators.js @@ -1,12 +1,18 @@ import produce from 'immer'; -import userWorkspacesListQuery from '../../common/graphql/queries/user_workspaces_list.query.graphql'; +import userWorkspacesTabListQuery from '../../common/graphql/queries/user_workspaces_tab_list.query.graphql'; import { WORKSPACES_LIST_PAGE_SIZE } from '../constants'; export const addWorkspace = (store, workspace) => { store.updateQuery( { - query: userWorkspacesListQuery, - variables: { after: null, before: null, first: WORKSPACES_LIST_PAGE_SIZE }, + query: userWorkspacesTabListQuery, + variables: { + first: WORKSPACES_LIST_PAGE_SIZE, + activeAfter: null, + activeBefore: null, + terminatedAfter: null, + terminatedBefore: null, + }, }, (sourceData) => produce(sourceData, (draftData) => { @@ -15,7 +21,7 @@ export const addWorkspace = (store, workspace) => { return; } - draftData.currentUser.workspaces.nodes.unshift(workspace); + draftData.currentUser.activeWorkspaces.nodes.unshift(workspace); }), ); }; diff --git a/ee/spec/frontend/workspaces/common/components/workspace_tab_spec.js b/ee/spec/frontend/workspaces/common/components/workspace_tab_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..14c58a65fc62fc11d160839acdc8431ead59eada --- /dev/null +++ b/ee/spec/frontend/workspaces/common/components/workspace_tab_spec.js @@ -0,0 +1,147 @@ +import { mount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import Vue, { nextTick } from 'vue'; +import { GlSkeletonLoader, GlTab } from '@gitlab/ui'; + +import WorkspaceTab from 'ee/workspaces/common/components/workspace_tab.vue'; +import WorkspaceTable from 'ee/workspaces/common/components/workspaces_list/workspaces_table.vue'; +import WorkspacesListPagination from 'ee/workspaces/common/components/workspaces_list/workspaces_list_pagination.vue'; +import { populateWorkspacesWithProjectDetails } from 'ee/workspaces/common/services/utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { + USER_WORKSPACES_LIST_QUERY_RESULT, + GET_PROJECTS_DETAILS_QUERY_RESULT, +} from '../../mock_data'; + +jest.mock('~/lib/logger'); + +Vue.use(VueApollo); + +const MOCK_WORKSPACES = populateWorkspacesWithProjectDetails( + USER_WORKSPACES_LIST_QUERY_RESULT.data.currentUser.workspaces.nodes, + GET_PROJECTS_DETAILS_QUERY_RESULT.data.projects.nodes, +); + +describe('workspaces/common/components/workspace_tab.vue', () => { + let wrapper; + + const createWrapper = (props) => { + wrapper = extendedWrapper( + mount(WorkspaceTab, { + propsData: { + tabName: 'terminated', + workspaces: MOCK_WORKSPACES, + loading: false, + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + }, + ...props, + }, + }), + ); + }; + const findTab = () => wrapper.findComponent(GlTab); + const findTable = () => wrapper.findComponent(WorkspaceTable); + const findPagination = () => wrapper.findComponent(WorkspacesListPagination); + const findLoader = () => wrapper.findComponent(GlSkeletonLoader); + + it('shows loading state when workspaces are being fetched', () => { + createWrapper({ loading: true }); + expect(findLoader().exists()).toBe(true); + }); + + describe('when no workspaces are available', () => { + beforeEach(() => { + createWrapper({ workspaces: [] }); + }); + + it('renders tab', () => { + expect(findTab().exists()).toBe(true); + }); + + it('renders empty state when no workspaces are available', () => { + const emptyState = wrapper.findByTestId('empty-state'); + + expect(emptyState.exists()).toBe(true); + }); + + it('does not render table and pagination', () => { + expect(findTable().exists()).toBe(false); + expect(findPagination().exists()).toBe(false); + }); + }); + + describe('with workspaces', () => { + beforeEach(() => { + createWrapper({ workspaces: MOCK_WORKSPACES }); + }); + + it('renders the tab', () => { + expect(findTab().exists()).toBe(true); + }); + + it('renders table', () => { + expect(findTable().exists()).toBe(true); + }); + + it('provides workspaces data to the workspaces table', () => { + expect(findTable(wrapper).props('workspaces')).toEqual(MOCK_WORKSPACES); + }); + + it('renders pagination component', () => { + expect(findPagination().exists()).toBe(true); + }); + }); + + describe('when pagination component emits events', () => { + it.each(['active', 'terminated'])( + 'emits onPaginationInput event with correct variables for %s tab when input event is emitted', + async (tab) => { + const pageVariables = { + after: 'end', + first: 10, + }; + + createWrapper({ tabName: tab }); + + await waitForPromises(); + + findPagination().vm.$emit('input', pageVariables); + + await waitForPromises(); + + expect(wrapper.emitted('onPaginationInput')).toHaveLength(1); + expect(wrapper.emitted('onPaginationInput')[0]).toEqual([ + { tab, paginationVariables: pageVariables }, + ]); + }, + ); + + it('emits error event with error message when updatedFailed event is emitted', async () => { + const mockError = 'Failed to stop workspace'; + createWrapper(); + + await waitForPromises(); + findTable().vm.$emit('updateFailed', { error: mockError }); + + await nextTick(); + + expect(wrapper.emitted('error')).toHaveLength(1); + expect(wrapper.emitted('error')[0]).toEqual([mockError]); + }); + + it('emits error event with empty string when updateSucceed event is emitted', async () => { + createWrapper(); + + await waitForPromises(); + findTable().vm.$emit('updateSucceed'); + + await nextTick(); + + expect(wrapper.emitted('error')).toHaveLength(1); + expect(wrapper.emitted('error')[0]).toEqual(['']); + }); + }); +}); diff --git a/ee/spec/frontend/workspaces/common/components/workspaces_list/base_workspaces_list_spec.js b/ee/spec/frontend/workspaces/common/components/workspaces_list/base_workspaces_list_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..125a4c12f4135f000dcc0fd59674124a00b601d5 --- /dev/null +++ b/ee/spec/frontend/workspaces/common/components/workspaces_list/base_workspaces_list_spec.js @@ -0,0 +1,104 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlSkeletonLoader, GlAlert } from '@gitlab/ui'; + +import BaseWorkspacesList from 'ee/workspaces/common/components/workspaces_list/base_workspaces_list.vue'; +import WorkspaceEmptyState from 'ee/workspaces/common/components/workspaces_list/empty_state.vue'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; + +const findAlert = (wrapper) => wrapper.findComponent(GlAlert); + +describe('workspaces/common/components/workspaces_list/base_workspaces_list.vue', () => { + let wrapper; + + function createWrapper(props) { + wrapper = extendedWrapper( + shallowMount(BaseWorkspacesList, { + propsData: { + empty: true, + loading: false, + error: null, + newWorkspacePath: '/some-path', + ...props, + }, + }), + ); + } + + describe('is loading', () => { + beforeEach(() => { + createWrapper({ + empty: true, + loading: true, + }); + }); + + it('does not render empty state', () => { + const emptyState = wrapper.findComponent(WorkspaceEmptyState); + expect(emptyState.exists()).toBe(false); + }); + }); + + describe('is empty', () => { + beforeEach(() => { + createWrapper(); + }); + + it('renders an empty state', () => { + const emptyState = wrapper.findComponent(WorkspaceEmptyState); + expect(emptyState.exists()).toBe(true); + }); + + it('does not render header', () => { + const header = wrapper.findByTestId('workspaces-list-header'); + expect(header.exists()).toBe(false); + }); + + it('does not render error', () => { + expect(findAlert(wrapper).exists()).toBe(false); + }); + }); + + describe('is not empty', () => { + beforeEach(() => { + createWrapper({ + empty: false, + loading: false, + }); + }); + + it('does not render loading state', () => { + expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(false); + }); + + it('does not render empty state', () => { + const emptyState = wrapper.findComponent(WorkspaceEmptyState); + expect(emptyState.exists()).toBe(false); + }); + + it('renders header', () => { + const header = wrapper.findByTestId('workspaces-list-header'); + expect(header.exists()).toBe(true); + }); + + it('does not render error', () => { + expect(findAlert(wrapper).exists()).toBe(false); + }); + }); + + describe('on error', () => { + const MOCK_ERROR = + 'Unable to load current workspaces. Please try again or contact an administrator.'; + + beforeEach(() => { + createWrapper({ + empty: false, + loading: false, + error: MOCK_ERROR, + }); + }); + + it('shows alert', () => { + expect(findAlert(wrapper).text()).toBe(MOCK_ERROR); + }); + }); +}); diff --git a/ee/spec/frontend/workspaces/common/components/workspaces_list/monitor_terminating_workspace_spec.js b/ee/spec/frontend/workspaces/common/components/workspaces_list/monitor_terminating_workspace_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..f720d68df829dcd26e1c6b302c6b82de1aa9b5cd --- /dev/null +++ b/ee/spec/frontend/workspaces/common/components/workspaces_list/monitor_terminating_workspace_spec.js @@ -0,0 +1,85 @@ +import { shallowMount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import Vue from 'vue'; +import MonitorTerminatingWorkspace from 'ee/workspaces/common/components/monitor_terminating_workspace.vue'; + +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; + +import getWorkspaceStateQuery from 'ee/workspaces/common/graphql/queries/get_workspace_state.query.graphql'; +import { GET_WORKSPACE_STATE_INTERVAL, WORKSPACE_STATES } from 'ee/workspaces/common/constants'; +import { GET_WORKSPACE_STATE_QUERY_RESULT } from '../../../mock_data'; + +Vue.use(VueApollo); +describe('workspaces/common/components/workspaces_list/monitor_terminating_workspace.vue', () => { + // eslint-disable-next-line no-unused-vars + let wrapper; + let mockApollo; + + const $toast = { + show: jest.fn(), + }; + + const createComponent = ( + options = { + getWorkspaceStateQueryHandler: jest.fn().mockResolvedValue(GET_WORKSPACE_STATE_QUERY_RESULT), + }, + ) => { + mockApollo = createMockApollo([ + [getWorkspaceStateQuery, options.getWorkspaceStateQueryHandler], + ]); + + wrapper = shallowMount(MonitorTerminatingWorkspace, { + apolloProvider: mockApollo, + propsData: { + workspaceId: '1', + }, + mocks: { + $toast, + }, + }); + }; + + describe('terminated workspace', () => { + const mockTerminatedWorkspaceStateResult = { + data: { + workspace: { + ...GET_WORKSPACE_STATE_QUERY_RESULT.data.workspace, + actualState: WORKSPACE_STATES.terminated, + }, + }, + }; + + const advanceToNextFetch = () => { + jest.advanceTimersByTime(GET_WORKSPACE_STATE_INTERVAL); + }; + + beforeEach(async () => { + createComponent({ + getWorkspaceStateQueryHandler: jest + .fn() + .mockResolvedValueOnce(GET_WORKSPACE_STATE_QUERY_RESULT) + .mockResolvedValue(mockTerminatedWorkspaceStateResult), + }); + await waitForPromises(); + }); + + afterEach(() => { + mockApollo = null; + }); + + it('does not show toast when no workspace terminated', () => { + expect($toast.show).toHaveBeenCalledTimes(0); + }); + + it('shows toast when workspace is successfully terminated', async () => { + await advanceToNextFetch(); + await waitForPromises(); + + expect($toast.show).toHaveBeenCalledTimes(1); + expect($toast.show).toHaveBeenCalledWith( + `${mockTerminatedWorkspaceStateResult.data.workspace.name} has been terminated.`, + ); + }); + }); +}); diff --git a/ee/spec/frontend/workspaces/mock_data/index.js b/ee/spec/frontend/workspaces/mock_data/index.js index cc4c90628fd561cf0cf4bb6f09e22ee80ca81f03..c08e5ab4d0cfe17b188fcb8b5731bd9ffd8a08c8 100644 --- a/ee/spec/frontend/workspaces/mock_data/index.js +++ b/ee/spec/frontend/workspaces/mock_data/index.js @@ -79,6 +79,130 @@ export const USER_WORKSPACES_LIST_QUERY_RESULT = { }, }; +export const USER_WORKSPACES_TAB_LIST_QUERY_RESULT = { + data: { + currentUser: { + id: 1, + activeWorkspaces: { + nodes: [ + { + __typename: 'Workspace', + id: 'gid://gitlab/RemoteDevelopment::Workspace/2', + name: 'workspace-1-1-idmi02', + namespace: 'gl-rd-ns-1-1-idmi02', + desiredState: 'Stopped', + actualState: 'CreationRequested', + 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', + maxHoursBeforeTermination: 120, + }, + { + __typename: 'Workspace', + id: 'gid://gitlab/RemoteDevelopment::Workspace/1', + name: 'workspace-1-1-rfu27q', + namespace: 'gl-rd-ns-1-1-rfu27q', + desiredState: 'Running', + actualState: 'Running', + 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', + maxHoursBeforeTermination: 120, + }, + ], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + }, + terminatedWorkspaces: { + nodes: [ + { + __typename: 'Workspace', + id: 'gid://gitlab/RemoteDevelopment::Workspace/4', + name: 'workspace-1-1-iawi02', + namespace: 'gl-rd-ns-1-1-iawi02', + desiredState: 'Terminated', + actualState: 'Terminated', + 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', + maxHoursBeforeTermination: 120, + }, + { + __typename: 'Workspace', + id: 'gid://gitlab/RemoteDevelopment::Workspace/3', + name: 'workspace-1-1-rsl27q', + namespace: 'gl-rd-ns-1-1-rsl27q', + desiredState: 'Terminated', + actualState: 'Terminated', + 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', + maxHoursBeforeTermination: 120, + }, + ], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + }, + }, + }, +}; + +export const GET_WORKSPACE_STATE_QUERY_RESULT = { + data: { + workspace: { + id: '1', + name: 'workspace-1-1-rfu27q', + actualState: 'Running', + }, + }, +}; + +export const USER_WORKSPACES_TAB_LIST_QUERY_EMPTY_RESULT = { + data: { + currentUser: { + id: 1, + activeWorkspaces: { + nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + }, + terminatedWorkspaces: { + nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + }, + }, + }, +}; + export const USER_WORKSPACES_LIST_QUERY_EMPTY_RESULT = { data: { currentUser: { diff --git a/ee/spec/frontend/workspaces/user/init_user_workspaces_app_spec.js b/ee/spec/frontend/workspaces/user/init_user_workspaces_app_spec.js index e14fc0c8b469a916498fb135864bc8ebb311b654..834ef884a22f09a3976beb26977857297a3ad840 100644 --- a/ee/spec/frontend/workspaces/user/init_user_workspaces_app_spec.js +++ b/ee/spec/frontend/workspaces/user/init_user_workspaces_app_spec.js @@ -1,5 +1,5 @@ import { escape } from 'lodash'; -import { GlEmptyState } from '@gitlab/ui'; + import { createWrapper } from '@vue/test-utils'; import { injectVueAppBreadcrumbs } from '~/lib/utils/breadcrumbs'; import { initUserWorkspacesApp } from 'ee/workspaces/user/init_user_workspaces_app'; @@ -35,10 +35,6 @@ describe('ee/workspaces/init_user_workspaces_app', () => { expect(wrapper.vm.$router.options.base).toBe('/aaa'); }); - it('renders empty state', () => { - expect(wrapper.findComponent(GlEmptyState).props('svgPath')).toBe('/bbb'); - }); - it('renders list component', () => { const workspaceListComponent = wrapper.findComponent(WorkspaceList); diff --git a/ee/spec/frontend/workspaces/user/pages/create_spec.js b/ee/spec/frontend/workspaces/user/pages/create_spec.js index 4f8e52a4677960fe6c7eae6529fd8f3acc05fd32..99a9ec9c743eb550248fbc1ac7800d201f5bbeb5 100644 --- a/ee/spec/frontend/workspaces/user/pages/create_spec.js +++ b/ee/spec/frontend/workspaces/user/pages/create_spec.js @@ -31,11 +31,11 @@ import waitForPromises from 'helpers/wait_for_promises'; import { logError } from '~/lib/logger'; import { createAlert } from '~/alert'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import userWorkspacesListQuery from 'ee/workspaces/common/graphql/queries/user_workspaces_list.query.graphql'; +import userWorkspacesTabListQuery from 'ee/workspaces/common/graphql/queries/user_workspaces_tab_list.query.graphql'; import workspaceCreateMutation from 'ee/workspaces/user/graphql/mutations/workspace_create.mutation.graphql'; import { GET_PROJECT_DETAILS_QUERY_RESULT, - USER_WORKSPACES_LIST_QUERY_RESULT, + USER_WORKSPACES_TAB_LIST_QUERY_RESULT, WORKSPACE_CREATE_MUTATION_RESULT, WORKSPACE_QUERY_RESULT, } from '../../mock_data'; @@ -74,32 +74,37 @@ describe('workspaces/user/pages/create.vue', () => { const readCachedWorkspaces = () => { const apolloClient = mockApollo.clients.defaultClient; const result = apolloClient.readQuery({ - query: userWorkspacesListQuery, + query: userWorkspacesTabListQuery, variables: { - before: null, - after: null, + activeBefore: null, + activeAfter: null, + terminatedBefore: null, + terminatedAfter: null, first: WORKSPACES_LIST_PAGE_SIZE, }, }); - return result?.currentUser.workspaces.nodes; + return result?.currentUser.activeWorkspaces.nodes; }; const writeCachedWorkspaces = (workspaces) => { const apolloClient = mockApollo.clients.defaultClient; apolloClient.writeQuery({ - query: userWorkspacesListQuery, + query: userWorkspacesTabListQuery, variables: { - before: null, - after: null, + activeBefore: null, + activeAfter: null, + terminatedBefore: null, + terminatedAfter: null, first: WORKSPACES_LIST_PAGE_SIZE, }, data: { currentUser: { - ...USER_WORKSPACES_LIST_QUERY_RESULT.data.currentUser, - workspaces: { + ...USER_WORKSPACES_TAB_LIST_QUERY_RESULT.data.currentUser, + activeWorkspaces: { nodes: workspaces, - pageInfo: USER_WORKSPACES_LIST_QUERY_RESULT.data.currentUser.workspaces.pageInfo, + pageInfo: + USER_WORKSPACES_TAB_LIST_QUERY_RESULT.data.currentUser.activeWorkspaces.pageInfo, }, }, }, @@ -425,6 +430,7 @@ describe('workspaces/user/pages/create.vue', () => { it('when workspaces are previously cached, updates cache', async () => { const originalWorkspace = WORKSPACE_QUERY_RESULT.data.workspace; + writeCachedWorkspaces([originalWorkspace]); await submitCreateWorkspaceForm(); diff --git a/ee/spec/frontend/workspaces/user/pages/list_spec.js b/ee/spec/frontend/workspaces/user/pages/list_spec.js index 4adb654efcb45e6dcb8e9c43f942a5862ff1afb2..d2e02c36e1d77020afe95b415b8c625a292f6645 100644 --- a/ee/spec/frontend/workspaces/user/pages/list_spec.js +++ b/ee/spec/frontend/workspaces/user/pages/list_spec.js @@ -1,22 +1,25 @@ import { mount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import Vue, { nextTick } from 'vue'; -import { GlAlert, GlButton, GlLink, GlSkeletonLoader } from '@gitlab/ui'; +import { GlAlert, GlButton, GlLink, GlSkeletonLoader, GlTabs } from '@gitlab/ui'; import { logError } from '~/lib/logger'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import WorkspaceEmptyState from 'ee/workspaces/common/components/workspaces_list/empty_state.vue'; -import WorkspacesTable from 'ee/workspaces/common/components/workspaces_list/workspaces_table.vue'; -import WorkspacesListPagination from 'ee/workspaces/common/components/workspaces_list/workspaces_list_pagination.vue'; -import userWorkspacesListQuery from 'ee/workspaces/common/graphql/queries/user_workspaces_list.query.graphql'; +import WorkspaceTab from 'ee/workspaces/common/components/workspace_tab.vue'; +import WorkspaceTable from 'ee/workspaces/common/components/workspaces_list/workspaces_table.vue'; +import userWorkspacesTabListQuery from 'ee/workspaces/common/graphql/queries/user_workspaces_tab_list.query.graphql'; import getProjectsDetailsQuery from 'ee/workspaces/common/graphql/queries/get_projects_details.query.graphql'; -import { populateWorkspacesWithProjectDetails } from 'ee/workspaces/common/services/utils'; +import getWorkspaceStateQuery from 'ee/workspaces/common/graphql/queries/get_workspace_state.query.graphql'; import List from 'ee/workspaces/user/pages/list.vue'; import { ROUTES } from 'ee/workspaces/user/constants'; +import { WORKSPACE_STATES } from 'ee/workspaces/common/constants'; +import MonitorTerminatingWorkspace from 'ee/workspaces/common/components/monitor_terminating_workspace.vue'; import { - USER_WORKSPACES_LIST_QUERY_RESULT, - USER_WORKSPACES_LIST_QUERY_EMPTY_RESULT, + USER_WORKSPACES_TAB_LIST_QUERY_RESULT, + USER_WORKSPACES_TAB_LIST_QUERY_EMPTY_RESULT, GET_PROJECTS_DETAILS_QUERY_RESULT, + GET_WORKSPACE_STATE_QUERY_RESULT, } from '../../mock_data'; jest.mock('~/lib/logger'); @@ -28,22 +31,24 @@ const SVG_PATH = '/assets/illustrations/empty_states/empty_workspaces.svg'; describe('workspaces/user/pages/list.vue', () => { let wrapper; let mockApollo; - let userWorkspacesListQueryHandler; + let userWorkspacesTabListQueryHandler; let getProjectsDetailsQueryHandler; + let getWorkspaceStateQueryHandler; const buildMockApollo = () => { - userWorkspacesListQueryHandler = jest + userWorkspacesTabListQueryHandler = jest .fn() - .mockResolvedValueOnce(USER_WORKSPACES_LIST_QUERY_RESULT); - getProjectsDetailsQueryHandler = jest - .fn() - .mockResolvedValueOnce(GET_PROJECTS_DETAILS_QUERY_RESULT); + .mockResolvedValue(USER_WORKSPACES_TAB_LIST_QUERY_RESULT); + getProjectsDetailsQueryHandler = jest.fn().mockResolvedValue(GET_PROJECTS_DETAILS_QUERY_RESULT); + getWorkspaceStateQueryHandler = jest.fn().mockResolvedValue(GET_WORKSPACE_STATE_QUERY_RESULT); mockApollo = createMockApollo([ - [userWorkspacesListQuery, userWorkspacesListQueryHandler], + [userWorkspacesTabListQuery, userWorkspacesTabListQueryHandler], [getProjectsDetailsQuery, getProjectsDetailsQueryHandler], + [getWorkspaceStateQuery, getWorkspaceStateQueryHandler], ]); }; + const createWrapper = () => { // noinspection JSCheckFunctionSignatures - TODO: Address in https://gitlab.com/gitlab-org/gitlab/-/issues/437600 wrapper = mount(List, { @@ -55,9 +60,11 @@ describe('workspaces/user/pages/list.vue', () => { }; const findAlert = () => wrapper.findComponent(GlAlert); const findHelpLink = () => wrapper.findComponent(GlLink); - const findTable = () => wrapper.findComponent(WorkspacesTable); - const findPagination = () => wrapper.findComponent(WorkspacesListPagination); + const findTabContainer = () => wrapper.findComponent(GlTabs); + const findTabs = () => wrapper.findAllComponents(WorkspaceTab); + const findTable = () => wrapper.findComponent(WorkspaceTable); const findNewWorkspaceButton = () => wrapper.findComponent(GlButton); + const findAllConfirmButtons = () => wrapper.findAllComponents(GlButton).filter((button) => button.props().variant === 'confirm'); @@ -67,8 +74,10 @@ describe('workspaces/user/pages/list.vue', () => { describe('when no workspaces are available', () => { beforeEach(async () => { - userWorkspacesListQueryHandler.mockReset(); - userWorkspacesListQueryHandler.mockResolvedValueOnce(USER_WORKSPACES_LIST_QUERY_EMPTY_RESULT); + userWorkspacesTabListQueryHandler.mockReset(); + userWorkspacesTabListQueryHandler.mockResolvedValueOnce( + USER_WORKSPACES_TAB_LIST_QUERY_EMPTY_RESULT, + ); createWrapper(); await waitForPromises(); @@ -82,12 +91,11 @@ describe('workspaces/user/pages/list.vue', () => { expect(findAllConfirmButtons().length).toBe(1); }); - it('does not render the workspaces table', () => { - expect(findTable().exists()).toBe(false); - }); - - it('does not render the workspaces pagination', () => { - expect(findPagination().exists()).toBe(false); + it('does not render the workspace tabs', () => { + const tabContainer = findTabContainer(); + const tabs = findTabs(); + expect(tabContainer.exists()).toBe(false); + expect(tabs.exists()).toBe(false); }); }); @@ -102,21 +110,17 @@ describe('workspaces/user/pages/list.vue', () => { await waitForPromises(); }); - it('renders table', () => { - expect(findTable().exists()).toBe(true); - }); + it('renders the workspace tabs', () => { + const tabContainer = findTabContainer(); + const tabs = findTabs(); - it('renders pagination', () => { - expect(findPagination().exists()).toBe(true); - }); + expect(tabContainer.exists()).toBe(true); + expect(tabContainer.props('syncActiveTabWithQueryParams')).toBe(true); - it('provides workspaces data to the workspaces table', () => { - expect(findTable(wrapper).props('workspaces')).toEqual( - populateWorkspacesWithProjectDetails( - USER_WORKSPACES_LIST_QUERY_RESULT.data.currentUser.workspaces.nodes, - GET_PROJECTS_DETAILS_QUERY_RESULT.data.projects.nodes, - ), - ); + expect(tabs.exists()).toBe(true); + expect(tabs).toHaveLength(2); + expect(tabs.at(0).props('tabName')).toEqual('active'); + expect(tabs.at(1).props('tabName')).toEqual('terminated'); }); it('does not call log error', () => { @@ -126,25 +130,6 @@ describe('workspaces/user/pages/list.vue', () => { it('does not show alert', () => { expect(findAlert(wrapper).exists()).toBe(false); }); - - describe('when pagination component emits input event', () => { - it('refetches workspaces starting at the specified cursor', async () => { - const pageVariables = { after: 'end', first: 10 }; - - createWrapper(); - - await waitForPromises(); - - expect(userWorkspacesListQueryHandler).toHaveBeenCalledTimes(1); - - findPagination().vm.$emit('input', pageVariables); - - await waitForPromises(); - - expect(userWorkspacesListQueryHandler).toHaveBeenCalledTimes(2); - expect(userWorkspacesListQueryHandler).toHaveBeenLastCalledWith(pageVariables); - }); - }); }); describe('when workspace table emits updateFailed event', () => { @@ -176,10 +161,58 @@ describe('workspaces/user/pages/list.vue', () => { }); }); + describe('when workspace tab emits onPaginationInput event', () => { + const EXPECTED_ACTIVE_WORKSPACES_PAGINATION_VARIABLES = { + first: 10, + activeAfter: 'end', + activeBefore: null, + terminatedAfter: null, + terminatedBefore: null, + }; + const EXPECTED_TERMINATED_WORKSPACES_PAGINATION_VARIABLES = { + first: 10, + terminatedAfter: 'end', + activeAfter: null, + activeBefore: null, + terminatedBefore: null, + }; + + it.each` + tabName | tabIdx | expectedPaginationVariables + ${'active'} | ${0} | ${EXPECTED_ACTIVE_WORKSPACES_PAGINATION_VARIABLES} + ${'terminated'} | ${1} | ${EXPECTED_TERMINATED_WORKSPACES_PAGINATION_VARIABLES} + `( + 'correctly sets pagination variables for $tabName tab', + async ({ tabName, tabIdx, expectedPaginationVariables }) => { + const pageVariables = { after: 'end', first: 10 }; + + createWrapper(); + + await waitForPromises(); + + expect(userWorkspacesTabListQueryHandler).toHaveBeenCalledTimes(1); + + const workspaceTab = findTabs().at(tabIdx); + + workspaceTab.vm.$emit('onPaginationInput', { + tab: tabName, + paginationVariables: pageVariables, + }); + + await nextTick(); + + expect(userWorkspacesTabListQueryHandler).toHaveBeenCalledTimes(2); + expect(userWorkspacesTabListQueryHandler).toHaveBeenLastCalledWith( + expectedPaginationVariables, + ); + }, + ); + }); + describe.each` - query | queryHandlerFactory - ${'userWorkspaces'} | ${() => userWorkspacesListQueryHandler} - ${'projectsDetails'} | ${() => getProjectsDetailsQueryHandler} + query | queryHandlerFactory + ${'userWorkspacesTabList'} | ${() => userWorkspacesTabListQueryHandler} + ${'projectsDetails'} | ${() => getProjectsDetailsQueryHandler} `('when $query query fails', ({ queryHandlerFactory }) => { const ERROR = new Error('Something bad!'); @@ -193,10 +226,6 @@ describe('workspaces/user/pages/list.vue', () => { await waitForPromises(); }); - it('does not render table', () => { - expect(findTable().exists()).toBe(false); - }); - it('logs error', () => { expect(logError).toHaveBeenCalledWith(ERROR); }); @@ -222,6 +251,7 @@ describe('workspaces/user/pages/list.vue', () => { await waitForPromises(); }); + it('displays a link button that navigates to the create workspace page', () => { expect(findNewWorkspaceButton().attributes().to).toBe(ROUTES.new); expect(findNewWorkspaceButton().text()).toMatch(/New workspace/); @@ -231,4 +261,59 @@ describe('workspaces/user/pages/list.vue', () => { expect(findHelpLink().attributes().href).toContain('user/workspace/index.md'); }); }); + + describe('terminating workspaces', () => { + const MOCK_PAGE_INFO = { + hasNextPage: false, + hasPreviousPage: true, + startCursor: 'start', + endCursor: 'end', + }; + + const mockActiveWorkspaces = + USER_WORKSPACES_TAB_LIST_QUERY_RESULT.data.currentUser.activeWorkspaces.nodes; + + const createMockTerminatingWorkspace = (id) => ({ + ...mockActiveWorkspaces[0], + id, + name: id, + namespace: id, + desiredState: WORKSPACE_STATES.terminated, + }); + + const createMockWorkspaceQueryResult = (workspaces) => ({ + data: { + currentUser: { + ...USER_WORKSPACES_TAB_LIST_QUERY_RESULT.data.currentUser, + activeWorkspaces: { + ...USER_WORKSPACES_TAB_LIST_QUERY_RESULT.data.currentUser.activeWorkspaces, + nodes: [...workspaces], + pageInfo: { + ...USER_WORKSPACES_TAB_LIST_QUERY_RESULT.data.currentUser.activeWorkspaces.pageInfo, + ...MOCK_PAGE_INFO, + }, + }, + }, + }, + }); + + beforeEach(async () => { + const mockTerminatingWorkspacesQueryResult = createMockWorkspaceQueryResult([ + createMockTerminatingWorkspace('test1'), + createMockTerminatingWorkspace('test2'), + ...mockActiveWorkspaces, + ]); + userWorkspacesTabListQueryHandler.mockReset(); + userWorkspacesTabListQueryHandler.mockResolvedValueOnce(mockTerminatingWorkspacesQueryResult); + createWrapper(); + await waitForPromises(); + }); + + it('renders correct amount of MonitorTerminatingWorkspace components', () => { + const monitorTerminatingWorkspaceComponents = wrapper.findAllComponents( + MonitorTerminatingWorkspace, + ); + expect(monitorTerminatingWorkspaceComponents).toHaveLength(2); + }); + }); }); diff --git a/ee/spec/frontend/workspaces/user/router/index_spec.js b/ee/spec/frontend/workspaces/user/router/index_spec.js index 3acf45dcd1f966f3f7302066b46ef3f5233fc8b6..5f6b97eeebc0cc7d2014a6ca5ef53297b9db51f2 100644 --- a/ee/spec/frontend/workspaces/user/router/index_spec.js +++ b/ee/spec/frontend/workspaces/user/router/index_spec.js @@ -3,7 +3,7 @@ import VueRouter from 'vue-router'; import VueApollo from 'vue-apollo'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import getProjectsDetailsQuery from 'ee/workspaces/common/graphql/queries/get_projects_details.query.graphql'; -import userWorkspacesListQuery from 'ee/workspaces/common/graphql/queries/user_workspaces_list.query.graphql'; +import userWorkspacesTabListQuery from 'ee/workspaces/common/graphql/queries/user_workspaces_tab_list.query.graphql'; import App from 'ee/workspaces/user/pages/app.vue'; import WorkspacesList from 'ee/workspaces/user/pages/list.vue'; import createRouter from 'ee/workspaces/user/router/index'; @@ -12,7 +12,7 @@ import { ROUTES } from 'ee/workspaces/user/constants'; import createMockApollo from 'helpers/mock_apollo_helper'; import { GET_PROJECTS_DETAILS_QUERY_RESULT, - USER_WORKSPACES_LIST_QUERY_EMPTY_RESULT, + USER_WORKSPACES_TAB_LIST_QUERY_EMPTY_RESULT, } from '../../mock_data'; Vue.use(VueRouter); @@ -42,8 +42,8 @@ describe('workspaces/router/index.js', () => { router, apolloProvider: createMockApollo([ [ - userWorkspacesListQuery, - jest.fn().mockResolvedValue(USER_WORKSPACES_LIST_QUERY_EMPTY_RESULT), + userWorkspacesTabListQuery, + jest.fn().mockResolvedValue(USER_WORKSPACES_TAB_LIST_QUERY_EMPTY_RESULT), ], [ getProjectsDetailsQuery, diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 35bebb41c732bbc9475046f697648f092639c2a7..a9e756d412216281471c78f052224b06c50a5040 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -51203,6 +51203,9 @@ msgstr "" msgid "Status checks must pass." msgstr "" +msgid "Status not supported" +msgstr "" + msgid "Status was retried." msgstr "" @@ -60283,12 +60286,18 @@ msgstr "" msgid "Workspaces" msgstr "" +msgid "Workspaces|%{workspaceName} has been terminated." +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|Active" +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 "" @@ -60379,6 +60388,12 @@ msgstr "" msgid "Workspaces|New workspace" msgstr "" +msgid "Workspaces|No active workspaces. Create a workspace to get started." +msgstr "" + +msgid "Workspaces|No terminated workspaces to show." +msgstr "" + msgid "Workspaces|Path to devfile" msgstr "" diff --git a/qa/qa/ee/page/workspace/list.rb b/qa/qa/ee/page/workspace/list.rb index 12def2374a9796839ca7d9daf7d08da194707bc1..a3fb895e0283b3326cb951f72ef6c706f272e151 100644 --- a/qa/qa/ee/page/workspace/list.rb +++ b/qa/qa/ee/page/workspace/list.rb @@ -26,6 +26,8 @@ def has_empty_workspace? end def create_workspace(agent, project) + existing_workspaces = get_workspaces_list + if has_empty_workspace? click_element('new-workspace-button', skip_finished_loading_check: true) else @@ -38,7 +40,22 @@ def create_workspace(agent, project) new.add_new_variable('VAR1', 'value 1') new.save_workspace end - Support::WaitForRequests.wait_for_requests(skip_finished_loading_check: true) + + Support::Waiter.wait_until(max_duration: 5, raise_on_failure: false) do + get_workspaces_list.length > existing_workspaces.length + end + + updated_workspaces = get_workspaces_list + + workspace_name = (updated_workspaces - existing_workspaces).fetch(0, '').to_s + + raise "Workspace name is empty" if workspace_name == '' + + workspace_name + end + + def click_terminated_tab + find('a[role="tab"]', text: "Terminated", exact_text: true).click end def get_workspaces_list diff --git a/qa/qa/specs/features/shared_examples/create_and_terminate_workspace_shared_examples.rb b/qa/qa/specs/features/shared_examples/create_and_terminate_workspace_shared_examples.rb index 7a9a485d6112e5e628ceae97f05e3f17f1daebff..b4313e3873f25fa7ea2e8841436a431573bd5311 100644 --- a/qa/qa/specs/features/shared_examples/create_and_terminate_workspace_shared_examples.rb +++ b/qa/qa/specs/features/shared_examples/create_and_terminate_workspace_shared_examples.rb @@ -7,11 +7,7 @@ module QA workspace_name = "" QA::EE::Page::Workspace::List.perform do |list| - existing_workspaces = list.get_workspaces_list - list.create_workspace(kubernetes_agent.name, devfile_project.name) - updated_workspaces = list.get_workspaces_list - workspace_name = (updated_workspaces - existing_workspaces).fetch(0, '').to_s - raise "Workspace name is empty" if workspace_name == '' + workspace_name = list.create_workspace(kubernetes_agent.name, devfile_project.name) expect(list).to have_workspace_state(workspace_name, "Creating") list.wait_for_workspaces_creation(workspace_name) @@ -31,6 +27,7 @@ module QA end QA::EE::Page::Workspace::List.perform do |list_item| + list_item.click_terminated_tab expect(list_item).to have_workspace_state(workspace_name, "Terminated") end end
+ {{ $options.getEmptyStateText(tabName) }} +