Commit f3f97c34 authored by Brandon Labuschagne's avatar Brandon Labuschagne Committed by Paul Slaughter

Add DevOps Adoption Overview table

This commit intriduces a table into the
overview tab of the DevOps Adoption feature.
https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68447

Changelog: added
EE: true
parent a762ccc6
......@@ -284,6 +284,7 @@ export default {
:loading="isLoadingAdoptionData"
:data="devopsAdoptionEnabledNamespaces"
:timestamp="timestamp"
@enabledNamespacesRemoved="deleteEnabledNamespacesFromCache"
/>
</gl-tab>
......
......@@ -7,7 +7,6 @@ import {
I18N_DELETE_MODAL_CANCEL,
I18N_DELETE_MODAL_CONFIRM,
I18N_DELETE_MODAL_ERROR,
DELETE_MODAL_ID,
} from '../constants';
import disableDevopsAdoptionNamespaceMutation from '../graphql/mutations/disable_devops_adoption_namespace.mutation.graphql';
......@@ -21,8 +20,11 @@ export default {
confirm: I18N_DELETE_MODAL_CONFIRM,
error: I18N_DELETE_MODAL_ERROR,
},
deleteModalId: DELETE_MODAL_ID,
props: {
modalId: {
required: true,
type: String,
},
namespace: {
type: Object,
required: true,
......@@ -103,7 +105,7 @@ export default {
<template>
<gl-modal
ref="modal"
:modal-id="$options.deleteModalId"
:modal-id="modalId"
size="sm"
:action-primary="primaryOptions"
:action-cancel="cancelOptions"
......
......@@ -7,11 +7,13 @@ import {
I18N_TABLE_HEADER_TEXT,
} from '../constants';
import DevopsAdoptionOverviewCard from './devops_adoption_overview_card.vue';
import DevopsAdoptionOverviewTable from './devops_adoption_overview_table.vue';
export default {
name: 'DevopsAdoptionOverview',
components: {
DevopsAdoptionOverviewCard,
DevopsAdoptionOverviewTable,
GlLoadingIcon,
},
props: {
......@@ -79,5 +81,6 @@ export default {
:display-meta="item.displayMeta"
/>
</div>
<devops-adoption-overview-table :data="data" v-on="$listeners" />
</div>
</template>
<script>
import {
GlTable,
GlButton,
GlModalDirective,
GlTooltipDirective,
GlIcon,
GlBadge,
GlProgressBar,
} from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { formatNumber } from '~/locale';
import {
TABLE_TEST_IDS_HEADERS,
I18N_GROUP_COL_LABEL,
I18N_TABLE_REMOVE_BUTTON_DISABLED,
I18N_TABLE_REMOVE_BUTTON,
I18N_OVERVIEW_TABLE_HEADER_GROUP,
I18N_OVERVIEW_TABLE_HEADER_SUBGROUP,
TABLE_TEST_IDS_ACTIONS,
TABLE_TEST_IDS_NAMESPACE,
DEVOPS_ADOPTION_TABLE_CONFIGURATION,
} from '../constants';
import DevopsAdoptionDeleteModal from './devops_adoption_delete_modal.vue';
const thClass = ['gl-bg-white!', 'gl-text-gray-400'];
const fieldOptions = {
thClass,
thAttr: { 'data-testid': TABLE_TEST_IDS_HEADERS },
};
export default {
name: 'DevopsAdoptionOverviewTable',
components: {
GlTable,
GlButton,
GlIcon,
GlBadge,
GlProgressBar,
DevopsAdoptionDeleteModal,
},
directives: {
GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
},
inject: {
groupGid: {
default: null,
},
},
testids: {
ACTIONS: TABLE_TEST_IDS_ACTIONS,
NAMESPACE: TABLE_TEST_IDS_NAMESPACE,
},
cols: DEVOPS_ADOPTION_TABLE_CONFIGURATION,
props: {
data: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
selectedNamespace: null,
deleteModalId: uniqueId('delete-modal-'),
};
},
computed: {
tableHeader() {
return this.groupGid ? I18N_OVERVIEW_TABLE_HEADER_SUBGROUP : I18N_OVERVIEW_TABLE_HEADER_GROUP;
},
tableHeaderFields() {
return [
{
key: 'name',
label: I18N_GROUP_COL_LABEL,
...fieldOptions,
thClass: ['gl-w-grid-size-30', ...thClass],
tdClass: 'header-cell da-table-mobile-header',
},
...DEVOPS_ADOPTION_TABLE_CONFIGURATION.map((item) => ({
...item,
...fieldOptions,
label: item.title,
tdClass: 'da-table-mobile-header',
})),
{
key: 'actions',
tdClass: 'actions-cell',
...fieldOptions,
},
];
},
formattedData() {
return this.data.nodes.map((group) => ({
group,
adoption: DEVOPS_ADOPTION_TABLE_CONFIGURATION.map((item) => {
const total = item.cols.length;
const adopted = item.cols.filter(
(col) => group.latestSnapshot && Boolean(group.latestSnapshot[col.key]),
).length;
const ratio = total ? adopted / total : 1;
return {
[item.key]: {
total,
adopted,
percent: formatNumber(ratio, { style: 'percent' }),
},
};
}).reduce((values, formatted) => ({ ...values, ...formatted }), {}),
}));
},
},
methods: {
setSelectedNamespace(namespace) {
this.selectedNamespace = namespace;
},
isCurrentGroup(item) {
return item.namespace?.id === this.groupGid;
},
getDeleteButtonTooltipText(item) {
return this.isCurrentGroup(item)
? I18N_TABLE_REMOVE_BUTTON_DISABLED
: I18N_TABLE_REMOVE_BUTTON;
},
headerSlotName(key) {
return `head(${key})`;
},
cellSlotName(key) {
return `cell(${key})`;
},
},
};
</script>
<template>
<div>
<h4>{{ tableHeader }}</h4>
<gl-table
:fields="tableHeaderFields"
:items="formattedData"
thead-class="gl-border-t-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-100"
stacked="md"
>
<template v-for="header in tableHeaderFields" #[headerSlotName(header.key)]>
{{ header.label }}
</template>
<template #cell(name)="{ item }">
<div :data-testid="$options.testids.NAMESPACE">
<span v-if="item.group.latestSnapshot" class="gl-font-weight-bold">{{
item.group.namespace.fullName
}}</span>
<template v-else>
<span class="gl-text-gray-400">{{ item.group.namespace.fullName }}</span>
<gl-icon name="hourglass" class="gl-text-gray-400" />
</template>
<gl-badge v-if="isCurrentGroup(item.group)" class="gl-ml-1" variant="info">{{
__('This group')
}}</gl-badge>
</div>
</template>
<template v-for="col in $options.cols" #[cellSlotName(col.key)]="{ item }">
<div
v-if="item.group.latestSnapshot"
:key="col.key"
:data-testid="col.testId"
class="gl-display-flex gl-align-items-center gl-justify-content-end gl-justify-content-md-start"
>
<span class="gl-w-7 gl-mr-3">{{ item.adoption[col.key].percent }}</span>
<gl-progress-bar
:value="item.adoption[col.key].adopted"
:max="item.adoption[col.key].total"
class="gl-w-half"
:variant="col.variant"
/>
</div>
</template>
<template #cell(actions)="{ item }">
<span
v-gl-tooltip.hover="getDeleteButtonTooltipText(item.group)"
:data-testid="$options.testids.ACTIONS"
>
<gl-button
v-gl-modal="deleteModalId"
:disabled="isCurrentGroup(item.group)"
category="tertiary"
icon="remove"
:aria-label="getDeleteButtonTooltipText(item.group)"
@click="setSelectedNamespace(item.group)"
/>
</span>
</template>
</gl-table>
<devops-adoption-delete-modal
v-if="selectedNamespace"
:modal-id="deleteModalId"
:namespace="selectedNamespace"
@enabledNamespacesRemoved="$emit('enabledNamespacesRemoved', $event)"
@trackModalOpenState="$emit('trackModalOpenState', $event)"
/>
</div>
</template>
......@@ -7,6 +7,7 @@ import {
GlIcon,
GlBadge,
} from '@gitlab/ui';
import { uniqueId } from 'lodash';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import {
TABLE_TEST_IDS_HEADERS,
......@@ -14,7 +15,6 @@ import {
TABLE_TEST_IDS_ACTIONS,
TABLE_TEST_IDS_LOCAL_STORAGE_SORT_BY,
TABLE_TEST_IDS_LOCAL_STORAGE_SORT_DESC,
DELETE_MODAL_ID,
TABLE_SORT_BY_STORAGE_KEY,
TABLE_SORT_DESC_STORAGE_KEY,
I18N_TABLE_REMOVE_BUTTON,
......@@ -74,7 +74,6 @@ export default {
removeButtonDisabled: I18N_TABLE_REMOVE_BUTTON_DISABLED,
removeButton: I18N_TABLE_REMOVE_BUTTON,
},
deleteModalId: DELETE_MODAL_ID,
testids: {
NAMESPACE: TABLE_TEST_IDS_NAMESPACE,
ACTIONS: TABLE_TEST_IDS_ACTIONS,
......@@ -98,6 +97,7 @@ export default {
sortBy: NAME_HEADER,
sortDesc: false,
selectedNamespace: null,
deleteModalId: uniqueId('delete-modal-'),
};
},
computed: {
......@@ -206,7 +206,7 @@ export default {
:data-testid="$options.testids.ACTIONS"
>
<gl-button
v-gl-modal="$options.deleteModalId"
v-gl-modal="deleteModalId"
:disabled="isCurrentGroup(item)"
category="tertiary"
icon="remove"
......@@ -218,6 +218,7 @@ export default {
</gl-table>
<devops-adoption-delete-modal
v-if="selectedNamespace"
:modal-id="deleteModalId"
:namespace="selectedNamespace"
@enabledNamespacesRemoved="$emit('enabledNamespacesRemoved', $event)"
@trackModalOpenState="$emit('trackModalOpenState', $event)"
......
......@@ -5,7 +5,6 @@ export const PER_PAGE = 20;
export const DEBOUNCE_DELAY = 500;
export const PROGRESS_BAR_HEIGHT = '8px';
export const DATE_TIME_FORMAT = 'yyyy-mm-dd HH:MM';
export const DELETE_MODAL_ID = 'devopsDeleteModal';
export const TABLE_TEST_IDS_HEADERS = 'header';
export const TABLE_TEST_IDS_NAMESPACE = 'namespaceCol';
......@@ -34,6 +33,9 @@ export const I18N_TABLE_REMOVE_BUTTON_DISABLED = s__(
'DevopsAdoption|You cannot remove the group you are currently in.',
);
export const I18N_OVERVIEW_TABLE_HEADER_GROUP = s__('DevopsAdoption|Adoption by group');
export const I18N_OVERVIEW_TABLE_HEADER_SUBGROUP = s__('DevopsAdoption|Adoption by subgroup');
export const I18N_GROUP_DROPDOWN_TEXT = s__('DevopsAdoption|Add or remove subgroups');
export const I18N_GROUP_DROPDOWN_HEADER = s__('DevopsAdoption|Edit subgroups');
export const I18N_ADMIN_DROPDOWN_TEXT = s__('DevopsAdoption|Add or remove groups');
......@@ -80,9 +82,11 @@ export const DEVOPS_ADOPTION_OVERALL_CONFIGURATION = {
export const DEVOPS_ADOPTION_TABLE_CONFIGURATION = [
{
title: s__('DevopsAdoption|Dev'),
key: 'dev',
tab: 'dev',
icon: 'code',
variant: 'warning',
testId: 'devCol',
cols: [
{
key: 'mergeRequestApproved',
......@@ -113,8 +117,10 @@ export const DEVOPS_ADOPTION_TABLE_CONFIGURATION = [
{
title: s__('DevopsAdoption|Sec'),
tab: 'sec',
key: 'sec',
icon: 'shield',
variant: 'info',
testId: 'secCol',
cols: [
{
key: 'dastEnabledCount',
......@@ -141,8 +147,10 @@ export const DEVOPS_ADOPTION_TABLE_CONFIGURATION = [
{
title: s__('DevopsAdoption|Ops'),
tab: 'ops',
key: 'ops',
icon: 'rocket',
variant: 'success',
testId: 'opsCol',
cols: [
{
key: 'deploySucceeded',
......
......@@ -39,13 +39,13 @@
}
}
@include media-breakpoint-up(sm) {
@include media-breakpoint-up(md) {
.actions-cell {
width: $gl-spacing-scale-6;
}
}
@include media-breakpoint-down(sm) {
@include media-breakpoint-down(md) {
.actions-cell {
div {
width: 100% !important;
......@@ -53,9 +53,22 @@
}
}
.actions-cell::before {
.header-cell {
div {
width: 100% !important;
text-align: left !important;
padding: 0 !important;
}
}
.actions-cell::before,
.header-cell::before {
@include gl-display-none;
}
.da-table-mobile-header::before {
@include gl-text-gray-400;
}
}
.progress-bar {
......
......@@ -4,7 +4,6 @@ import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import DevopsAdoptionDeleteModal from 'ee/analytics/devops_report/devops_adoption/components/devops_adoption_delete_modal.vue';
import { DELETE_MODAL_ID } from 'ee/analytics/devops_report/devops_adoption/constants';
import disableDevopsAdoptionNamespaceMutation from 'ee/analytics/devops_report/devops_adoption/graphql/mutations/disable_devops_adoption_namespace.mutation.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
......@@ -35,6 +34,8 @@ const mutateWithDataErrors = jest.fn().mockResolvedValue({
const mutateLoading = jest.fn().mockResolvedValue(new Promise(() => {}));
const mutateWithErrors = jest.fn().mockRejectedValue(genericDeleteErrorMessage);
const modalId = 'some-generated-id';
describe('DevopsAdoptionDeleteModal', () => {
let wrapper;
......@@ -47,6 +48,7 @@ describe('DevopsAdoptionDeleteModal', () => {
localVue,
apolloProvider: mockApollo,
propsData: {
modalId,
namespace: devopsAdoptionNamespaceData.nodes[0],
...props,
},
......@@ -72,7 +74,7 @@ describe('DevopsAdoptionDeleteModal', () => {
const modal = findModal();
expect(modal.exists()).toBe(true);
expect(modal.props('modalId')).toBe(DELETE_MODAL_ID);
expect(modal.props('modalId')).toBe(modalId);
});
it('displays the confirmation message', () => {
......
import { GlLoadingIcon } from '@gitlab/ui';
import DevopsAdoptionOverview from 'ee/analytics/devops_report/devops_adoption/components/devops_adoption_overview.vue';
import DevopsAdoptionOverviewCard from 'ee/analytics/devops_report/devops_adoption/components/devops_adoption_overview_card.vue';
import DevopsAdoptionOverviewTable from 'ee/analytics/devops_report/devops_adoption/components/devops_adoption_overview_table.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { devopsAdoptionNamespaceData, overallAdoptionData } from '../mock_data';
......@@ -45,6 +46,10 @@ describe('DevopsAdoptionOverview', () => {
overallAdoptionData,
);
});
it('displays the overview table', () => {
expect(wrapper.findComponent(DevopsAdoptionOverviewTable).exists()).toBe(true);
});
});
});
......
import { GlButton, GlIcon, GlBadge, GlProgressBar } from '@gitlab/ui';
import DevopsAdoptionDeleteModal from 'ee/analytics/devops_report/devops_adoption/components/devops_adoption_delete_modal.vue';
import DevopsAdoptionOverviewTable from 'ee/analytics/devops_report/devops_adoption/components/devops_adoption_overview_table.vue';
import {
TABLE_TEST_IDS_NAMESPACE,
TABLE_TEST_IDS_ACTIONS,
TABLE_TEST_IDS_HEADERS,
DEVOPS_ADOPTION_TABLE_CONFIGURATION,
} from 'ee/analytics/devops_report/devops_adoption/constants';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { devopsAdoptionNamespaceData } from '../mock_data';
const DELETE_MODAL_ID = 'delete-modal-test-unique-id';
jest.mock('lodash/uniqueId', () => (x) => `${x}test-unique-id`);
describe('DevopsAdoptionOverviewTable', () => {
let wrapper;
const createComponent = (options = {}) => {
const { provide = {} } = options;
wrapper = mountExtended(DevopsAdoptionOverviewTable, {
propsData: {
data: devopsAdoptionNamespaceData,
},
provide,
directives: {
GlTooltip: createMockDirective(),
GlModal: createMockDirective(),
},
});
};
afterEach(() => {
wrapper.destroy();
});
const findCol = (testId) => wrapper.findByTestId(testId);
const findColRowChild = (col, row, child) => wrapper.findAllByTestId(col).at(row).find(child);
const findColSubComponent = (colTestId, childComponent) =>
findCol(colTestId).find(childComponent);
const findDeleteModal = () => wrapper.findComponent(DevopsAdoptionDeleteModal);
describe('table headings', () => {
beforeEach(() => {
createComponent();
});
it('displays the table headings', () => {
const headerTexts = wrapper
.findAllByTestId(TABLE_TEST_IDS_HEADERS)
.wrappers.map((x) => x.text());
expect(headerTexts).toEqual(['Group', 'Dev', 'Sec', 'Ops', '']);
});
});
describe('table fields', () => {
describe('enabled namespace name', () => {
it('displays the correct name', () => {
createComponent();
expect(findCol(TABLE_TEST_IDS_NAMESPACE).text()).toBe('Group 1');
});
describe('"This group" badge', () => {
const thisGroupGid = devopsAdoptionNamespaceData.nodes[0].namespace.id;
it.each`
scenario | expected | provide
${'is not shown by default'} | ${false} | ${null}
${'is not shown for other groups'} | ${false} | ${{ groupGid: 'anotherGroupGid' }}
${'is shown for the current group'} | ${true} | ${{ groupGid: thisGroupGid }}
`('$scenario', ({ expected, provide }) => {
createComponent({ provide });
const badge = findColSubComponent(TABLE_TEST_IDS_NAMESPACE, GlBadge);
expect(badge.exists()).toBe(expected);
});
});
describe('pending state (no snapshot data available)', () => {
beforeEach(() => {
createComponent();
});
it('grays the text out', () => {
const name = findColRowChild(TABLE_TEST_IDS_NAMESPACE, 1, 'span');
expect(name.classes()).toStrictEqual(['gl-text-gray-400']);
});
describe('hourglass icon', () => {
let icon;
beforeEach(() => {
icon = findColRowChild(TABLE_TEST_IDS_NAMESPACE, 1, GlIcon);
});
it('displays the icon', () => {
expect(icon.exists()).toBe(true);
expect(icon.props('name')).toBe('hourglass');
});
});
});
});
const testCols = DEVOPS_ADOPTION_TABLE_CONFIGURATION.map((col) => [col.title, col.testId]);
it.each(testCols)('displays the progress bar for %s', (title, testId) => {
createComponent();
const progressBar = findColSubComponent(testId, GlProgressBar);
expect(progressBar.exists()).toBe(true);
});
describe.each`
scenario | tooltipText | provide | disabled
${'not active group'} | ${'Remove Group from the table.'} | ${{}} | ${false}
${'active group'} | ${'You cannot remove the group you are currently in.'} | ${{ groupGid: devopsAdoptionNamespaceData.nodes[0].namespace.id }} | ${true}
`('actions column when $scenario', ({ tooltipText, provide, disabled }) => {
beforeEach(() => {
createComponent({ provide });
});
it('displays the actions icon', () => {
const button = findColSubComponent(TABLE_TEST_IDS_ACTIONS, GlButton);
const buttonModalId = getBinding(button.element, 'gl-modal').value;
expect(button.exists()).toBe(true);
expect(button.props('disabled')).toBe(disabled);
expect(button.props('icon')).toBe('remove');
expect(button.props('category')).toBe('tertiary');
expect(buttonModalId).toBe(DELETE_MODAL_ID);
});
it('wraps the icon in an element with a tooltip', () => {
const iconWrapper = findCol(TABLE_TEST_IDS_ACTIONS);
const tooltip = getBinding(iconWrapper.element, 'gl-tooltip');
expect(iconWrapper.exists()).toBe(true);
expect(tooltip).toBeDefined();
expect(tooltip.value).toBe(tooltipText);
});
});
});
describe('when delete button is clicked', () => {
beforeEach(async () => {
createComponent();
const deleteButton = findColSubComponent(TABLE_TEST_IDS_ACTIONS, GlButton);
deleteButton.vm.$emit('click');
await deleteButton.vm.$nextTick();
});
it('renders delete modal', () => {
expect(findDeleteModal().props()).toEqual({
modalId: DELETE_MODAL_ID,
namespace: expect.objectContaining(devopsAdoptionNamespaceData.nodes[0]),
});
});
it.each(['trackModalOpenState', 'enabledNamespacesRemoved'])(
're emits %s with the given value',
(event) => {
expect(wrapper.emitted(event)).toBeFalsy();
const arg = {};
findDeleteModal().vm.$emit(event, arg);
expect(wrapper.emitted(event)).toStrictEqual([[arg]]);
},
);
});
});
......@@ -11496,6 +11496,12 @@ msgstr ""
msgid "DevopsAdoption|Adopted"
msgstr ""
msgid "DevopsAdoption|Adoption by group"
msgstr ""
msgid "DevopsAdoption|Adoption by subgroup"
msgstr ""
msgid "DevopsAdoption|An error occurred while removing the group. Please try again."
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