Commit 930b9b15 authored by Andrew Fontaine's avatar Andrew Fontaine

Add tabs to new environments page

The tabs select the scope of environments to show, and are synced to the
URL query parameters.

Testing the tab selection is a little tricky due to the reactive poll
interval, so if there is no interval, just skip polling.

This is also good in the event polling is disabled.
parent 4f163766
<script> <script>
import { GlBadge, GlTab, GlTabs } from '@gitlab/ui'; import { GlBadge, GlTab, GlTabs } from '@gitlab/ui';
import { s__ } from '~/locale'; import { __, s__ } from '~/locale';
import environmentAppQuery from '../graphql/queries/environment_app.query.graphql'; import environmentAppQuery from '../graphql/queries/environment_app.query.graphql';
import pollIntervalQuery from '../graphql/queries/poll_interval.query.graphql'; import pollIntervalQuery from '../graphql/queries/poll_interval.query.graphql';
import EnvironmentFolder from './new_environment_folder.vue'; import EnvironmentFolder from './new_environment_folder.vue';
...@@ -17,6 +17,11 @@ export default { ...@@ -17,6 +17,11 @@ export default {
apollo: { apollo: {
environmentApp: { environmentApp: {
query: environmentAppQuery, query: environmentAppQuery,
variables() {
return {
scope: this.scope,
};
},
pollInterval() { pollInterval() {
return this.interval; return this.interval;
}, },
...@@ -29,10 +34,13 @@ export default { ...@@ -29,10 +34,13 @@ export default {
i18n: { i18n: {
newEnvironmentButtonLabel: s__('Environments|New environment'), newEnvironmentButtonLabel: s__('Environments|New environment'),
reviewAppButtonLabel: s__('Environments|Enable review app'), reviewAppButtonLabel: s__('Environments|Enable review app'),
available: __('Available'),
stopped: __('Stopped'),
}, },
modalId: 'enable-review-app-info', modalId: 'enable-review-app-info',
data() { data() {
return { interval: undefined, isReviewAppModalVisible: false }; const scope = new URLSearchParams(window.location.search).get('scope') || 'available';
return { interval: undefined, scope, isReviewAppModalVisible: false };
}, },
computed: { computed: {
canSetupReviewApp() { canSetupReviewApp() {
...@@ -71,11 +79,25 @@ export default { ...@@ -71,11 +79,25 @@ export default {
}, },
}; };
}, },
stoppedCount() {
return this.environmentApp?.stoppedCount;
},
}, },
methods: { methods: {
showReviewAppModal() { showReviewAppModal() {
this.isReviewAppModalVisible = true; this.isReviewAppModalVisible = true;
}, },
setScope(scope) {
this.scope = scope;
this.$apollo.queries.environmentApp.stopPolling();
this.$nextTick(() => {
if (this.interval) {
this.$apollo.queries.environmentApp.startPolling(this.interval);
} else {
this.$apollo.queries.environmentApp.refetch({ scope });
}
});
},
}, },
}; };
</script> </script>
...@@ -90,22 +112,32 @@ export default { ...@@ -90,22 +112,32 @@ export default {
<gl-tabs <gl-tabs
:action-secondary="addEnvironment" :action-secondary="addEnvironment"
:action-primary="openReviewAppModal" :action-primary="openReviewAppModal"
sync-active-tab-with-query-params
query-param-name="scope"
@primary="showReviewAppModal" @primary="showReviewAppModal"
> >
<gl-tab> <gl-tab query-param-value="available" @click="setScope('available')">
<template #title> <template #title>
<span>{{ __('Available') }}</span> <span>{{ $options.i18n.available }}</span>
<gl-badge size="sm" class="gl-tab-counter-badge"> <gl-badge size="sm" class="gl-tab-counter-badge">
{{ availableCount }} {{ availableCount }}
</gl-badge> </gl-badge>
</template> </template>
<environment-folder </gl-tab>
v-for="folder in folders" <gl-tab query-param-value="stopped" @click="setScope('stopped')">
:key="folder.name" <template #title>
class="gl-mb-3" <span>{{ $options.i18n.stopped }}</span>
:nested-environment="folder" <gl-badge size="sm" class="gl-tab-counter-badge">
/> {{ stoppedCount }}
</gl-badge>
</template>
</gl-tab> </gl-tab>
</gl-tabs> </gl-tabs>
<environment-folder
v-for="folder in folders"
:key="folder.name"
class="gl-mb-3"
:nested-environment="folder"
/>
</div> </div>
</template> </template>
query getEnvironmentApp { query getEnvironmentApp($scope: String) {
environmentApp @client { environmentApp(scope: $scope) @client {
availableCount availableCount
stoppedCount
environments environments
reviewApp reviewApp
stoppedCount stoppedCount
......
...@@ -19,12 +19,12 @@ const mapEnvironment = (env) => ({ ...@@ -19,12 +19,12 @@ const mapEnvironment = (env) => ({
export const resolvers = (endpoint) => ({ export const resolvers = (endpoint) => ({
Query: { Query: {
environmentApp(_context, _variables, { cache }) { environmentApp(_context, { scope }, { cache }) {
return axios.get(endpoint, { params: { nested: true } }).then((res) => { return axios.get(endpoint, { params: { nested: true, scope } }).then((res) => {
const interval = res.headers['poll-interval']; const interval = res.headers['poll-interval'];
if (interval) { if (interval) {
cache.writeQuery({ query: pollIntervalQuery, data: { interval } }); cache.writeQuery({ query: pollIntervalQuery, data: { interval: parseFloat(interval) } });
} else { } else {
cache.writeQuery({ query: pollIntervalQuery, data: { interval: undefined } }); cache.writeQuery({ query: pollIntervalQuery, data: { interval: undefined } });
} }
......
...@@ -23,9 +23,10 @@ describe('~/frontend/environments/graphql/resolvers', () => { ...@@ -23,9 +23,10 @@ describe('~/frontend/environments/graphql/resolvers', () => {
describe('environmentApp', () => { describe('environmentApp', () => {
it('should fetch environments and map them to frontend data', async () => { it('should fetch environments and map them to frontend data', async () => {
const cache = { writeQuery: jest.fn() }; const cache = { writeQuery: jest.fn() };
mock.onGet(ENDPOINT, { params: { nested: true } }).reply(200, environmentsApp, {}); const scope = 'available';
mock.onGet(ENDPOINT, { params: { nested: true, scope } }).reply(200, environmentsApp, {});
const app = await mockResolvers.Query.environmentApp(null, null, { cache }); const app = await mockResolvers.Query.environmentApp(null, { scope }, { cache });
expect(app).toEqual(resolvedEnvironmentsApp); expect(app).toEqual(resolvedEnvironmentsApp);
expect(cache.writeQuery).toHaveBeenCalledWith({ expect(cache.writeQuery).toHaveBeenCalledWith({
query: pollIntervalQuery, query: pollIntervalQuery,
...@@ -34,11 +35,12 @@ describe('~/frontend/environments/graphql/resolvers', () => { ...@@ -34,11 +35,12 @@ describe('~/frontend/environments/graphql/resolvers', () => {
}); });
it('should set the poll interval when there is one', async () => { it('should set the poll interval when there is one', async () => {
const cache = { writeQuery: jest.fn() }; const cache = { writeQuery: jest.fn() };
const scope = 'stopped';
mock mock
.onGet(ENDPOINT, { params: { nested: true } }) .onGet(ENDPOINT, { params: { nested: true, scope } })
.reply(200, environmentsApp, { 'poll-interval': 3000 }); .reply(200, environmentsApp, { 'poll-interval': 3000 });
await mockResolvers.Query.environmentApp(null, null, { cache }); await mockResolvers.Query.environmentApp(null, { scope }, { cache });
expect(cache.writeQuery).toHaveBeenCalledWith({ expect(cache.writeQuery).toHaveBeenCalledWith({
query: pollIntervalQuery, query: pollIntervalQuery,
data: { interval: 3000 }, data: { interval: 3000 },
......
import Vue from 'vue'; import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import { mountExtended } from 'helpers/vue_test_utils_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import { s__ } from '~/locale'; import { __, s__ } from '~/locale';
import EnvironmentsApp from '~/environments/components/new_environments_app.vue'; import EnvironmentsApp from '~/environments/components/new_environments_app.vue';
import EnvironmentsFolder from '~/environments/components/new_environment_folder.vue'; import EnvironmentsFolder from '~/environments/components/new_environment_folder.vue';
import { resolvedEnvironmentsApp, resolvedFolder } from './graphql/mock_data'; import { resolvedEnvironmentsApp, resolvedFolder } from './graphql/mock_data';
...@@ -17,7 +17,10 @@ describe('~/environments/components/new_environments_app.vue', () => { ...@@ -17,7 +17,10 @@ describe('~/environments/components/new_environments_app.vue', () => {
const createApolloProvider = () => { const createApolloProvider = () => {
const mockResolvers = { const mockResolvers = {
Query: { environmentApp: environmentAppMock, folder: environmentFolderMock }, Query: {
environmentApp: environmentAppMock,
folder: environmentFolderMock,
},
}; };
return createMockApollo([], mockResolvers); return createMockApollo([], mockResolvers);
...@@ -34,6 +37,16 @@ describe('~/environments/components/new_environments_app.vue', () => { ...@@ -34,6 +37,16 @@ describe('~/environments/components/new_environments_app.vue', () => {
apolloProvider, apolloProvider,
}); });
const createWrapperWithMocked = async ({ provide = {}, environmentsApp, folder }) => {
environmentAppMock.mockReturnValue(environmentsApp);
environmentFolderMock.mockReturnValue(folder);
const apolloProvider = createApolloProvider();
wrapper = createWrapper({ apolloProvider, provide });
await waitForPromises();
await nextTick();
};
beforeEach(() => { beforeEach(() => {
environmentAppMock = jest.fn(); environmentAppMock = jest.fn();
environmentFolderMock = jest.fn(); environmentFolderMock = jest.fn();
...@@ -44,13 +57,10 @@ describe('~/environments/components/new_environments_app.vue', () => { ...@@ -44,13 +57,10 @@ describe('~/environments/components/new_environments_app.vue', () => {
}); });
it('should show all the folders that are fetched', async () => { it('should show all the folders that are fetched', async () => {
environmentAppMock.mockReturnValue(resolvedEnvironmentsApp); await createWrapperWithMocked({
environmentFolderMock.mockReturnValue(resolvedFolder); environmentsApp: resolvedEnvironmentsApp,
const apolloProvider = createApolloProvider(); folder: resolvedFolder,
wrapper = createWrapper({ apolloProvider }); });
await waitForPromises();
await Vue.nextTick();
const text = wrapper.findAllComponents(EnvironmentsFolder).wrappers.map((w) => w.text()); const text = wrapper.findAllComponents(EnvironmentsFolder).wrappers.map((w) => w.text());
...@@ -59,64 +69,91 @@ describe('~/environments/components/new_environments_app.vue', () => { ...@@ -59,64 +69,91 @@ describe('~/environments/components/new_environments_app.vue', () => {
}); });
it('should show a button to create a new environment', async () => { it('should show a button to create a new environment', async () => {
environmentAppMock.mockReturnValue(resolvedEnvironmentsApp); await createWrapperWithMocked({
environmentFolderMock.mockReturnValue(resolvedFolder); environmentsApp: resolvedEnvironmentsApp,
const apolloProvider = createApolloProvider(); folder: resolvedFolder,
wrapper = createWrapper({ apolloProvider }); });
await waitForPromises();
await Vue.nextTick();
const button = wrapper.findByRole('link', { name: s__('Environments|New environment') }); const button = wrapper.findByRole('link', { name: s__('Environments|New environment') });
expect(button.attributes('href')).toBe('/environments/new'); expect(button.attributes('href')).toBe('/environments/new');
}); });
it('should not show a button to create a new environment if the user has no permissions', async () => { it('should not show a button to create a new environment if the user has no permissions', async () => {
environmentAppMock.mockReturnValue(resolvedEnvironmentsApp); await createWrapperWithMocked({
environmentFolderMock.mockReturnValue(resolvedFolder); environmentsApp: resolvedEnvironmentsApp,
const apolloProvider = createApolloProvider(); folder: resolvedFolder,
wrapper = createWrapper({
apolloProvider,
provide: { canCreateEnvironment: false, newEnvironmentPath: '' }, provide: { canCreateEnvironment: false, newEnvironmentPath: '' },
}); });
await waitForPromises();
await Vue.nextTick();
const button = wrapper.findByRole('link', { name: s__('Environments|New environment') }); const button = wrapper.findByRole('link', { name: s__('Environments|New environment') });
expect(button.exists()).toBe(false); expect(button.exists()).toBe(false);
}); });
it('should show a button to open the review app modal', async () => { it('should show a button to open the review app modal', async () => {
environmentAppMock.mockReturnValue(resolvedEnvironmentsApp); await createWrapperWithMocked({
environmentFolderMock.mockReturnValue(resolvedFolder); environmentsApp: resolvedEnvironmentsApp,
const apolloProvider = createApolloProvider(); folder: resolvedFolder,
wrapper = createWrapper({ apolloProvider }); });
await waitForPromises();
await Vue.nextTick();
const button = wrapper.findByRole('button', { name: s__('Environments|Enable review app') }); const button = wrapper.findByRole('button', { name: s__('Environments|Enable review app') });
button.trigger('click'); button.trigger('click');
await Vue.nextTick(); await nextTick();
expect(wrapper.findByText(s__('ReviewApp|Enable Review App')).exists()).toBe(true); expect(wrapper.findByText(s__('ReviewApp|Enable Review App')).exists()).toBe(true);
}); });
it('should not show a button to open the review app modal if review apps are configured', async () => { it('should not show a button to open the review app modal if review apps are configured', async () => {
environmentAppMock.mockReturnValue({ await createWrapperWithMocked({
...resolvedEnvironmentsApp, environmentsApp: {
reviewApp: { canSetupReviewApp: false }, ...resolvedEnvironmentsApp,
reviewApp: { canSetupReviewApp: false },
},
folder: resolvedFolder,
});
await waitForPromises();
await nextTick();
const button = wrapper.findByRole('button', { name: s__('Environments|Enable review app') });
expect(button.exists()).toBe(false);
});
it('should show tabs for available and stopped environmets', async () => {
await createWrapperWithMocked({
environmentsApp: resolvedEnvironmentsApp,
folder: resolvedFolder,
}); });
const [available, stopped] = wrapper.findAllByRole('tab').wrappers;
expect(available.text()).toContain(__('Available'));
expect(available.text()).toContain(resolvedEnvironmentsApp.availableCount);
expect(stopped.text()).toContain(__('Stopped'));
expect(stopped.text()).toContain(resolvedEnvironmentsApp.stoppedCount);
});
it('should change the requested scope on tab change', async () => {
environmentAppMock.mockReturnValue(resolvedEnvironmentsApp);
environmentFolderMock.mockReturnValue(resolvedFolder); environmentFolderMock.mockReturnValue(resolvedFolder);
const apolloProvider = createApolloProvider(); const apolloProvider = createApolloProvider();
wrapper = createWrapper({ apolloProvider }); wrapper = createWrapper({ apolloProvider });
await waitForPromises(); await waitForPromises();
await Vue.nextTick(); await nextTick();
const stopped = wrapper.findByRole('tab', {
name: `${__('Stopped')} ${resolvedEnvironmentsApp.stoppedCount}`,
});
const button = wrapper.findByRole('button', { name: s__('Environments|Enable review app') }); stopped.trigger('click');
expect(button.exists()).toBe(false);
await nextTick();
await waitForPromises();
expect(environmentAppMock).toHaveBeenCalledWith(
expect.anything(),
{ scope: 'stopped' },
expect.anything(),
expect.anything(),
);
}); });
}); });
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment