diff --git a/ee/app/assets/javascripts/on_demand_scans_form/graphql/provider.js b/ee/app/assets/javascripts/on_demand_scans_form/graphql/provider.js deleted file mode 100644 index ef96b443da8d33b03b587827a13b5d9c53a40fd3..0000000000000000000000000000000000000000 --- a/ee/app/assets/javascripts/on_demand_scans_form/graphql/provider.js +++ /dev/null @@ -1,9 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; - -Vue.use(VueApollo); - -export default new VueApollo({ - defaultClient: createDefaultClient(), -}); diff --git a/ee/app/assets/javascripts/on_demand_scans_form/index.js b/ee/app/assets/javascripts/on_demand_scans_form/index.js index 25f1a2045df0aecb630acd439d7ad7f8a493d754..77f14dd2a870e8b95bd7eec11b1c9c6749e69307 100644 --- a/ee/app/assets/javascripts/on_demand_scans_form/index.js +++ b/ee/app/assets/javascripts/on_demand_scans_form/index.js @@ -1,6 +1,6 @@ import Vue from 'vue'; +import apolloProvider from 'ee/vue_shared/security_configuration/graphql/provider'; import OnDemandScansForm from './components/on_demand_scans_form.vue'; -import apolloProvider from './graphql/provider'; export default () => { const el = document.querySelector('#js-on-demand-scans-form'); diff --git a/ee/app/assets/javascripts/security_configuration/dast/index.js b/ee/app/assets/javascripts/security_configuration/dast/index.js index 35af286c05c6091d2d6dc0f5e333d35438c7d9d9..5486849698200ca8b5e30876cae05019670f3dfb 100644 --- a/ee/app/assets/javascripts/security_configuration/dast/index.js +++ b/ee/app/assets/javascripts/security_configuration/dast/index.js @@ -1,6 +1,5 @@ import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; +import apolloProvider from 'ee/vue_shared/security_configuration/graphql/provider'; import DastConfigurationApp from './components/app.vue'; export default function init() { @@ -10,12 +9,6 @@ export default function init() { return undefined; } - Vue.use(VueApollo); - - const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), - }); - const { securityConfigurationPath, fullPath, diff --git a/ee/app/assets/javascripts/security_configuration/dast_profiles/components/base_dast_profile_form.vue b/ee/app/assets/javascripts/security_configuration/dast_profiles/components/base_dast_profile_form.vue index d93587e40bcd8aafdb8ce5f64482c5f38f7845c1..018ce8e68b6e6d5bee6cd7d8cb2d097d2068f72d 100644 --- a/ee/app/assets/javascripts/security_configuration/dast_profiles/components/base_dast_profile_form.vue +++ b/ee/app/assets/javascripts/security_configuration/dast_profiles/components/base_dast_profile_form.vue @@ -1,6 +1,8 @@ <script> import { GlAlert, GlButton, GlForm, GlModal } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; +import { s__ } from '~/locale'; +import dastProfileConfiguratorMixin from 'ee/security_configuration/dast_profiles/dast_profiles_configurator_mixin'; export default { components: { @@ -9,6 +11,22 @@ export default { GlForm, GlModal, }, + i18n: { + discardChangesHeader: s__('OnDemandScans|You have unsaved changes'), + discardChangesText: s__( + 'OnDemandScans|Do you want to discard the changes or keep editing this profile? Unsaved changes will be lost.', + ), + }, + modal: { + actionPrimary: { + text: s__('OnDemandScans|Discard changes'), + attributes: { variant: 'danger', 'data-testid': 'form-touched-warning' }, + }, + actionCancel: { + text: s__('OnDemandScans|Keep editing'), + }, + }, + mixins: [dastProfileConfiguratorMixin()], props: { profile: { type: Object, @@ -47,18 +65,20 @@ export default { required: false, default: false, }, - modalProps: { - type: Object, - required: true, - }, }, data() { return { isLoading: false, showAlert: false, errors: [], + sharedData: {}, }; }, + watch: { + formTouched(isTouched) { + this.setFormTouched({ isTouched }); + }, + }, methods: { onSubmit() { this.$emit('submit'); @@ -100,16 +120,20 @@ export default { this.isLoading = false; }); }, - onCancelClicked() { + async onCancelClicked() { if (!this.formTouched) { - this.discard(); + this.$emit('cancel'); } else { - this.$refs[this.$options.modalId].show(); + await this.toggleModal({ showModal: true }); } }, - discard() { + async discard() { + await this.discardChanges(); this.$emit('cancel'); }, + async keepEditing() { + await this.toggleModal({ showModal: false }); + }, showErrors(errors = []) { this.errors = errors; this.showAlert = true; @@ -174,12 +198,19 @@ export default { <gl-modal :ref="$options.modalId" + size="sm" :modal-id="$options.modalId" - v-bind="modalProps" + :visible="sharedData.showDiscardChangesModal" + :title="$options.i18n.discardChangesHeader" + :action-primary="$options.modal.actionPrimary" + :action-cancel="$options.modal.actionCancel" ok-variant="danger" - body-class="gl-display-none" data-testid="dast-profile-form-cancel-modal" - @ok="discard" - /> + @change="keepEditing" + @canceled="keepEditing" + @primary="discard" + > + {{ $options.i18n.discardChangesText }} + </gl-modal> </gl-form> </template> diff --git a/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_profiles_configurator/dast_profiles_configurator.vue b/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_profiles_configurator/dast_profiles_configurator.vue index 3bac20f105eaea2a2eb138a59ccce6128dcfab25..eb1e9fde25d5a141f722ffd32cd3c112a863e456 100644 --- a/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_profiles_configurator/dast_profiles_configurator.vue +++ b/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_profiles_configurator/dast_profiles_configurator.vue @@ -8,6 +8,7 @@ import { TYPE_SCANNER_PROFILE, TYPE_SITE_PROFILE } from '~/graphql_shared/consta import DastProfilesSidebar from 'ee/security_configuration/dast_profiles/dast_profiles_sidebar/dast_profiles_sidebar.vue'; import ScannerProfileSelector from 'ee/security_configuration/dast_profiles/dast_profile_selector/scanner_profile_selector.vue'; import SiteProfileSelector from 'ee/security_configuration/dast_profiles/dast_profile_selector/site_profile_selector.vue'; +import dastProfileConfiguratorMixin from 'ee/security_configuration/dast_profiles/dast_profiles_configurator_mixin'; import { DAST_CONFIGURATION_HELP_PATH, SCANNER_TYPE, @@ -83,6 +84,7 @@ export default { SITE_PROFILES_QUERY, ), }, + mixins: [dastProfileConfiguratorMixin()], props: { configurationHeader: { type: String, @@ -124,11 +126,9 @@ export default { return { scannerProfiles: [], siteProfiles: [], - sidebarViewMode: SIDEBAR_VIEW_MODE.READING_MODE, errorType: null, isSideDrawerOpen: false, - profileType: '', - activeProfile: {}, + activeProfile: undefined, selectedScannerProfileId: this.savedProfiles?.dastScannerProfile.id || null, selectedSiteProfileId: this.savedProfiles?.dastSiteProfile.id || null, }; @@ -196,14 +196,31 @@ export default { findSavedProfileId(profiles, name) { return profiles.find(({ profileName }) => name === profileName)?.id || null; }, - enableEditingMode(type) { - this.selectActiveProfile(type); - this.openProfileDrawer({ profileType: type, mode: SIDEBAR_VIEW_MODE.EDITING_MODE }); + enableEditingMode({ profileType }) { + this.resetActiveProfile(); + + this.$nextTick(() => { + this.selectActiveProfile(profileType); + this.openProfileDrawer({ profileType, mode: SIDEBAR_VIEW_MODE.EDITING_MODE }); + }); + }, + reopenProfileDrawer() { + this.isSideDrawerOpen = false; + this.$nextTick(() => { + this.isSideDrawerOpen = true; + }); }, - openProfileDrawer({ profileType, mode }) { + async openProfileDrawer({ profileType, mode }) { + if (this.sharedData.formTouched) { + this.toggleModal({ showModal: true }); + await this.setCachedPayload({ profileType, mode }); + + return; + } + + await this.goFirstStep({ profileType, mode }); + this.isSideDrawerOpen = false; - this.sidebarViewMode = mode; - this.profileType = profileType; this.$nextTick(() => { this.isSideDrawerOpen = true; }); @@ -211,14 +228,15 @@ export default { closeProfileDrawer() { this.isSideDrawerOpen = false; this.activeProfile = {}; - this.sidebarViewMode = SIDEBAR_VIEW_MODE.READING_MODE; }, selectActiveProfile(type) { this.activeProfile = type === SCANNER_TYPE ? this.selectedScannerProfile : this.selectedSiteProfile; }, - selectProfile(payload) { + async selectProfile(payload) { this.updateProfileFromSelector(payload); + await this.goBack(); + this.closeProfileDrawer(); }, isNewProfile(id) { @@ -251,6 +269,9 @@ export default { const type = `${profileType}Profiles`; this.$apollo.queries[type].refetch(); }, + resetActiveProfile() { + this.activeProfile = undefined; + }, }, }; </script> @@ -282,7 +303,11 @@ export default { mode: $options.SIDEBAR_VIEW_MODE.READING_MODE, }) " - @edit="enableEditingMode($options.SCANNER_TYPE)" + @edit=" + enableEditingMode({ + profileType: $options.SCANNER_TYPE, + }) + " /> <site-profile-selector @@ -295,7 +320,11 @@ export default { mode: $options.SIDEBAR_VIEW_MODE.READING_MODE, }) " - @edit="enableEditingMode($options.SITE_TYPE)" + @edit=" + enableEditingMode({ + profileType: $options.SITE_TYPE, + }) + " /> </template> </section-layout> @@ -305,13 +334,11 @@ export default { :profile-id-in-use="profileIdInUse" :active-profile="activeProfile" :library-link="libraryLink" - :profile-type="profileType" :is-open="isSideDrawerOpen" :is-loading="isLoadingProfiles" :selected-profile-id="selectedProfileId" - :sidebar-view-mode="sidebarViewMode" @close-drawer="closeProfileDrawer" - @reopen-drawer="openProfileDrawer" + @reopen-drawer="reopenProfileDrawer" @select-profile="selectProfile" @profile-submitted="onScannerProfileCreated" /> diff --git a/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_profiles_configurator_mixin.js b/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_profiles_configurator_mixin.js new file mode 100644 index 0000000000000000000000000000000000000000..592e58e42caba8d5e674e884ec9eb5f698dc1a22 --- /dev/null +++ b/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_profiles_configurator_mixin.js @@ -0,0 +1,99 @@ +import { SCANNER_TYPE, SIDEBAR_VIEW_MODE } from 'ee/on_demand_scans/constants'; +import getSharedStateQuery from 'ee/vue_shared/security_configuration/graphql/client/queries/shared_sidebar_state.query.graphql'; +import goFirstStepMutation from 'ee/vue_shared/security_configuration/graphql/client/mutations/go_first_history_step.mutation.graphql'; +import goForwardMutation from 'ee/vue_shared/security_configuration/graphql/client/mutations/go_forward_history.mutation.graphql'; +import discardMutationsMutation from 'ee/vue_shared/security_configuration/graphql/client/mutations/discard_changes.mutation.graphql'; +import toggleModalMutation from 'ee/vue_shared/security_configuration/graphql/client/mutations/toggle_modal.mutation.graphql'; +import resetHistoryMutation from 'ee/vue_shared/security_configuration/graphql/client/mutations/reset_history.mutation.graphql'; +import setFormTouchedMutation from 'ee/vue_shared/security_configuration/graphql/client/mutations/set_form_touched.mutation.graphql'; +import setResetAndClose from 'ee/vue_shared/security_configuration/graphql/client/mutations/set_reset_and_close.mutation.graphql'; +import setCachedPayloadMutation from 'ee/vue_shared/security_configuration/graphql/client/mutations/set_cached_payload.mutation.graphql'; +import goBackMutation from 'ee/vue_shared/security_configuration/graphql/client/mutations/go_back_history.mutation.graphql'; + +export default () => ({ + apollo: { + sharedData: { + query: getSharedStateQuery, + }, + }, + data() { + return { + sharedData: {}, + }; + }, + computed: { + hasCachedPayload() { + return Boolean(this.sharedData.cachedPayload?.profileType); + }, + cachedPayload() { + return this.sharedData.cachedPayload; + }, + lastHistoryIndex() { + return (this.sharedData.history?.length || 0) > 0 ? this.sharedData.history.length - 1 : 0; + }, + profileType() { + return this.sharedData.history?.[this.lastHistoryIndex]?.profileType || SCANNER_TYPE; + }, + sidebarViewMode() { + return ( + this.sharedData.history?.[this.lastHistoryIndex]?.mode || SIDEBAR_VIEW_MODE.READING_MODE + ); + }, + eventName() { + return (this.sharedData.history?.length || 0) > 0 ? 'reopen-drawer' : 'close-drawer'; + }, + }, + methods: { + goFirstStep({ profileType, mode }) { + return this.$apollo.mutate({ + mutation: goFirstStepMutation, + variables: { profileType, mode }, + }); + }, + goForward({ profileType, mode }) { + return this.$apollo.mutate({ + mutation: goForwardMutation, + variables: { profileType, mode }, + }); + }, + goBack() { + return this.$apollo.mutate({ + mutation: goBackMutation, + }); + }, + toggleModal({ showModal }) { + return this.$apollo.mutate({ + mutation: toggleModalMutation, + variables: { showDiscardChangesModal: showModal }, + }); + }, + discardChanges() { + return this.$apollo.mutate({ + mutation: discardMutationsMutation, + }); + }, + setFormTouched({ isTouched }) { + return this.$apollo.mutate({ + mutation: setFormTouchedMutation, + variables: { formTouched: isTouched }, + }); + }, + resetHistory() { + return this.$apollo.mutate({ + mutation: resetHistoryMutation, + }); + }, + setCachedPayload({ profileType, mode } = { profileType: '', mode: '' }) { + return this.$apollo.mutate({ + mutation: setCachedPayloadMutation, + variables: { cachedPayload: { profileType, mode } }, + }); + }, + setResetAndClose({ resetAndClose }) { + return this.$apollo.mutate({ + mutation: setResetAndClose, + variables: { resetAndClose }, + }); + }, + }, +}); diff --git a/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_profiles_sidebar/dast_profiles_sidebar.vue b/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_profiles_sidebar/dast_profiles_sidebar.vue index fb6f52439bb867de6f592297844e62a1483a775b..57e389de5473a7f25880173b4b863747475ae75b 100644 --- a/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_profiles_sidebar/dast_profiles_sidebar.vue +++ b/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_profiles_sidebar/dast_profiles_sidebar.vue @@ -2,35 +2,15 @@ import { isEmpty } from 'lodash'; import { GlDrawer, GlLink } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; -import { SCANNER_TYPE, SIDEBAR_VIEW_MODE } from 'ee/on_demand_scans/constants'; -import { REFERRAL } from 'ee/security_configuration/dast_profiles/dast_scanner_profiles/constants'; +import { SIDEBAR_VIEW_MODE } from 'ee/on_demand_scans/constants'; import DastProfilesLoader from 'ee/security_configuration/dast_profiles/components/dast_profiles_loader.vue'; import { getContentWrapperHeight } from 'ee/security_orchestration/utils'; +import dastProfileConfiguratorMixin from 'ee/security_configuration/dast_profiles/dast_profiles_configurator_mixin'; import DastProfilesSidebarHeader from './dast_profiles_sidebar_header.vue'; import DastProfilesSidebarEmptyState from './dast_profiles_sidebar_empty_state.vue'; import DastProfilesSidebarForm from './dast_profiles_sidebar_form.vue'; import DastProfilesSidebarList from './dast_profiles_sidebar_list.vue'; -/** - * Referral - * / \ - * Parent Self - * / \ / \ - * New Edit New Edit - * / \ / \ / \ / \ - * C R C C C R R R - * - * New profile or edit existing can be called both from component and parent - * When form is opened it can be closed either by submit or cancel - * This tree represent behaviour of a drawer. - * Bottom level left subtree is after submit right subtree is after cancel - * - * For example Opened from parent -> new profile -> close after submit or reopen after cancel - * - * C-close - * R-reopen - */ - export default { SIDEBAR_VIEW_MODE, i18n: { @@ -45,6 +25,7 @@ export default { DastProfilesSidebarForm, DastProfilesSidebarList, }, + mixins: [dastProfileConfiguratorMixin()], props: { isOpen: { type: Boolean, @@ -66,15 +47,6 @@ export default { required: false, default: null, }, - /** - * String type in case - * there will be more types - */ - profileType: { - type: String, - required: false, - default: SCANNER_TYPE, - }, isLoading: { type: Boolean, required: false, @@ -92,11 +64,6 @@ export default { required: false, default: () => ({}), }, - sidebarViewMode: { - type: String, - required: false, - default: SIDEBAR_VIEW_MODE.READING_MODE, - }, libraryLink: { type: String, required: false, @@ -106,7 +73,7 @@ export default { data() { return { profileForEditing: {}, - referral: REFERRAL.SELF, + sharedData: {}, }; }, computed: { @@ -144,53 +111,73 @@ export default { watch: { activeProfile(newVal) { if (!isEmpty(newVal)) { - this.referral = REFERRAL.PARENT; - this.enableEditingMode({ - profile: this.activeProfile, - mode: SIDEBAR_VIEW_MODE.EDITING_MODE, - }); + this.profileForEditing = this.activeProfile; } }, }, methods: { - resetAndEmitCloseEvent() { + async resetAndEmitCloseEvent() { + if (this.sharedData.formTouched) { + await this.toggleModal({ showModal: true }); + await this.setResetAndClose({ resetAndClose: true }); + + return; + } + this.resetEditingMode(); + await this.resetHistory(); this.$emit('close-drawer'); }, resetEditingMode() { this.profileForEditing = {}; - this.referral = REFERRAL.SELF; }, enableEditingMode({ profile = {}, mode }) { this.profileForEditing = profile; + + this.goForward({ profileType: this.profileType, mode }); this.$emit('reopen-drawer', { profileType: this.profileType, mode }); }, /** * reopen even for closing editing layer * and opening drawer with profiles list */ - cancelEditingMode() { - const event = this.referral === REFERRAL.PARENT ? 'close-drawer' : 'reopen-drawer'; + async cancelEditingMode() { + if (this.sharedData.resetAndClose) { + await this.resetAndEmitCloseEvent(); + this.setResetAndClose({ resetAndClose: false }); + } - this.$emit(event, { profileType: this.profileType, mode: SIDEBAR_VIEW_MODE.READING_MODE }); - this.resetEditingMode(); + await this.goBack(); + + if (this.hasCachedPayload) { + await this.goFirstStep(this.cachedPayload); + } + + this.$emit(this.eventName, { profileType: this.profileType, mode: this.sidebarViewMode }); + await this.setCachedPayload(undefined); }, - profileCreated(profile) { + async profileCreated(profile) { this.$emit('profile-submitted', { profile, profileType: this.profileType }); - this.$emit('close-drawer', { + + await this.resetHistory(); + this.$emit(this.eventName, { profileType: this.profileType, mode: SIDEBAR_VIEW_MODE.READING_MODE, }); + + await this.discardChanges(); this.resetEditingMode(); }, - profileEdited(profile) { + async profileEdited(profile) { this.$emit('profile-submitted', { profile, profileType: this.profileType }); - const secondaryEvent = this.referral === REFERRAL.PARENT ? 'close-drawer' : 'reopen-drawer'; - this.$emit(secondaryEvent, { + await this.goBack(); + this.$emit(this.eventName, { profileType: this.profileType, mode: SIDEBAR_VIEW_MODE.READING_MODE, }); + + await this.discardChanges(); this.resetEditingMode(); }, }, diff --git a/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_scanner_profiles/components/dast_scanner_profile_form.vue b/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_scanner_profiles/components/dast_scanner_profile_form.vue index 5995c3a7514e3551c69079935585f98190f0b542..2ede07bcb81ab0cca51c07a846e41112d9eafdfc 100644 --- a/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_scanner_profiles/components/dast_scanner_profile_form.vue +++ b/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_scanner_profiles/components/dast_scanner_profile_form.vue @@ -10,7 +10,7 @@ import { } from '@gitlab/ui'; import { initFormField } from 'ee/security_configuration/utils'; import { serializeFormObject } from '~/lib/utils/forms'; -import { __, s__ } from '~/locale'; +import { s__ } from '~/locale'; import BaseDastProfileForm from '../../components/base_dast_profile_form.vue'; import dastProfileFormMixin from '../../dast_profile_form_mixin'; import { SCAN_TYPE, SCAN_TYPE_OPTIONS } from '../constants'; @@ -94,13 +94,6 @@ export default { errorMessage: isEdit ? s__('DastProfiles|Could not update the scanner profile. Please try again.') : s__('DastProfiles|Could not create the scanner profile. Please try again.'), - modal: { - title: isEdit - ? s__('DastProfiles|Do you want to discard your changes?') - : s__('DastProfiles|Do you want to discard this scanner profile?'), - okTitle: __('Discard'), - cancelTitle: __('Cancel'), - }, tooltips: { spiderTimeout: s__( 'DastProfiles|The maximum number of minutes allowed for the spider to traverse the site.', @@ -142,11 +135,6 @@ export default { :is-policy-profile="isPolicyProfile" :block-submit="!form.state" :show-header="!stacked" - :modal-props="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { - title: i18n.modal.title, - okTitle: i18n.modal.okTitle, - cancelTitle: i18n.modal.cancelTitle, - } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" @submit="form.showValidation = true" v-on="$listeners" > diff --git a/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_scanner_profiles/dast_scanner_profiles_bundle.js b/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_scanner_profiles/dast_scanner_profiles_bundle.js index 84abf39f5093224ec5c0b8402f0ac78d64c27c9d..bd48200620e873c7bdc55e0bb91ab18cb8e77eed 100644 --- a/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_scanner_profiles/dast_scanner_profiles_bundle.js +++ b/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_scanner_profiles/dast_scanner_profiles_bundle.js @@ -1,8 +1,8 @@ import Vue from 'vue'; import { returnToPreviousPageFactory } from 'ee/security_configuration/dast_profiles/redirect'; +import apolloProvider from 'ee/vue_shared/security_configuration/graphql/provider'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import DastScannerProfileForm from './components/dast_scanner_profile_form.vue'; -import apolloProvider from './graphql/provider'; export default () => { const el = document.querySelector('.js-dast-scanner-profile-form'); diff --git a/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_scanner_profiles/graphql/provider.js b/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_scanner_profiles/graphql/provider.js deleted file mode 100644 index ef96b443da8d33b03b587827a13b5d9c53a40fd3..0000000000000000000000000000000000000000 --- a/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_scanner_profiles/graphql/provider.js +++ /dev/null @@ -1,9 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; - -Vue.use(VueApollo); - -export default new VueApollo({ - defaultClient: createDefaultClient(), -}); diff --git a/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_site_profiles/components/dast_site_profile_form.vue b/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_site_profiles/components/dast_site_profile_form.vue index f65692b252e888d6435e2b27d48e3987197aef4d..fd556a7f84c80e3f8e5be78a3b061e91ba978655 100644 --- a/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_site_profiles/components/dast_site_profile_form.vue +++ b/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_site_profiles/components/dast_site_profile_form.vue @@ -106,13 +106,6 @@ export default { errorMessage: isEdit ? s__('DastProfiles|Could not update the site profile. Please try again.') : s__('DastProfiles|Could not create the site profile. Please try again.'), - modal: { - title: isEdit - ? s__('DastProfiles|Do you want to discard your changes?') - : s__('DastProfiles|Do you want to discard this site profile?'), - okTitle: __('Discard'), - cancelTitle: __('Cancel'), - }, excludedUrls: { label: this.isTargetAPI ? s__('DastProfiles|Excluded paths (optional)') @@ -233,11 +226,6 @@ export default { :is-policy-profile="isPolicyProfile" :block-submit="isSubmitBlocked" :show-header="!stacked" - :modal-props="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { - title: i18n.modal.title, - okTitle: i18n.modal.okTitle, - cancelTitle: i18n.modal.cancelTitle, - } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" @submit="form.showValidation = true" v-on="$listeners" > diff --git a/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_site_profiles/dast_site_profiles_bundle.js b/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_site_profiles/dast_site_profiles_bundle.js index cf32c7fd1ed64460f3c2587ea29d3ec2e98c281b..71bea316f560253071b3253abaf5e8a8751c96f4 100644 --- a/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_site_profiles/dast_site_profiles_bundle.js +++ b/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_site_profiles/dast_site_profiles_bundle.js @@ -1,8 +1,8 @@ import Vue from 'vue'; import { returnToPreviousPageFactory } from 'ee/security_configuration/dast_profiles/redirect'; +import apolloProvider from 'ee/vue_shared/security_configuration/graphql/provider'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import DastSiteProfileForm from './components/dast_site_profile_form.vue'; -import apolloProvider from './graphql/provider'; export default () => { const el = document.querySelector('.js-dast-site-profile-form'); diff --git a/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_site_profiles/graphql/provider.js b/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_site_profiles/graphql/provider.js deleted file mode 100644 index ef96b443da8d33b03b587827a13b5d9c53a40fd3..0000000000000000000000000000000000000000 --- a/ee/app/assets/javascripts/security_configuration/dast_profiles/dast_site_profiles/graphql/provider.js +++ /dev/null @@ -1,9 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; - -Vue.use(VueApollo); - -export default new VueApollo({ - defaultClient: createDefaultClient(), -}); diff --git a/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/mutations/discard_changes.mutation.graphql b/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/mutations/discard_changes.mutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..17891f8a83ed9d502c811e037f6592cfb2847906 --- /dev/null +++ b/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/mutations/discard_changes.mutation.graphql @@ -0,0 +1,3 @@ +mutation discardChanges { + discardChanges @client +} diff --git a/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/mutations/go_back_history.mutation.graphql b/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/mutations/go_back_history.mutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..ad079622389ddf9b9a0bd9c684d565b7501ad1d8 --- /dev/null +++ b/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/mutations/go_back_history.mutation.graphql @@ -0,0 +1,3 @@ +mutation goBack { + goBack @client +} diff --git a/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/mutations/go_first_history_step.mutation.graphql b/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/mutations/go_first_history_step.mutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..243e18179d330a4049cf20103fe69aef8e24915e --- /dev/null +++ b/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/mutations/go_first_history_step.mutation.graphql @@ -0,0 +1,3 @@ +mutation goFirstStep($profileType: String!, $mode: String!) { + goFirstStep(profileType: $profileType, mode: $mode) @client +} diff --git a/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/mutations/go_forward_history.mutation.graphql b/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/mutations/go_forward_history.mutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..74787a151212a284fe4e20b38194f2fcc9c3a50d --- /dev/null +++ b/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/mutations/go_forward_history.mutation.graphql @@ -0,0 +1,3 @@ +mutation goForward($profileType: String!, $mode: String!) { + goForward(profileType: $profileType, mode: $mode) @client +} diff --git a/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/mutations/reset_history.mutation.graphql b/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/mutations/reset_history.mutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..720f568adca272405730678d895a037668e08a91 --- /dev/null +++ b/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/mutations/reset_history.mutation.graphql @@ -0,0 +1,3 @@ +mutation resetHistory { + resetHistory @client +} diff --git a/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/mutations/set_cached_payload.mutation.graphql b/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/mutations/set_cached_payload.mutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..32a965865fd2ab91c589ce1104161f92069f8854 --- /dev/null +++ b/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/mutations/set_cached_payload.mutation.graphql @@ -0,0 +1,3 @@ +mutation setCachedPayload($cachedPayload: CachedPayloadInput!) { + setCachedPayload(cachedPayload: $cachedPayload) @client +} diff --git a/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/mutations/set_form_touched.mutation.graphql b/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/mutations/set_form_touched.mutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..3f3e4df9960ec503b809940fa389667f64aa136d --- /dev/null +++ b/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/mutations/set_form_touched.mutation.graphql @@ -0,0 +1,3 @@ +mutation setFormTouched($formTouched: Boolean!) { + setFormTouched(formTouched: $formTouched) @client +} diff --git a/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/mutations/set_reset_and_close.mutation.graphql b/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/mutations/set_reset_and_close.mutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..cfb5eea3d9834db0c81982b41773d9a2ec691f60 --- /dev/null +++ b/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/mutations/set_reset_and_close.mutation.graphql @@ -0,0 +1,3 @@ +mutation setResetAndClose($resetAndClose: Boolean!) { + setResetAndClose(resetAndClose: $resetAndClose) @client +} diff --git a/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/mutations/toggle_modal.mutation.graphql b/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/mutations/toggle_modal.mutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..b3a8a02d5551b5e27a0ed98c308c0e1a81d70245 --- /dev/null +++ b/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/mutations/toggle_modal.mutation.graphql @@ -0,0 +1,3 @@ +mutation toggleModal($showDiscardChangesModal: Boolean!) { + toggleModal(showDiscardChangesModal: $showDiscardChangesModal) @client +} diff --git a/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/queries/shared_sidebar_state.query.graphql b/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/queries/shared_sidebar_state.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..7517fa3fe66d8f79cd8090de395c75f4ae07a093 --- /dev/null +++ b/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/client/queries/shared_sidebar_state.query.graphql @@ -0,0 +1,15 @@ +query sharedData { + sharedData @client { + showDiscardChangesModal + formTouched + history { + profileType + mode + } + cachedPayload { + profileType + mode + } + resetAndClose + } +} diff --git a/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/provider.js b/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/provider.js new file mode 100644 index 0000000000000000000000000000000000000000..4e8fb96c3ad7e8f7b3a95682e3b82187cc548435 --- /dev/null +++ b/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/provider.js @@ -0,0 +1,38 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import resolvers from './resolvers/resolvers'; +import typeDefs from './typedefs.graphql'; + +Vue.use(VueApollo); + +export const typePolicies = { + Query: { + fields: { + sharedData: { + read(cachedData) { + return ( + cachedData || { + showDiscardChangesModal: false, + formTouched: false, + history: [], + cachedPayload: { + __typename: 'CachedPayload', + profileType: '', + mode: '', + }, + resetAndClose: false, + __typename: 'SharedData', + } + ); + }, + }, + }, + }, +}; + +const defaultClient = createDefaultClient(resolvers, { cacheConfig: { typePolicies }, typeDefs }); + +export default new VueApollo({ + defaultClient, +}); diff --git a/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/resolvers/resolvers.js b/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/resolvers/resolvers.js new file mode 100644 index 0000000000000000000000000000000000000000..c3d3e37ab30351fdc1e634ff8e1fb2951a682fbd --- /dev/null +++ b/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/resolvers/resolvers.js @@ -0,0 +1,79 @@ +import getSharedStateQuery from '../client/queries/shared_sidebar_state.query.graphql'; + +export default { + Mutation: { + discardChanges(_, __, { cache }) { + cache.updateQuery({ query: getSharedStateQuery }, ({ sharedData }) => ({ + sharedData: { + ...sharedData, + showDiscardChangesModal: false, + formTouched: false, + }, + })); + }, + goFirstStep(_, { profileType, mode }, { cache }) { + cache.updateQuery({ query: getSharedStateQuery }, ({ sharedData }) => ({ + sharedData: { + ...sharedData, + history: [{ profileType, mode }], + }, + })); + }, + goForward(_, { profileType, mode }, { cache }) { + cache.updateQuery({ query: getSharedStateQuery }, ({ sharedData }) => ({ + sharedData: { + ...sharedData, + history: [...sharedData.history, { profileType, mode }], + }, + })); + }, + goBack(_, __, { cache }) { + cache.updateQuery({ query: getSharedStateQuery }, ({ sharedData }) => ({ + sharedData: { + ...sharedData, + history: sharedData.history.slice(0, -1), + }, + })); + }, + resetHistory(_, __, { cache }) { + cache.updateQuery({ query: getSharedStateQuery }, ({ sharedData }) => ({ + sharedData: { + ...sharedData, + history: [], + }, + })); + }, + toggleModal(_, { showDiscardChangesModal }, { cache }) { + cache.updateQuery({ query: getSharedStateQuery }, ({ sharedData }) => ({ + sharedData: { + ...sharedData, + showDiscardChangesModal, + }, + })); + }, + setCachedPayload(_, { cachedPayload = { profileType: '', mode: '' } }, { cache }) { + cache.updateQuery({ query: getSharedStateQuery }, ({ sharedData }) => ({ + sharedData: { + ...sharedData, + cachedPayload, + }, + })); + }, + setFormTouched(_, { formTouched }, { cache }) { + cache.updateQuery({ query: getSharedStateQuery }, ({ sharedData }) => ({ + sharedData: { + ...sharedData, + formTouched, + }, + })); + }, + setResetAndClose(_, { resetAndClose }, { cache }) { + cache.updateQuery({ query: getSharedStateQuery }, ({ sharedData }) => ({ + sharedData: { + ...sharedData, + resetAndClose, + }, + })); + }, + }, +}; diff --git a/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/typedefs.graphql b/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/typedefs.graphql new file mode 100644 index 0000000000000000000000000000000000000000..4004f0c69c24eed6fe1bbd9ea5a24c1e5e2b8337 --- /dev/null +++ b/ee/app/assets/javascripts/vue_shared/security_configuration/graphql/typedefs.graphql @@ -0,0 +1,33 @@ +type CachedPayload { + profileType: String! + mode: String! +} + +input CachedPayloadInput { + profileType: String! + mode: String! +} + +type SharedData { + showDiscardChangesModal: Boolean + formTouched: Boolean + history: [CachedPayload!]! + cachedPayload: CachedPayload! + resetAndClose: Boolean +} + +extend type Query { + sharedData: SharedData +} + +extend type Mutation { + discardChanges: LocalErrors + goBack: LocalErrors + goFirstStep(profileType: String!, mode: String!): LocalErrors + goForward(profileType: String!, mode: String!): LocalErrors + resetHistory: LocalErrors + setCachedPayload(cachedPayload: CachedPayloadInput): LocalErrors + setFormTouched(formTouched: Boolean!): LocalErrors + setResetAndClose(resetAndClose: Boolean!): LocalErrors + toggleModal(showDiscardChangesModal: Boolean!): LocalErrors +} diff --git a/ee/spec/frontend/security_configuration/dast_profiles/components/base_dast_profile_form_spec.js b/ee/spec/frontend/security_configuration/dast_profiles/components/base_dast_profile_form_spec.js index 605cba0f356a8edda03601fe724baf474f229d93..8436b8621138aa7c8339ed7fec0d30599621c99d 100644 --- a/ee/spec/frontend/security_configuration/dast_profiles/components/base_dast_profile_form_spec.js +++ b/ee/spec/frontend/security_configuration/dast_profiles/components/base_dast_profile_form_spec.js @@ -5,6 +5,8 @@ import VueApollo from 'vue-apollo'; import BaseDastProfileForm from 'ee/security_configuration/dast_profiles/components/base_dast_profile_form.vue'; import dastSiteProfileCreateMutation from 'ee/security_configuration/dast_profiles/dast_site_profiles/graphql/dast_site_profile_create.mutation.graphql'; import { dastSiteProfileCreate } from 'ee_jest/security_configuration/dast_profiles/dast_site_profiles/mock_data/apollo_mock'; +import resolvers from 'ee/vue_shared/security_configuration/graphql/resolvers/resolvers'; +import { typePolicies } from 'ee/vue_shared/security_configuration/graphql/provider'; import createMockApollo from 'helpers/mock_apollo_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -42,7 +44,11 @@ describe('BaseDastProfileForm', () => { const expectSubmitNotLoading = () => expect(findSubmitButton().props('loading')).toBe(false); const createComponent = (options) => { - const apolloProvider = createMockApollo([[dastSiteProfileCreateMutation, requestHandler]]); + const apolloProvider = createMockApollo( + [[dastSiteProfileCreateMutation, requestHandler]], + resolvers, + { typePolicies }, + ); const mountOpts = merge( {}, @@ -145,10 +151,6 @@ describe('BaseDastProfileForm', () => { }, }); }); - - it('passes props to the modal', () => { - expect(findCancelModal().props()).toEqual(expect.objectContaining(modalProps)); - }); }); describe('when submitting the form', () => { @@ -248,24 +250,35 @@ describe('BaseDastProfileForm', () => { }); describe('after changing the form', () => { - beforeEach(() => { + it('asks the user to confirm the action', async () => { createComponent({ propsData: { formTouched: true, }, stubs: { GlModal }, }); - }); - it('asks the user to confirm the action', () => { - jest.spyOn(findCancelModal().vm, 'show').mockReturnValue(); + await waitForPromises(); + + const toggleModalMock = jest.spyOn(resolvers.Mutation, 'toggleModal').mockReturnValue(); findCancelButton().vm.$emit('click'); - expect(findCancelModal().vm.show).toHaveBeenCalled(); + await waitForPromises(); + + expect(toggleModalMock).toHaveBeenCalled(); }); - it('emits cancel event upon confirming', () => { - findCancelModal().vm.$emit('ok'); + it('emits cancel event upon confirming', async () => { + createComponent({ + propsData: { + formTouched: false, + }, + stubs: { GlModal }, + }); + await waitForPromises(); + + findCancelModal().vm.$emit('primary'); + await waitForPromises(); expect(wrapper.emitted('cancel')).toHaveLength(1); }); diff --git a/ee/spec/frontend/security_configuration/dast_profiles/dast_profiles_configurator/dast_profiles_configurator_spec.js b/ee/spec/frontend/security_configuration/dast_profiles/dast_profiles_configurator/dast_profiles_configurator_spec.js index bf5acae678fbe80461844a3563260fa20e83d3ab..106630ce38e54fb14e90ceb23ca6370d3f0fff63 100644 --- a/ee/spec/frontend/security_configuration/dast_profiles/dast_profiles_configurator/dast_profiles_configurator_spec.js +++ b/ee/spec/frontend/security_configuration/dast_profiles/dast_profiles_configurator/dast_profiles_configurator_spec.js @@ -1,7 +1,6 @@ import { merge } from 'lodash'; import { GlLink } from '@gitlab/ui'; import VueApollo from 'vue-apollo'; -import { nextTick } from 'vue'; import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; import siteProfilesFixtures from 'test_fixtures/graphql/security_configuration/dast_profiles/graphql/dast_site_profiles.query.graphql.basic.json'; import scannerProfilesFixtures from 'test_fixtures/graphql/security_configuration/dast_profiles/graphql/dast_scanner_profiles.query.graphql.basic.json'; @@ -18,6 +17,9 @@ import SectionLayout from '~/vue_shared/security_configuration/components/sectio import SectionLoader from '~/vue_shared/security_configuration/components/section_loader.vue'; import dastScannerProfilesQuery from 'ee/security_configuration/dast_profiles/graphql/dast_scanner_profiles.query.graphql'; import dastSiteProfilesQuery from 'ee/security_configuration/dast_profiles/graphql/dast_site_profiles.query.graphql'; +import resolvers from 'ee/vue_shared/security_configuration/graphql/resolvers/resolvers'; +import { typePolicies } from 'ee/vue_shared/security_configuration/graphql/provider'; +import waitForPromises from 'helpers/wait_for_promises'; import createApolloProvider from 'helpers/mock_apollo_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { @@ -31,21 +33,27 @@ describe('DastProfilesConfigurator', () => { let requestHandlers; const projectPath = 'projectPath'; - const createMockApolloProvider = (handlers) => { + const createMockApolloProvider = () => { localVue.use(VueApollo); requestHandlers = { dastScannerProfiles: jest.fn().mockResolvedValue(scannerProfilesFixtures), dastSiteProfiles: jest.fn().mockResolvedValue(siteProfilesFixtures), - ...handlers, }; - return createApolloProvider([ - [dastScannerProfilesQuery, requestHandlers.dastScannerProfiles], - [dastSiteProfilesQuery, requestHandlers.dastSiteProfiles], - ]); + return createApolloProvider( + [ + [dastScannerProfilesQuery, requestHandlers.dastScannerProfiles], + [dastSiteProfilesQuery, requestHandlers.dastSiteProfiles], + ], + resolvers, + { typePolicies }, + ); }; + const findModal = () => wrapper.findByTestId('dast-profile-form-cancel-modal'); + const findProfileNameInput = () => wrapper.findByTestId('profile-name-input'); + const findEditBtn = () => wrapper.findByTestId('profile-edit-btn'); const findNewScanButton = () => wrapper.findByTestId('new-profile-button'); const findOpenDrawerButton = () => wrapper.findByTestId('select-profile-action-btn'); const findCancelButton = () => wrapper.findByTestId('dast-profile-form-cancel-button'); @@ -62,25 +70,14 @@ describe('DastProfilesConfigurator', () => { const openDrawer = async () => { findOpenDrawerButton().vm.$emit('click'); - await nextTick(); + await waitForPromises(); }; - const createComponentFactory = (mountFn = shallowMount) => (options = {}, withHandlers) => { + const createComponentFactory = (mountFn = shallowMount) => (options = {}) => { localVue = createLocalVue(); - let defaultMocks = { - $apollo: { - mutate: jest.fn(), - queries: { - scannerProfiles: jest.fn(), - siteProfiles: jest.fn(), - }, - }, - }; - let apolloProvider; - if (withHandlers) { - apolloProvider = createMockApolloProvider(withHandlers); - defaultMocks = {}; - } + + const apolloProvider = createMockApolloProvider(); + wrapper = extendedWrapper( mountFn( DastProfilesConfigurator, @@ -90,18 +87,17 @@ describe('DastProfilesConfigurator', () => { propsData: { ...options, }, - mocks: defaultMocks, stubs: { SectionLayout, ScannerProfileSelector, SiteProfileSelector, + GlModal: true, }, provide: { projectPath, }, }, { ...options, localVue, apolloProvider }, - { data() { return { @@ -125,9 +121,10 @@ describe('DastProfilesConfigurator', () => { }); describe('when default state', () => { - it('renders properly', () => { + it('renders properly', async () => { const sectionHeader = s__('OnDemandScans|DAST configuration'); createComponent({ configurationHeader: sectionHeader }); + await waitForPromises(); expect(findSectionLayout().find('h2').text()).toContain(sectionHeader); expect(findScannerProfilesSelector().exists()).toBe(true); @@ -144,40 +141,22 @@ describe('DastProfilesConfigurator', () => { ); }); - it.each` - scannerProfilesLoading | siteProfilesLoading | isLoading - ${true} | ${true} | ${true} - ${false} | ${true} | ${true} - ${true} | ${false} | ${true} - ${false} | ${false} | ${false} - `( - 'sets loading state to $isLoading if scanner profiles loading is $scannerProfilesLoading and site profiles loading is $siteProfilesLoading', - ({ scannerProfilesLoading, siteProfilesLoading, isLoading }) => { - createShallowComponent({ - mocks: { - $apollo: { - queries: { - scannerProfiles: { loading: scannerProfilesLoading }, - siteProfiles: { loading: siteProfilesLoading }, - }, - }, - }, - }); + it('shows loader in loading state', () => { + createShallowComponent(); - expect(findSectionLoader().exists()).toBe(isLoading); - }, - ); + expect(findSectionLoader().exists()).toBe(true); + }); }); describe('profile drawer', () => { - beforeEach(() => { - createComponent(); + beforeEach(async () => { + createComponent({}, true); + await waitForPromises(); }); it('should open drawer with scanner profiles', async () => { findScannerProfilesSelector().vm.$emit('open-drawer'); - - await nextTick(); + await waitForPromises(); expect(findDastProfileSidebar().exists()).toBe(true); expect(findAllScannerProfileSummary()).toHaveLength(scannerProfiles.length); @@ -185,8 +164,7 @@ describe('DastProfilesConfigurator', () => { it('should open drawer with site profiles', async () => { findSiteProfilesSelector().vm.$emit('open-drawer'); - - await nextTick(); + await waitForPromises(); expect(findDastProfileSidebar().exists()).toBe(true); expect(findAllSiteProfileSummary()).toHaveLength(siteProfiles.length); @@ -199,8 +177,9 @@ describe('DastProfilesConfigurator', () => { dastSiteProfile: { id: siteProfiles[0].id }, }; - it('should have profiles selected if saved profiles exist', () => { + it('should have profiles selected if saved profiles exist', async () => { createComponent({ savedProfiles }); + await waitForPromises(); expect(findScannerProfilesSelector().find('h3').text()).toContain( scannerProfiles[0].profileName, @@ -215,7 +194,7 @@ describe('DastProfilesConfigurator', () => { beforeEach(async () => { createComponent({ savedSiteProfileName, savedScannerProfileName }, true); - await nextTick(); + await waitForPromises(); }); it('should have saved profiles selected', async () => { @@ -230,13 +209,14 @@ describe('DastProfilesConfigurator', () => { }); describe('switching between modes', () => { - beforeEach(() => { - createComponent(); + beforeEach(async () => { + createComponent({}, true); + await waitForPromises(); }); const expectEditingMode = async () => { findNewScanButton().vm.$emit('click'); - await nextTick(); + await waitForPromises(); expect(findDastProfilesSidebarForm().exists()).toBe(true); }; @@ -256,9 +236,35 @@ describe('DastProfilesConfigurator', () => { await expectEditingMode(); findCancelButton().vm.$emit('click'); - await nextTick(); + await waitForPromises(); expect(findDastProfilesSidebarList().exists()).toBe(true); }); }); + + describe('warning modal', () => { + beforeEach(async () => { + createComponent({}, true); + await waitForPromises(); + }); + + it('should show warning modal when changes are unsaved', async () => { + const mutateMock = jest.spyOn(wrapper.vm.$apollo, 'mutate'); + + findOpenDrawerButton().vm.$emit('click'); + await waitForPromises(); + + findEditBtn().vm.$emit('click'); + await waitForPromises(); + + findProfileNameInput().vm.$emit('input', 'another value'); + await waitForPromises(); + + findOpenDrawerButton().vm.$emit('click'); + await waitForPromises(); + + expect(findModal().attributes('visible')).toEqual(String(true)); + expect(mutateMock).toHaveBeenCalledTimes(3); + }); + }); }); diff --git a/ee/spec/frontend/security_configuration/dast_profiles/dast_profiles_sidebar/dast_profiles_sidebar_spec.js b/ee/spec/frontend/security_configuration/dast_profiles/dast_profiles_sidebar/dast_profiles_sidebar_spec.js index dce4c9954633503eda6404a80b6823b9b4bececc..e25f906147a08a1c75670ae430b3e6c0dcec8e56 100644 --- a/ee/spec/frontend/security_configuration/dast_profiles/dast_profiles_sidebar/dast_profiles_sidebar_spec.js +++ b/ee/spec/frontend/security_configuration/dast_profiles/dast_profiles_sidebar/dast_profiles_sidebar_spec.js @@ -1,24 +1,39 @@ import { GlDrawer, GlLink } from '@gitlab/ui'; -import { nextTick } from 'vue'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { __ } from '~/locale'; import { mountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import DastProfilesSidebar from 'ee/security_configuration/dast_profiles/dast_profiles_sidebar/dast_profiles_sidebar.vue'; import DastProfilesLoader from 'ee/security_configuration/dast_profiles/components/dast_profiles_loader.vue'; -import { scannerProfiles } from 'ee_jest/security_configuration/dast_profiles/mocks/mock_data'; -import { SCANNER_TYPE, SITE_TYPE, SIDEBAR_VIEW_MODE } from 'ee/on_demand_scans/constants'; +import { + scannerProfiles, + mockSharedData, +} from 'ee_jest/security_configuration/dast_profiles/mocks/mock_data'; +import { SCANNER_TYPE, SIDEBAR_VIEW_MODE } from 'ee/on_demand_scans/constants'; +import resolvers from 'ee/vue_shared/security_configuration/graphql/resolvers/resolvers'; +import { typePolicies } from 'ee/vue_shared/security_configuration/graphql/provider'; describe('DastProfilesSidebar', () => { let wrapper; + let fakeApollo; const projectPath = 'projectPath'; const libraryLink = 'libraryLink'; + Vue.use(VueApollo); + const createComponent = (options = {}) => { + fakeApollo = createMockApollo([], resolvers, { typePolicies }); + wrapper = mountExtended(DastProfilesSidebar, { + apolloProvider: fakeApollo, propsData: { ...options, }, stubs: { GlDrawer: true, + GlModal: true, }, provide: { projectPath, @@ -26,6 +41,9 @@ describe('DastProfilesSidebar', () => { }); }; + const findProfileNameInput = () => wrapper.findByTestId('profile-name-input'); + const findModal = () => wrapper.findByTestId('dast-profile-form-cancel-modal'); + const findEditButton = () => wrapper.findByTestId('profile-edit-btn'); const findSidebarHeader = () => wrapper.findByTestId('sidebar-header'); const findEmptyStateHeader = () => wrapper.findByTestId('empty-state-header'); const findNewScanButton = () => wrapper.findByTestId('new-profile-button'); @@ -36,70 +54,61 @@ describe('DastProfilesSidebar', () => { const findSkeletonLoader = () => wrapper.findComponent(DastProfilesLoader); const findGlDrawer = () => wrapper.findComponent(GlDrawer); + const openEditForm = async () => { + findEditButton().vm.$emit('click'); + await waitForPromises(); + }; + afterEach(() => { - wrapper.destroy(); + mockSharedData.history = []; }); - describe.each` - scannerType | header | expectedResult - ${SCANNER_TYPE} | ${'Scanner'} | ${SCANNER_TYPE} - ${SITE_TYPE} | ${'Site'} | ${SITE_TYPE} - `('should emit correct event', ({ scannerType, header, expectedResult }) => { - createComponent({ profileType: scannerType }); + it('should render empty state', async () => { + createComponent(); + + await waitForPromises(); + expect(findEmptyStateHeader().exists()).toBe(true); - expect(findEmptyStateHeader().text()).toContain(`No ${expectedResult} profiles found for DAST`); - expect(findSidebarHeader().text()).toContain(`${header} profile library`); + expect(findEmptyStateHeader().text()).toContain(`No ${SCANNER_TYPE} profiles found for DAST`); + expect(findSidebarHeader().text()).toContain('Scanner profile library'); }); - it('should render new scan button when profiles exists', () => { + it('should render new scan button when profiles exists', async () => { createComponent({ profiles: scannerProfiles }); + await waitForPromises(); expect(findNewScanButton().exists()).toBe(true); }); - it('should hide new scan button when no profiles exists', () => { + it('should hide new scan button when no profiles exists', async () => { createComponent(); - + await waitForPromises(); expect(findNewScanButton().exists()).toBe(false); }); - it('should open new scanner profile form when in editing mode', async () => { - createComponent({ - profileType: SCANNER_TYPE, - profiles: scannerProfiles, - sidebarViewMode: SIDEBAR_VIEW_MODE.EDITING_MODE, - }); - - await nextTick(); - - expect(findNewDastScannerProfileForm().exists()).toBe(true); - expect(findSidebarHeader().text()).toContain('New scanner profile'); - }); - describe('new profile form', () => { - describe.each` - scannerType | expectedResult - ${SCANNER_TYPE} | ${SCANNER_TYPE} - ${SITE_TYPE} | ${SITE_TYPE} - `('should emit correct event', ({ scannerType, expectedResult }) => { - createComponent({ profileType: scannerType }); + it('should emit correct event', async () => { + createComponent(); + await waitForPromises(); + findEmptyNewScanButton().vm.$emit('click'); + await waitForPromises(); expect(wrapper.emitted()).toEqual({ - 'reopen-drawer': [[{ mode: SIDEBAR_VIEW_MODE.EDITING_MODE, profileType: expectedResult }]], + 'reopen-drawer': [[{ mode: SIDEBAR_VIEW_MODE.EDITING_MODE, profileType: SCANNER_TYPE }]], }); expect(findNewScanButton().exists()).toBe(false); }); it('should close form when cancelled', async () => { - createComponent({ profileType: SITE_TYPE, sidebarViewMode: SIDEBAR_VIEW_MODE.EDITING_MODE }); + createComponent({ profiles: scannerProfiles }); + await waitForPromises(); - findCancelButton().vm.$emit('click'); + await openEditForm(); - await nextTick(); + findCancelButton().vm.$emit('click'); + await waitForPromises(); - expect(wrapper.emitted()).toEqual({ - 'reopen-drawer': [[{ mode: SIDEBAR_VIEW_MODE.READING_MODE, profileType: SITE_TYPE }]], - }); + expect(wrapper.emitted()['close-drawer']).toBeTruthy(); }); }); @@ -113,12 +122,11 @@ describe('DastProfilesSidebar', () => { describe('editing mode', () => { it('should be possible to edit profile', async () => { createComponent({ - profileType: SCANNER_TYPE, profiles: scannerProfiles, - sidebarViewMode: SIDEBAR_VIEW_MODE.EDITING_MODE, }); - wrapper.setProps({ activeProfile: scannerProfiles[0] }); - await nextTick(); + await waitForPromises(); + + await openEditForm(); expect(findNewDastScannerProfileForm().exists()).toBe(true); expect(findNewScanButton().exists()).toBe(false); @@ -127,36 +135,108 @@ describe('DastProfilesSidebar', () => { }); describe('sticky header', () => { - it('should have sticky header always enabled', () => { + it('should have sticky header always enabled', async () => { createComponent(); + await waitForPromises(); - expect(findGlDrawer().props('headerSticky')).toEqual(true); + expect(findGlDrawer().props('headerSticky')).toBe(true); }); }); describe('sticky footer', () => { - it.each` - profileType | expectedResult - ${SCANNER_TYPE} | ${__(`Manage ${SCANNER_TYPE} profiles`)} - ${SITE_TYPE} | ${__(`Manage ${SITE_TYPE} profiles`)} - `('renders correctly for $profileType profiles', ({ profileType, expectedResult }) => { - createComponent({ profileType, libraryLink }); - - expect(findFooterLink().text()).toBe(expectedResult); + it('renders correctly', async () => { + createComponent({ libraryLink }); + await waitForPromises(); + + expect(findFooterLink().text()).toBe(__(`Manage ${SCANNER_TYPE} profiles`)); expect(findFooterLink().attributes('href')).toEqual(libraryLink); }); - it.each` - sidebarViewMode | expectedResult - ${SIDEBAR_VIEW_MODE.READING_MODE} | ${true} - ${SIDEBAR_VIEW_MODE.EDITING_MODE} | ${false} - `( - 'should set footer visibility as $expectedResult for $sidebarViewMode mode', - ({ sidebarViewMode, expectedResult }) => { - createComponent({ profiles: scannerProfiles, sidebarViewMode, libraryLink }); + it('should have footer in reading mode', async () => { + createComponent({ profiles: scannerProfiles, libraryLink }); + await waitForPromises(); - expect(findFooterLink().exists()).toBe(expectedResult); - }, - ); + expect(findFooterLink().exists()).toBe(true); + }); + + it('should have footer hidden in editing mode', async () => { + createComponent({ profiles: scannerProfiles, libraryLink }); + await waitForPromises(); + + await openEditForm(); + + expect(findFooterLink().exists()).toBe(false); + }); + }); + + describe('warning modal', () => { + it('should show modal on cancel if there are unsaved changes', async () => { + const showModalMock = jest.spyOn(resolvers.Mutation, 'toggleModal'); + createComponent({ profiles: scannerProfiles }); + await waitForPromises(); + + await openEditForm(); + + findProfileNameInput().vm.$emit('input', 'another value'); + await waitForPromises(); + + findCancelButton().vm.$emit('click'); + await waitForPromises(); + + expect(findModal().attributes('visible')).toEqual(String(true)); + expect(showModalMock).toHaveBeenCalledTimes(1); + }); + + it('should show modal before closing drawer if there are unsaved changes', async () => { + const showModalMock = jest.spyOn(resolvers.Mutation, 'toggleModal'); + createComponent({ profiles: scannerProfiles }); + await waitForPromises(); + + await openEditForm(); + + findProfileNameInput().vm.$emit('input', 'another value'); + await waitForPromises(); + + findGlDrawer().vm.$emit('close'); + await waitForPromises(); + + expect(findModal().attributes('visible')).toEqual(String(true)); + expect(showModalMock).toHaveBeenCalledTimes(1); + }); + + it('should reset state history and close drawer if there are no unsaved changes', async () => { + const resetHistoryMock = jest.spyOn(resolvers.Mutation, 'resetHistory'); + createComponent({ profiles: scannerProfiles }); + await waitForPromises(); + + findGlDrawer().vm.$emit('close'); + await waitForPromises(); + + expect(resetHistoryMock).toHaveBeenCalledTimes(1); + expect(wrapper.emitted()['close-drawer']).toBeTruthy(); + }); + + it('should discard changes from warning modal', async () => { + const goBackMock = jest.spyOn(resolvers.Mutation, 'goBack'); + const setCachedPayloadMock = jest.spyOn(resolvers.Mutation, 'setCachedPayload'); + createComponent({ profiles: scannerProfiles }); + await waitForPromises(); + + await openEditForm(); + + findProfileNameInput().vm.$emit('input', 'another value'); + await waitForPromises(); + + findGlDrawer().vm.$emit('close'); + await waitForPromises(); + + findModal().vm.$emit('primary'); + await waitForPromises(); + + expect(findModal().exists()).toBe(false); + expect(goBackMock).toHaveBeenCalledTimes(1); + expect(setCachedPayloadMock).toHaveBeenCalledTimes(1); + expect(wrapper.emitted()['close-drawer']).toBeTruthy(); + }); }); }); diff --git a/ee/spec/frontend/security_configuration/dast_profiles/dast_site_profiles/components/dast_site_profile_form_spec.js b/ee/spec/frontend/security_configuration/dast_profiles/dast_site_profiles/components/dast_site_profile_form_spec.js index e6d0bfc767ef9ca4c7e1678153b3e9a0d5c6488b..9a5cc5ee2fb1d29ebb87d9a310c3ae167a90bb24 100644 --- a/ee/spec/frontend/security_configuration/dast_profiles/dast_site_profiles/components/dast_site_profile_form_spec.js +++ b/ee/spec/frontend/security_configuration/dast_profiles/dast_site_profiles/components/dast_site_profile_form_spec.js @@ -1,3 +1,5 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { GlForm } from '@gitlab/ui'; import { within } from '@testing-library/dom'; import merge from 'lodash/merge'; @@ -8,7 +10,11 @@ import DastSiteProfileForm from 'ee/security_configuration/dast_profiles/dast_si import dastSiteProfileCreateMutation from 'ee/security_configuration/dast_profiles/dast_site_profiles/graphql/dast_site_profile_create.mutation.graphql'; import dastSiteProfileUpdateMutation from 'ee/security_configuration/dast_profiles/dast_site_profiles/graphql/dast_site_profile_update.mutation.graphql'; import { policySiteProfiles } from 'ee_jest/security_configuration/dast_profiles/mocks/mock_data'; +import resolvers from 'ee/vue_shared/security_configuration/graphql/resolvers/resolvers'; +import { typePolicies } from 'ee/vue_shared/security_configuration/graphql/provider'; import { TEST_HOST } from 'helpers/test_constants'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { SCAN_METHODS, @@ -30,6 +36,8 @@ const defaultProps = { projectFullPath, }; +Vue.use(VueApollo); + describe('DastSiteProfileForm', () => { let wrapper; @@ -79,13 +87,18 @@ describe('DastSiteProfileForm', () => { return radio.trigger('change'); }; - const createComponentFactory = (mountFn = mountExtended) => (options) => { + const createComponentFactory = (mountFn = mountExtended) => (options = {}) => { + const apolloProvider = createMockApollo([], resolvers, { typePolicies }); + const mountOpts = merge( {}, { propsData: defaultProps, provide: { glFeatures: { dastApiScanner: true } }, }, + { + apolloProvider, + }, options, ); @@ -119,8 +132,9 @@ describe('DastSiteProfileForm', () => { describe('target URL input', () => { const errorMessage = 'Please enter a valid URL format, ex: http://www.example.com/home'; - beforeEach(() => { + beforeEach(async () => { createComponent(); + await waitForPromises(); }); it.each(['asd', 'example.com'])( @@ -140,8 +154,9 @@ describe('DastSiteProfileForm', () => { }); describe('additional fields', () => { - beforeEach(() => { + beforeEach(async () => { createComponent(); + await waitForPromises(); }); it('should render correctly with default values', () => { @@ -246,12 +261,13 @@ describe('DastSiteProfileForm', () => { ${'New site profile'} | ${{}} | ${{ fullPath: projectFullPath }} | ${dastSiteProfileCreateMutation} | ${'dastSiteProfileCreate'} ${'Edit site profile'} | ${siteProfileWithSecrets} | ${{ id: siteProfileWithSecrets.id }} | ${dastSiteProfileUpdateMutation} | ${'dastSiteProfileUpdate'} `('$title', ({ profile, mutationVars, mutation, mutationKind }) => { - beforeEach(() => { + beforeEach(async () => { createComponent({ propsData: { profile, }, }); + await waitForPromises(); }); it('passes correct props to base component', async () => { diff --git a/ee/spec/frontend/security_configuration/dast_profiles/mocks/mock_data.js b/ee/spec/frontend/security_configuration/dast_profiles/mocks/mock_data.js index a881419706139b6e26855ee58d9d02aa3a432f2f..f8c0ac60ed3a244f3a05dfbf04ddfee7d6a08329 100644 --- a/ee/spec/frontend/security_configuration/dast_profiles/mocks/mock_data.js +++ b/ee/spec/frontend/security_configuration/dast_profiles/mocks/mock_data.js @@ -22,3 +22,16 @@ export const scannerProfiles = scannerProfilesFixtures.data.project.scannerProfi export const failedSiteValidations = dastFailedSiteValidationsFixtures.data.project.validations.nodes; + +export const mockSharedData = { + showDiscardChangesModal: false, + formTouched: false, + history: [], + cashedPayload: { + __typename: 'CachedPayload', + profileType: '', + mode: '', + }, + resetAndClose: false, + __typename: 'SharedData', +}; diff --git a/locale/gitlab.pot b/locale/gitlab.pot index ab1356dd35c521b863e96303c2749ae7b93a9aa6..e46cf7594106cb72967c461527c8f83f362c662b 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -11894,15 +11894,6 @@ msgstr "" msgid "DastProfiles|Delete profile" msgstr "" -msgid "DastProfiles|Do you want to discard this scanner profile?" -msgstr "" - -msgid "DastProfiles|Do you want to discard this site profile?" -msgstr "" - -msgid "DastProfiles|Do you want to discard your changes?" -msgstr "" - msgid "DastProfiles|Edit profile" msgstr "" @@ -27282,6 +27273,12 @@ msgstr "" msgid "OnDemandScans|Description (optional)" msgstr "" +msgid "OnDemandScans|Discard changes" +msgstr "" + +msgid "OnDemandScans|Do you want to discard the changes or keep editing this profile? Unsaved changes will be lost." +msgstr "" + msgid "OnDemandScans|Dynamic Application Security Testing (DAST)" msgstr "" @@ -27303,6 +27300,9 @@ msgstr "" msgid "OnDemandScans|For example: Tests the login page for SQL injections" msgstr "" +msgid "OnDemandScans|Keep editing" +msgstr "" + msgid "OnDemandScans|Manage scanner profiles" msgstr "" @@ -27426,6 +27426,9 @@ msgstr "" msgid "OnDemandScans|View results" msgstr "" +msgid "OnDemandScans|You have unsaved changes" +msgstr "" + msgid "OnDemandScans|You must create a repository within your project to run an on-demand scan." msgstr ""