Commit 2d3fc0b4 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '222964-collapse-button' into 'master'

Add expandable view  to Terraform MR widget

Closes #222964

See merge request gitlab-org/gitlab!34879
parents 0d276f9d e043aa91
<script>
import { __ } from '~/locale';
import { GlButton, GlCollapse, GlIcon } from '@gitlab/ui';
/**
* Renders header section with icon and expand button
* Renders expanable content section with grey background
*/
export default {
name: 'MrWidgetExpanableSection',
components: {
GlButton,
GlCollapse,
GlIcon,
},
props: {
iconName: {
type: String,
required: false,
default: 'status_warning',
},
},
data() {
return {
contentIsVisible: false,
};
},
computed: {
collapseButtonText() {
if (this.contentIsVisible) {
return __('Collapse');
}
return __('Expand');
},
},
methods: {
updateContentVisibility() {
this.contentIsVisible = !this.contentIsVisible;
},
},
};
</script>
<template>
<div>
<div class="mr-widget-body gl-display-flex">
<span
class="gl-display-flex gl-align-items-center gl-justify-content-center append-right-default gl-align-self-start gl-mt-1"
>
<gl-icon :name="iconName" :size="24" />
</span>
<div class="gl-display-flex gl-flex-fill-1 gl-flex-direction-column gl-md-flex-direction-row">
<slot name="header"></slot>
<div>
<gl-button @click="updateContentVisibility">
{{ collapseButtonText }}
</gl-button>
</div>
</div>
</div>
<gl-collapse
:visible="contentIsVisible"
class="gl-bg-gray-10 gl-border-t-solid gl-border-gray-100 gl-border-1"
>
<slot name="content"></slot>
</gl-collapse>
</div>
</template>
<script>
import { GlSkeletonLoading } from '@gitlab/ui';
import { n__ } from '~/locale';
import { GlSkeletonLoading, GlSprintf } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import MrWidgetExpanableSection from '../mr_widget_expandable_section.vue';
import Poll from '~/lib/utils/poll';
import TerraformPlan from './terraform_plan.vue';
......@@ -8,6 +10,8 @@ export default {
name: 'MRWidgetTerraformContainer',
components: {
GlSkeletonLoading,
GlSprintf,
MrWidgetExpanableSection,
TerraformPlan,
},
props: {
......@@ -19,10 +23,43 @@ export default {
data() {
return {
loading: true,
plans: {},
plansObject: {},
poll: null,
};
},
computed: {
inValidPlanCountText() {
if (this.numberOfInvalidPlans === 0) {
return null;
}
return n__(
'Terraform|%{number} Terraform report failed to generate',
'Terraform|%{number} Terraform reports failed to generate',
this.numberOfInvalidPlans,
);
},
numberOfInvalidPlans() {
return Object.values(this.plansObject).filter(plan => plan.tf_report_error).length;
},
numberOfPlans() {
return Object.keys(this.plansObject).length;
},
numberOfValidPlans() {
return this.numberOfPlans - this.numberOfInvalidPlans;
},
validPlanCountText() {
if (this.numberOfValidPlans === 0) {
return null;
}
return n__(
'Terraform|%{number} Terraform report was generated in your pipelines',
'Terraform|%{number} Terraform reports were generated in your pipelines',
this.numberOfValidPlans,
);
},
},
created() {
this.fetchPlans();
},
......@@ -40,15 +77,15 @@ export default {
data: this.endpoint,
method: 'fetchPlans',
successCallback: ({ data }) => {
this.plans = data;
this.plansObject = data;
if (Object.keys(this.plans).length) {
if (this.numberOfPlans > 0) {
this.loading = false;
this.poll.stop();
}
},
errorCallback: () => {
this.plans = { bad_plan: {} };
this.plansObject = { bad_plan: { tf_report_error: 'api_error' } };
this.loading = false;
this.poll.stop();
},
......@@ -62,16 +99,42 @@ export default {
<template>
<section class="mr-widget-section">
<div v-if="loading" class="mr-widget-body media">
<div v-if="loading" class="mr-widget-body">
<gl-skeleton-loading />
</div>
<terraform-plan
v-for="(plan, key) in plans"
v-else
:key="key"
:plan="plan"
class="mr-widget-body media"
/>
<mr-widget-expanable-section v-else>
<template #header>
<div
data-testid="terraform-header-text"
class="gl-flex-fill-1 gl-display-flex gl-flex-direction-column"
>
<p v-if="validPlanCountText" class="gl-m-0">
<gl-sprintf :message="validPlanCountText">
<template #number>
<strong>{{ numberOfValidPlans }}</strong>
</template>
</gl-sprintf>
</p>
<p v-if="inValidPlanCountText" class="gl-m-0">
<gl-sprintf :message="inValidPlanCountText">
<template #number>
<strong>{{ numberOfInvalidPlans }}</strong>
</template>
</gl-sprintf>
</p>
</div>
</template>
<template #content>
<terraform-plan
v-for="(plan, key) in plansObject"
:key="key"
:plan="plan"
class="mr-widget-body"
/>
</template>
</mr-widget-expanable-section>
</section>
</template>
......@@ -25,21 +25,28 @@ export default {
deleteNum() {
return Number(this.plan.delete);
},
iconType() {
return this.validPlanValues ? 'doc-changes' : 'warning';
},
reportChangeText() {
if (this.validPlanValues) {
return __(
'Reported Resource Changes: %{addNum} to add, %{changeNum} to change, %{deleteNum} to delete',
'Terraform|Reported Resource Changes: %{addNum} to add, %{changeNum} to change, %{deleteNum} to delete',
);
}
return __('Generating the report caused an error.');
return __('Terraform|Generating the report caused an error.');
},
reportHeaderText() {
if (this.plan.job_name) {
return __('The Terraform report %{name} was generated in your pipelines.');
if (this.validPlanValues) {
return this.plan.job_name
? __('Terraform|The Terraform report %{name} was generated in your pipelines.')
: __('Terraform|A Terraform report was generated in your pipelines.');
}
return __('A Terraform report was generated in your pipelines.');
return this.plan.job_name
? __('Terraform|The Terraform report %{name} failed to generate.')
: __('Terraform|A Terraform report failed to generate.');
},
validPlanValues() {
return this.addNum + this.changeNum + this.deleteNum >= 0;
......@@ -53,11 +60,11 @@ export default {
<span
class="gl-display-flex gl-align-items-center gl-justify-content-center append-right-default gl-align-self-start gl-mt-1"
>
<gl-icon name="status_warning" :size="24" />
<gl-icon :name="iconType" :size="18" data-testid="change-type-icon" />
</span>
<div class="gl-display-flex gl-flex-fill-1 gl-flex-direction-column flex-md-row">
<div class="terraform-mr-plan-text normal gl-display-flex gl-flex-direction-column">
<div class="gl-flex-fill-1 gl-display-flex gl-flex-direction-column">
<p class="gl-m-0 gl-pr-1">
<gl-sprintf :message="reportHeaderText">
<template #name>
......@@ -88,10 +95,11 @@ export default {
v-if="plan.job_path"
:href="plan.job_path"
target="_blank"
data-testid="terraform-report-link"
data-track-event="click_terraform_mr_plan_button"
data-track-label="mr_widget_terraform_mr_plan_button"
data-track-property="terraform_mr_plan_button"
class="btn btn-sm js-terraform-report-link"
class="btn btn-sm"
rel="noopener"
>
{{ __('View full log') }}
......
---
title: Add expand/collapse view to Terraform MR widget
merge_request: 34879
author:
type: changed
......@@ -999,9 +999,6 @@ msgstr ""
msgid "A Let's Encrypt account will be configured for this GitLab installation using your email address. You will receive emails to warn of expiring certificates."
msgstr ""
msgid "A Terraform report was generated in your pipelines."
msgstr ""
msgid "A basic page and serverless function that uses AWS Lambda, AWS API Gateway, and GitLab Pages"
msgstr ""
......@@ -10274,9 +10271,6 @@ msgstr ""
msgid "Generate new export"
msgstr ""
msgid "Generating the report caused an error."
msgstr ""
msgid "Geo"
msgstr ""
......@@ -19100,9 +19094,6 @@ msgstr ""
msgid "Reported %{timeAgo} by %{reportedBy}"
msgstr ""
msgid "Reported Resource Changes: %{addNum} to add, %{changeNum} to change, %{deleteNum} to delete"
msgstr ""
msgid "Reporter"
msgstr ""
......@@ -22363,6 +22354,34 @@ msgstr ""
msgid "Terms of Service and Privacy Policy"
msgstr ""
msgid "Terraform|%{number} Terraform report failed to generate"
msgid_plural "Terraform|%{number} Terraform reports failed to generate"
msgstr[0] ""
msgstr[1] ""
msgid "Terraform|%{number} Terraform report was generated in your pipelines"
msgid_plural "Terraform|%{number} Terraform reports were generated in your pipelines"
msgstr[0] ""
msgstr[1] ""
msgid "Terraform|A Terraform report failed to generate."
msgstr ""
msgid "Terraform|A Terraform report was generated in your pipelines."
msgstr ""
msgid "Terraform|Generating the report caused an error."
msgstr ""
msgid "Terraform|Reported Resource Changes: %{addNum} to add, %{changeNum} to change, %{deleteNum} to delete"
msgstr ""
msgid "Terraform|The Terraform report %{name} failed to generate."
msgstr ""
msgid "Terraform|The Terraform report %{name} was generated in your pipelines."
msgstr ""
msgid "Test"
msgstr ""
......@@ -22490,9 +22509,6 @@ msgstr ""
msgid "The Prometheus server responded with \"bad request\". Please check your queries are correct and are supported in your Prometheus version. %{documentationLink}"
msgstr ""
msgid "The Terraform report %{name} was generated in your pipelines."
msgstr ""
msgid "The URL defined on the primary node that secondary nodes should use to contact it."
msgstr ""
......
import { GlButton, GlCollapse, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import MrCollapsibleSection from '~/vue_merge_request_widget/components/mr_widget_expandable_section.vue';
describe('MrWidgetExpanableSection', () => {
let wrapper;
const findButton = () => wrapper.find(GlButton);
const findCollapse = () => wrapper.find(GlCollapse);
beforeEach(() => {
wrapper = shallowMount(MrCollapsibleSection, {
slots: {
content: '<span>Collapsable Content</span>',
header: '<span>Header Content</span>',
},
});
});
it('renders Icon', () => {
expect(wrapper.contains(GlIcon)).toBe(true);
});
it('renders header slot', () => {
expect(wrapper.text()).toContain('Header Content');
});
it('renders content slot', () => {
expect(wrapper.text()).toContain('Collapsable Content');
});
describe('when collapse section is closed', () => {
it('renders button with expand text', () => {
expect(findButton().text()).toBe('Expand');
});
it('renders a collpased section with no visibility', () => {
const collapse = findCollapse();
expect(collapse.exists()).toBe(true);
expect(collapse.attributes('visible')).toBeUndefined();
});
});
describe('when collapse section is open', () => {
beforeEach(() => {
findButton().vm.$emit('click');
return wrapper.vm.$nextTick();
});
it('renders button with collapse text', () => {
const button = findButton();
expect(button.exists()).toBe(true);
expect(button.text()).toBe('Collapse');
});
it('renders a collpased section with visible content', () => {
const collapse = findCollapse();
expect(collapse.exists()).toBe(true);
expect(collapse.attributes('visible')).toBe('true');
});
});
});
export const invalidPlan = {};
export const invalidPlanWithName = {
job_name: 'Invalid Plan',
job_path: '/path/to/ci/logs/1',
tf_report_error: 'api_error',
};
export const invalidPlanWithoutName = {
tf_report_error: 'invalid_json_format',
};
export const validPlanWithName = {
create: 10,
update: 20,
delete: 30,
job_name: 'Valid Plan',
job_path: '/path/to/ci/logs/1',
};
export const validPlan = {
export const validPlanWithoutName = {
create: 10,
update: 20,
delete: 30,
job_name: 'Plan Changes',
job_path: '/path/to/ci/logs/1',
};
export const plans = {
'1': validPlan,
'2': invalidPlan,
'3': {
create: 1,
update: 2,
delete: 3,
job_name: 'Plan 3',
job_path: '/path/to/ci/logs/3',
},
invalid_plan_one: invalidPlanWithName,
invalid_plan_two: invalidPlanWithName,
valid_plan_one: validPlanWithName,
valid_plan_two: validPlanWithoutName,
};
import { GlSkeletonLoading } from '@gitlab/ui';
import { plans } from './mock_data';
import { GlSkeletonLoading, GlSprintf } from '@gitlab/ui';
import { invalidPlanWithName, plans, validPlanWithName } from './mock_data';
import { shallowMount } from '@vue/test-utils';
import axios from '~/lib/utils/axios_utils';
import MockAdapter from 'axios-mock-adapter';
import MrWidgetExpanableSection from '~/vue_merge_request_widget/components/mr_widget_expandable_section.vue';
import MrWidgetTerraformContainer from '~/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue';
import Poll from '~/lib/utils/poll';
import TerraformPlan from '~/vue_merge_request_widget/components/terraform/terraform_plan.vue';
......@@ -13,6 +14,7 @@ describe('MrWidgetTerraformConainer', () => {
const propsData = { endpoint: '/path/to/terraform/report.json' };
const findHeader = () => wrapper.find('[data-testid="terraform-header-text"]');
const findPlans = () => wrapper.findAll(TerraformPlan).wrappers.map(x => x.props('plan'));
const mockPollingApi = (response, body, header) => {
......@@ -20,7 +22,10 @@ describe('MrWidgetTerraformConainer', () => {
};
const mountWrapper = () => {
wrapper = shallowMount(MrWidgetTerraformContainer, { propsData });
wrapper = shallowMount(MrWidgetTerraformContainer, {
propsData,
stubs: { MrWidgetExpanableSection, GlSprintf },
});
return axios.waitForAll();
};
......@@ -44,9 +49,76 @@ describe('MrWidgetTerraformConainer', () => {
});
it('diplays loading skeleton', () => {
expect(wrapper.find(GlSkeletonLoading).exists()).toBe(true);
expect(wrapper.contains(GlSkeletonLoading)).toBe(true);
expect(wrapper.contains(MrWidgetExpanableSection)).toBe(false);
});
});
describe('when data has finished loading', () => {
beforeEach(() => {
mockPollingApi(200, plans, {});
return mountWrapper();
});
it('displays terraform content', () => {
expect(wrapper.contains(GlSkeletonLoading)).toBe(false);
expect(wrapper.contains(MrWidgetExpanableSection)).toBe(true);
expect(findPlans()).toEqual(Object.values(plans));
});
describe('when data includes one invalid plan', () => {
beforeEach(() => {
const invalidPlanGroup = { bad_plan: invalidPlanWithName };
mockPollingApi(200, invalidPlanGroup, {});
return mountWrapper();
});
expect(findPlans()).toEqual([]);
it('displays header text for one invalid plan', () => {
expect(findHeader().text()).toBe('1 Terraform report failed to generate');
});
});
describe('when data includes multiple invalid plans', () => {
beforeEach(() => {
const invalidPlanGroup = {
bad_plan_one: invalidPlanWithName,
bad_plan_two: invalidPlanWithName,
};
mockPollingApi(200, invalidPlanGroup, {});
return mountWrapper();
});
it('displays header text for multiple invalid plans', () => {
expect(findHeader().text()).toBe('2 Terraform reports failed to generate');
});
});
describe('when data includes one valid plan', () => {
beforeEach(() => {
const validPlanGroup = { valid_plan: validPlanWithName };
mockPollingApi(200, validPlanGroup, {});
return mountWrapper();
});
it('displays header text for one valid plans', () => {
expect(findHeader().text()).toBe('1 Terraform report was generated in your pipelines');
});
});
describe('when data includes multiple valid plans', () => {
beforeEach(() => {
const validPlanGroup = {
valid_plan_one: validPlanWithName,
valid_plan_two: validPlanWithName,
};
mockPollingApi(200, validPlanGroup, {});
return mountWrapper();
});
it('displays header text for multiple valid plans', () => {
expect(findHeader().text()).toBe('2 Terraform reports were generated in your pipelines');
});
});
});
......@@ -71,12 +143,6 @@ describe('MrWidgetTerraformConainer', () => {
return mountWrapper();
});
it('diplays terraform components and stops loading', () => {
expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false);
expect(findPlans()).toEqual(Object.values(plans));
});
it('does not make additional requests after poll is successful', () => {
expect(pollRequest).toHaveBeenCalledTimes(1);
expect(pollStop).toHaveBeenCalledTimes(1);
......@@ -90,11 +156,11 @@ describe('MrWidgetTerraformConainer', () => {
});
it('stops loading', () => {
expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false);
expect(wrapper.contains(GlSkeletonLoading)).toBe(false);
});
it('generates one broken plan', () => {
expect(findPlans()).toEqual([{}]);
expect(findPlans()).toEqual([{ tf_report_error: 'api_error' }]);
});
it('does not make additional requests after poll is unsuccessful', () => {
......
import { invalidPlan, validPlan } from './mock_data';
import { GlLink, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import TerraformPlan from '~/vue_merge_request_widget/components/terraform/terraform_plan.vue';
import {
invalidPlanWithName,
invalidPlanWithoutName,
validPlanWithName,
validPlanWithoutName,
} from './mock_data';
describe('TerraformPlan', () => {
let wrapper;
const findLogButton = () => wrapper.find('.js-terraform-report-link');
const findIcon = () => wrapper.find('[data-testid="change-type-icon"]');
const findLogButton = () => wrapper.find('[data-testid="terraform-report-link"]');
const mountWrapper = propsData => {
wrapper = shallowMount(TerraformPlan, { stubs: { GlLink, GlSprintf }, propsData });
......@@ -16,20 +22,24 @@ describe('TerraformPlan', () => {
wrapper.destroy();
});
describe('validPlan', () => {
describe('valid plan with job_name', () => {
beforeEach(() => {
mountWrapper({ plan: validPlan });
mountWrapper({ plan: validPlanWithName });
});
it('diplays the plan job_name', () => {
it('displays a document icon', () => {
expect(findIcon().attributes('name')).toBe('doc-changes');
});
it('diplays the header text with a name', () => {
expect(wrapper.text()).toContain(
`The Terraform report ${validPlan.job_name} was generated in your pipelines.`,
`The Terraform report ${validPlanWithName.job_name} was generated in your pipelines.`,
);
});
it('diplays the reported changes', () => {
expect(wrapper.text()).toContain(
`Reported Resource Changes: ${validPlan.create} to add, ${validPlan.update} to change, ${validPlan.delete} to delete`,
`Reported Resource Changes: ${validPlanWithName.create} to add, ${validPlanWithName.update} to change, ${validPlanWithName.delete} to delete`,
);
});
......@@ -39,18 +49,44 @@ describe('TerraformPlan', () => {
});
});
describe('invalidPlan', () => {
describe('valid plan without job_name', () => {
beforeEach(() => {
mountWrapper({ plan: invalidPlan });
mountWrapper({ plan: validPlanWithoutName });
});
it('diplays generic header since job_name is missing', () => {
it('diplays the header text without a name', () => {
expect(wrapper.text()).toContain('A Terraform report was generated in your pipelines.');
});
});
describe('invalid plan with job_name', () => {
beforeEach(() => {
mountWrapper({ plan: invalidPlanWithName });
});
it('displays a warning icon', () => {
expect(findIcon().attributes('name')).toBe('warning');
});
it('diplays the header text with a name', () => {
expect(wrapper.text()).toContain(
`The Terraform report ${invalidPlanWithName.job_name} failed to generate.`,
);
});
it('diplays generic error since report values are missing', () => {
expect(wrapper.text()).toContain('Generating the report caused an error.');
});
});
describe('invalid plan with out job_name', () => {
beforeEach(() => {
mountWrapper({ plan: invalidPlanWithoutName });
});
it('diplays the header text without a name', () => {
expect(wrapper.text()).toContain('A Terraform report failed to generate.');
});
it('does not render button because url is missing', () => {
expect(findLogButton().exists()).toBe(false);
......
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