Commit 95de9f1b authored by Sarah Groff Hennigh-Palermo's avatar Sarah Groff Hennigh-Palermo

Merge branch '321201-api-fuzzing-form-ux-fine-tuning-banner' into 'master'

Add an alert in the pipeline editor

See merge request gitlab-org/gitlab!58664
parents f351b2b8 6c0448fe
<script>
import { GlAlert } from '@gitlab/ui';
import { CODE_SNIPPET_SOURCES, CODE_SNIPPET_SOURCE_SETTINGS } from './constants';
export default {
name: 'CodeSnippetAlert',
components: {
GlAlert,
},
inject: ['configurationPaths'],
props: {
source: {
type: String,
required: true,
validator: (source) => CODE_SNIPPET_SOURCES.includes(source),
},
},
computed: {
settings() {
return CODE_SNIPPET_SOURCE_SETTINGS[this.source];
},
configurationPath() {
return this.configurationPaths[this.source];
},
},
};
</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="settings.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_SETTINGS = {
[CODE_SNIPPET_SOURCE_API_FUZZING]: {
datasetKey: 'apiFuzzingConfigurationPath',
docsPath: helpPagePath('user/application_security/api_fuzzing/index'),
},
};
...@@ -3,6 +3,7 @@ import Vue from 'vue'; ...@@ -3,6 +3,7 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; import { resetServiceWorkersPublicPath } from '../lib/utils/webpack';
import { CODE_SNIPPET_SOURCE_SETTINGS } from './components/code_snippet_alert/constants';
import getCommitSha from './graphql/queries/client/commit_sha.graphql'; import getCommitSha from './graphql/queries/client/commit_sha.graphql';
import getCurrentBranch from './graphql/queries/client/current_branch.graphql'; import getCurrentBranch from './graphql/queries/client/current_branch.graphql';
import { resolvers } from './graphql/resolvers'; import { resolvers } from './graphql/resolvers';
...@@ -37,6 +38,13 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { ...@@ -37,6 +38,13 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
ymlHelpPagePath, ymlHelpPagePath,
} = el?.dataset; } = el?.dataset;
const configurationPaths = Object.fromEntries(
Object.entries(CODE_SNIPPET_SOURCE_SETTINGS).map(([source, { datasetKey }]) => [
source,
el.dataset[datasetKey],
]),
);
Vue.use(VueApollo); Vue.use(VueApollo);
const apolloProvider = new VueApollo({ const apolloProvider = new VueApollo({
...@@ -71,6 +79,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { ...@@ -71,6 +79,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
projectPath, projectPath,
projectNamespace, projectNamespace,
ymlHelpPagePath, ymlHelpPagePath,
configurationPaths,
}, },
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,10 @@ export default { ...@@ -51,8 +58,10 @@ export default {
showFailureAlert: false, showFailureAlert: false,
showSuccessAlert: false, showSuccessAlert: false,
successType: null, successType: null,
codeSnippetCopiedFrom: '',
}; };
}, },
apollo: { apollo: {
initialCiFileContent: { initialCiFileContent: {
query: getBlobContent, query: getBlobContent,
...@@ -187,6 +196,9 @@ export default { ...@@ -187,6 +196,9 @@ export default {
} }
}, },
}, },
created() {
this.parseCodeSnippetSourceParam();
},
methods: { methods: {
handleBlobContentError(error = {}) { handleBlobContentError(error = {}) {
const { networkError } = error; const { networkError } = error;
...@@ -254,6 +266,20 @@ export default { ...@@ -254,6 +266,20 @@ export default {
// if the user has made changes to the file that are unsaved. // if the user has made changes to the file that are unsaved.
this.lastCommittedContent = this.currentCiFileContent; this.lastCommittedContent = this.currentCiFileContent;
}, },
parseCodeSnippetSourceParam() {
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]),
);
}
},
dismissCodeSnippetAlert() {
this.codeSnippetCopiedFrom = '';
},
}, },
}; };
</script> </script>
...@@ -266,6 +292,12 @@ export default { ...@@ -266,6 +292,12 @@ export default {
@createEmptyConfigFile="setNewEmptyCiConfigFile" @createEmptyConfigFile="setNewEmptyCiConfigFile"
/> />
<div v-else> <div v-else>
<code-snippet-alert
v-if="codeSnippetCopiedFrom"
:source="codeSnippetCopiedFrom"
class="gl-mb-5"
@dismiss="dismissCodeSnippetAlert"
/>
<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,
...@@ -333,6 +335,7 @@ export default { ...@@ -333,6 +335,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=${redirectParam}`,
);
}); });
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: {
configurationPaths: {
[CODE_SNIPPET_SOURCE_API_FUZZING]: 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,11 @@ import { GlAlert, GlButton, GlLoadingIcon, GlTabs } from '@gitlab/ui'; ...@@ -2,8 +2,11 @@ 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 { CODE_SNIPPET_SOURCES } from '~/pipeline_editor/components/code_snippet_alert/constants';
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 +108,7 @@ describe('Pipeline editor app component', () => { ...@@ -105,6 +108,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.findComponent(CodeSnippetAlert);
beforeEach(() => { beforeEach(() => {
mockBlobContentData = jest.fn(); mockBlobContentData = jest.fn();
...@@ -127,6 +131,48 @@ describe('Pipeline editor app component', () => { ...@@ -127,6 +131,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.each(CODE_SNIPPET_SOURCES)('shows if URL param is %s, and cleans up URL', (source) => {
jest.spyOn(window.history, 'replaceState');
setCodeSnippetUrlParam(source);
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