Add an alert in the pipeline editor

When copying an API Fuzzing configuration snippet and being redirected
to the pipeline editor, an alert is shown at the top of the page to give
a few indications to the user.
parent 87765223
<script>
import { GlAlert } from '@gitlab/ui';
import { CODE_SNIPPET_SOURCES, CODE_SNIPPET_SOURCE_PATH } from './constants';
export default {
name: 'CodeSnippetAlert',
components: {
GlAlert,
},
inject: Object.values(CODE_SNIPPET_SOURCE_PATH).map(
({ configurationPathInjectKey }) => configurationPathInjectKey,
),
props: {
source: {
type: String,
required: true,
validator: (source) => CODE_SNIPPET_SOURCES.includes(source),
},
},
computed: {
pathsSettings() {
return CODE_SNIPPET_SOURCE_PATH[this.source];
},
configurationPath() {
const injectKey = this.pathsSettings.configurationPathInjectKey;
return this[injectKey];
},
},
};
</script>
<template>
<gl-alert
variant="tip"
:title="__('Code snippet copied. Insert it in the correct location in the YAML file.')"
:dismiss-label="__('Dismiss')"
:primary-button-link="pathsSettings.docsPath"
:primary-button-text="__('Read documentation')"
:secondary-button-link="configurationPath"
:secondary-button-text="__('Go back to configuration')"
v-on="$listeners"
>
{{ __('Before inserting code, be sure to read the comment that separated each code group.') }}
</gl-alert>
</template>
import { helpPagePath } from '~/helpers/help_page_helper';
export const CODE_SNIPPET_SOURCE_URL_PARAM = 'code_snippet_copied_from';
export const CODE_SNIPPET_SOURCE_API_FUZZING = 'api_fuzzing';
export const CODE_SNIPPET_SOURCES = [CODE_SNIPPET_SOURCE_API_FUZZING];
export const CODE_SNIPPET_SOURCE_PATH = {
[CODE_SNIPPET_SOURCE_API_FUZZING]: {
configurationPathInjectKey: 'apiFuzzingConfigurationPath',
docsPath: helpPagePath('user/application_security/api_fuzzing/index'),
},
};
...@@ -35,6 +35,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { ...@@ -35,6 +35,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
projectPath, projectPath,
projectNamespace, projectNamespace,
ymlHelpPagePath, ymlHelpPagePath,
apiFuzzingConfigurationPath,
} = el?.dataset; } = el?.dataset;
Vue.use(VueApollo); Vue.use(VueApollo);
...@@ -71,6 +72,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { ...@@ -71,6 +72,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
projectPath, projectPath,
projectNamespace, projectNamespace,
ymlHelpPagePath, ymlHelpPagePath,
apiFuzzingConfigurationPath,
}, },
render(h) { render(h) {
return h(PipelineEditorApp); return h(PipelineEditorApp);
......
<script> <script>
import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import httpStatusCodes from '~/lib/utils/http_status'; import httpStatusCodes from '~/lib/utils/http_status';
import { getParameterValues, removeParams } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils'; import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils';
import CodeSnippetAlert from './components/code_snippet_alert/code_snippet_alert.vue';
import {
CODE_SNIPPET_SOURCE_URL_PARAM,
CODE_SNIPPET_SOURCES,
} from './components/code_snippet_alert/constants';
import ConfirmUnsavedChangesDialog from './components/ui/confirm_unsaved_changes_dialog.vue'; import ConfirmUnsavedChangesDialog from './components/ui/confirm_unsaved_changes_dialog.vue';
import PipelineEditorEmptyState from './components/ui/pipeline_editor_empty_state.vue'; import PipelineEditorEmptyState from './components/ui/pipeline_editor_empty_state.vue';
import { import {
...@@ -29,6 +35,7 @@ export default { ...@@ -29,6 +35,7 @@ export default {
GlLoadingIcon, GlLoadingIcon,
PipelineEditorEmptyState, PipelineEditorEmptyState,
PipelineEditorHome, PipelineEditorHome,
CodeSnippetAlert,
}, },
inject: { inject: {
ciConfigPath: { ciConfigPath: {
...@@ -51,8 +58,11 @@ export default { ...@@ -51,8 +58,11 @@ export default {
showFailureAlert: false, showFailureAlert: false,
showSuccessAlert: false, showSuccessAlert: false,
successType: null, successType: null,
codeSnippetCopiedFrom: '',
codeSnippetAlertDismissed: false,
}; };
}, },
apollo: { apollo: {
initialCiFileContent: { initialCiFileContent: {
query: getBlobContent, query: getBlobContent,
...@@ -166,6 +176,9 @@ export default { ...@@ -166,6 +176,9 @@ export default {
return null; return null;
} }
}, },
showCodeSnippetAlert() {
return this.codeSnippetCopiedFrom && !this.codeSnippetAlertDismissed;
},
}, },
i18n: { i18n: {
tabEdit: s__('Pipelines|Write pipeline configuration'), tabEdit: s__('Pipelines|Write pipeline configuration'),
...@@ -187,6 +200,17 @@ export default { ...@@ -187,6 +200,17 @@ export default {
} }
}, },
}, },
created() {
const [codeSnippetCopiedFrom] = getParameterValues(CODE_SNIPPET_SOURCE_URL_PARAM);
if (codeSnippetCopiedFrom && CODE_SNIPPET_SOURCES.includes(codeSnippetCopiedFrom)) {
this.codeSnippetCopiedFrom = codeSnippetCopiedFrom;
window.history.replaceState(
{},
document.title,
removeParams([CODE_SNIPPET_SOURCE_URL_PARAM]),
);
}
},
methods: { methods: {
handleBlobContentError(error = {}) { handleBlobContentError(error = {}) {
const { networkError } = error; const { networkError } = error;
...@@ -266,6 +290,12 @@ export default { ...@@ -266,6 +290,12 @@ export default {
@createEmptyConfigFile="setNewEmptyCiConfigFile" @createEmptyConfigFile="setNewEmptyCiConfigFile"
/> />
<div v-else> <div v-else>
<code-snippet-alert
v-if="showCodeSnippetAlert"
:source="codeSnippetCopiedFrom"
class="gl-mb-5"
@dismiss="codeSnippetAlertDismissed = true"
/>
<gl-alert <gl-alert
v-if="showSuccessAlert" v-if="showSuccessAlert"
:variant="success.variant" :variant="success.variant"
......
...@@ -11,4 +11,5 @@ ...@@ -11,4 +11,5 @@
"project-full-path" => @project.full_path, "project-full-path" => @project.full_path,
"project-namespace" => @project.namespace.full_path, "project-namespace" => @project.namespace.full_path,
"yml-help-page-path" => help_page_path('ci/yaml/README'), "yml-help-page-path" => help_page_path('ci/yaml/README'),
"api-fuzzing-configuration-path" => project_security_configuration_api_fuzzing_path(@project),
} } } }
...@@ -13,6 +13,7 @@ import { ...@@ -13,6 +13,7 @@ import {
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
import { isEmptyValue } from '~/lib/utils/forms'; import { isEmptyValue } from '~/lib/utils/forms';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import { CODE_SNIPPET_SOURCE_API_FUZZING } from '~/pipeline_editor/components/code_snippet_alert/constants';
import DropdownInput from '../../components/dropdown_input.vue'; import DropdownInput from '../../components/dropdown_input.vue';
import DynamicFields from '../../components/dynamic_fields.vue'; import DynamicFields from '../../components/dynamic_fields.vue';
import FormInput from '../../components/form_input.vue'; import FormInput from '../../components/form_input.vue';
...@@ -23,6 +24,7 @@ import ConfigurationSnippetModal from './configuration_snippet_modal.vue'; ...@@ -23,6 +24,7 @@ import ConfigurationSnippetModal from './configuration_snippet_modal.vue';
export default { export default {
CONFIGURATION_SNIPPET_MODAL_ID, CONFIGURATION_SNIPPET_MODAL_ID,
CODE_SNIPPET_SOURCE_API_FUZZING,
components: { components: {
GlAccordion, GlAccordion,
GlAccordionItem, GlAccordionItem,
...@@ -329,6 +331,7 @@ export default { ...@@ -329,6 +331,7 @@ export default {
:ref="$options.CONFIGURATION_SNIPPET_MODAL_ID" :ref="$options.CONFIGURATION_SNIPPET_MODAL_ID"
:ci-yaml-edit-url="ciYamlEditPath" :ci-yaml-edit-url="ciYamlEditPath"
:yaml="configurationYamlWithTips" :yaml="configurationYamlWithTips"
:redirect-param="$options.CODE_SNIPPET_SOURCE_API_FUZZING"
/> />
</form> </form>
</template> </template>
<script> <script>
import { GlModal } from '@gitlab/ui'; import { GlModal } from '@gitlab/ui';
import Clipboard from 'clipboard'; import Clipboard from 'clipboard';
import { redirectTo } from '~/lib/utils/url_utility'; import { getBaseURL, setUrlParams, redirectTo } from '~/lib/utils/url_utility';
import { CODE_SNIPPET_SOURCE_URL_PARAM } from '~/pipeline_editor/components/code_snippet_alert/constants';
import { CONFIGURATION_SNIPPET_MODAL_ID } from '../constants'; import { CONFIGURATION_SNIPPET_MODAL_ID } from '../constants';
export default { export default {
...@@ -18,6 +19,10 @@ export default { ...@@ -18,6 +19,10 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
redirectParam: {
type: String,
required: true,
},
}, },
methods: { methods: {
show() { show() {
...@@ -33,7 +38,15 @@ export default { ...@@ -33,7 +38,15 @@ export default {
}); });
clipboard.on('success', () => { clipboard.on('success', () => {
if (andRedirect) { if (andRedirect) {
redirectTo(this.ciYamlEditUrl); const url = new URL(this.ciYamlEditUrl, getBaseURL());
redirectTo(
setUrlParams(
{
[CODE_SNIPPET_SOURCE_URL_PARAM]: this.redirectParam,
},
url,
),
);
} }
}); });
}, },
......
---
title: Add tips about copied API Fuzzing configuration snippet in the pipeline editor
merge_request: 58664
author:
type: changed
...@@ -13,6 +13,7 @@ import FormInput from 'ee/security_configuration/components/form_input.vue'; ...@@ -13,6 +13,7 @@ import FormInput from 'ee/security_configuration/components/form_input.vue';
import { stripTypenames } from 'helpers/graphql_helpers'; import { stripTypenames } from 'helpers/graphql_helpers';
import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import { CODE_SNIPPET_SOURCE_API_FUZZING } from '~/pipeline_editor/components/code_snippet_alert/constants';
import { import {
apiFuzzingConfigurationQueryResponse, apiFuzzingConfigurationQueryResponse,
createApiFuzzingConfigurationMutationResponse, createApiFuzzingConfigurationMutationResponse,
...@@ -251,6 +252,7 @@ include: ...@@ -251,6 +252,7 @@ include:
# Tip: Insert the following variables anywhere below stages and include # Tip: Insert the following variables anywhere below stages and include
variables: variables:
- FOO: bar`, - FOO: bar`,
redirectParam: CODE_SNIPPET_SOURCE_API_FUZZING,
}); });
}); });
......
...@@ -14,12 +14,16 @@ jest.mock('clipboard', () => ...@@ -14,12 +14,16 @@ jest.mock('clipboard', () =>
); );
jest.mock('~/lib/utils/url_utility', () => ({ jest.mock('~/lib/utils/url_utility', () => ({
redirectTo: jest.fn(), redirectTo: jest.fn(),
joinPaths: jest.fn(),
getBaseURL: jest.fn().mockReturnValue('http://gitlab.local/'),
setUrlParams: jest.requireActual('~/lib/utils/url_utility').setUrlParams,
})); }));
const { const {
gitlabCiYamlEditPath, gitlabCiYamlEditPath,
configurationYaml, configurationYaml,
} = createApiFuzzingConfigurationMutationResponse.data.apiFuzzingCiConfigurationCreate; } = createApiFuzzingConfigurationMutationResponse.data.apiFuzzingCiConfigurationCreate;
const redirectParam = 'foo';
describe('EE - ApiFuzzingConfigurationSnippetModal', () => { describe('EE - ApiFuzzingConfigurationSnippetModal', () => {
let wrapper; let wrapper;
...@@ -36,6 +40,7 @@ describe('EE - ApiFuzzingConfigurationSnippetModal', () => { ...@@ -36,6 +40,7 @@ describe('EE - ApiFuzzingConfigurationSnippetModal', () => {
propsData: { propsData: {
ciYamlEditUrl: gitlabCiYamlEditPath, ciYamlEditUrl: gitlabCiYamlEditPath,
yaml: configurationYaml, yaml: configurationYaml,
redirectParam,
}, },
attrs: { attrs: {
static: true, static: true,
...@@ -66,7 +71,9 @@ describe('EE - ApiFuzzingConfigurationSnippetModal', () => { ...@@ -66,7 +71,9 @@ describe('EE - ApiFuzzingConfigurationSnippetModal', () => {
expect(Clipboard).toHaveBeenCalledWith('#copy-yaml-snippet-and-edit-button', { expect(Clipboard).toHaveBeenCalledWith('#copy-yaml-snippet-and-edit-button', {
text: expect.any(Function), text: expect.any(Function),
}); });
expect(redirectTo).toHaveBeenCalledWith(gitlabCiYamlEditPath); expect(redirectTo).toHaveBeenCalledWith(
`http://gitlab.local${gitlabCiYamlEditPath}?code_snippet_copied_from=foo`,
);
}); });
it('on secondary event, text is copied to the clipbard', async () => { it('on secondary event, text is copied to the clipbard', async () => {
......
...@@ -4768,6 +4768,9 @@ msgstr "" ...@@ -4768,6 +4768,9 @@ msgstr ""
msgid "Be careful. Renaming a project's repository can have unintended side effects." msgid "Be careful. Renaming a project's repository can have unintended side effects."
msgstr "" msgstr ""
msgid "Before inserting code, be sure to read the comment that separated each code group."
msgstr ""
msgid "Before this can be merged, a Jira issue must be linked in the title or description" msgid "Before this can be merged, a Jira issue must be linked in the title or description"
msgstr "" msgstr ""
...@@ -7689,6 +7692,9 @@ msgstr "" ...@@ -7689,6 +7692,9 @@ msgstr ""
msgid "Code owners" msgid "Code owners"
msgstr "" msgstr ""
msgid "Code snippet copied. Insert it in the correct location in the YAML file."
msgstr ""
msgid "CodeIntelligence|This is the definition" msgid "CodeIntelligence|This is the definition"
msgstr "" msgstr ""
...@@ -14696,6 +14702,9 @@ msgstr "" ...@@ -14696,6 +14702,9 @@ msgstr ""
msgid "Go back (while searching for files)" msgid "Go back (while searching for files)"
msgstr "" msgstr ""
msgid "Go back to configuration"
msgstr ""
msgid "Go full screen" msgid "Go full screen"
msgstr "" msgstr ""
...@@ -25559,6 +25568,9 @@ msgstr "" ...@@ -25559,6 +25568,9 @@ msgstr ""
msgid "Re-verification interval" msgid "Re-verification interval"
msgstr "" msgstr ""
msgid "Read documentation"
msgstr ""
msgid "Read more" msgid "Read more"
msgstr "" msgstr ""
......
import { within } from '@testing-library/dom';
import { mount } from '@vue/test-utils';
import { merge } from 'lodash';
import { TEST_HOST } from 'helpers/test_constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import CodeSnippetAlert from '~/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue';
import { CODE_SNIPPET_SOURCE_API_FUZZING } from '~/pipeline_editor/components/code_snippet_alert/constants';
const apiFuzzingConfigurationPath = '/namespace/project/-/security/configuration/api_fuzzing';
describe('EE - CodeSnippetAlert', () => {
let wrapper;
const createWrapper = (options) => {
wrapper = extendedWrapper(
mount(
CodeSnippetAlert,
merge(
{
provide: {
apiFuzzingConfigurationPath,
},
propsData: {
source: CODE_SNIPPET_SOURCE_API_FUZZING,
},
},
options,
),
),
);
};
const withinComponent = () => within(wrapper.element);
const findDocsLink = () => withinComponent().getByRole('link', { name: /read documentation/i });
const findConfigurationLink = () =>
withinComponent().getByRole('link', { name: /Go back to configuration/i });
beforeEach(() => {
createWrapper();
});
afterEach(() => {
wrapper.destroy();
});
it("provides a link to the feature's documentation", () => {
const docsLink = findDocsLink();
expect(docsLink).not.toBe(null);
expect(docsLink.href).toBe(`${TEST_HOST}/help/user/application_security/api_fuzzing/index`);
});
it("provides a link to the feature's configuration form", () => {
const configurationLink = findConfigurationLink();
expect(configurationLink).not.toBe(null);
expect(configurationLink.href).toBe(TEST_HOST + apiFuzzingConfigurationPath);
});
});
...@@ -2,8 +2,10 @@ import { GlAlert, GlButton, GlLoadingIcon, GlTabs } from '@gitlab/ui'; ...@@ -2,8 +2,10 @@ import { GlAlert, GlButton, GlLoadingIcon, GlTabs } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import httpStatusCodes from '~/lib/utils/http_status'; import httpStatusCodes from '~/lib/utils/http_status';
import CodeSnippetAlert from '~/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue';
import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue'; import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue';
import TextEditor from '~/pipeline_editor/components/editor/text_editor.vue'; import TextEditor from '~/pipeline_editor/components/editor/text_editor.vue';
...@@ -105,6 +107,7 @@ describe('Pipeline editor app component', () => { ...@@ -105,6 +107,7 @@ describe('Pipeline editor app component', () => {
const findEmptyState = () => wrapper.findComponent(PipelineEditorEmptyState); const findEmptyState = () => wrapper.findComponent(PipelineEditorEmptyState);
const findEmptyStateButton = () => const findEmptyStateButton = () =>
wrapper.findComponent(PipelineEditorEmptyState).findComponent(GlButton); wrapper.findComponent(PipelineEditorEmptyState).findComponent(GlButton);
const findCodeSnippetAlert = () => wrapper.find(CodeSnippetAlert);
beforeEach(() => { beforeEach(() => {
mockBlobContentData = jest.fn(); mockBlobContentData = jest.fn();
...@@ -127,6 +130,48 @@ describe('Pipeline editor app component', () => { ...@@ -127,6 +130,48 @@ describe('Pipeline editor app component', () => {
}); });
}); });
describe('code snippet alert', () => {
const setCodeSnippetUrlParam = (value) => {
global.jsdom.reconfigure({
url: `${TEST_HOST}/?code_snippet_copied_from=${value}`,
});
};
it('does not show by default', () => {
createComponent();
expect(findCodeSnippetAlert().exists()).toBe(false);
});
it('shows if URL param is set and supported, and cleans up URL', () => {
jest.spyOn(window.history, 'replaceState');
setCodeSnippetUrlParam('api_fuzzing');
createComponent();
expect(findCodeSnippetAlert().exists()).toBe(true);
expect(window.history.replaceState).toHaveBeenCalledWith({}, document.title, `${TEST_HOST}/`);
});
it('does not show if URL param is invalid', () => {
setCodeSnippetUrlParam('foo_bar');
createComponent();
expect(findCodeSnippetAlert().exists()).toBe(false);
});
it('disappears on dismiss', async () => {
setCodeSnippetUrlParam('api_fuzzing');
createComponent();
const alert = findCodeSnippetAlert();
expect(alert.exists()).toBe(true);
await alert.vm.$emit('dismiss');
expect(alert.exists()).toBe(false);
});
});
describe('when queries are called', () => { describe('when queries are called', () => {
beforeEach(() => { beforeEach(() => {
mockBlobContentData.mockResolvedValue(mockCiYml); mockBlobContentData.mockResolvedValue(mockCiYml);
......
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