From bfb0d93c767b4df4ab31a836d40cd7a1657916b5 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Thu, 6 May 2021 00:10:32 +0000 Subject: [PATCH] Add latest changes from gitlab-org/gitlab@master --- .../frequent_items/components/app.vue | 27 +- .../components/frequent_items_list_item.vue | 5 +- .../frequent_items_search_input.vue | 7 +- .../javascripts/frequent_items/constants.js | 13 + .../javascripts/frequent_items/index.js | 46 ++- .../javascripts/frequent_items/store/index.js | 23 +- .../lib/utils/vuex_module_mappers.js | 91 ++++ .../components/configuration_table.vue | 15 +- .../{scanners_constants.js => constants.js} | 16 + .../components/manage_sast.vue | 59 --- .../components/upgrade.vue | 2 +- .../components/vuex_module_provider.vue | 18 + .../components/manage_via_mr.vue | 73 ++++ .../security_configuration/provider.js | 9 + app/models/repository.rb | 16 +- app/models/terraform/state.rb | 2 + ...ange-group-affiliation-of-feature-flag.yml | 5 + .../unreleased/ag-verify-terraform-state.yml | 5 + ...age_data_a_compliance_audit_events_api.yml | 8 - ...ted_at_to_terraform_state_version_table.rb | 10 + ...ion_indexes_to_terraform_state_versions.rb | 26 ++ db/schema_migrations/20210407140539 | 1 + db/schema_migrations/20210420173030 | 1 + db/structure.sql | 10 + .../monitoring/prometheus/gitlab_metrics.md | 6 +- doc/development/documentation/structure.md | 3 + doc/user/group/epics/index.md | 4 +- doc/user/group/epics/manage_epics.md | 2 + .../known_events/common.yml | 2 +- locale/gitlab.pot | 6 - .../frequent_items/components/app_spec.js | 389 +++++++++--------- .../frequent_items_list_item_spec.js | 16 +- .../components/frequent_items_list_spec.js | 10 +- .../frequent_items_search_input_spec.js | 16 +- .../lib/utils/vuex_module_mappers_spec.js | 138 +++++++ .../configuration_table_spec.js | 16 +- .../manage_sast_spec.js | 136 ------ .../security_configuration/upgrade_spec.js | 2 +- .../components/vuex_module_provider_spec.js | 31 ++ .../components/apollo_mocks.js | 12 + .../components/manage_via_mr_spec.js | 140 +++++++ 41 files changed, 932 insertions(+), 485 deletions(-) create mode 100644 app/assets/javascripts/lib/utils/vuex_module_mappers.js rename app/assets/javascripts/security_configuration/components/{scanners_constants.js => constants.js} (91%) delete mode 100644 app/assets/javascripts/security_configuration/components/manage_sast.vue create mode 100644 app/assets/javascripts/vue_shared/components/vuex_module_provider.vue create mode 100644 app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue create mode 100644 app/assets/javascripts/vue_shared/security_configuration/provider.js create mode 100644 changelogs/unreleased/223786-change-group-affiliation-of-feature-flag.yml create mode 100644 changelogs/unreleased/ag-verify-terraform-state.yml delete mode 100644 config/feature_flags/development/usage_data_a_compliance_audit_events_api.yml create mode 100644 db/migrate/20210407140539_add_verification_state_and_started_at_to_terraform_state_version_table.rb create mode 100644 db/migrate/20210420173030_add_verification_indexes_to_terraform_state_versions.rb create mode 100644 db/schema_migrations/20210407140539 create mode 100644 db/schema_migrations/20210420173030 create mode 100644 spec/frontend/lib/utils/vuex_module_mappers_spec.js delete mode 100644 spec/frontend/security_configuration/manage_sast_spec.js create mode 100644 spec/frontend/vue_shared/components/vuex_module_provider_spec.js create mode 100644 spec/frontend/vue_shared/security_reports/components/apollo_mocks.js create mode 100644 spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js diff --git a/app/assets/javascripts/frequent_items/components/app.vue b/app/assets/javascripts/frequent_items/components/app.vue index 00d32727ad0..e103949b86a 100644 --- a/app/assets/javascripts/frequent_items/components/app.vue +++ b/app/assets/javascripts/frequent_items/components/app.vue @@ -1,7 +1,11 @@ diff --git a/app/assets/javascripts/frequent_items/constants.js b/app/assets/javascripts/frequent_items/constants.js index 9bc17f5ef4f..5af107d9083 100644 --- a/app/assets/javascripts/frequent_items/constants.js +++ b/app/assets/javascripts/frequent_items/constants.js @@ -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', + }, +]; diff --git a/app/assets/javascripts/frequent_items/index.js b/app/assets/javascripts/frequent_items/index.js index eb8a404e8a5..f1540ffac28 100644 --- a/app/assets/javascripts/frequent_items/index.js +++ b/app/assets/javascripts/frequent_items/index.js @@ -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, + }, + }), + ], + ); }, }); }) diff --git a/app/assets/javascripts/frequent_items/store/index.js b/app/assets/javascripts/frequent_items/store/index.js index 83176d69802..47fad112297 100644 --- a/app/assets/javascripts/frequent_items/store/index.js +++ b/app/assets/javascripts/frequent_items/store/index.js @@ -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 }), + }), + {}, + ), }); }; diff --git a/app/assets/javascripts/lib/utils/vuex_module_mappers.js b/app/assets/javascripts/lib/utils/vuex_module_mappers.js new file mode 100644 index 00000000000..95a794dd268 --- /dev/null +++ b/app/assets/javascripts/lib/utils/vuex_module_mappers.js @@ -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}`], + }); diff --git a/app/assets/javascripts/security_configuration/components/configuration_table.vue b/app/assets/javascripts/security_configuration/components/configuration_table.vue index 4a3f988296c..2110af1522b 100644 --- a/app/assets/javascripts/security_configuration/components/configuration_table.vue +++ b/app/assets/javascripts/security_configuration/components/configuration_table.vue @@ -1,6 +1,7 @@ - - diff --git a/app/assets/javascripts/security_configuration/components/upgrade.vue b/app/assets/javascripts/security_configuration/components/upgrade.vue index 518eb57731d..2541c29224a 100644 --- a/app/assets/javascripts/security_configuration/components/upgrade.vue +++ b/app/assets/javascripts/security_configuration/components/upgrade.vue @@ -1,6 +1,6 @@ diff --git a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue new file mode 100644 index 00000000000..de7374ff4b2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue @@ -0,0 +1,73 @@ + + + diff --git a/app/assets/javascripts/vue_shared/security_configuration/provider.js b/app/assets/javascripts/vue_shared/security_configuration/provider.js new file mode 100644 index 00000000000..ef96b443da8 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_configuration/provider.js @@ -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(), +}); diff --git a/app/models/repository.rb b/app/models/repository.rb index b2efc9b480b..9d2e514f377 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -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 diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb index eb7d465d585..88dde62d4f2 100644 --- a/app/models/terraform/state.rb +++ b/app/models/terraform/state.rb @@ -104,3 +104,5 @@ module Terraform end end end + +Terraform::State.prepend_if_ee('EE::Terraform::State') diff --git a/changelogs/unreleased/223786-change-group-affiliation-of-feature-flag.yml b/changelogs/unreleased/223786-change-group-affiliation-of-feature-flag.yml new file mode 100644 index 00000000000..d7db25648ca --- /dev/null +++ b/changelogs/unreleased/223786-change-group-affiliation-of-feature-flag.yml @@ -0,0 +1,5 @@ +--- +title: Delete feature flag for usage_data_a_compliance_audit_events_api +merge_request: 52947 +author: +type: removed diff --git a/changelogs/unreleased/ag-verify-terraform-state.yml b/changelogs/unreleased/ag-verify-terraform-state.yml new file mode 100644 index 00000000000..6947c81884c --- /dev/null +++ b/changelogs/unreleased/ag-verify-terraform-state.yml @@ -0,0 +1,5 @@ +--- +title: 'Geo: Add verification for Terraform States' +merge_request: 58800 +author: +type: changed diff --git a/config/feature_flags/development/usage_data_a_compliance_audit_events_api.yml b/config/feature_flags/development/usage_data_a_compliance_audit_events_api.yml deleted file mode 100644 index 9d668c73052..00000000000 --- a/config/feature_flags/development/usage_data_a_compliance_audit_events_api.yml +++ /dev/null @@ -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 diff --git a/db/migrate/20210407140539_add_verification_state_and_started_at_to_terraform_state_version_table.rb b/db/migrate/20210407140539_add_verification_state_and_started_at_to_terraform_state_version_table.rb new file mode 100644 index 00000000000..987be4ab1f0 --- /dev/null +++ b/db/migrate/20210407140539_add_verification_state_and_started_at_to_terraform_state_version_table.rb @@ -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 diff --git a/db/migrate/20210420173030_add_verification_indexes_to_terraform_state_versions.rb b/db/migrate/20210420173030_add_verification_indexes_to_terraform_state_versions.rb new file mode 100644 index 00000000000..2c0d0bee39d --- /dev/null +++ b/db/migrate/20210420173030_add_verification_indexes_to_terraform_state_versions.rb @@ -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 diff --git a/db/schema_migrations/20210407140539 b/db/schema_migrations/20210407140539 new file mode 100644 index 00000000000..3d861cfee82 --- /dev/null +++ b/db/schema_migrations/20210407140539 @@ -0,0 +1 @@ +9f19b44a4ef3131e6ddd9cfea0d8b1eb4499754f2200bea90b5ed41eb688f622 \ No newline at end of file diff --git a/db/schema_migrations/20210420173030 b/db/schema_migrations/20210420173030 new file mode 100644 index 00000000000..e7e3caf8365 --- /dev/null +++ b/db/schema_migrations/20210420173030 @@ -0,0 +1 @@ +3a223c462b10edb9eb68fc0adf42f046a45f554f35b4b4ee64a834cd7372f827 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 2f833db4290..94fb77644ca 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -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); diff --git a/doc/administration/monitoring/prometheus/gitlab_metrics.md b/doc/administration/monitoring/prometheus/gitlab_metrics.md index 6019c606c5a..e8cf090b19e 100644 --- a/doc/administration/monitoring/prometheus/gitlab_metrics.md +++ b/doc/administration/monitoring/prometheus/gitlab_metrics.md @@ -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` | diff --git a/doc/development/documentation/structure.md b/doc/development/documentation/structure.md index 8e245a38490..0e8e956a98a 100644 --- a/doc/development/documentation/structure.md +++ b/doc/development/documentation/structure.md @@ -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 diff --git a/doc/user/group/epics/index.md b/doc/user/group/epics/index.md index 12377b3926d..050ff4c6566 100644 --- a/doc/user/group/epics/index.md +++ b/doc/user/group/epics/index.md @@ -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. diff --git a/doc/user/group/epics/manage_epics.md b/doc/user/group/epics/manage_epics.md index 1999e5ba214..d24aea60344 100644 --- a/doc/user/group/epics/manage_epics.md +++ b/doc/user/group/epics/manage_epics.md @@ -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: diff --git a/lib/gitlab/usage_data_counters/known_events/common.yml b/lib/gitlab/usage_data_counters/known_events/common.yml index bbfa69f47ac..f2504396cc4 100644 --- a/lib/gitlab/usage_data_counters/known_events/common.yml +++ b/lib/gitlab/usage_data_counters/known_events/common.yml @@ -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 diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 7ce3232567a..0c53056c34c 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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 "" diff --git a/spec/frontend/frequent_items/components/app_spec.js b/spec/frontend/frequent_items/components/app_spec.js index 80059c4c87f..7a1026e8bfc 100644 --- a/spec/frontend/frequent_items/components/app_spec.js +++ b/spec/frontend/frequent_items/components/app_spec.js @@ -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); }); }); }); diff --git a/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js b/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js index 66fb346cb38..9a68115e4f6 100644 --- a/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js +++ b/spec/frontend/frequent_items/components/frequent_items_list_item_spec.js @@ -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', }); }); }); diff --git a/spec/frontend/frequent_items/components/frequent_items_list_spec.js b/spec/frontend/frequent_items/components/frequent_items_list_spec.js index bd0711005b3..c015914c991 100644 --- a/spec/frontend/frequent_items/components/frequent_items_list_spec.js +++ b/spec/frontend/frequent_items/components/frequent_items_list_spec.js @@ -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', + }, }); }; diff --git a/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js b/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js index 0280fdb0ca2..c9b7e0f3d13 100644 --- a/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js +++ b/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js @@ -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); }); }); }); diff --git a/spec/frontend/lib/utils/vuex_module_mappers_spec.js b/spec/frontend/lib/utils/vuex_module_mappers_spec.js new file mode 100644 index 00000000000..d7e51e4daca --- /dev/null +++ b/spec/frontend/lib/utils/vuex_module_mappers_spec.js @@ -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: ` +
+
{{ stateJson }}
+
{{ gettersJson }}
+
`, +}); + +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, + ); + }); + }); +}); diff --git a/spec/frontend/security_configuration/configuration_table_spec.js b/spec/frontend/security_configuration/configuration_table_spec.js index a1789052c92..fbd72265c4b 100644 --- a/spec/frontend/security_configuration/configuration_table_spec.js +++ b/spec/frontend/security_configuration/configuration_table_spec.js @@ -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); } }); diff --git a/spec/frontend/security_configuration/manage_sast_spec.js b/spec/frontend/security_configuration/manage_sast_spec.js deleted file mode 100644 index 15a57210246..00000000000 --- a/spec/frontend/security_configuration/manage_sast_spec.js +++ /dev/null @@ -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); - }); - }); -}); diff --git a/spec/frontend/security_configuration/upgrade_spec.js b/spec/frontend/security_configuration/upgrade_spec.js index 1f0cc795fc5..20bb38aa469 100644 --- a/spec/frontend/security_configuration/upgrade_spec.js +++ b/spec/frontend/security_configuration/upgrade_spec.js @@ -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'; diff --git a/spec/frontend/vue_shared/components/vuex_module_provider_spec.js b/spec/frontend/vue_shared/components/vuex_module_provider_spec.js new file mode 100644 index 00000000000..01fe5af49fd --- /dev/null +++ b/spec/frontend/vue_shared/components/vuex_module_provider_spec.js @@ -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: `
{{ vuexModule }}
`, +}); + +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); + }); +}); diff --git a/spec/frontend/vue_shared/security_reports/components/apollo_mocks.js b/spec/frontend/vue_shared/security_reports/components/apollo_mocks.js new file mode 100644 index 00000000000..066f9a57bc6 --- /dev/null +++ b/spec/frontend/vue_shared/security_reports/components/apollo_mocks.js @@ -0,0 +1,12 @@ +export const buildConfigureSecurityFeatureMockFactory = (mutationType) => ({ + successPath = 'testSuccessPath', + errors = [], +} = {}) => ({ + data: { + [mutationType]: { + successPath, + errors, + __typename: `${mutationType}Payload`, + }, + }, +}); diff --git a/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js b/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js new file mode 100644 index 00000000000..6efb9aef0c0 --- /dev/null +++ b/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js @@ -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); + }); + }); + }); +});