Commit 07486f6f authored by Anna Vovchenko's avatar Anna Vovchenko Committed by Natalia Tepluhina

Refactor Terraform Widget to extension

parent 26a6a3f2
......@@ -70,6 +70,7 @@ export default {
variant="confirm"
size="small"
class="gl-display-none gl-md-display-block gl-float-left"
data-testid="extension-actions-button"
@click="onClickAction(btn)"
>
{{ btn.text }}
......
import { __, n__, s__, sprintf } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import Poll from '~/lib/utils/poll';
import { EXTENSION_ICONS } from '../../constants';
export default {
name: 'WidgetTerraform',
i18n: {
label: s__('Terraform|Terraform reports'),
loading: s__('Terraform|Loading Terraform reports...'),
error: s__('Terraform|Failed to load Terraform reports'),
reportGenerated: s__('Terraform|A Terraform report was generated in your pipelines.'),
namedReportGenerated: s__(
'Terraform|The job %{strong_start}%{name}%{strong_end} generated a report.',
),
reportChanges: s__(
'Terraform|Reported Resource Changes: %{addNum} to add, %{changeNum} to change, %{deleteNum} to delete',
),
reportFailed: s__('Terraform|A Terraform report failed to generate.'),
namedReportFailed: s__(
'Terraform|The job %{strong_start}%{name}%{strong_end} failed to generate a report.',
),
reportErrored: s__('Terraform|Generating the report caused an error.'),
fullLog: __('Full log'),
},
expandEvent: 'i_testing_terraform_widget_total',
props: ['terraformReportsPath'],
computed: {
// Extension computed props
statusIcon() {
return EXTENSION_ICONS.warning;
},
},
methods: {
// Extension methods
summary({ valid = [], invalid = [] }) {
let title;
let subtitle = '';
const validText = sprintf(
n__(
'Terraform|%{strong_start}%{number}%{strong_end} Terraform report was generated in your pipelines',
'Terraform|%{strong_start}%{number}%{strong_end} Terraform reports were generated in your pipelines',
valid.length,
),
{
number: valid.length,
},
false,
);
const invalidText = sprintf(
n__(
'Terraform|%{strong_start}%{number}%{strong_end} Terraform report failed to generate',
'Terraform|%{strong_start}%{number}%{strong_end} Terraform reports failed to generate',
invalid.length,
),
{
number: invalid.length,
},
false,
);
if (valid.length) {
title = validText;
if (invalid.length) {
subtitle = sprintf(`<br>%{small_start}${invalidText}%{small_end}`);
}
} else {
title = invalidText;
}
return `${title}${subtitle}`;
},
fetchCollapsedData() {
return Promise.resolve(this.fetchPlans().then(this.prepareReports));
},
fetchFullData() {
const { valid, invalid } = this.collapsedData;
return Promise.resolve([...valid, ...invalid]);
},
// Custom methods
fetchPlans() {
return new Promise((resolve) => {
const poll = new Poll({
resource: {
fetchPlans: () => axios.get(this.terraformReportsPath),
},
data: this.terraformReportsPath,
method: 'fetchPlans',
successCallback: ({ data }) => {
if (Object.keys(data).length > 0) {
poll.stop();
const result = Object.keys(data).map((key) => {
return data[key];
});
resolve(result);
}
},
errorCallback: () => {
const invalidData = { tf_report_error: 'api_error' };
poll.stop();
const result = [invalidData];
resolve(result);
},
});
poll.makeRequest();
});
},
createReportRow(report, iconName) {
const addNum = Number(report.create);
const changeNum = Number(report.update);
const deleteNum = Number(report.delete);
const validPlanValues = addNum + changeNum + deleteNum >= 0;
const actions = [];
let title;
let subtitle;
if (report.job_path) {
const action = {
href: report.job_path,
text: this.$options.i18n.fullLog,
target: '_blank',
};
actions.push(action);
}
if (validPlanValues) {
if (report.job_name) {
title = sprintf(
this.$options.i18n.namedReportGenerated,
{
name: report.job_name,
},
false,
);
} else {
title = this.$options.i18n.reportGenerated;
}
subtitle = sprintf(`%{small_start}${this.$options.i18n.reportChanges}%{small_end}`, {
addNum,
changeNum,
deleteNum,
});
} else {
if (report.job_name) {
title = sprintf(
this.$options.i18n.namedReportFailed,
{
name: report.job_name,
},
false,
);
} else {
title = this.$options.i18n.reportFailed;
}
subtitle = sprintf(`%{small_start}${this.$options.i18n.reportErrored}%{small_end}`);
}
return {
text: `${title}
<br>
${subtitle}`,
icon: { name: iconName },
actions,
};
},
prepareReports(reports) {
const valid = [];
const invalid = [];
reports.forEach((report) => {
if (report.tf_report_error) {
invalid.push(this.createReportRow(report, EXTENSION_ICONS.error));
} else {
valid.push(this.createReportRow(report, EXTENSION_ICONS.success));
}
});
return { valid, invalid };
},
},
};
<script>
import { GlSafeHtmlDirective } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { registerExtension } from '~/vue_merge_request_widget/components/extensions';
import MrWidgetApprovals from 'ee_else_ce/vue_merge_request_widget/components/approvals/approvals.vue';
import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service';
import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store';
......@@ -43,6 +44,7 @@ import { STATE_MACHINE, stateToComponentMap } from './constants';
import eventHub from './event_hub';
import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variables';
import getStateQuery from './queries/get_state.query.graphql';
import terraformExtension from './extensions/terraform';
export default {
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25
......@@ -184,6 +186,9 @@ export default {
shouldRenderSecurityReport() {
return Boolean(this.mr.pipeline.id);
},
shouldRenderTerraformPlans() {
return Boolean(this.mr?.terraformReportsPath);
},
mergeError() {
let { mergeError } = this.mr;
......@@ -230,6 +235,11 @@ export default {
this.initPostMergeDeploymentsPolling();
}
},
shouldRenderTerraformPlans(newVal) {
if (newVal) {
this.registerTerraformPlans();
}
},
},
mounted() {
MRWidgetService.fetchInitialData()
......@@ -463,6 +473,11 @@ export default {
dismissSuggestPipelines() {
this.mr.isDismissedSuggestPipeline = true;
},
registerTerraformPlans() {
if (this.shouldRenderTerraformPlans && this.shouldShowExtension) {
registerExtension(terraformExtension);
}
},
},
};
</script>
......@@ -542,7 +557,10 @@ export default {
:pipeline-path="mr.pipeline.path"
/>
<terraform-plan v-if="mr.terraformReportsPath" :endpoint="mr.terraformReportsPath" />
<terraform-plan
v-if="mr.terraformReportsPath && !shouldShowExtension"
:endpoint="mr.terraformReportsPath"
/>
<grouped-accessibility-reports-app
v-if="shouldShowAccessibilityReport"
......
......@@ -42,7 +42,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:restructured_mr_widget, project, default_enabled: :yaml)
push_frontend_feature_flag(:mr_changes_fluid_layout, project, default_enabled: :yaml)
push_frontend_feature_flag(:mr_attention_requests, project, default_enabled: :yaml)
push_frontend_feature_flag(:refactor_mr_widgets_extensions, @project, default_enabled: :yaml)
# Usage data feature flags
push_frontend_feature_flag(:users_expanding_widgets_usage_data, @project, default_enabled: :yaml)
push_frontend_feature_flag(:diff_settings_usage_data, default_enabled: :yaml)
......
......@@ -466,7 +466,10 @@ export default {
:pipeline-path="mr.pipeline.path"
/>
<terraform-plan v-if="mr.terraformReportsPath" :endpoint="mr.terraformReportsPath" />
<terraform-plan
v-if="mr.terraformReportsPath && !shouldShowExtension"
:endpoint="mr.terraformReportsPath"
/>
<grouped-accessibility-reports-app
v-if="shouldShowAccessibilityReport"
......
......@@ -15473,6 +15473,9 @@ msgstr ""
msgid "Full"
msgstr ""
msgid "Full log"
msgstr ""
msgid "Full name"
msgstr ""
......@@ -34664,9 +34667,25 @@ msgid_plural "Terraform|%{number} Terraform reports were generated in your pipel
msgstr[0] ""
msgstr[1] ""
msgid "Terraform|%{strong_start}%{number}%{strong_end} Terraform report failed to generate"
msgid_plural "Terraform|%{strong_start}%{number}%{strong_end} Terraform reports failed to generate"
msgstr[0] ""
msgstr[1] ""
msgid "Terraform|%{strong_start}%{number}%{strong_end} Terraform report was generated in your pipelines"
msgid_plural "Terraform|%{strong_start}%{number}%{strong_end} Terraform reports were generated in your pipelines"
msgstr[0] ""
msgstr[1] ""
msgid "Terraform|%{user} updated %{timeAgo}"
msgstr ""
msgid "Terraform|A Terraform report failed to generate."
msgstr ""
msgid "Terraform|A Terraform report was generated in your pipelines."
msgstr ""
msgid "Terraform|A report failed to generate."
msgstr ""
......@@ -34697,6 +34716,9 @@ msgstr ""
msgid "Terraform|Download JSON"
msgstr ""
msgid "Terraform|Failed to load Terraform reports"
msgstr ""
msgid "Terraform|Generating the report caused an error."
msgstr ""
......@@ -34709,6 +34731,9 @@ msgstr ""
msgid "Terraform|Job status"
msgstr ""
msgid "Terraform|Loading Terraform reports..."
msgstr ""
msgid "Terraform|Lock"
msgstr ""
......@@ -34745,12 +34770,21 @@ msgstr ""
msgid "Terraform|Terraform init command"
msgstr ""
msgid "Terraform|Terraform reports"
msgstr ""
msgid "Terraform|The job %{name} failed to generate a report."
msgstr ""
msgid "Terraform|The job %{name} generated a report."
msgstr ""
msgid "Terraform|The job %{strong_start}%{name}%{strong_end} failed to generate a report."
msgstr ""
msgid "Terraform|The job %{strong_start}%{name}%{strong_end} generated a report."
msgstr ""
msgid "Terraform|To get access to this terraform state from your local computer, run the following command at the command line. The first line requires a personal access token with API read and write access. %{linkStart}How do I create a personal access token?%{linkEnd}."
msgstr ""
......
export const invalidPlanWithName = {
job_name: 'Invalid Plan',
job_path: '/path/to/ci/logs/1',
job_path: '/path/to/ci/logs/3',
tf_report_error: 'api_error',
};
......@@ -20,12 +20,12 @@ export const validPlanWithoutName = {
create: 10,
update: 20,
delete: 30,
job_path: '/path/to/ci/logs/1',
job_path: '/path/to/ci/logs/2',
};
export const plans = {
invalid_plan_one: invalidPlanWithName,
invalid_plan_two: invalidPlanWithName,
invalid_plan_two: invalidPlanWithoutName,
valid_plan_one: validPlanWithName,
valid_plan_two: validPlanWithoutName,
};
import MockAdapter from 'axios-mock-adapter';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import Poll from '~/lib/utils/poll';
import extensionsContainer from '~/vue_merge_request_widget/components/extensions/container';
import { registerExtension } from '~/vue_merge_request_widget/components/extensions';
import terraformExtension from '~/vue_merge_request_widget/extensions/terraform';
import {
plans,
validPlanWithName,
validPlanWithoutName,
invalidPlanWithName,
invalidPlanWithoutName,
} from '../../components/terraform/mock_data';
describe('Terraform extension', () => {
let wrapper;
let mock;
const endpoint = '/path/to/terraform/report.json';
const successStatusCode = 200;
const errorStatusCode = 500;
const findListItem = (at) => wrapper.findAllByTestId('extension-list-item').at(at);
registerExtension(terraformExtension);
const mockPollingApi = (response, body, header) => {
mock.onGet(endpoint).reply(response, body, header);
};
const createComponent = () => {
wrapper = mountExtended(extensionsContainer, {
propsData: {
mr: {
terraformReportsPath: endpoint,
},
},
});
return axios.waitForAll();
};
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
wrapper.destroy();
mock.restore();
});
describe('summary', () => {
describe('while loading', () => {
const loadingText = 'Loading Terraform reports...';
it('should render loading text', async () => {
mockPollingApi(successStatusCode, plans, {});
createComponent();
expect(wrapper.text()).toContain(loadingText);
await waitForPromises();
expect(wrapper.text()).not.toContain(loadingText);
});
});
describe('when the fetching fails', () => {
beforeEach(() => {
mockPollingApi(errorStatusCode, null, {});
return createComponent();
});
it('should generate one invalid plan and render correct summary text', () => {
expect(wrapper.text()).toContain('1 Terraform report failed to generate');
});
});
describe('when the fetching succeeds', () => {
describe.each`
responseType | response | summaryTitle | summarySubtitle
${'1 invalid report'} | ${{ 0: invalidPlanWithName }} | ${'1 Terraform report failed to generate'} | ${''}
${'2 valid reports'} | ${{ 0: validPlanWithName, 1: validPlanWithName }} | ${'2 Terraform reports were generated in your pipelines'} | ${''}
${'1 valid and 2 invalid reports'} | ${{ 0: validPlanWithName, 1: invalidPlanWithName, 2: invalidPlanWithName }} | ${'Terraform report was generated in your pipelines'} | ${'2 Terraform reports failed to generate'}
`('and received $responseType', ({ response, summaryTitle, summarySubtitle }) => {
beforeEach(async () => {
mockPollingApi(successStatusCode, response, {});
return createComponent();
});
it(`should render correct summary text`, () => {
expect(wrapper.text()).toContain(summaryTitle);
if (summarySubtitle) {
expect(wrapper.text()).toContain(summarySubtitle);
}
});
});
});
});
describe('expanded data', () => {
beforeEach(async () => {
mockPollingApi(successStatusCode, plans, {});
await createComponent();
wrapper.findByTestId('toggle-button').trigger('click');
});
describe.each`
reportType | title | subtitle | logLink | lineNumber
${'a valid report with name'} | ${`The job ${validPlanWithName.job_name} generated a report.`} | ${`Reported Resource Changes: ${validPlanWithName.create} to add, ${validPlanWithName.update} to change, ${validPlanWithName.delete} to delete`} | ${validPlanWithName.job_path} | ${0}
${'a valid report without name'} | ${'A Terraform report was generated in your pipelines.'} | ${`Reported Resource Changes: ${validPlanWithoutName.create} to add, ${validPlanWithoutName.update} to change, ${validPlanWithoutName.delete} to delete`} | ${validPlanWithoutName.job_path} | ${1}
${'an invalid report with name'} | ${`The job ${invalidPlanWithName.job_name} failed to generate a report.`} | ${'Generating the report caused an error.'} | ${invalidPlanWithName.job_path} | ${2}
${'an invalid report without name'} | ${'A Terraform report failed to generate.'} | ${'Generating the report caused an error.'} | ${invalidPlanWithoutName.job_path} | ${3}
`('renders correct text for $reportType', ({ title, subtitle, logLink, lineNumber }) => {
it('renders correct text', () => {
expect(findListItem(lineNumber).text()).toContain(title);
expect(findListItem(lineNumber).text()).toContain(subtitle);
});
it(`${logLink ? 'renders' : "doesn't render"} the log link`, () => {
const logText = 'Full log';
if (logLink) {
expect(
findListItem(lineNumber)
.find('[data-testid="extension-actions-button"]')
.attributes('href'),
).toBe(logLink);
} else {
expect(findListItem(lineNumber).text()).not.toContain(logText);
}
});
});
});
describe('polling', () => {
let pollRequest;
let pollStop;
beforeEach(() => {
pollRequest = jest.spyOn(Poll.prototype, 'makeRequest');
pollStop = jest.spyOn(Poll.prototype, 'stop');
});
afterEach(() => {
pollRequest.mockRestore();
pollStop.mockRestore();
});
describe('successful poll', () => {
beforeEach(() => {
mockPollingApi(successStatusCode, plans, {});
return createComponent();
});
it('does not make additional requests after poll is successful', () => {
expect(pollRequest).toHaveBeenCalledTimes(1);
expect(pollStop).toHaveBeenCalledTimes(1);
});
});
describe('polling fails', () => {
beforeEach(() => {
mockPollingApi(errorStatusCode, null, {});
return createComponent();
});
it('generates one broken plan', () => {
expect(wrapper.text()).toContain('1 Terraform report failed to generate');
});
it('does not make additional requests after poll is unsuccessful', () => {
expect(pollRequest).toHaveBeenCalledTimes(1);
expect(pollStop).toHaveBeenCalledTimes(1);
});
});
});
});
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