Commit f5bd4c28 authored by Ezekiel Kigbo's avatar Ezekiel Kigbo

Merge branch 'add-devops-adoption-segment-modal' into 'master'

Add devops adoption segment modal component

See merge request gitlab-org/gitlab!47623
parents d46b4a0d f7abb0d0
...@@ -4,6 +4,7 @@ import * as Sentry from '~/sentry/wrapper'; ...@@ -4,6 +4,7 @@ import * as Sentry from '~/sentry/wrapper';
import getGroupsQuery from '../graphql/queries/get_groups.query.graphql'; import getGroupsQuery from '../graphql/queries/get_groups.query.graphql';
import DevopsAdoptionEmptyState from './devops_adoption_empty_state.vue'; import DevopsAdoptionEmptyState from './devops_adoption_empty_state.vue';
import { DEVOPS_ADOPTION_STRINGS, MAX_REQUEST_COUNT } from '../constants'; import { DEVOPS_ADOPTION_STRINGS, MAX_REQUEST_COUNT } from '../constants';
import DevopsAdoptionSegmentModal from './devops_adoption_segment_modal.vue';
export default { export default {
name: 'DevopsAdoptionApp', name: 'DevopsAdoptionApp',
...@@ -11,6 +12,7 @@ export default { ...@@ -11,6 +12,7 @@ export default {
GlAlert, GlAlert,
GlLoadingIcon, GlLoadingIcon,
DevopsAdoptionEmptyState, DevopsAdoptionEmptyState,
DevopsAdoptionSegmentModal,
}, },
i18n: { i18n: {
...DEVOPS_ADOPTION_STRINGS.app, ...DEVOPS_ADOPTION_STRINGS.app,
...@@ -19,6 +21,7 @@ export default { ...@@ -19,6 +21,7 @@ export default {
return { return {
requestCount: MAX_REQUEST_COUNT, requestCount: MAX_REQUEST_COUNT,
loadingError: false, loadingError: false,
selectedSegmentId: null,
}; };
}, },
apollo: { apollo: {
...@@ -38,12 +41,12 @@ export default { ...@@ -38,12 +41,12 @@ export default {
}, },
}, },
computed: { computed: {
hasGroupData() {
return Boolean(this.groups?.nodes?.length);
},
isLoading() { isLoading() {
return this.$apollo.queries.groups.loading; return this.$apollo.queries.groups.loading;
}, },
isEmpty() {
return this.groups?.nodes?.length === 0;
},
}, },
methods: { methods: {
handleError(error) { handleError(error) {
...@@ -73,5 +76,12 @@ export default { ...@@ -73,5 +76,12 @@ export default {
{{ $options.i18n.groupsError }} {{ $options.i18n.groupsError }}
</gl-alert> </gl-alert>
<gl-loading-icon v-else-if="isLoading" size="md" class="gl-my-5" /> <gl-loading-icon v-else-if="isLoading" size="md" class="gl-my-5" />
<devops-adoption-empty-state v-else-if="isEmpty" /> <div v-else>
<devops-adoption-empty-state :has-groups-data="hasGroupData" />
<devops-adoption-segment-modal
v-if="hasGroupData"
:groups="groups.nodes"
:segment-id="selectedSegmentId"
/>
</div>
</template> </template>
<script> <script>
import { GlEmptyState, GlButton } from '@gitlab/ui'; import { GlEmptyState, GlButton, GlModalDirective } from '@gitlab/ui';
import { DEVOPS_ADOPTION_STRINGS } from '../constants'; import { DEVOPS_ADOPTION_STRINGS, DEVOPS_ADOPTION_SEGMENT_MODAL_ID } from '../constants';
export default { export default {
name: 'DevopsAdoptionEmptyState', name: 'DevopsAdoptionEmptyState',
...@@ -9,7 +9,17 @@ export default { ...@@ -9,7 +9,17 @@ export default {
GlEmptyState, GlEmptyState,
GlButton, GlButton,
}, },
directives: {
GlModal: GlModalDirective,
},
i18n: DEVOPS_ADOPTION_STRINGS.emptyState, i18n: DEVOPS_ADOPTION_STRINGS.emptyState,
devopsSegmentModalId: DEVOPS_ADOPTION_SEGMENT_MODAL_ID,
props: {
hasGroupsData: {
type: Boolean,
required: true,
},
},
}; };
</script> </script>
<template> <template>
...@@ -19,7 +29,12 @@ export default { ...@@ -19,7 +29,12 @@ export default {
:svg-path="emptyStateSvgPath" :svg-path="emptyStateSvgPath"
> >
<template #actions> <template #actions>
<gl-button variant="info">{{ $options.i18n.button }}</gl-button> <gl-button
v-gl-modal="$options.devopsSegmentModalId"
:disabled="!hasGroupsData"
variant="info"
>{{ $options.i18n.button }}</gl-button
>
</template> </template>
</gl-empty-state> </gl-empty-state>
</template> </template>
<script>
import { GlFormGroup, GlFormInput, GlFormCheckboxTree, GlModal, GlSprintf } from '@gitlab/ui';
import { DEVOPS_ADOPTION_STRINGS, DEVOPS_ADOPTION_SEGMENT_MODAL_ID } from '../constants';
export default {
name: 'DevopsAdoptionSegmentModal',
components: {
GlModal,
GlFormGroup,
GlFormInput,
GlFormCheckboxTree,
GlSprintf,
},
props: {
segmentId: {
type: String,
required: false,
default: null,
},
groups: {
type: Array,
required: true,
},
},
i18n: DEVOPS_ADOPTION_STRINGS.modal,
data() {
return {
name: '',
checkboxValues: [],
};
},
computed: {
checkboxOptions() {
return this.groups.map(({ id, full_name }) => ({ label: full_name, value: id }));
},
},
methods: {
createSegment() {},
},
devopsSegmentModalId: DEVOPS_ADOPTION_SEGMENT_MODAL_ID,
};
</script>
<template>
<gl-modal
:modal-id="$options.devopsSegmentModalId"
:title="$options.i18n.title"
:ok-title="$options.i18n.button"
ok-variant="info"
size="sm"
scrollable
@ok="createSegment"
>
<gl-form-group :label="$options.i18n.nameLabel" label-for="name" data-testid="name">
<gl-form-input
id="name"
v-model="name"
type="text"
:placeholder="$options.i18n.namePlaceholder"
:required="true"
/>
</gl-form-group>
<gl-form-group class="gl-mb-0" data-testid="groups">
<gl-form-checkbox-tree
v-model="checkboxValues"
:options="checkboxOptions"
:hide-toggle-all="true"
class="gl-p-3 gl-pb-0 gl-mb-2 gl-border-1 gl-border-solid gl-border-gray-100 gl-rounded-base"
/>
<div class="gl-text-gray-400" data-testid="groupsHelperText">
<gl-sprintf
:message="
n__(
$options.i18n.selectedGroupsTextSingular,
$options.i18n.selectedGroupsTextPlural,
checkboxValues.length,
)
"
>
<template #selectedCount>
{{ checkboxValues.length }}
</template>
</gl-sprintf>
</div>
</gl-form-group>
</gl-modal>
</template>
import { s__ } from '~/locale'; import { s__ } from '~/locale';
export const MAX_REQUEST_COUNT = 10; export const MAX_REQUEST_COUNT = 10;
export const DEVOPS_ADOPTION_SEGMENT_MODAL_ID = 'devopsSegmentModal';
export const DEVOPS_ADOPTION_STRINGS = { export const DEVOPS_ADOPTION_STRINGS = {
app: { app: {
groupsError: s__('DevopsAdoption|There was an error fetching Groups'), groupsError: s__('DevopsAdoption|There was an error fetching Groups'),
...@@ -12,6 +15,14 @@ export const DEVOPS_ADOPTION_STRINGS = { ...@@ -12,6 +15,14 @@ export const DEVOPS_ADOPTION_STRINGS = {
), ),
button: s__('DevopsAdoption|Add new segment'), button: s__('DevopsAdoption|Add new segment'),
}, },
modal: {
title: s__('DevopsAdoption|New segment'),
button: s__('DevopsAdoption|Create new segment'),
namePlaceholder: s__('DevopsAdoption|My segment'),
nameLabel: s__('DevopsAdoption|Name'),
selectedGroupsTextSingular: s__('DevopsAdoption|%{selectedCount} group selected (20 max)'),
selectedGroupsTextPlural: s__('DevopsAdoption|%{selectedCount} groups selected (20 max)'),
},
}; };
export const DEVOPS_ADOPTION_TABLE_TEST_IDS = { export const DEVOPS_ADOPTION_TABLE_TEST_IDS = {
......
...@@ -7,6 +7,7 @@ import waitForPromises from 'helpers/wait_for_promises'; ...@@ -7,6 +7,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import getGroupsQuery from 'ee/admin/dev_ops_report/graphql/queries/get_groups.query.graphql'; import getGroupsQuery from 'ee/admin/dev_ops_report/graphql/queries/get_groups.query.graphql';
import DevopsAdoptionApp from 'ee/admin/dev_ops_report/components/devops_adoption_app.vue'; import DevopsAdoptionApp from 'ee/admin/dev_ops_report/components/devops_adoption_app.vue';
import DevopsAdoptionEmptyState from 'ee/admin/dev_ops_report/components/devops_adoption_empty_state.vue'; import DevopsAdoptionEmptyState from 'ee/admin/dev_ops_report/components/devops_adoption_empty_state.vue';
import DevopsAdoptionSegmentModal from 'ee/admin/dev_ops_report/components/devops_adoption_segment_modal.vue';
import { DEVOPS_ADOPTION_STRINGS } from 'ee/admin/dev_ops_report/constants'; import { DEVOPS_ADOPTION_STRINGS } from 'ee/admin/dev_ops_report/constants';
import * as Sentry from '~/sentry/wrapper'; import * as Sentry from '~/sentry/wrapper';
import { groupNodes, nextGroupNode, groupPageInfo } from '../mock_data'; import { groupNodes, nextGroupNode, groupPageInfo } from '../mock_data';
...@@ -79,7 +80,7 @@ describe('DevopsAdoptionApp', () => { ...@@ -79,7 +80,7 @@ describe('DevopsAdoptionApp', () => {
groupsSpy = null; groupsSpy = null;
}); });
describe('when no data is present', () => { describe('when no group data is present', () => {
beforeEach(async () => { beforeEach(async () => {
groupsSpy = jest.fn().mockResolvedValueOnce({ __typename: 'Groups', nodes: [] }); groupsSpy = jest.fn().mockResolvedValueOnce({ __typename: 'Groups', nodes: [] });
const mockApollo = createMockApolloProvider({ groupsSpy }); const mockApollo = createMockApolloProvider({ groupsSpy });
...@@ -87,8 +88,8 @@ describe('DevopsAdoptionApp', () => { ...@@ -87,8 +88,8 @@ describe('DevopsAdoptionApp', () => {
await waitForPromises(); await waitForPromises();
}); });
it('displays the empty state', () => { it('does not render the segment modal', () => {
expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(true); expect(wrapper.find(DevopsAdoptionSegmentModal).exists()).toBe(false);
}); });
it('does not display the loader', () => { it('does not display the loader', () => {
...@@ -96,7 +97,7 @@ describe('DevopsAdoptionApp', () => { ...@@ -96,7 +97,7 @@ describe('DevopsAdoptionApp', () => {
}); });
}); });
describe('when data is present', () => { describe('when group data is present', () => {
beforeEach(async () => { beforeEach(async () => {
groupsSpy = jest.fn().mockResolvedValueOnce({ ...initialResponse, pageInfo: null }); groupsSpy = jest.fn().mockResolvedValueOnce({ ...initialResponse, pageInfo: null });
const mockApollo = createMockApolloProvider({ groupsSpy }); const mockApollo = createMockApolloProvider({ groupsSpy });
...@@ -105,14 +106,14 @@ describe('DevopsAdoptionApp', () => { ...@@ -105,14 +106,14 @@ describe('DevopsAdoptionApp', () => {
await waitForPromises(); await waitForPromises();
}); });
it('does not display the empty state', () => {
expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(false);
});
it('does not display the loader', () => { it('does not display the loader', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
}); });
it('renders the segment modal', () => {
expect(wrapper.find(DevopsAdoptionSegmentModal).exists()).toBe(true);
});
it('should fetch data once', () => { it('should fetch data once', () => {
expect(groupsSpy).toHaveBeenCalledTimes(1); expect(groupsSpy).toHaveBeenCalledTimes(1);
}); });
...@@ -134,14 +135,14 @@ describe('DevopsAdoptionApp', () => { ...@@ -134,14 +135,14 @@ describe('DevopsAdoptionApp', () => {
await waitForPromises(); await waitForPromises();
}); });
it('does not display the empty state', () => {
expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(false);
});
it('does not display the loader', () => { it('does not display the loader', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
}); });
it('does not render the segment modal', () => {
expect(wrapper.find(DevopsAdoptionSegmentModal).exists()).toBe(false);
});
it('should fetch data once', () => { it('should fetch data once', () => {
expect(groupsSpy).toHaveBeenCalledTimes(1); expect(groupsSpy).toHaveBeenCalledTimes(1);
}); });
...@@ -166,7 +167,7 @@ describe('DevopsAdoptionApp', () => { ...@@ -166,7 +167,7 @@ describe('DevopsAdoptionApp', () => {
groupsSpy = null; groupsSpy = null;
}); });
describe('when data is present', () => { describe('when group data is present', () => {
beforeEach(async () => { beforeEach(async () => {
groupsSpy = jest groupsSpy = jest
.fn() .fn()
...@@ -179,14 +180,14 @@ describe('DevopsAdoptionApp', () => { ...@@ -179,14 +180,14 @@ describe('DevopsAdoptionApp', () => {
await waitForPromises(); await waitForPromises();
}); });
it('does not display the empty state', () => {
expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(false);
});
it('does not display the loader', () => { it('does not display the loader', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
}); });
it('renders the segment modal', () => {
expect(wrapper.find(DevopsAdoptionSegmentModal).exists()).toBe(true);
});
it('should fetch data twice', () => { it('should fetch data twice', () => {
expect(groupsSpy).toHaveBeenCalledTimes(2); expect(groupsSpy).toHaveBeenCalledTimes(2);
}); });
...@@ -211,10 +212,6 @@ describe('DevopsAdoptionApp', () => { ...@@ -211,10 +212,6 @@ describe('DevopsAdoptionApp', () => {
await waitForPromises(); await waitForPromises();
}); });
it('does not display the empty state', () => {
expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(false);
});
it('does not display the loader', () => { it('does not display the loader', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
}); });
...@@ -237,21 +234,21 @@ describe('DevopsAdoptionApp', () => { ...@@ -237,21 +234,21 @@ describe('DevopsAdoptionApp', () => {
.fn() .fn()
.mockResolvedValueOnce(initialResponse) .mockResolvedValueOnce(initialResponse)
// `fetchMore` response // `fetchMore` response
.mockRejectedValueOnce(error); .mockRejectedValue(error);
const mockApollo = createMockApolloProvider({ groupsSpy }); const mockApollo = createMockApolloProvider({ groupsSpy });
wrapper = createComponent({ mockApollo }); wrapper = createComponent({ mockApollo });
jest.spyOn(wrapper.vm.$apollo.queries.groups, 'fetchMore'); jest.spyOn(wrapper.vm.$apollo.queries.groups, 'fetchMore');
await waitForPromises(); await waitForPromises();
}); });
it('does not display the empty state', () => {
expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(false);
});
it('does not display the loader', () => { it('does not display the loader', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
}); });
it('does not render the segment modal', () => {
expect(wrapper.find(DevopsAdoptionSegmentModal).exists()).toBe(false);
});
it('should fetch data twice', () => { it('should fetch data twice', () => {
expect(groupsSpy).toHaveBeenCalledTimes(2); expect(groupsSpy).toHaveBeenCalledTimes(2);
}); });
......
import { shallowMount, mount } from '@vue/test-utils';
import { GlEmptyState, GlButton } from '@gitlab/ui'; import { GlEmptyState, GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import DevopsAdoptionEmptyState from 'ee/admin/dev_ops_report/components/devops_adoption_empty_state.vue'; import DevopsAdoptionEmptyState from 'ee/admin/dev_ops_report/components/devops_adoption_empty_state.vue';
import { DEVOPS_ADOPTION_STRINGS } from 'ee/admin/dev_ops_report/constants'; import {
DEVOPS_ADOPTION_STRINGS,
DEVOPS_ADOPTION_SEGMENT_MODAL_ID,
} from 'ee/admin/dev_ops_report/constants';
const emptyStateSvgPath = 'illustrations/monitoring/getting_started.svg'; const emptyStateSvgPath = 'illustrations/monitoring/getting_started.svg';
...@@ -9,11 +12,16 @@ describe('DevopsAdoptionEmptyState', () => { ...@@ -9,11 +12,16 @@ describe('DevopsAdoptionEmptyState', () => {
let wrapper; let wrapper;
const createComponent = (options = {}) => { const createComponent = (options = {}) => {
const { stubs = {} } = options; const { stubs = {}, props = {}, func = shallowMount } = options;
return shallowMount(DevopsAdoptionEmptyState, {
return func(DevopsAdoptionEmptyState, {
provide: { provide: {
emptyStateSvgPath, emptyStateSvgPath,
}, },
propsData: {
hasGroupsData: true,
...props,
},
stubs, stubs,
}); });
}; };
...@@ -41,12 +49,42 @@ describe('DevopsAdoptionEmptyState', () => { ...@@ -41,12 +49,42 @@ describe('DevopsAdoptionEmptyState', () => {
expect(emptyState.props('description')).toBe(DEVOPS_ADOPTION_STRINGS.emptyState.description); expect(emptyState.props('description')).toBe(DEVOPS_ADOPTION_STRINGS.emptyState.description);
}); });
it('contains an overridden action button', () => { describe('action button', () => {
wrapper = createComponent({ stubs: { GlEmptyState } }); it('displays an overridden action button', () => {
wrapper = createComponent({ stubs: { GlEmptyState } });
const actionButton = findEmptyStateAction();
expect(actionButton.exists()).toBe(true);
expect(actionButton.text()).toBe(DEVOPS_ADOPTION_STRINGS.emptyState.button);
});
it('is enabled when there is group data', () => {
wrapper = createComponent({ stubs: { GlEmptyState } });
const actionButton = findEmptyStateAction();
expect(actionButton.props('disabled')).toBe(false);
});
it('is disabled when there is no group data', () => {
wrapper = createComponent({ stubs: { GlEmptyState }, props: { hasGroupsData: false } });
const actionButton = findEmptyStateAction();
expect(actionButton.props('disabled')).toBe(true);
});
it('calls the gl-modal show', async () => {
wrapper = createComponent({ func: mount });
const actionButton = findEmptyStateAction(); const actionButton = findEmptyStateAction();
const rootEmit = jest.spyOn(wrapper.vm.$root, '$emit');
expect(actionButton.exists()).toBe(true); actionButton.trigger('click');
expect(actionButton.text()).toBe(DEVOPS_ADOPTION_STRINGS.emptyState.button);
expect(rootEmit.mock.calls[0][0]).toContain('show');
expect(rootEmit.mock.calls[0][1]).toBe(DEVOPS_ADOPTION_SEGMENT_MODAL_ID);
});
}); });
}); });
import { shallowMount } from '@vue/test-utils';
import { GlModal, GlFormInput, GlFormCheckboxTree, GlSprintf } from '@gitlab/ui';
import { getByText } from '@testing-library/dom';
import { nextTick } from 'vue';
import DevopsAdoptionSegmentModal from 'ee/admin/dev_ops_report/components/devops_adoption_segment_modal.vue';
import { DEVOPS_ADOPTION_SEGMENT_MODAL_ID } from 'ee/admin/dev_ops_report/constants';
import { groupNodes } from '../mock_data';
describe('DevopsAdoptionSegmentModal', () => {
let wrapper;
const createComponent = () => {
wrapper = shallowMount(DevopsAdoptionSegmentModal, {
propsData: {
groups: groupNodes,
},
stubs: {
GlSprintf,
},
});
};
const findModal = () => wrapper.find(GlModal);
const findByTestId = testId => findModal().find(`[data-testid="${testId}"`);
const assertHelperText = text => expect(getByText(wrapper.element, text)).not.toBeNull();
beforeEach(() => createComponent());
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('contains the corrrect id', () => {
const modal = findModal();
expect(modal.exists()).toBe(true);
expect(modal.props('modalId')).toBe(DEVOPS_ADOPTION_SEGMENT_MODAL_ID);
});
describe('displays the correct content', () => {
const isCorrectShape = option => {
const keys = Object.keys(option);
return keys.includes('label') && keys.includes('value');
};
it('displays the name field', () => {
const name = findByTestId('name');
expect(name.exists()).toBe(true);
expect(name.find(GlFormInput).exists()).toBe(true);
});
it('contains the checkbox tree component', () => {
const checkboxes = findByTestId('groups').find(GlFormCheckboxTree);
expect(checkboxes.exists()).toBe(true);
const options = checkboxes.props('options');
expect(options.length).toBe(2);
expect(options.every(isCorrectShape)).toBe(true);
});
describe('selected groups helper text', () => {
it('displays the plural text when 0 groups are selected', () => {
assertHelperText('0 groups selected (20 max)');
});
it('dispalys the singular text when only 1 group is selected', async () => {
wrapper.setData({ checkboxValues: [groupNodes[0]] });
await nextTick();
assertHelperText('1 group selected (20 max)');
});
it('displays the plural text when multiple groups are selected', async () => {
wrapper.setData({ checkboxValues: groupNodes });
await nextTick();
assertHelperText('2 groups selected (20 max)');
});
});
});
});
...@@ -9436,6 +9436,12 @@ msgstr "" ...@@ -9436,6 +9436,12 @@ msgstr ""
msgid "DevopsAdoptionSegment|The maximum number of segments has been reached" msgid "DevopsAdoptionSegment|The maximum number of segments has been reached"
msgstr "" msgstr ""
msgid "DevopsAdoption|%{selectedCount} group selected (20 max)"
msgstr ""
msgid "DevopsAdoption|%{selectedCount} groups selected (20 max)"
msgstr ""
msgid "DevopsAdoption|Add a segment to get started" msgid "DevopsAdoption|Add a segment to get started"
msgstr "" msgstr ""
...@@ -9445,6 +9451,9 @@ msgstr "" ...@@ -9445,6 +9451,9 @@ msgstr ""
msgid "DevopsAdoption|Approvals" msgid "DevopsAdoption|Approvals"
msgstr "" msgstr ""
msgid "DevopsAdoption|Create new segment"
msgstr ""
msgid "DevopsAdoption|Deploys" msgid "DevopsAdoption|Deploys"
msgstr "" msgstr ""
...@@ -9457,6 +9466,15 @@ msgstr "" ...@@ -9457,6 +9466,15 @@ msgstr ""
msgid "DevopsAdoption|MRs" msgid "DevopsAdoption|MRs"
msgstr "" msgstr ""
msgid "DevopsAdoption|My segment"
msgstr ""
msgid "DevopsAdoption|Name"
msgstr ""
msgid "DevopsAdoption|New segment"
msgstr ""
msgid "DevopsAdoption|Pipelines" msgid "DevopsAdoption|Pipelines"
msgstr "" msgstr ""
......
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