Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
d1fd19e525
commit
bfb0d93c76
|
|
@ -1,7 +1,11 @@
|
|||
<script>
|
||||
import { GlLoadingIcon } from '@gitlab/ui';
|
||||
import { mapState, mapActions, mapGetters } from 'vuex';
|
||||
import AccessorUtilities from '~/lib/utils/accessor';
|
||||
import {
|
||||
mapVuexModuleState,
|
||||
mapVuexModuleActions,
|
||||
mapVuexModuleGetters,
|
||||
} from '~/lib/utils/vuex_module_mappers';
|
||||
import { FREQUENT_ITEMS, STORAGE_KEY } from '../constants';
|
||||
import eventHub from '../event_hub';
|
||||
import { isMobile, updateExistingFrequentItem, sanitizeItem } from '../utils';
|
||||
|
|
@ -16,6 +20,7 @@ export default {
|
|||
GlLoadingIcon,
|
||||
},
|
||||
mixins: [frequentItemsMixin],
|
||||
inject: ['vuexModule'],
|
||||
props: {
|
||||
currentUserName: {
|
||||
type: String,
|
||||
|
|
@ -27,8 +32,13 @@ export default {
|
|||
},
|
||||
},
|
||||
computed: {
|
||||
...mapState(['searchQuery', 'isLoadingItems', 'isFetchFailed', 'items']),
|
||||
...mapGetters(['hasSearchQuery']),
|
||||
...mapVuexModuleState((vm) => vm.vuexModule, [
|
||||
'searchQuery',
|
||||
'isLoadingItems',
|
||||
'isFetchFailed',
|
||||
'items',
|
||||
]),
|
||||
...mapVuexModuleGetters((vm) => vm.vuexModule, ['hasSearchQuery']),
|
||||
translations() {
|
||||
return this.getTranslations(['loadingMessage', 'header']);
|
||||
},
|
||||
|
|
@ -56,7 +66,11 @@ export default {
|
|||
eventHub.$off(`${this.namespace}-dropdownOpen`, this.dropdownOpenHandler);
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['setNamespace', 'setStorageKey', 'fetchFrequentItems']),
|
||||
...mapVuexModuleActions((vm) => vm.vuexModule, [
|
||||
'setNamespace',
|
||||
'setStorageKey',
|
||||
'fetchFrequentItems',
|
||||
]),
|
||||
dropdownOpenHandler() {
|
||||
if (this.searchQuery === '' || isMobile()) {
|
||||
this.fetchFrequentItems();
|
||||
|
|
@ -101,14 +115,15 @@ export default {
|
|||
|
||||
<template>
|
||||
<div class="gl-display-flex gl-flex-direction-column gl-flex-align-items-stretch gl-h-full">
|
||||
<frequent-items-search-input :namespace="namespace" />
|
||||
<frequent-items-search-input :namespace="namespace" data-testid="frequent-items-search-input" />
|
||||
<gl-loading-icon
|
||||
v-if="isLoadingItems"
|
||||
:label="translations.loadingMessage"
|
||||
size="lg"
|
||||
class="loading-animation prepend-top-20"
|
||||
data-testid="loading"
|
||||
/>
|
||||
<div v-if="!isLoadingItems && !hasSearchQuery" class="section-header">
|
||||
<div v-if="!isLoadingItems && !hasSearchQuery" class="section-header" data-testid="header">
|
||||
{{ translations.header }}
|
||||
</div>
|
||||
<frequent-items-list
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<script>
|
||||
/* eslint-disable vue/require-default-prop, vue/no-v-html */
|
||||
import { mapState } from 'vuex';
|
||||
import highlight from '~/lib/utils/highlight';
|
||||
import { truncateNamespace } from '~/lib/utils/text_utility';
|
||||
import { mapVuexModuleState } from '~/lib/utils/vuex_module_mappers';
|
||||
import Tracking from '~/tracking';
|
||||
import Identicon from '~/vue_shared/components/identicon.vue';
|
||||
|
||||
|
|
@ -13,6 +13,7 @@ export default {
|
|||
Identicon,
|
||||
},
|
||||
mixins: [trackingMixin],
|
||||
inject: ['vuexModule'],
|
||||
props: {
|
||||
matcher: {
|
||||
type: String,
|
||||
|
|
@ -42,7 +43,7 @@ export default {
|
|||
},
|
||||
},
|
||||
computed: {
|
||||
...mapState(['dropdownType']),
|
||||
...mapVuexModuleState((vm) => vm.vuexModule, ['dropdownType']),
|
||||
truncatedNamespace() {
|
||||
return truncateNamespace(this.namespace);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import { GlSearchBoxByType } from '@gitlab/ui';
|
||||
import { debounce } from 'lodash';
|
||||
import { mapActions, mapState } from 'vuex';
|
||||
import { mapVuexModuleActions, mapVuexModuleState } from '~/lib/utils/vuex_module_mappers';
|
||||
import Tracking from '~/tracking';
|
||||
import frequentItemsMixin from './frequent_items_mixin';
|
||||
|
||||
|
|
@ -12,13 +12,14 @@ export default {
|
|||
GlSearchBoxByType,
|
||||
},
|
||||
mixins: [frequentItemsMixin, trackingMixin],
|
||||
inject: ['vuexModule'],
|
||||
data() {
|
||||
return {
|
||||
searchQuery: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(['dropdownType']),
|
||||
...mapVuexModuleState((vm) => vm.vuexModule, ['dropdownType']),
|
||||
translations() {
|
||||
return this.getTranslations(['searchInputPlaceholder']);
|
||||
},
|
||||
|
|
@ -32,7 +33,7 @@ export default {
|
|||
}, 500),
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['setSearchQuery']),
|
||||
...mapVuexModuleActions((vm) => vm.vuexModule, ['setSearchQuery']),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -36,3 +36,16 @@ export const TRANSLATION_KEYS = {
|
|||
searchInputPlaceholder: s__('GroupsDropdown|Search your groups'),
|
||||
},
|
||||
};
|
||||
|
||||
export const FREQUENT_ITEMS_DROPDOWNS = [
|
||||
{
|
||||
namespace: 'projects',
|
||||
key: 'project',
|
||||
vuexModule: 'frequentProjects',
|
||||
},
|
||||
{
|
||||
namespace: 'groups',
|
||||
key: 'group',
|
||||
vuexModule: 'frequentGroups',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,25 +1,20 @@
|
|||
import $ from 'jquery';
|
||||
import Vue from 'vue';
|
||||
import Vuex from 'vuex';
|
||||
import { createStore } from '~/frequent_items/store';
|
||||
import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue';
|
||||
import Translate from '~/vue_shared/translate';
|
||||
import { FREQUENT_ITEMS_DROPDOWNS } from './constants';
|
||||
import eventHub from './event_hub';
|
||||
|
||||
Vue.use(Vuex);
|
||||
Vue.use(Translate);
|
||||
|
||||
const frequentItemDropdowns = [
|
||||
{
|
||||
namespace: 'projects',
|
||||
key: 'project',
|
||||
},
|
||||
{
|
||||
namespace: 'groups',
|
||||
key: 'group',
|
||||
},
|
||||
];
|
||||
|
||||
export default function initFrequentItemDropdowns() {
|
||||
frequentItemDropdowns.forEach((dropdown) => {
|
||||
const { namespace, key } = dropdown;
|
||||
const store = createStore();
|
||||
|
||||
FREQUENT_ITEMS_DROPDOWNS.forEach((dropdown) => {
|
||||
const { namespace, key, vuexModule } = dropdown;
|
||||
const el = document.getElementById(`js-${namespace}-dropdown`);
|
||||
const navEl = document.getElementById(`nav-${namespace}-dropdown`);
|
||||
|
||||
|
|
@ -29,9 +24,6 @@ export default function initFrequentItemDropdowns() {
|
|||
return;
|
||||
}
|
||||
|
||||
const dropdownType = namespace;
|
||||
const store = createStore({ dropdownType });
|
||||
|
||||
import('./components/app.vue')
|
||||
.then(({ default: FrequentItems }) => {
|
||||
// eslint-disable-next-line no-new
|
||||
|
|
@ -55,13 +47,23 @@ export default function initFrequentItemDropdowns() {
|
|||
};
|
||||
},
|
||||
render(createElement) {
|
||||
return createElement(FrequentItems, {
|
||||
props: {
|
||||
namespace,
|
||||
currentUserName: this.currentUserName,
|
||||
currentItem: this.currentItem,
|
||||
return createElement(
|
||||
VuexModuleProvider,
|
||||
{
|
||||
props: {
|
||||
vuexModule,
|
||||
},
|
||||
},
|
||||
});
|
||||
[
|
||||
createElement(FrequentItems, {
|
||||
props: {
|
||||
namespace,
|
||||
currentUserName: this.currentUserName,
|
||||
currentItem: this.currentItem,
|
||||
},
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,17 +1,26 @@
|
|||
import Vue from 'vue';
|
||||
import Vuex from 'vuex';
|
||||
import { FREQUENT_ITEMS_DROPDOWNS } from '../constants';
|
||||
import * as actions from './actions';
|
||||
import * as getters from './getters';
|
||||
import mutations from './mutations';
|
||||
import state from './state';
|
||||
|
||||
Vue.use(Vuex);
|
||||
export const createFrequentItemsModule = (initState = {}) => ({
|
||||
namespaced: true,
|
||||
actions,
|
||||
getters,
|
||||
mutations,
|
||||
state: state(initState),
|
||||
});
|
||||
|
||||
export const createStore = (initState = {}) => {
|
||||
export const createStore = () => {
|
||||
return new Vuex.Store({
|
||||
actions,
|
||||
getters,
|
||||
mutations,
|
||||
state: state(initState),
|
||||
modules: FREQUENT_ITEMS_DROPDOWNS.reduce(
|
||||
(acc, { namespace, vuexModule }) =>
|
||||
Object.assign(acc, {
|
||||
[vuexModule]: createFrequentItemsModule({ dropdownType: namespace }),
|
||||
}),
|
||||
{},
|
||||
),
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
import { mapValues, isString } from 'lodash';
|
||||
import { mapState, mapActions } from 'vuex';
|
||||
|
||||
export const REQUIRE_STRING_ERROR_MESSAGE =
|
||||
'`vuex_module_mappers` can only be used with an array of strings, or an object with string values. Consider using the regular `vuex` map helpers instead.';
|
||||
|
||||
const normalizeFieldsToObject = (fields) => {
|
||||
return Array.isArray(fields)
|
||||
? fields.reduce((acc, key) => Object.assign(acc, { [key]: key }), {})
|
||||
: fields;
|
||||
};
|
||||
|
||||
const mapVuexModuleFields = ({ namespaceSelector, fields, vuexHelper, selector } = {}) => {
|
||||
// The `vuexHelper` needs an object which maps keys to field selector functions.
|
||||
const map = mapValues(normalizeFieldsToObject(fields), (value) => {
|
||||
if (!isString(value)) {
|
||||
throw new Error(REQUIRE_STRING_ERROR_MESSAGE);
|
||||
}
|
||||
|
||||
// We need to use a good ol' function to capture the right "this".
|
||||
return function mappedFieldSelector(...args) {
|
||||
const namespace = namespaceSelector(this);
|
||||
|
||||
return selector(namespace, value, ...args);
|
||||
};
|
||||
});
|
||||
|
||||
return vuexHelper(map);
|
||||
};
|
||||
|
||||
/**
|
||||
* Like `mapState`, but takes a function in the first param for selecting a namespace.
|
||||
*
|
||||
* ```
|
||||
* computed: {
|
||||
* ...mapVuexModuleState(vm => vm.vuexModule, ['foo']),
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param {Function} namespaceSelector
|
||||
* @param {Array|Object} fields
|
||||
*/
|
||||
export const mapVuexModuleState = (namespaceSelector, fields) =>
|
||||
mapVuexModuleFields({
|
||||
namespaceSelector,
|
||||
fields,
|
||||
vuexHelper: mapState,
|
||||
selector: (namespace, value, state) => state[namespace][value],
|
||||
});
|
||||
|
||||
/**
|
||||
* Like `mapActions`, but takes a function in the first param for selecting a namespace.
|
||||
*
|
||||
* ```
|
||||
* methods: {
|
||||
* ...mapVuexModuleActions(vm => vm.vuexModule, ['fetchFoos']),
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param {Function} namespaceSelector
|
||||
* @param {Array|Object} fields
|
||||
*/
|
||||
export const mapVuexModuleActions = (namespaceSelector, fields) =>
|
||||
mapVuexModuleFields({
|
||||
namespaceSelector,
|
||||
fields,
|
||||
vuexHelper: mapActions,
|
||||
selector: (namespace, value, dispatch, ...args) => dispatch(`${namespace}/${value}`, ...args),
|
||||
});
|
||||
|
||||
/**
|
||||
* Like `mapGetters`, but takes a function in the first param for selecting a namespace.
|
||||
*
|
||||
* ```
|
||||
* computed: {
|
||||
* ...mapGetters(vm => vm.vuexModule, ['hasSearchInfo']),
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param {Function} namespaceSelector
|
||||
* @param {Array|Object} fields
|
||||
*/
|
||||
export const mapVuexModuleGetters = (namespaceSelector, fields) =>
|
||||
mapVuexModuleFields({
|
||||
namespaceSelector,
|
||||
fields,
|
||||
// `mapGetters` does not let us pass an object which maps to functions. Thankfully `mapState` does
|
||||
// and gives us access to the getters.
|
||||
vuexHelper: mapState,
|
||||
selector: (namespace, value, state, getters) => getters[`${namespace}/${value}`],
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<script>
|
||||
import { GlLink, GlTable, GlAlert } from '@gitlab/ui';
|
||||
import { s__, sprintf } from '~/locale';
|
||||
import ManageViaMR from '~/vue_shared/security_configuration/components/manage_via_mr.vue';
|
||||
import {
|
||||
REPORT_TYPE_SAST,
|
||||
REPORT_TYPE_DAST,
|
||||
|
|
@ -11,8 +12,8 @@ import {
|
|||
REPORT_TYPE_API_FUZZING,
|
||||
REPORT_TYPE_LICENSE_COMPLIANCE,
|
||||
} from '~/vue_shared/security_reports/constants';
|
||||
import ManageSast from './manage_sast.vue';
|
||||
import { scanners } from './scanners_constants';
|
||||
|
||||
import { scanners } from './constants';
|
||||
import Upgrade from './upgrade.vue';
|
||||
|
||||
const borderClasses = 'gl-border-b-1! gl-border-b-solid! gl-border-gray-100!';
|
||||
|
|
@ -40,7 +41,7 @@ export default {
|
|||
},
|
||||
getComponentForItem(item) {
|
||||
const COMPONENTS = {
|
||||
[REPORT_TYPE_SAST]: ManageSast,
|
||||
[REPORT_TYPE_SAST]: ManageViaMR,
|
||||
[REPORT_TYPE_DAST]: Upgrade,
|
||||
[REPORT_TYPE_DAST_PROFILES]: Upgrade,
|
||||
[REPORT_TYPE_DEPENDENCY_SCANNING]: Upgrade,
|
||||
|
|
@ -49,7 +50,6 @@ export default {
|
|||
[REPORT_TYPE_API_FUZZING]: Upgrade,
|
||||
[REPORT_TYPE_LICENSE_COMPLIANCE]: Upgrade,
|
||||
};
|
||||
|
||||
return COMPONENTS[item.type];
|
||||
},
|
||||
},
|
||||
|
|
@ -95,7 +95,12 @@ export default {
|
|||
</template>
|
||||
|
||||
<template #cell(manage)="{ item }">
|
||||
<component :is="getComponentForItem(item)" :data-testid="item.type" @error="onError" />
|
||||
<component
|
||||
:is="getComponentForItem(item)"
|
||||
:feature="item"
|
||||
:data-testid="item.type"
|
||||
@error="onError"
|
||||
/>
|
||||
</template>
|
||||
</gl-table>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { helpPagePath } from '~/helpers/help_page_helper';
|
||||
import { __, s__ } from '~/locale';
|
||||
|
||||
import configureSastMutation from '~/security_configuration/graphql/configure_sast.mutation.graphql';
|
||||
import {
|
||||
REPORT_TYPE_SAST,
|
||||
REPORT_TYPE_DAST,
|
||||
|
|
@ -134,3 +135,18 @@ export const scanners = [
|
|||
type: REPORT_TYPE_LICENSE_COMPLIANCE,
|
||||
},
|
||||
];
|
||||
|
||||
export const featureToMutationMap = {
|
||||
[REPORT_TYPE_SAST]: {
|
||||
mutationId: 'configureSast',
|
||||
getMutationPayload: (projectPath) => ({
|
||||
mutation: configureSastMutation,
|
||||
variables: {
|
||||
input: {
|
||||
projectPath,
|
||||
configuration: { global: [], pipeline: [], analyzers: [] },
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
<script>
|
||||
import { GlButton } from '@gitlab/ui';
|
||||
import { redirectTo } from '~/lib/utils/url_utility';
|
||||
import { s__ } from '~/locale';
|
||||
import configureSastMutation from '~/security_configuration/graphql/configure_sast.mutation.graphql';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlButton,
|
||||
},
|
||||
inject: {
|
||||
projectPath: {
|
||||
from: 'projectPath',
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isLoading: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async mutate() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const { data } = await this.$apollo.mutate({
|
||||
mutation: configureSastMutation,
|
||||
variables: {
|
||||
input: {
|
||||
projectPath: this.projectPath,
|
||||
configuration: { global: [], pipeline: [], analyzers: [] },
|
||||
},
|
||||
},
|
||||
});
|
||||
const { errors, successPath } = data.configureSast;
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors[0]);
|
||||
}
|
||||
|
||||
if (!successPath) {
|
||||
throw new Error(s__('SecurityConfiguration|SAST merge request creation mutation failed'));
|
||||
}
|
||||
|
||||
redirectTo(successPath);
|
||||
} catch (e) {
|
||||
this.$emit('error', e.message);
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<gl-button :loading="isLoading" variant="success" category="secondary" @click="mutate">{{
|
||||
s__('SecurityConfiguration|Configure via merge request')
|
||||
}}</gl-button>
|
||||
</template>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<script>
|
||||
import { GlLink, GlSprintf } from '@gitlab/ui';
|
||||
import { UPGRADE_CTA } from './scanners_constants';
|
||||
import { UPGRADE_CTA } from './constants';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
<script>
|
||||
export default {
|
||||
provide() {
|
||||
return {
|
||||
vuexModule: this.vuexModule,
|
||||
};
|
||||
},
|
||||
props: {
|
||||
vuexModule: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
render() {
|
||||
return this.$slots.default;
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
<script>
|
||||
import { GlButton } from '@gitlab/ui';
|
||||
import { featureToMutationMap } from 'ee_else_ce/security_configuration/components/constants';
|
||||
import { redirectTo } from '~/lib/utils/url_utility';
|
||||
import { sprintf, s__ } from '~/locale';
|
||||
import apolloProvider from '../provider';
|
||||
|
||||
export default {
|
||||
apolloProvider,
|
||||
components: {
|
||||
GlButton,
|
||||
},
|
||||
inject: ['projectPath'],
|
||||
props: {
|
||||
feature: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isLoading: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
featureSettings() {
|
||||
return featureToMutationMap[this.feature.type];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async mutate() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const mutation = this.featureSettings;
|
||||
const { data } = await this.$apollo.mutate(mutation.getMutationPayload(this.projectPath));
|
||||
const { errors, successPath } = data[mutation.mutationId];
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors[0]);
|
||||
}
|
||||
|
||||
if (!successPath) {
|
||||
throw new Error(
|
||||
sprintf(this.$options.i18n.noSuccessPathError, { featureName: this.feature.name }),
|
||||
);
|
||||
}
|
||||
|
||||
redirectTo(successPath);
|
||||
} catch (e) {
|
||||
this.$emit('error', e.message);
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
i18n: {
|
||||
buttonLabel: s__('SecurityConfiguration|Configure via Merge Request'),
|
||||
noSuccessPathError: s__(
|
||||
'SecurityConfiguration|%{featureName} merge request creation mutation failed',
|
||||
),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<gl-button
|
||||
v-if="!feature.configured"
|
||||
:loading="isLoading"
|
||||
variant="success"
|
||||
category="secondary"
|
||||
@click="mutate"
|
||||
>{{ $options.i18n.buttonLabel }}</gl-button
|
||||
>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import Vue from 'vue';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import createDefaultClient from '~/lib/graphql';
|
||||
|
||||
Vue.use(VueApollo);
|
||||
|
||||
export default new VueApollo({
|
||||
defaultClient: createDefaultClient(),
|
||||
});
|
||||
|
|
@ -1165,17 +1165,13 @@ class Repository
|
|||
end
|
||||
|
||||
def tags_sorted_by_committed_date
|
||||
tags.sort_by do |tag|
|
||||
# Annotated tags can point to any object (e.g. a blob), but generally
|
||||
# tags point to a commit. If we don't have a commit, then just default
|
||||
# to putting the tag at the end of the list.
|
||||
target = tag.dereferenced_target
|
||||
# Annotated tags can point to any object (e.g. a blob), but generally
|
||||
# tags point to a commit. If we don't have a commit, then just default
|
||||
# to putting the tag at the end of the list.
|
||||
default = Time.current
|
||||
|
||||
if target
|
||||
target.committed_date
|
||||
else
|
||||
Time.current
|
||||
end
|
||||
tags.sort_by do |tag|
|
||||
tag.dereferenced_target&.committed_date || default
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -104,3 +104,5 @@ module Terraform
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
Terraform::State.prepend_if_ee('EE::Terraform::State')
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Delete feature flag for usage_data_a_compliance_audit_events_api
|
||||
merge_request: 52947
|
||||
author:
|
||||
type: removed
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: 'Geo: Add verification for Terraform States'
|
||||
merge_request: 58800
|
||||
author:
|
||||
type: changed
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
---
|
||||
name: usage_data_a_compliance_audit_events_api
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/41689
|
||||
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/233786
|
||||
milestone: '13.4'
|
||||
type: development
|
||||
group: group::compliance
|
||||
default_enabled: true
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddVerificationStateAndStartedAtToTerraformStateVersionTable < ActiveRecord::Migration[6.0]
|
||||
def change
|
||||
change_table(:terraform_state_versions) do |t|
|
||||
t.column :verification_started_at, :datetime_with_timezone
|
||||
t.integer :verification_state, default: 0, limit: 2, null: false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddVerificationIndexesToTerraformStateVersions < ActiveRecord::Migration[6.0]
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
|
||||
VERIFICATION_STATE_INDEX_NAME = "index_terraform_state_versions_on_verification_state"
|
||||
PENDING_VERIFICATION_INDEX_NAME = "index_terraform_state_versions_pending_verification"
|
||||
FAILED_VERIFICATION_INDEX_NAME = "index_terraform_state_versions_failed_verification"
|
||||
NEEDS_VERIFICATION_INDEX_NAME = "index_terraform_state_versions_needs_verification"
|
||||
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
add_concurrent_index :terraform_state_versions, :verification_state, name: VERIFICATION_STATE_INDEX_NAME
|
||||
add_concurrent_index :terraform_state_versions, :verified_at, where: "(verification_state = 0)", order: { verified_at: 'ASC NULLS FIRST' }, name: PENDING_VERIFICATION_INDEX_NAME
|
||||
add_concurrent_index :terraform_state_versions, :verification_retry_at, where: "(verification_state = 3)", order: { verification_retry_at: 'ASC NULLS FIRST' }, name: FAILED_VERIFICATION_INDEX_NAME
|
||||
add_concurrent_index :terraform_state_versions, :verification_state, where: "(verification_state = 0 OR verification_state = 3)", name: NEEDS_VERIFICATION_INDEX_NAME
|
||||
end
|
||||
|
||||
def down
|
||||
remove_concurrent_index_by_name :terraform_state_versions, VERIFICATION_STATE_INDEX_NAME
|
||||
remove_concurrent_index_by_name :terraform_state_versions, PENDING_VERIFICATION_INDEX_NAME
|
||||
remove_concurrent_index_by_name :terraform_state_versions, FAILED_VERIFICATION_INDEX_NAME
|
||||
remove_concurrent_index_by_name :terraform_state_versions, NEEDS_VERIFICATION_INDEX_NAME
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1 @@
|
|||
9f19b44a4ef3131e6ddd9cfea0d8b1eb4499754f2200bea90b5ed41eb688f622
|
||||
|
|
@ -0,0 +1 @@
|
|||
3a223c462b10edb9eb68fc0adf42f046a45f554f35b4b4ee64a834cd7372f827
|
||||
|
|
@ -18076,6 +18076,8 @@ CREATE TABLE terraform_state_versions (
|
|||
verification_checksum bytea,
|
||||
verification_failure text,
|
||||
ci_build_id bigint,
|
||||
verification_started_at timestamp with time zone,
|
||||
verification_state smallint DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT check_0824bb7bbd CHECK ((char_length(file) <= 255)),
|
||||
CONSTRAINT tf_state_versions_verification_failure_text_limit CHECK ((char_length(verification_failure) <= 255))
|
||||
);
|
||||
|
|
@ -24286,12 +24288,20 @@ CREATE INDEX index_term_agreements_on_term_id ON term_agreements USING btree (te
|
|||
|
||||
CREATE INDEX index_term_agreements_on_user_id ON term_agreements USING btree (user_id);
|
||||
|
||||
CREATE INDEX index_terraform_state_versions_failed_verification ON terraform_state_versions USING btree (verification_retry_at NULLS FIRST) WHERE (verification_state = 3);
|
||||
|
||||
CREATE INDEX index_terraform_state_versions_needs_verification ON terraform_state_versions USING btree (verification_state) WHERE ((verification_state = 0) OR (verification_state = 3));
|
||||
|
||||
CREATE INDEX index_terraform_state_versions_on_ci_build_id ON terraform_state_versions USING btree (ci_build_id);
|
||||
|
||||
CREATE INDEX index_terraform_state_versions_on_created_by_user_id ON terraform_state_versions USING btree (created_by_user_id);
|
||||
|
||||
CREATE UNIQUE INDEX index_terraform_state_versions_on_state_id_and_version ON terraform_state_versions USING btree (terraform_state_id, version);
|
||||
|
||||
CREATE INDEX index_terraform_state_versions_on_verification_state ON terraform_state_versions USING btree (verification_state);
|
||||
|
||||
CREATE INDEX index_terraform_state_versions_pending_verification ON terraform_state_versions USING btree (verified_at NULLS FIRST) WHERE (verification_state = 0);
|
||||
|
||||
CREATE INDEX index_terraform_states_on_file_store ON terraform_states USING btree (file_store);
|
||||
|
||||
CREATE INDEX index_terraform_states_on_locked_by_user_id ON terraform_states USING btree (locked_by_user_id);
|
||||
|
|
|
|||
|
|
@ -215,11 +215,15 @@ configuration option in `gitlab.yml`. These metrics are served from the
|
|||
| `geo_package_files_failed` | Gauge | 13.3 | Number of syncable package files failed to sync on secondary | `url` |
|
||||
| `geo_package_files_registry` | Gauge | 13.3 | Number of package files in the registry | `url` |
|
||||
| `geo_terraform_state_versions` | Gauge | 13.5 | Number of terraform state versions on primary | `url` |
|
||||
| `geo_terraform_state_versions_checksummed` | Gauge | 13.5 | Number of terraform state versions checksummed on primary | `url` |
|
||||
| `geo_terraform_state_versions_checksummed` | Gauge | 13.5 | Number of terraform state versions checksummed successfully on primary | `url` |
|
||||
| `geo_terraform_state_versions_checksum_failed` | Gauge | 13.5 | Number of terraform state versions failed to calculate the checksum on primary | `url` |
|
||||
| `geo_terraform_state_versions_checksum_total` | Gauge | 13.12 | Number of terraform state versions tried to checksum on primary | `url` |
|
||||
| `geo_terraform_state_versions_synced` | Gauge | 13.5 | Number of syncable terraform state versions synced on secondary | `url` |
|
||||
| `geo_terraform_state_versions_failed` | Gauge | 13.5 | Number of syncable terraform state versions failed to sync on secondary | `url` |
|
||||
| `geo_terraform_state_versions_registry` | Gauge | 13.5 | Number of terraform state versions in the registry | `url` |
|
||||
| `geo_terraform_state_versions_verified` | Gauge | 13.12 | Number of terraform state versions verified on secondary | `url` |
|
||||
| `geo_terraform_state_versions_verification_failed` | Gauge | 13.12 | Number of terraform state versions verifications failed on secondary | `url` |
|
||||
| `geo_terraform_state_versions_verification_total` | Gauge | 13.12 | Number of terraform state versions verifications tried on secondary | `url` |
|
||||
| `global_search_bulk_cron_queue_size` | Gauge | 12.10 | Number of database records waiting to be synchronized to Elasticsearch | |
|
||||
| `global_search_awaiting_indexing_queue_size` | Gauge | 13.2 | Number of database updates waiting to be synchronized to Elasticsearch while indexing is paused | |
|
||||
| `geo_merge_request_diffs` | Gauge | 13.4 | Number of merge request diffs on primary | `url` |
|
||||
|
|
|
|||
|
|
@ -58,6 +58,9 @@ Don't tell them **how** to do this thing. Tell them **what it is**.
|
|||
|
||||
If you start describing another topic, start a new concept and link to it.
|
||||
|
||||
Also, do not use "Overview" or "Introduction" for the topic title. Instead,
|
||||
use a noun or phrase that someone would search for.
|
||||
|
||||
Concept topics should be in this format:
|
||||
|
||||
```markdown
|
||||
|
|
|
|||
|
|
@ -87,12 +87,12 @@ Report or respond to the health of issues and epics by setting a red, amber, or
|
|||
|
||||
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/8333) in GitLab Ultimate 11.7.
|
||||
|
||||
Any epic that belongs to a group, or subgroup of the parent epic's group, is eligible to be added.
|
||||
You can add any epic that belongs to a group or subgroup of the parent epic's group.
|
||||
New child epics appear at the top of the list of epics in the **Epics and Issues** tab.
|
||||
|
||||
When you add an epic that's already linked to a parent epic, the link to its current parent is removed.
|
||||
|
||||
An epic can have multiple child epics up to the maximum depth of seven.
|
||||
Epics can contain multiple nested child epics, up to a total of seven levels deep.
|
||||
|
||||
See [Manage multi-level child epics](manage_epics.md#manage-multi-level-child-epics) for
|
||||
steps to create, move, reorder, or delete child epics.
|
||||
|
|
|
|||
|
|
@ -277,6 +277,8 @@ For more on epic templates, see [Epic Templates - Repeatable sets of issues](htt
|
|||
|
||||
## Manage multi-level child epics **(ULTIMATE)**
|
||||
|
||||
With [multi-level epics](index.md#multi-level-child-epics), you can manage more complex projects.
|
||||
|
||||
### Add a child epic to an epic
|
||||
|
||||
To add a child epic to an epic:
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
category: compliance
|
||||
redis_slot: compliance
|
||||
aggregation: weekly
|
||||
feature_flag: usage_data_a_compliance_audit_events_api
|
||||
feature_flag: track_unique_visits
|
||||
- name: g_edit_by_web_ide
|
||||
category: ide_edit
|
||||
redis_slot: edit
|
||||
|
|
|
|||
|
|
@ -28507,9 +28507,6 @@ msgstr ""
|
|||
msgid "SecurityConfiguration|Configure via Merge Request"
|
||||
msgstr ""
|
||||
|
||||
msgid "SecurityConfiguration|Configure via merge request"
|
||||
msgstr ""
|
||||
|
||||
msgid "SecurityConfiguration|Could not retrieve configuration data. Please refresh the page, or try again later."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -28546,9 +28543,6 @@ msgstr ""
|
|||
msgid "SecurityConfiguration|SAST Configuration"
|
||||
msgstr ""
|
||||
|
||||
msgid "SecurityConfiguration|SAST merge request creation mutation failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "SecurityConfiguration|Security Control"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import MockAdapter from 'axios-mock-adapter';
|
||||
import Vue from 'vue';
|
||||
import { useRealDate } from 'helpers/fake_date';
|
||||
import Vuex from 'vuex';
|
||||
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
|
||||
import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
|
||||
import { mountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import appComponent from '~/frequent_items/components/app.vue';
|
||||
import App from '~/frequent_items/components/app.vue';
|
||||
import FrequentItemsList from '~/frequent_items/components/frequent_items_list.vue';
|
||||
import { FREQUENT_ITEMS, HOUR_IN_MS } from '~/frequent_items/constants';
|
||||
import eventHub from '~/frequent_items/event_hub';
|
||||
import { createStore } from '~/frequent_items/store';
|
||||
|
|
@ -12,246 +13,230 @@ import { getTopFrequentItems } from '~/frequent_items/utils';
|
|||
import axios from '~/lib/utils/axios_utils';
|
||||
import { currentSession, mockFrequentProjects, mockSearchedProjects } from '../mock_data';
|
||||
|
||||
Vue.use(Vuex);
|
||||
|
||||
useLocalStorageSpy();
|
||||
|
||||
let session;
|
||||
const createComponentWithStore = (namespace = 'projects') => {
|
||||
session = currentSession[namespace];
|
||||
gon.api_version = session.apiVersion;
|
||||
const Component = Vue.extend(appComponent);
|
||||
const store = createStore();
|
||||
|
||||
return mountComponentWithStore(Component, {
|
||||
store,
|
||||
props: {
|
||||
namespace,
|
||||
currentUserName: session.username,
|
||||
currentItem: session.project || session.group,
|
||||
},
|
||||
});
|
||||
};
|
||||
const TEST_NAMESPACE = 'projects';
|
||||
const TEST_VUEX_MODULE = 'frequentProjects';
|
||||
const TEST_PROJECT = currentSession[TEST_NAMESPACE].project;
|
||||
const TEST_STORAGE_KEY = currentSession[TEST_NAMESPACE].storageKey;
|
||||
|
||||
describe('Frequent Items App Component', () => {
|
||||
let vm;
|
||||
let wrapper;
|
||||
let mock;
|
||||
let store;
|
||||
|
||||
const createComponent = ({ currentItem = null } = {}) => {
|
||||
const session = currentSession[TEST_NAMESPACE];
|
||||
gon.api_version = session.apiVersion;
|
||||
|
||||
wrapper = mountExtended(App, {
|
||||
store,
|
||||
propsData: {
|
||||
namespace: TEST_NAMESPACE,
|
||||
currentUserName: session.username,
|
||||
currentItem: currentItem || session.project,
|
||||
},
|
||||
provide: {
|
||||
vuexModule: TEST_VUEX_MODULE,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const triggerDropdownOpen = () => eventHub.$emit(`${TEST_NAMESPACE}-dropdownOpen`);
|
||||
const getStoredProjects = () => JSON.parse(localStorage.getItem(TEST_STORAGE_KEY));
|
||||
const findSearchInput = () => wrapper.findByTestId('frequent-items-search-input');
|
||||
const findLoading = () => wrapper.findByTestId('loading');
|
||||
const findSectionHeader = () => wrapper.findByTestId('header');
|
||||
const findFrequentItemsList = () => wrapper.findComponent(FrequentItemsList);
|
||||
const findFrequentItems = () => findFrequentItemsList().findAll('li');
|
||||
const setSearch = (search) => {
|
||||
const searchInput = wrapper.find('input');
|
||||
|
||||
searchInput.setValue(search);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(axios);
|
||||
vm = createComponentWithStore();
|
||||
store = createStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
vm.$destroy();
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
describe('methods', () => {
|
||||
describe('dropdownOpenHandler', () => {
|
||||
it('should fetch frequent items when no search has been previously made on desktop', () => {
|
||||
jest.spyOn(vm, 'fetchFrequentItems').mockImplementation(() => {});
|
||||
describe('default', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(store, 'dispatch');
|
||||
|
||||
vm.dropdownOpenHandler();
|
||||
|
||||
expect(vm.fetchFrequentItems).toHaveBeenCalledWith();
|
||||
});
|
||||
createComponent();
|
||||
});
|
||||
|
||||
describe('logItemAccess', () => {
|
||||
let storage;
|
||||
it('should fetch frequent items', () => {
|
||||
triggerDropdownOpen();
|
||||
|
||||
beforeEach(() => {
|
||||
storage = {};
|
||||
|
||||
localStorage.setItem.mockImplementation((storageKey, value) => {
|
||||
storage[storageKey] = value;
|
||||
});
|
||||
|
||||
localStorage.getItem.mockImplementation((storageKey) => {
|
||||
if (storage[storageKey]) {
|
||||
return storage[storageKey];
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a project store if it does not exist and adds a project', () => {
|
||||
vm.logItemAccess(session.storageKey, session.project);
|
||||
|
||||
const projects = JSON.parse(storage[session.storageKey]);
|
||||
|
||||
expect(projects.length).toBe(1);
|
||||
expect(projects[0].frequency).toBe(1);
|
||||
expect(projects[0].lastAccessedOn).toBeDefined();
|
||||
});
|
||||
|
||||
it('should prevent inserting same report multiple times into store', () => {
|
||||
vm.logItemAccess(session.storageKey, session.project);
|
||||
vm.logItemAccess(session.storageKey, session.project);
|
||||
|
||||
const projects = JSON.parse(storage[session.storageKey]);
|
||||
|
||||
expect(projects.length).toBe(1);
|
||||
});
|
||||
|
||||
describe('with real date', () => {
|
||||
useRealDate();
|
||||
|
||||
it('should increase frequency of report if it was logged multiple times over the course of an hour', () => {
|
||||
let projects;
|
||||
const newTimestamp = Date.now() + HOUR_IN_MS + 1;
|
||||
|
||||
vm.logItemAccess(session.storageKey, session.project);
|
||||
projects = JSON.parse(storage[session.storageKey]);
|
||||
|
||||
expect(projects[0].frequency).toBe(1);
|
||||
|
||||
vm.logItemAccess(session.storageKey, {
|
||||
...session.project,
|
||||
lastAccessedOn: newTimestamp,
|
||||
});
|
||||
projects = JSON.parse(storage[session.storageKey]);
|
||||
|
||||
expect(projects[0].frequency).toBe(2);
|
||||
expect(projects[0].lastAccessedOn).not.toBe(session.project.lastAccessedOn);
|
||||
});
|
||||
});
|
||||
|
||||
it('should always update project metadata', () => {
|
||||
let projects;
|
||||
const oldProject = {
|
||||
...session.project,
|
||||
};
|
||||
|
||||
const newProject = {
|
||||
...session.project,
|
||||
name: 'New Name',
|
||||
avatarUrl: 'new/avatar.png',
|
||||
namespace: 'New / Namespace',
|
||||
webUrl: 'http://localhost/new/web/url',
|
||||
};
|
||||
|
||||
vm.logItemAccess(session.storageKey, oldProject);
|
||||
projects = JSON.parse(storage[session.storageKey]);
|
||||
|
||||
expect(projects[0].name).toBe(oldProject.name);
|
||||
expect(projects[0].avatarUrl).toBe(oldProject.avatarUrl);
|
||||
expect(projects[0].namespace).toBe(oldProject.namespace);
|
||||
expect(projects[0].webUrl).toBe(oldProject.webUrl);
|
||||
|
||||
vm.logItemAccess(session.storageKey, newProject);
|
||||
projects = JSON.parse(storage[session.storageKey]);
|
||||
|
||||
expect(projects[0].name).toBe(newProject.name);
|
||||
expect(projects[0].avatarUrl).toBe(newProject.avatarUrl);
|
||||
expect(projects[0].namespace).toBe(newProject.namespace);
|
||||
expect(projects[0].webUrl).toBe(newProject.webUrl);
|
||||
});
|
||||
|
||||
it('should not add more than 20 projects in store', () => {
|
||||
for (let id = 0; id < FREQUENT_ITEMS.MAX_COUNT; id += 1) {
|
||||
const project = {
|
||||
...session.project,
|
||||
id,
|
||||
};
|
||||
vm.logItemAccess(session.storageKey, project);
|
||||
}
|
||||
|
||||
const projects = JSON.parse(storage[session.storageKey]);
|
||||
|
||||
expect(projects.length).toBe(FREQUENT_ITEMS.MAX_COUNT);
|
||||
});
|
||||
expect(store.dispatch).toHaveBeenCalledWith(`${TEST_VUEX_MODULE}/fetchFrequentItems`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('created', () => {
|
||||
it('should bind event listeners on eventHub', (done) => {
|
||||
jest.spyOn(eventHub, '$on').mockImplementation(() => {});
|
||||
it('should not fetch frequent items if detroyed', () => {
|
||||
wrapper.destroy();
|
||||
triggerDropdownOpen();
|
||||
|
||||
createComponentWithStore().$mount();
|
||||
|
||||
Vue.nextTick(() => {
|
||||
expect(eventHub.$on).toHaveBeenCalledWith('projects-dropdownOpen', expect.any(Function));
|
||||
done();
|
||||
});
|
||||
expect(store.dispatch).not.toHaveBeenCalledWith(`${TEST_VUEX_MODULE}/fetchFrequentItems`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('beforeDestroy', () => {
|
||||
it('should unbind event listeners on eventHub', (done) => {
|
||||
jest.spyOn(eventHub, '$off').mockImplementation(() => {});
|
||||
|
||||
vm.$mount();
|
||||
vm.$destroy();
|
||||
|
||||
Vue.nextTick(() => {
|
||||
expect(eventHub.$off).toHaveBeenCalledWith('projects-dropdownOpen', expect.any(Function));
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('template', () => {
|
||||
it('should render search input', () => {
|
||||
expect(vm.$el.querySelector('.search-input-container')).toBeDefined();
|
||||
expect(findSearchInput().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('should render loading animation', (done) => {
|
||||
vm.$store.dispatch('fetchSearchedItems');
|
||||
it('should render loading animation', async () => {
|
||||
triggerDropdownOpen();
|
||||
store.state[TEST_VUEX_MODULE].isLoadingItems = true;
|
||||
|
||||
Vue.nextTick(() => {
|
||||
const loadingEl = vm.$el.querySelector('.loading-animation');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(loadingEl).toBeDefined();
|
||||
expect(loadingEl.classList.contains('prepend-top-20')).toBe(true);
|
||||
expect(loadingEl.querySelector('span').getAttribute('aria-label')).toBe('Loading projects');
|
||||
done();
|
||||
});
|
||||
const loading = findLoading();
|
||||
|
||||
expect(loading.exists()).toBe(true);
|
||||
expect(loading.find('[aria-label="Loading projects"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('should render frequent projects list header', (done) => {
|
||||
Vue.nextTick(() => {
|
||||
const sectionHeaderEl = vm.$el.querySelector('.section-header');
|
||||
it('should render frequent projects list header', () => {
|
||||
const sectionHeader = findSectionHeader();
|
||||
|
||||
expect(sectionHeaderEl).toBeDefined();
|
||||
expect(sectionHeaderEl.innerText.trim()).toBe('Frequently visited');
|
||||
done();
|
||||
});
|
||||
expect(sectionHeader.exists()).toBe(true);
|
||||
expect(sectionHeader.text()).toBe('Frequently visited');
|
||||
});
|
||||
|
||||
it('should render frequent projects list', (done) => {
|
||||
it('should render frequent projects list', async () => {
|
||||
const expectedResult = getTopFrequentItems(mockFrequentProjects);
|
||||
localStorage.getItem.mockImplementation(() => JSON.stringify(mockFrequentProjects));
|
||||
localStorage.setItem(TEST_STORAGE_KEY, JSON.stringify(mockFrequentProjects));
|
||||
|
||||
expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(1);
|
||||
expect(findFrequentItems().length).toBe(1);
|
||||
|
||||
vm.fetchFrequentItems();
|
||||
Vue.nextTick(() => {
|
||||
expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(
|
||||
expectedResult.length,
|
||||
);
|
||||
done();
|
||||
triggerDropdownOpen();
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(findFrequentItems().length).toBe(expectedResult.length);
|
||||
expect(findFrequentItemsList().props()).toEqual({
|
||||
items: expectedResult,
|
||||
namespace: TEST_NAMESPACE,
|
||||
hasSearchQuery: false,
|
||||
isFetchFailed: false,
|
||||
matcher: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should render searched projects list', (done) => {
|
||||
mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects);
|
||||
it('should render searched projects list', async () => {
|
||||
mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects.data);
|
||||
|
||||
expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(1);
|
||||
setSearch('gitlab');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
vm.$store.dispatch('setSearchQuery', 'gitlab');
|
||||
vm.$nextTick()
|
||||
.then(() => {
|
||||
expect(vm.$el.querySelector('.loading-animation')).toBeDefined();
|
||||
})
|
||||
.then(waitForPromises)
|
||||
.then(() => {
|
||||
expect(vm.$el.querySelectorAll('.frequent-items-list-container li').length).toBe(
|
||||
mockSearchedProjects.data.length,
|
||||
);
|
||||
})
|
||||
.then(done)
|
||||
.catch(done.fail);
|
||||
expect(findLoading().exists()).toBe(true);
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(findFrequentItems().length).toBe(mockSearchedProjects.data.length);
|
||||
expect(findFrequentItemsList().props()).toEqual(
|
||||
expect.objectContaining({
|
||||
items: mockSearchedProjects.data.map(
|
||||
({ avatar_url, web_url, name_with_namespace, ...item }) => ({
|
||||
...item,
|
||||
avatarUrl: avatar_url,
|
||||
webUrl: web_url,
|
||||
namespace: name_with_namespace,
|
||||
}),
|
||||
),
|
||||
namespace: TEST_NAMESPACE,
|
||||
hasSearchQuery: true,
|
||||
isFetchFailed: false,
|
||||
matcher: 'gitlab',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logging', () => {
|
||||
it('when created, it should create a project storage entry and adds a project', () => {
|
||||
createComponent();
|
||||
|
||||
expect(getStoredProjects()).toEqual([
|
||||
expect.objectContaining({
|
||||
frequency: 1,
|
||||
lastAccessedOn: Date.now(),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
describe('when created multiple times', () => {
|
||||
beforeEach(() => {
|
||||
createComponent();
|
||||
wrapper.destroy();
|
||||
createComponent();
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('should only log once', () => {
|
||||
expect(getStoredProjects()).toEqual([
|
||||
expect.objectContaining({
|
||||
lastAccessedOn: Date.now(),
|
||||
frequency: 1,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should increase frequency, when created an hour later', () => {
|
||||
const hourLater = Date.now() + HOUR_IN_MS + 1;
|
||||
|
||||
jest.spyOn(Date, 'now').mockReturnValue(hourLater);
|
||||
createComponent({ currentItem: { ...TEST_PROJECT, lastAccessedOn: hourLater } });
|
||||
|
||||
expect(getStoredProjects()).toEqual([
|
||||
expect.objectContaining({
|
||||
lastAccessedOn: hourLater,
|
||||
frequency: 2,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should always update project metadata', () => {
|
||||
const oldProject = {
|
||||
...TEST_PROJECT,
|
||||
};
|
||||
|
||||
const newProject = {
|
||||
...oldProject,
|
||||
name: 'New Name',
|
||||
avatarUrl: 'new/avatar.png',
|
||||
namespace: 'New / Namespace',
|
||||
webUrl: 'http://localhost/new/web/url',
|
||||
};
|
||||
|
||||
createComponent({ currentItem: oldProject });
|
||||
wrapper.destroy();
|
||||
expect(getStoredProjects()).toEqual([expect.objectContaining(oldProject)]);
|
||||
|
||||
createComponent({ currentItem: newProject });
|
||||
wrapper.destroy();
|
||||
|
||||
expect(getStoredProjects()).toEqual([expect.objectContaining(newProject)]);
|
||||
});
|
||||
|
||||
it('should not add more than 20 projects in store', () => {
|
||||
for (let id = 0; id < FREQUENT_ITEMS.MAX_COUNT + 10; id += 1) {
|
||||
const project = {
|
||||
...TEST_PROJECT,
|
||||
id,
|
||||
};
|
||||
createComponent({ currentItem: project });
|
||||
wrapper.destroy();
|
||||
}
|
||||
|
||||
expect(getStoredProjects().length).toBe(FREQUENT_ITEMS.MAX_COUNT);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
import Vuex from 'vuex';
|
||||
import { trimText } from 'helpers/text_helper';
|
||||
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
|
||||
import frequentItemsListItemComponent from '~/frequent_items/components/frequent_items_list_item.vue';
|
||||
import { createStore } from '~/frequent_items/store';
|
||||
import { mockProject } from '../mock_data';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(Vuex);
|
||||
|
||||
describe('FrequentItemsListItemComponent', () => {
|
||||
let wrapper;
|
||||
let trackingSpy;
|
||||
let store = createStore();
|
||||
let store;
|
||||
|
||||
const findTitle = () => wrapper.find({ ref: 'frequentItemsItemTitle' });
|
||||
const findAvatar = () => wrapper.find({ ref: 'frequentItemsItemAvatar' });
|
||||
|
|
@ -31,11 +35,15 @@ describe('FrequentItemsListItemComponent', () => {
|
|||
avatarUrl: mockProject.avatarUrl,
|
||||
...props,
|
||||
},
|
||||
provide: {
|
||||
vuexModule: 'frequentProjects',
|
||||
},
|
||||
localVue,
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
store = createStore({ dropdownType: 'project' });
|
||||
store = createStore();
|
||||
trackingSpy = mockTracking('_category_', document, jest.spyOn);
|
||||
trackingSpy.mockImplementation(() => {});
|
||||
});
|
||||
|
|
@ -119,7 +127,7 @@ describe('FrequentItemsListItemComponent', () => {
|
|||
});
|
||||
link.trigger('click');
|
||||
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_link', {
|
||||
label: 'project_dropdown_frequent_items_list_item',
|
||||
label: 'projects_dropdown_frequent_items_list_item',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import { mount } from '@vue/test-utils';
|
||||
import { mount, createLocalVue } from '@vue/test-utils';
|
||||
import Vuex from 'vuex';
|
||||
import frequentItemsListComponent from '~/frequent_items/components/frequent_items_list.vue';
|
||||
import frequentItemsListItemComponent from '~/frequent_items/components/frequent_items_list_item.vue';
|
||||
import { createStore } from '~/frequent_items/store';
|
||||
import { mockFrequentProjects } from '../mock_data';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(Vuex);
|
||||
|
||||
describe('FrequentItemsListComponent', () => {
|
||||
let wrapper;
|
||||
|
||||
|
|
@ -18,6 +22,10 @@ describe('FrequentItemsListComponent', () => {
|
|||
matcher: 'lab',
|
||||
...props,
|
||||
},
|
||||
localVue,
|
||||
provide: {
|
||||
vuexModule: 'frequentProjects',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import { GlSearchBoxByType } from '@gitlab/ui';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
import Vuex from 'vuex';
|
||||
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
|
||||
import searchComponent from '~/frequent_items/components/frequent_items_search_input.vue';
|
||||
import { createStore } from '~/frequent_items/store';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(Vuex);
|
||||
|
||||
describe('FrequentItemsSearchInputComponent', () => {
|
||||
let wrapper;
|
||||
let trackingSpy;
|
||||
|
|
@ -14,12 +18,16 @@ describe('FrequentItemsSearchInputComponent', () => {
|
|||
shallowMount(searchComponent, {
|
||||
store,
|
||||
propsData: { namespace },
|
||||
localVue,
|
||||
provide: {
|
||||
vuexModule: 'frequentProjects',
|
||||
},
|
||||
});
|
||||
|
||||
const findSearchBoxByType = () => wrapper.find(GlSearchBoxByType);
|
||||
|
||||
beforeEach(() => {
|
||||
store = createStore({ dropdownType: 'project' });
|
||||
store = createStore();
|
||||
jest.spyOn(store, 'dispatch').mockImplementation(() => {});
|
||||
|
||||
trackingSpy = mockTracking('_category_', document, jest.spyOn);
|
||||
|
|
@ -57,9 +65,9 @@ describe('FrequentItemsSearchInputComponent', () => {
|
|||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'type_search_query', {
|
||||
label: 'project_dropdown_frequent_items_search_input',
|
||||
label: 'projects_dropdown_frequent_items_search_input',
|
||||
});
|
||||
expect(store.dispatch).toHaveBeenCalledWith('setSearchQuery', value);
|
||||
expect(store.dispatch).toHaveBeenCalledWith('frequentProjects/setSearchQuery', value);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
import { mount, createLocalVue } from '@vue/test-utils';
|
||||
import Vue from 'vue';
|
||||
import Vuex from 'vuex';
|
||||
import {
|
||||
mapVuexModuleActions,
|
||||
mapVuexModuleGetters,
|
||||
mapVuexModuleState,
|
||||
REQUIRE_STRING_ERROR_MESSAGE,
|
||||
} from '~/lib/utils/vuex_module_mappers';
|
||||
|
||||
const TEST_MODULE_NAME = 'testModuleName';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(Vuex);
|
||||
|
||||
// setup test component and store ----------------------------------------------
|
||||
//
|
||||
// These are used to indirectly test `vuex_module_mappers`.
|
||||
const TestComponent = Vue.extend({
|
||||
props: {
|
||||
vuexModule: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapVuexModuleState((vm) => vm.vuexModule, { name: 'name', value: 'count' }),
|
||||
...mapVuexModuleGetters((vm) => vm.vuexModule, ['hasValue', 'hasName']),
|
||||
stateJson() {
|
||||
return JSON.stringify({
|
||||
name: this.name,
|
||||
value: this.value,
|
||||
});
|
||||
},
|
||||
gettersJson() {
|
||||
return JSON.stringify({
|
||||
hasValue: this.hasValue,
|
||||
hasName: this.hasName,
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapVuexModuleActions((vm) => vm.vuexModule, ['increment']),
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<pre data-testid="state">{{ stateJson }}</pre>
|
||||
<pre data-testid="getters">{{ gettersJson }}</pre>
|
||||
</div>`,
|
||||
});
|
||||
|
||||
const createTestStore = () => {
|
||||
return new Vuex.Store({
|
||||
modules: {
|
||||
[TEST_MODULE_NAME]: {
|
||||
namespaced: true,
|
||||
state: {
|
||||
name: 'Lorem',
|
||||
count: 0,
|
||||
},
|
||||
mutations: {
|
||||
INCREMENT: (state, amount) => {
|
||||
state.count += amount;
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
increment({ commit }, amount) {
|
||||
commit('INCREMENT', amount);
|
||||
},
|
||||
},
|
||||
getters: {
|
||||
hasValue: (state) => state.count > 0,
|
||||
hasName: (state) => Boolean(state.name.length),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe('~/lib/utils/vuex_module_mappers', () => {
|
||||
let store;
|
||||
let wrapper;
|
||||
|
||||
const getJsonInTemplate = (testId) =>
|
||||
JSON.parse(wrapper.find(`[data-testid="${testId}"]`).text());
|
||||
const getMappedState = () => getJsonInTemplate('state');
|
||||
const getMappedGetters = () => getJsonInTemplate('getters');
|
||||
|
||||
beforeEach(() => {
|
||||
store = createTestStore();
|
||||
|
||||
wrapper = mount(TestComponent, {
|
||||
propsData: {
|
||||
vuexModule: TEST_MODULE_NAME,
|
||||
},
|
||||
store,
|
||||
localVue,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
describe('from module defined by prop', () => {
|
||||
it('maps state', () => {
|
||||
expect(getMappedState()).toEqual({
|
||||
name: store.state[TEST_MODULE_NAME].name,
|
||||
value: store.state[TEST_MODULE_NAME].count,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps getters', () => {
|
||||
expect(getMappedGetters()).toEqual({
|
||||
hasName: true,
|
||||
hasValue: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps action', () => {
|
||||
jest.spyOn(store, 'dispatch');
|
||||
|
||||
expect(store.dispatch).not.toHaveBeenCalled();
|
||||
|
||||
wrapper.vm.increment(10);
|
||||
|
||||
expect(store.dispatch).toHaveBeenCalledWith(`${TEST_MODULE_NAME}/increment`, 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with non-string object value', () => {
|
||||
it('throws helpful error', () => {
|
||||
expect(() => mapVuexModuleActions((vm) => vm.bogus, { foo: () => {} })).toThrowError(
|
||||
REQUIRE_STRING_ERROR_MESSAGE,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { mount } from '@vue/test-utils';
|
||||
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
|
||||
import ConfigurationTable from '~/security_configuration/components/configuration_table.vue';
|
||||
import { scanners, UPGRADE_CTA } from '~/security_configuration/components/scanners_constants';
|
||||
import { scanners, UPGRADE_CTA } from '~/security_configuration/components/constants';
|
||||
|
||||
import {
|
||||
REPORT_TYPE_SAST,
|
||||
|
|
@ -12,7 +12,13 @@ describe('Configuration Table Component', () => {
|
|||
let wrapper;
|
||||
|
||||
const createComponent = () => {
|
||||
wrapper = extendedWrapper(mount(ConfigurationTable, {}));
|
||||
wrapper = extendedWrapper(
|
||||
mount(ConfigurationTable, {
|
||||
provide: {
|
||||
projectPath: 'testProjectPath',
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const findHelpLinks = () => wrapper.findAll('[data-testid="help-link"]');
|
||||
|
|
@ -30,8 +36,10 @@ describe('Configuration Table Component', () => {
|
|||
expect(wrapper.text()).toContain(scanner.name);
|
||||
expect(wrapper.text()).toContain(scanner.description);
|
||||
if (scanner.type === REPORT_TYPE_SAST) {
|
||||
expect(wrapper.findByTestId(scanner.type).text()).toBe('Configure via merge request');
|
||||
} else if (scanner.type !== REPORT_TYPE_SECRET_DETECTION) {
|
||||
expect(wrapper.findByTestId(scanner.type).text()).toBe('Configure via Merge Request');
|
||||
} else if (scanner.type === REPORT_TYPE_SECRET_DETECTION) {
|
||||
expect(wrapper.findByTestId(scanner.type).exists()).toBe(false);
|
||||
} else {
|
||||
expect(wrapper.findByTestId(scanner.type).text()).toMatchInterpolatedText(UPGRADE_CTA);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,136 +0,0 @@
|
|||
import { GlButton } from '@gitlab/ui';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import Vue from 'vue';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import createMockApollo from 'helpers/mock_apollo_helper';
|
||||
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import { redirectTo } from '~/lib/utils/url_utility';
|
||||
import ManageSast from '~/security_configuration/components/manage_sast.vue';
|
||||
import configureSastMutation from '~/security_configuration/graphql/configure_sast.mutation.graphql';
|
||||
|
||||
jest.mock('~/lib/utils/url_utility', () => ({
|
||||
redirectTo: jest.fn(),
|
||||
}));
|
||||
|
||||
Vue.use(VueApollo);
|
||||
|
||||
describe('Manage Sast Component', () => {
|
||||
let wrapper;
|
||||
|
||||
const findButton = () => wrapper.findComponent(GlButton);
|
||||
const successHandler = async () => {
|
||||
return {
|
||||
data: {
|
||||
configureSast: {
|
||||
successPath: 'testSuccessPath',
|
||||
errors: [],
|
||||
__typename: 'ConfigureSastPayload',
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const noSuccessPathHandler = async () => {
|
||||
return {
|
||||
data: {
|
||||
configureSast: {
|
||||
successPath: '',
|
||||
errors: [],
|
||||
__typename: 'ConfigureSastPayload',
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const errorHandler = async () => {
|
||||
return {
|
||||
data: {
|
||||
configureSast: {
|
||||
successPath: 'testSuccessPath',
|
||||
errors: ['foo'],
|
||||
__typename: 'ConfigureSastPayload',
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const pendingHandler = () => new Promise(() => {});
|
||||
|
||||
function createMockApolloProvider(handler) {
|
||||
const requestHandlers = [[configureSastMutation, handler]];
|
||||
|
||||
return createMockApollo(requestHandlers);
|
||||
}
|
||||
|
||||
function createComponent(options = {}) {
|
||||
const { mockApollo } = options;
|
||||
wrapper = extendedWrapper(
|
||||
mount(ManageSast, {
|
||||
apolloProvider: mockApollo,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
wrapper = null;
|
||||
});
|
||||
|
||||
it('should render Button with correct text', () => {
|
||||
createComponent();
|
||||
expect(findButton().text()).toContain('Configure via merge request');
|
||||
});
|
||||
|
||||
describe('given a successful response', () => {
|
||||
beforeEach(() => {
|
||||
const mockApollo = createMockApolloProvider(successHandler);
|
||||
createComponent({ mockApollo });
|
||||
});
|
||||
|
||||
it('should call redirect helper with correct value', async () => {
|
||||
await wrapper.trigger('click');
|
||||
await waitForPromises();
|
||||
expect(redirectTo).toHaveBeenCalledTimes(1);
|
||||
expect(redirectTo).toHaveBeenCalledWith('testSuccessPath');
|
||||
// This is done for UX reasons. If the loading prop is set to false
|
||||
// on success, then there's a period where the button is clickable
|
||||
// again. Instead, we want the button to display a loading indicator
|
||||
// for the remainder of the lifetime of the page (i.e., until the
|
||||
// browser can start painting the new page it's been redirected to).
|
||||
expect(findButton().props().loading).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('given a pending response', () => {
|
||||
beforeEach(() => {
|
||||
const mockApollo = createMockApolloProvider(pendingHandler);
|
||||
createComponent({ mockApollo });
|
||||
});
|
||||
|
||||
it('renders spinner correctly', async () => {
|
||||
expect(findButton().props('loading')).toBe(false);
|
||||
await wrapper.trigger('click');
|
||||
await waitForPromises();
|
||||
expect(findButton().props('loading')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each`
|
||||
handler | message
|
||||
${noSuccessPathHandler} | ${'SAST merge request creation mutation failed'}
|
||||
${errorHandler} | ${'foo'}
|
||||
`('given an error response', ({ handler, message }) => {
|
||||
beforeEach(() => {
|
||||
const mockApollo = createMockApolloProvider(handler);
|
||||
createComponent({ mockApollo });
|
||||
});
|
||||
|
||||
it('should catch and emit error', async () => {
|
||||
await wrapper.trigger('click');
|
||||
await waitForPromises();
|
||||
expect(wrapper.emitted('error')).toEqual([[message]]);
|
||||
expect(findButton().props('loading')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { mount } from '@vue/test-utils';
|
||||
import { UPGRADE_CTA } from '~/security_configuration/components/scanners_constants';
|
||||
import { UPGRADE_CTA } from '~/security_configuration/components/constants';
|
||||
import Upgrade from '~/security_configuration/components/upgrade.vue';
|
||||
|
||||
const TEST_URL = 'http://www.example.test';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import { mount } from '@vue/test-utils';
|
||||
import Vue from 'vue';
|
||||
import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue';
|
||||
|
||||
const TestComponent = Vue.extend({
|
||||
inject: ['vuexModule'],
|
||||
template: `<div data-testid="vuexModule">{{ vuexModule }}</div> `,
|
||||
});
|
||||
|
||||
const TEST_VUEX_MODULE = 'testVuexModule';
|
||||
|
||||
describe('~/vue_shared/components/vuex_module_provider', () => {
|
||||
let wrapper;
|
||||
|
||||
const findProvidedVuexModule = () => wrapper.find('[data-testid="vuexModule"]').text();
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = mount(VuexModuleProvider, {
|
||||
propsData: {
|
||||
vuexModule: TEST_VUEX_MODULE,
|
||||
},
|
||||
slots: {
|
||||
default: TestComponent,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('provides "vuexModule" set from prop', () => {
|
||||
expect(findProvidedVuexModule()).toBe(TEST_VUEX_MODULE);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
export const buildConfigureSecurityFeatureMockFactory = (mutationType) => ({
|
||||
successPath = 'testSuccessPath',
|
||||
errors = [],
|
||||
} = {}) => ({
|
||||
data: {
|
||||
[mutationType]: {
|
||||
successPath,
|
||||
errors,
|
||||
__typename: `${mutationType}Payload`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
import { GlButton } from '@gitlab/ui';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import Vue from 'vue';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import createMockApollo from 'helpers/mock_apollo_helper';
|
||||
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import { redirectTo } from '~/lib/utils/url_utility';
|
||||
import configureSast from '~/security_configuration/graphql/configure_sast.mutation.graphql';
|
||||
import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue';
|
||||
import { REPORT_TYPE_SAST } from '~/vue_shared/security_reports/constants';
|
||||
import { buildConfigureSecurityFeatureMockFactory } from './apollo_mocks';
|
||||
|
||||
jest.mock('~/lib/utils/url_utility');
|
||||
|
||||
Vue.use(VueApollo);
|
||||
|
||||
describe('ManageViaMr component', () => {
|
||||
let wrapper;
|
||||
|
||||
const findButton = () => wrapper.findComponent(GlButton);
|
||||
describe.each`
|
||||
featureName | featureType | mutation | mutationId
|
||||
${'SAST'} | ${REPORT_TYPE_SAST} | ${configureSast} | ${'configureSast'}
|
||||
`('$featureType', ({ featureName, mutation, featureType, mutationId }) => {
|
||||
const buildConfigureSecurityFeatureMock = buildConfigureSecurityFeatureMockFactory(mutationId);
|
||||
const successHandler = async () => buildConfigureSecurityFeatureMock();
|
||||
const noSuccessPathHandler = async () =>
|
||||
buildConfigureSecurityFeatureMock({
|
||||
successPath: '',
|
||||
});
|
||||
const errorHandler = async () =>
|
||||
buildConfigureSecurityFeatureMock({
|
||||
errors: ['foo'],
|
||||
});
|
||||
const pendingHandler = () => new Promise(() => {});
|
||||
|
||||
function createMockApolloProvider(handler) {
|
||||
const requestHandlers = [[mutation, handler]];
|
||||
|
||||
return createMockApollo(requestHandlers);
|
||||
}
|
||||
|
||||
function createComponent({ mockApollo, isFeatureConfigured = false } = {}) {
|
||||
wrapper = extendedWrapper(
|
||||
mount(ManageViaMr, {
|
||||
apolloProvider: mockApollo,
|
||||
provide: {
|
||||
projectPath: 'testProjectPath',
|
||||
},
|
||||
propsData: {
|
||||
feature: {
|
||||
name: featureName,
|
||||
type: featureType,
|
||||
configured: isFeatureConfigured,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
describe('when feature is configured', () => {
|
||||
beforeEach(() => {
|
||||
const mockApollo = createMockApolloProvider(successHandler);
|
||||
createComponent({ mockApollo, isFeatureConfigured: true });
|
||||
});
|
||||
|
||||
it('it does not render a button', () => {
|
||||
expect(findButton().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when feature is not configured', () => {
|
||||
beforeEach(() => {
|
||||
const mockApollo = createMockApolloProvider(successHandler);
|
||||
createComponent({ mockApollo, isFeatureConfigured: false });
|
||||
});
|
||||
|
||||
it('it does render a button', () => {
|
||||
expect(findButton().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('given a pending response', () => {
|
||||
beforeEach(() => {
|
||||
const mockApollo = createMockApolloProvider(pendingHandler);
|
||||
createComponent({ mockApollo });
|
||||
});
|
||||
|
||||
it('renders spinner correctly', async () => {
|
||||
const button = findButton();
|
||||
expect(button.props('loading')).toBe(false);
|
||||
await button.trigger('click');
|
||||
expect(button.props('loading')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('given a successful response', () => {
|
||||
beforeEach(() => {
|
||||
const mockApollo = createMockApolloProvider(successHandler);
|
||||
createComponent({ mockApollo });
|
||||
});
|
||||
|
||||
it('should call redirect helper with correct value', async () => {
|
||||
await wrapper.trigger('click');
|
||||
await waitForPromises();
|
||||
expect(redirectTo).toHaveBeenCalledTimes(1);
|
||||
expect(redirectTo).toHaveBeenCalledWith('testSuccessPath');
|
||||
// This is done for UX reasons. If the loading prop is set to false
|
||||
// on success, then there's a period where the button is clickable
|
||||
// again. Instead, we want the button to display a loading indicator
|
||||
// for the remainder of the lifetime of the page (i.e., until the
|
||||
// browser can start painting the new page it's been redirected to).
|
||||
expect(findButton().props().loading).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each`
|
||||
handler | message
|
||||
${noSuccessPathHandler} | ${`${featureName} merge request creation mutation failed`}
|
||||
${errorHandler} | ${'foo'}
|
||||
`('given an error response', ({ handler, message }) => {
|
||||
beforeEach(() => {
|
||||
const mockApollo = createMockApolloProvider(handler);
|
||||
createComponent({ mockApollo });
|
||||
});
|
||||
|
||||
it('should catch and emit error', async () => {
|
||||
await wrapper.trigger('click');
|
||||
await waitForPromises();
|
||||
expect(wrapper.emitted('error')).toEqual([[message]]);
|
||||
expect(findButton().props('loading')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue