Commit 82b3fcf6 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '326480-devops-adoption-add-devsecops-tabs-to-table-2' into 'master'

Resolve "[DevOps Adoption] Refactor table into section component"

See merge request gitlab-org/gitlab!61298
parents 47276482 0dfb0978
<script>
import {
GlLoadingIcon,
GlButton,
GlSprintf,
GlAlert,
GlModalDirective,
GlTooltipDirective,
} from '@gitlab/ui';
import { GlAlert } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import dateformat from 'dateformat';
import {
......@@ -15,7 +8,6 @@ import {
MAX_REQUEST_COUNT,
MAX_SEGMENTS,
DATE_TIME_FORMAT,
DEVOPS_ADOPTION_SEGMENT_MODAL_ID,
DEFAULT_POLLING_INTERVAL,
DEVOPS_ADOPTION_GROUP_LEVEL_LABEL,
DEVOPS_ADOPTION_TABLE_CONFIGURATION,
......@@ -25,24 +17,15 @@ import devopsAdoptionSegmentsQuery from '../graphql/queries/devops_adoption_segm
import getGroupsQuery from '../graphql/queries/get_groups.query.graphql';
import { addSegmentsToCache, deleteSegmentsFromCache } from '../utils/cache_updates';
import { shouldPollTableData } from '../utils/helpers';
import DevopsAdoptionEmptyState from './devops_adoption_empty_state.vue';
import DevopsAdoptionSection from './devops_adoption_section.vue';
import DevopsAdoptionSegmentModal from './devops_adoption_segment_modal.vue';
import DevopsAdoptionTable from './devops_adoption_table.vue';
export default {
name: 'DevopsAdoptionApp',
components: {
GlAlert,
GlLoadingIcon,
DevopsAdoptionEmptyState,
DevopsAdoptionSection,
DevopsAdoptionSegmentModal,
DevopsAdoptionTable,
GlButton,
GlSprintf,
},
directives: {
GlModal: GlModalDirective,
GlTooltip: GlTooltipDirective,
},
inject: {
isGroup: {
......@@ -57,14 +40,12 @@ export default {
...DEVOPS_ADOPTION_STRINGS.app,
},
maxSegments: MAX_SEGMENTS,
devopsSegmentModalId: DEVOPS_ADOPTION_SEGMENT_MODAL_ID,
devopsAdoptionTableConfiguration: DEVOPS_ADOPTION_TABLE_CONFIGURATION,
data() {
return {
isLoadingGroups: false,
isLoadingEnableGroup: false,
requestCount: 0,
selectedSegment: null,
openModal: false,
errors: {
[DEVOPS_ADOPTION_ERROR_KEYS.groups]: false,
......@@ -129,20 +110,17 @@ export default {
this.$apollo.queries.devopsAdoptionSegments.loading
);
},
modalKey() {
return this.selectedSegment?.id;
},
segmentLimitReached() {
return this.devopsAdoptionSegments.nodes?.length > this.$options.maxSegments;
},
addSegmentButtonTooltipText() {
return this.segmentLimitReached ? this.$options.i18n.tableHeader.buttonTooltip : false;
return this.devopsAdoptionSegments?.nodes?.length > this.$options.maxSegments;
},
editGroupsButtonLabel() {
return this.isGroup
? this.$options.i18n.groupLevelLabel
: this.$options.i18n.tableHeader.button;
},
canRenderModal() {
return this.hasGroupData && !this.isLoading;
},
},
created() {
this.fetchGroups();
......@@ -151,6 +129,9 @@ export default {
clearInterval(this.pollingTableData);
},
methods: {
openAddRemoveModal() {
this.$refs.addRemoveModal.openModal();
},
enableGroup() {
this.isLoadingEnableGroup = true;
......@@ -228,12 +209,6 @@ export default {
})
.catch((error) => this.handleError(DEVOPS_ADOPTION_ERROR_KEYS.groups, error));
},
setSelectedSegment(segment) {
this.selectedSegment = segment;
},
clearSelectedSegment() {
this.selectedSegment = null;
},
addSegmentsToCache(segments) {
const { cache } = this.$apollo.getClient();
......@@ -255,53 +230,28 @@ export default {
</gl-alert>
</template>
</div>
<gl-loading-icon v-else-if="isLoading" size="md" class="gl-my-5" />
<div v-else>
<devops-adoption-segment-modal
v-if="hasGroupData"
:key="modalKey"
v-if="canRenderModal"
ref="addRemoveModal"
:groups="groups.nodes"
:enabled-groups="devopsAdoptionSegments.nodes"
@segmentsAdded="addSegmentsToCache"
@segmentsRemoved="deleteSegmentsFromCache"
@trackModalOpenState="trackModalOpenState"
/>
<div v-if="hasSegmentsData" class="gl-mt-3">
<div
class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-my-3"
data-testid="tableHeader"
>
<span class="gl-text-gray-400">
<gl-sprintf :message="$options.i18n.tableHeader.text">
<template #timestamp>{{ timestamp }}</template>
</gl-sprintf>
</span>
<span
v-if="hasGroupData"
v-gl-tooltip.hover="addSegmentButtonTooltipText"
data-testid="segmentButtonWrapper"
>
<gl-button
v-gl-modal="$options.devopsSegmentModalId"
:disabled="segmentLimitReached"
@click="clearSelectedSegment"
>{{ editGroupsButtonLabel }}</gl-button
></span
>
</div>
<devops-adoption-table
:cols="$options.devopsAdoptionTableConfiguration[0].cols"
:segments="devopsAdoptionSegments.nodes"
:selected-segment="selectedSegment"
@set-selected-segment="setSelectedSegment"
@segmentsRemoved="deleteSegmentsFromCache"
@trackModalOpenState="trackModalOpenState"
/>
</div>
<devops-adoption-empty-state
v-else
:has-groups-data="hasGroupData"
@clear-selected-segment="clearSelectedSegment"
<devops-adoption-section
:is-loading="isLoading"
:has-segments-data="hasSegmentsData"
:timestamp="timestamp"
:has-group-data="hasGroupData"
:segment-limit-reached="segmentLimitReached"
:edit-groups-button-label="editGroupsButtonLabel"
:cols="$options.devopsAdoptionTableConfiguration[0].cols"
:segments="devopsAdoptionSegments"
@segmentsRemoved="deleteSegmentsFromCache"
@openAddRemoveModal="openAddRemoveModal"
/>
</div>
</template>
<script>
import { GlLoadingIcon, GlTooltipDirective, GlButton, GlSprintf } from '@gitlab/ui';
import { TABLE_HEADER_TEXT, ADD_REMOVE_BUTTON_TOOLTIP } from '../constants';
import DevopsAdoptionEmptyState from './devops_adoption_empty_state.vue';
import DevopsAdoptionTable from './devops_adoption_table.vue';
export default {
components: {
DevopsAdoptionTable,
GlLoadingIcon,
GlButton,
GlSprintf,
DevopsAdoptionEmptyState,
},
i18n: {
tableHeaderText: TABLE_HEADER_TEXT,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
isLoading: {
type: Boolean,
required: true,
},
hasSegmentsData: {
type: Boolean,
required: true,
},
timestamp: {
type: String,
required: true,
},
hasGroupData: {
type: Boolean,
required: true,
},
segmentLimitReached: {
type: Boolean,
required: true,
},
editGroupsButtonLabel: {
type: String,
required: true,
},
cols: {
type: Array,
required: true,
},
segments: {
type: Object,
required: false,
default: () => {},
},
},
computed: {
addSegmentButtonTooltipText() {
return this.segmentLimitReached ? ADD_REMOVE_BUTTON_TOOLTIP : false;
},
},
};
</script>
<template>
<gl-loading-icon v-if="isLoading" size="md" class="gl-my-5" />
<div v-else-if="hasSegmentsData" class="gl-mt-3">
<div
class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-my-3"
data-testid="tableHeader"
>
<span class="gl-text-gray-400">
<gl-sprintf :message="$options.i18n.tableHeaderText">
<template #timestamp>{{ timestamp }}</template>
</gl-sprintf>
</span>
<span
v-if="hasGroupData"
v-gl-tooltip.hover="addSegmentButtonTooltipText"
data-testid="segmentButtonWrapper"
>
<gl-button :disabled="segmentLimitReached" @click="$emit('openAddRemoveModal')">{{
editGroupsButtonLabel
}}</gl-button></span
>
</div>
<devops-adoption-table
:cols="cols"
:segments="segments.nodes"
@segmentsRemoved="$emit('segmentsRemoved', $event)"
@trackModalOpenState="$emit('trackModalOpenState', $event)"
/>
</div>
<devops-adoption-empty-state v-else :has-groups-data="hasGroupData" />
</template>
......@@ -108,6 +108,16 @@ export default {
);
},
},
watch: {
enabledGroups(newValues) {
if (!this.loading) {
this.checkboxValuesFromEnabledGroups = newValues.map((group) =>
getIdFromGraphQLId(group.namespace.id),
);
this.checkboxValues = this.checkboxValuesFromEnabledGroups;
}
},
},
methods: {
async saveChanges() {
await this.deleteMissingGroups();
......@@ -201,6 +211,9 @@ export default {
clearErrors() {
this.errors = [];
},
openModal() {
this.$refs.modal.show();
},
closeModal() {
this.$refs.modal.hide();
},
......
......@@ -81,11 +81,6 @@ export default {
type: Array,
required: true,
},
selectedSegment: {
type: Object,
required: false,
default: null,
},
cols: {
type: Array,
required: true,
......@@ -95,6 +90,7 @@ export default {
return {
sortBy: NAME_HEADER,
sortDesc: false,
selectedSegment: null,
};
},
computed: {
......@@ -120,7 +116,7 @@ export default {
},
methods: {
setSelectedSegment(segment) {
this.$emit('set-selected-segment', segment);
this.selectedSegment = segment;
},
headerSlotName(key) {
return `head(${key})`;
......
......@@ -18,6 +18,17 @@ export const DEVOPS_ADOPTION_ERROR_KEYS = {
addSegment: 'addSegmentsError',
};
export const TABLE_HEADER_TEXT = s__(
'DevopsAdoption|Feature adoption is based on usage in the current calendar month. Last updated: %{timestamp}.',
);
export const ADD_REMOVE_BUTTON_TOOLTIP = sprintf(
s__('DevopsAdoption|Maximum %{maxSegments} groups allowed'),
{
maxSegments: MAX_SEGMENTS,
},
);
export const DEVOPS_ADOPTION_GROUP_LEVEL_LABEL = s__('DevopsAdoption|Add/remove sub-groups');
export const DEVOPS_ADOPTION_TABLE_REMOVE_BUTTON_DISABLED = s__(
......@@ -36,13 +47,7 @@ export const DEVOPS_ADOPTION_STRINGS = {
'DevopsAdoption|There was an error enabling the current group. Please refresh the page.',
),
tableHeader: {
text: s__(
'DevopsAdoption|Feature adoption is based on usage in the current calendar month. Last updated: %{timestamp}.',
),
button: s__('DevopsAdoption|Add/remove groups'),
buttonTooltip: sprintf(s__('DevopsAdoption|Maximum %{maxSegments} groups allowed'), {
maxSegments: MAX_SEGMENTS,
}),
},
},
emptyState: {
......
import { GlLoadingIcon, GlButton, GlSprintf } from '@gitlab/ui';
import { getByText } from '@testing-library/dom';
import { shallowMount } from '@vue/test-utils';
import DevopsAdoptionEmptyState from 'ee/analytics/devops_report/devops_adoption/components/devops_adoption_empty_state.vue';
import DevopsAdoptionSection from 'ee/analytics/devops_report/devops_adoption/components/devops_adoption_section.vue';
import DevopsAdoptionTable from 'ee/analytics/devops_report/devops_adoption/components/devops_adoption_table.vue';
import { DEVOPS_ADOPTION_TABLE_CONFIGURATION } from 'ee/analytics/devops_report/devops_adoption/constants';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { devopsAdoptionSegmentsData } from '../mock_data';
describe('DevopsAdoptionSection', () => {
let wrapper;
const createComponent = (props) => {
wrapper = extendedWrapper(
shallowMount(DevopsAdoptionSection, {
propsData: {
isLoading: false,
hasSegmentsData: true,
timestamp: '2020-10-31 23:59',
hasGroupData: true,
segmentLimitReached: false,
editGroupsButtonLabel: 'Add/Remove groups',
cols: DEVOPS_ADOPTION_TABLE_CONFIGURATION[0].cols,
segments: devopsAdoptionSegmentsData,
addSegmentButtonTooltipText: 'Maximum 30 groups allowed',
...props,
},
directives: {
GlTooltip: createMockDirective(),
},
stubs: {
GlSprintf,
},
}),
);
};
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findTableHeaderSection = () => wrapper.findByTestId('tableHeader');
const findTable = () => wrapper.findComponent(DevopsAdoptionTable);
const findEmptyState = () => wrapper.findComponent(DevopsAdoptionEmptyState);
const findAddEditButton = () => wrapper.findComponent(GlButton);
const findAddRemoveButtonWrapper = () => wrapper.findByTestId('segmentButtonWrapper');
describe('while loading', () => {
beforeEach(() => {
createComponent({ isLoading: true });
});
it('displays a loading icon', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
it('does not display the table header section', () => {
expect(findTableHeaderSection().exists()).toBe(false);
});
it('does not display the table', () => {
expect(findTable().exists()).toBe(false);
});
});
describe('with segment data', () => {
beforeEach(() => {
createComponent();
});
it('does not display a loading icon', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
it('does not display an empty state', () => {
expect(findEmptyState().exists()).toBe(false);
});
it('displays the table header section', () => {
expect(findTableHeaderSection().exists()).toBe(true);
});
it('displays the table', () => {
expect(findTableHeaderSection().exists()).toBe(true);
});
});
describe('with no segment data', () => {
beforeEach(() => {
createComponent({ hasSegmentsData: false });
});
it('displays an empty state', () => {
expect(findEmptyState().exists()).toBe(true);
});
});
describe('table header section', () => {
it('displays the header message with timestamp', () => {
createComponent();
const text =
'Feature adoption is based on usage in the current calendar month. Last updated: 2020-10-31 23:59.';
expect(getByText(wrapper.element, text)).not.toBeNull();
});
describe('with group data', () => {
it('displays the edit groups button', () => {
createComponent();
expect(findAddEditButton().exists()).toBe(true);
});
describe('edit groups button', () => {
describe('segment limit reached', () => {
beforeEach(() => {
createComponent({ segmentLimitReached: true });
});
it('is disabled', () => {
expect(findAddEditButton().props('disabled')).toBe(true);
});
it('displays a tooltip', () => {
const tooltip = getBinding(findAddRemoveButtonWrapper().element, 'gl-tooltip');
expect(tooltip).toBeDefined();
expect(tooltip.value).toBe('Maximum 30 groups allowed');
});
});
describe('segment limit not reached', () => {
beforeEach(() => {
createComponent();
});
it('is enabled', () => {
expect(findAddEditButton().props('disabled')).toBe(false);
});
it('does not display a tooltip', () => {
const tooltip = getBinding(findAddRemoveButtonWrapper().element, 'gl-tooltip');
expect(tooltip.value).toBe(false);
});
it('emits openAddRemoveModal when clicked', () => {
expect(wrapper.emitted('openAddRemoveModal')).toBeUndefined();
findAddEditButton().vm.$emit('click');
expect(wrapper.emitted('openAddRemoveModal')).toEqual([[]]);
});
});
});
});
describe('with no group data', () => {
beforeEach(() => {
createComponent({ hasGroupData: false });
});
it('does not display the edit groups button', () => {
expect(findAddEditButton().exists()).toBe(false);
});
});
});
});
......@@ -22,7 +22,6 @@ describe('DevopsAdoptionTable', () => {
propsData: {
cols: DEVOPS_ADOPTION_TABLE_CONFIGURATION[0].cols,
segments: devopsAdoptionSegmentsData.nodes,
selectedSegment: devopsAdoptionSegmentsData.nodes[0],
},
provide,
directives: {
......@@ -37,7 +36,6 @@ describe('DevopsAdoptionTable', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findTable = () => wrapper.find(GlTable);
......@@ -198,6 +196,8 @@ describe('DevopsAdoptionTable', () => {
describe('delete modal integration', () => {
beforeEach(() => {
createComponent();
wrapper.setData({ selectedSegment: devopsAdoptionSegmentsData.nodes[0] });
});
it('re emits trackModalOpenState with the given value', async () => {
......
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