Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-05-06 00:10:32 +00:00
parent d1fd19e525
commit bfb0d93c76
41 changed files with 932 additions and 485 deletions

View File

@ -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

View File

@ -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);
},

View File

@ -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>

View File

@ -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',
},
];

View File

@ -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,
},
}),
],
);
},
});
})

View File

@ -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 }),
}),
{},
),
});
};

View File

@ -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}`],
});

View File

@ -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>

View File

@ -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: [] },
},
},
}),
},
};

View File

@ -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>

View File

@ -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: {

View File

@ -0,0 +1,18 @@
<script>
export default {
provide() {
return {
vuexModule: this.vuexModule,
};
},
props: {
vuexModule: {
type: String,
required: true,
},
},
render() {
return this.$slots.default;
},
};
</script>

View File

@ -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>

View File

@ -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(),
});

View File

@ -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

View File

@ -104,3 +104,5 @@ module Terraform
end
end
end
Terraform::State.prepend_if_ee('EE::Terraform::State')

View File

@ -0,0 +1,5 @@
---
title: Delete feature flag for usage_data_a_compliance_audit_events_api
merge_request: 52947
author:
type: removed

View File

@ -0,0 +1,5 @@
---
title: 'Geo: Add verification for Terraform States'
merge_request: 58800
author:
type: changed

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
9f19b44a4ef3131e6ddd9cfea0d8b1eb4499754f2200bea90b5ed41eb688f622

View File

@ -0,0 +1 @@
3a223c462b10edb9eb68fc0adf42f046a45f554f35b4b4ee64a834cd7372f827

View File

@ -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);

View File

@ -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` |

View File

@ -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

View File

@ -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.

View File

@ -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:

View File

@ -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

View File

@ -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 ""

View File

@ -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);
});
});
});

View File

@ -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',
});
});
});

View File

@ -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',
},
});
};

View File

@ -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);
});
});
});

View File

@ -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,
);
});
});
});

View File

@ -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);
}
});

View File

@ -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);
});
});
});

View File

@ -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';

View File

@ -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);
});
});

View File

@ -0,0 +1,12 @@
export const buildConfigureSecurityFeatureMockFactory = (mutationType) => ({
successPath = 'testSuccessPath',
errors = [],
} = {}) => ({
data: {
[mutationType]: {
successPath,
errors,
__typename: `${mutationType}Payload`,
},
},
});

View File

@ -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);
});
});
});
});