Commit f7abb0d0 authored by Brandon Labuschagne's avatar Brandon Labuschagne Committed by Ezekiel Kigbo

Add devops adoption segment modal

This MR adds the devops adoption segment modal.
The modal will be used for add and editing segments.
parent 6967dd6d
......@@ -4,6 +4,7 @@ import * as Sentry from '~/sentry/wrapper';
import getGroupsQuery from '../graphql/queries/get_groups.query.graphql';
import DevopsAdoptionEmptyState from './devops_adoption_empty_state.vue';
import { DEVOPS_ADOPTION_STRINGS, MAX_REQUEST_COUNT } from '../constants';
import DevopsAdoptionSegmentModal from './devops_adoption_segment_modal.vue';
export default {
name: 'DevopsAdoptionApp',
......@@ -11,6 +12,7 @@ export default {
GlAlert,
GlLoadingIcon,
DevopsAdoptionEmptyState,
DevopsAdoptionSegmentModal,
},
i18n: {
...DEVOPS_ADOPTION_STRINGS.app,
......@@ -19,6 +21,7 @@ export default {
return {
requestCount: MAX_REQUEST_COUNT,
loadingError: false,
selectedSegmentId: null,
};
},
apollo: {
......@@ -38,12 +41,12 @@ export default {
},
},
computed: {
hasGroupData() {
return Boolean(this.groups?.nodes?.length);
},
isLoading() {
return this.$apollo.queries.groups.loading;
},
isEmpty() {
return this.groups?.nodes?.length === 0;
},
},
methods: {
handleError(error) {
......@@ -73,5 +76,12 @@ export default {
{{ $options.i18n.groupsError }}
</gl-alert>
<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>
<script>
import { GlEmptyState, GlButton } from '@gitlab/ui';
import { DEVOPS_ADOPTION_STRINGS } from '../constants';
import { GlEmptyState, GlButton, GlModalDirective } from '@gitlab/ui';
import { DEVOPS_ADOPTION_STRINGS, DEVOPS_ADOPTION_SEGMENT_MODAL_ID } from '../constants';
export default {
name: 'DevopsAdoptionEmptyState',
......@@ -9,7 +9,17 @@ export default {
GlEmptyState,
GlButton,
},
directives: {
GlModal: GlModalDirective,
},
i18n: DEVOPS_ADOPTION_STRINGS.emptyState,
devopsSegmentModalId: DEVOPS_ADOPTION_SEGMENT_MODAL_ID,
props: {
hasGroupsData: {
type: Boolean,
required: true,
},
},
};
</script>
<template>
......@@ -19,7 +29,12 @@ export default {
:svg-path="emptyStateSvgPath"
>
<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>
</gl-empty-state>
</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';
export const MAX_REQUEST_COUNT = 10;
export const DEVOPS_ADOPTION_SEGMENT_MODAL_ID = 'devopsSegmentModal';
export const DEVOPS_ADOPTION_STRINGS = {
app: {
groupsError: s__('DevopsAdoption|There was an error fetching Groups'),
......@@ -12,6 +15,14 @@ export const DEVOPS_ADOPTION_STRINGS = {
),
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 = {
......
......@@ -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 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 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 * as Sentry from '~/sentry/wrapper';
import { groupNodes, nextGroupNode, groupPageInfo } from '../mock_data';
......@@ -79,7 +80,7 @@ describe('DevopsAdoptionApp', () => {
groupsSpy = null;
});
describe('when no data is present', () => {
describe('when no group data is present', () => {
beforeEach(async () => {
groupsSpy = jest.fn().mockResolvedValueOnce({ __typename: 'Groups', nodes: [] });
const mockApollo = createMockApolloProvider({ groupsSpy });
......@@ -87,8 +88,8 @@ describe('DevopsAdoptionApp', () => {
await waitForPromises();
});
it('displays the empty state', () => {
expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(true);
it('does not render the segment modal', () => {
expect(wrapper.find(DevopsAdoptionSegmentModal).exists()).toBe(false);
});
it('does not display the loader', () => {
......@@ -96,7 +97,7 @@ describe('DevopsAdoptionApp', () => {
});
});
describe('when data is present', () => {
describe('when group data is present', () => {
beforeEach(async () => {
groupsSpy = jest.fn().mockResolvedValueOnce({ ...initialResponse, pageInfo: null });
const mockApollo = createMockApolloProvider({ groupsSpy });
......@@ -105,14 +106,14 @@ describe('DevopsAdoptionApp', () => {
await waitForPromises();
});
it('does not display the empty state', () => {
expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(false);
});
it('does not display the loader', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
});
it('renders the segment modal', () => {
expect(wrapper.find(DevopsAdoptionSegmentModal).exists()).toBe(true);
});
it('should fetch data once', () => {
expect(groupsSpy).toHaveBeenCalledTimes(1);
});
......@@ -134,14 +135,14 @@ describe('DevopsAdoptionApp', () => {
await waitForPromises();
});
it('does not display the empty state', () => {
expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(false);
});
it('does not display the loader', () => {
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', () => {
expect(groupsSpy).toHaveBeenCalledTimes(1);
});
......@@ -166,7 +167,7 @@ describe('DevopsAdoptionApp', () => {
groupsSpy = null;
});
describe('when data is present', () => {
describe('when group data is present', () => {
beforeEach(async () => {
groupsSpy = jest
.fn()
......@@ -179,14 +180,14 @@ describe('DevopsAdoptionApp', () => {
await waitForPromises();
});
it('does not display the empty state', () => {
expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(false);
});
it('does not display the loader', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
});
it('renders the segment modal', () => {
expect(wrapper.find(DevopsAdoptionSegmentModal).exists()).toBe(true);
});
it('should fetch data twice', () => {
expect(groupsSpy).toHaveBeenCalledTimes(2);
});
......@@ -211,10 +212,6 @@ describe('DevopsAdoptionApp', () => {
await waitForPromises();
});
it('does not display the empty state', () => {
expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(false);
});
it('does not display the loader', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
});
......@@ -237,21 +234,21 @@ describe('DevopsAdoptionApp', () => {
.fn()
.mockResolvedValueOnce(initialResponse)
// `fetchMore` response
.mockRejectedValueOnce(error);
.mockRejectedValue(error);
const mockApollo = createMockApolloProvider({ groupsSpy });
wrapper = createComponent({ mockApollo });
jest.spyOn(wrapper.vm.$apollo.queries.groups, 'fetchMore');
await waitForPromises();
});
it('does not display the empty state', () => {
expect(wrapper.find(DevopsAdoptionEmptyState).exists()).toBe(false);
});
it('does not display the loader', () => {
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', () => {
expect(groupsSpy).toHaveBeenCalledTimes(2);
});
......
import { shallowMount, mount } from '@vue/test-utils';
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 { 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';
......@@ -9,11 +12,16 @@ describe('DevopsAdoptionEmptyState', () => {
let wrapper;
const createComponent = (options = {}) => {
const { stubs = {} } = options;
return shallowMount(DevopsAdoptionEmptyState, {
const { stubs = {}, props = {}, func = shallowMount } = options;
return func(DevopsAdoptionEmptyState, {
provide: {
emptyStateSvgPath,
},
propsData: {
hasGroupsData: true,
...props,
},
stubs,
});
};
......@@ -41,12 +49,42 @@ describe('DevopsAdoptionEmptyState', () => {
expect(emptyState.props('description')).toBe(DEVOPS_ADOPTION_STRINGS.emptyState.description);
});
it('contains an overridden action button', () => {
wrapper = createComponent({ stubs: { GlEmptyState } });
describe('action button', () => {
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);
expect(actionButton.text()).toBe(DEVOPS_ADOPTION_STRINGS.emptyState.button);
actionButton.trigger('click');
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 ""
msgid "DevopsAdoptionSegment|The maximum number of segments has been reached"
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"
msgstr ""
......@@ -9445,6 +9451,9 @@ msgstr ""
msgid "DevopsAdoption|Approvals"
msgstr ""
msgid "DevopsAdoption|Create new segment"
msgstr ""
msgid "DevopsAdoption|Deploys"
msgstr ""
......@@ -9457,6 +9466,15 @@ msgstr ""
msgid "DevopsAdoption|MRs"
msgstr ""
msgid "DevopsAdoption|My segment"
msgstr ""
msgid "DevopsAdoption|Name"
msgstr ""
msgid "DevopsAdoption|New segment"
msgstr ""
msgid "DevopsAdoption|Pipelines"
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