diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-06-08 15:08:26 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-06-08 15:08:26 +0300 |
commit | f149549c3432ffb179f6904e4ba0ea64027202d0 (patch) | |
tree | 9c6dca1e76c0de43e5e5f6b5d34c5616abbc7501 | |
parent | b001207ce2033589373cd7558ca69c4e5732ce6b (diff) |
Add latest changes from gitlab-org/gitlab@master
49 files changed, 1191 insertions, 810 deletions
diff --git a/app/assets/javascripts/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_projects/components/import_projects_table.vue index 849bda28d03..f2ed16ba59f 100644 --- a/app/assets/javascripts/import_projects/components/import_projects_table.vue +++ b/app/assets/javascripts/import_projects/components/import_projects_table.vue @@ -1,11 +1,11 @@ <script> import { throttle } from 'lodash'; import { mapActions, mapState, mapGetters } from 'vuex'; -import { GlLoadingIcon } from '@gitlab/ui'; -import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import ImportedProjectTableRow from './imported_project_table_row.vue'; import ProviderRepoTableRow from './provider_repo_table_row.vue'; +import IncompatibleRepoTableRow from './incompatible_repo_table_row.vue'; import eventHub from '../event_hub'; const reposFetchThrottleDelay = 1000; @@ -15,8 +15,9 @@ export default { components: { ImportedProjectTableRow, ProviderRepoTableRow, - LoadingButton, + IncompatibleRepoTableRow, GlLoadingIcon, + GlButton, }, props: { providerTitle: { @@ -26,8 +27,25 @@ export default { }, computed: { - ...mapState(['importedProjects', 'providerRepos', 'isLoadingRepos', 'filter']), - ...mapGetters(['isImportingAnyRepo', 'hasProviderRepos', 'hasImportedProjects']), + ...mapState([ + 'importedProjects', + 'providerRepos', + 'incompatibleRepos', + 'isLoadingRepos', + 'filter', + ]), + ...mapGetters([ + 'isImportingAnyRepo', + 'hasProviderRepos', + 'hasImportedProjects', + 'hasIncompatibleRepos', + ]), + + importAllButtonText() { + return this.hasIncompatibleRepos + ? __('Import all compatible repositories') + : __('Import all repositories'); + }, emptyStateText() { return sprintf(__('No %{providerTitle} repositories found'), { @@ -68,7 +86,6 @@ export default { }, throttledFetchRepos: throttle(function fetch() { - eventHub.$off('importAll'); this.fetchRepos(); }, reposFetchThrottleDelay), }, @@ -80,17 +97,24 @@ export default { <p class="light text-nowrap mt-2"> {{ s__('ImportProjects|Select the projects you want to import') }} </p> - - <div class="d-flex justify-content-between align-items-end flex-wrap mb-3"> - <loading-button - container-class="btn btn-success js-import-all" + <template v-if="hasIncompatibleRepos"> + <slot name="incompatible-repos-warning"> </slot> + </template> + <div + v-if="!isLoadingRepos" + class="d-flex justify-content-between align-items-end flex-wrap mb-3" + > + <gl-button + variant="success" :loading="isImportingAnyRepo" - :label="__('Import all repositories')" :disabled="!hasProviderRepos" type="button" @click="importAll" - /> - <form novalidate @submit.prevent> + > + {{ importAllButtonText }} + </gl-button> + <slot name="actions"></slot> + <form class="gl-ml-auto" novalidate @submit.prevent> <input :value="filter" data-qa-selector="githubish_import_filter_field" @@ -109,7 +133,10 @@ export default { class="js-loading-button-icon import-projects-loading-icon" size="md" /> - <div v-else-if="hasProviderRepos || hasImportedProjects" class="table-responsive"> + <div + v-else-if="hasProviderRepos || hasImportedProjects || hasIncompatibleRepos" + class="table-responsive" + > <table class="table import-table"> <thead> <th class="import-jobs-from-col">{{ fromHeaderText }}</th> @@ -124,6 +151,11 @@ export default { :project="project" /> <provider-repo-table-row v-for="repo in providerRepos" :key="repo.id" :repo="repo" /> + <incompatible-repo-table-row + v-for="repo in incompatibleRepos" + :key="repo.id" + :repo="repo" + /> </tbody> </table> </div> diff --git a/app/assets/javascripts/import_projects/components/incompatible_repo_table_row.vue b/app/assets/javascripts/import_projects/components/incompatible_repo_table_row.vue new file mode 100644 index 00000000000..fa2fb439eac --- /dev/null +++ b/app/assets/javascripts/import_projects/components/incompatible_repo_table_row.vue @@ -0,0 +1,30 @@ +<script> +import { GlBadge } from '@gitlab/ui'; + +export default { + components: { + GlBadge, + }, + props: { + repo: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <tr class="import-row"> + <td> + <a :href="repo.providerLink" rel="noreferrer noopener" target="_blank"> + {{ repo.fullName }} + </a> + </td> + <td></td> + <td></td> + <td> + <gl-badge variant="danger">{{ __('Incompatible project') }}</gl-badge> + </td> + </tr> +</template> diff --git a/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue index 6e227ab3d82..63524d61146 100644 --- a/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue +++ b/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue @@ -53,7 +53,11 @@ export default { }, created() { - eventHub.$on('importAll', () => this.importRepo()); + eventHub.$on('importAll', this.importRepo); + }, + + beforeDestroy() { + eventHub.$off('importAll', this.importRepo); }, methods: { diff --git a/app/assets/javascripts/import_projects/index.js b/app/assets/javascripts/import_projects/index.js index b069dcb7766..49d38b56e40 100644 --- a/app/assets/javascripts/import_projects/index.js +++ b/app/assets/javascripts/import_projects/index.js @@ -1,5 +1,4 @@ import Vue from 'vue'; -import { mapActions } from 'vuex'; import Translate from '../vue_shared/translate'; import ImportProjectsTable from './components/import_projects_table.vue'; import { parseBoolean } from '../lib/utils/common_utils'; @@ -7,42 +6,44 @@ import createStore from './store'; Vue.use(Translate); -export default function mountImportProjectsTable(mountElement) { - if (!mountElement) return undefined; - +export function initStoreFromElement(element) { const { reposPath, provider, - providerTitle, canSelectNamespace, jobsPath, importPath, ciCdOnly, - } = mountElement.dataset; + } = element.dataset; - const store = createStore(); - return new Vue({ - el: mountElement, - store, + return createStore({ + reposPath, + provider, + jobsPath, + importPath, + defaultTargetNamespace: gon.current_username, + ciCdOnly: parseBoolean(ciCdOnly), + canSelectNamespace: parseBoolean(canSelectNamespace), + }); +} - created() { - this.setInitialData({ - reposPath, - provider, - jobsPath, - importPath, - defaultTargetNamespace: gon.current_username, - ciCdOnly: parseBoolean(ciCdOnly), - canSelectNamespace: parseBoolean(canSelectNamespace), - }); - }, +export function initPropsFromElement(element) { + return { + providerTitle: element.dataset.providerTitle, + }; +} - methods: { - ...mapActions(['setInitialData', 'setFilter']), - }, +export default function mountImportProjectsTable(mountElement) { + if (!mountElement) return undefined; + + const store = initStoreFromElement(mountElement); + const props = initPropsFromElement(mountElement); + return new Vue({ + el: mountElement, + store, render(createElement) { - return createElement(ImportProjectsTable, { props: { providerTitle } }); + return createElement(ImportProjectsTable, { props }); }, }); } diff --git a/app/assets/javascripts/import_projects/store/actions.js b/app/assets/javascripts/import_projects/store/actions.js index 0fb9a4cdfd4..6cf71126882 100644 --- a/app/assets/javascripts/import_projects/store/actions.js +++ b/app/assets/javascripts/import_projects/store/actions.js @@ -19,23 +19,18 @@ export const restartJobsPolling = () => { if (eTagPoll) eTagPoll.restart(); }; -export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data); export const setFilter = ({ commit }, filter) => commit(types.SET_FILTER, filter); -export const requestRepos = ({ commit }, repos) => commit(types.REQUEST_REPOS, repos); -export const receiveReposSuccess = ({ commit }, repos) => - commit(types.RECEIVE_REPOS_SUCCESS, repos); -export const receiveReposError = ({ commit }) => commit(types.RECEIVE_REPOS_ERROR); -export const fetchRepos = ({ state, dispatch }) => { +export const fetchRepos = ({ state, dispatch, commit }) => { dispatch('stopJobsPolling'); - dispatch('requestRepos'); + commit(types.REQUEST_REPOS); const { provider } = state; return axios .get(reposPathWithFilter(state)) .then(({ data }) => - dispatch('receiveReposSuccess', convertObjectPropsToCamelCase(data, { deep: true })), + commit(types.RECEIVE_REPOS_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true })), ) .then(() => dispatch('fetchJobs')) .catch(() => { @@ -45,19 +40,14 @@ export const fetchRepos = ({ state, dispatch }) => { }), ); - dispatch('receiveReposError'); + commit(types.RECEIVE_REPOS_ERROR); }); }; -export const requestImport = ({ commit, state }, repoId) => { - if (!state.reposBeingImported.includes(repoId)) commit(types.REQUEST_IMPORT, repoId); -}; -export const receiveImportSuccess = ({ commit }, { importedProject, repoId }) => - commit(types.RECEIVE_IMPORT_SUCCESS, { importedProject, repoId }); -export const receiveImportError = ({ commit }, repoId) => - commit(types.RECEIVE_IMPORT_ERROR, repoId); -export const fetchImport = ({ state, dispatch }, { newName, targetNamespace, repo }) => { - dispatch('requestImport', repo.id); +export const fetchImport = ({ state, commit }, { newName, targetNamespace, repo }) => { + if (!state.reposBeingImported.includes(repo.id)) { + commit(types.REQUEST_IMPORT, repo.id); + } return axios .post(state.importPath, { @@ -67,7 +57,7 @@ export const fetchImport = ({ state, dispatch }, { newName, targetNamespace, rep target_namespace: targetNamespace, }) .then(({ data }) => - dispatch('receiveImportSuccess', { + commit(types.RECEIVE_IMPORT_SUCCESS, { importedProject: convertObjectPropsToCamelCase(data, { deep: true }), repoId: repo.id, }), @@ -75,13 +65,14 @@ export const fetchImport = ({ state, dispatch }, { newName, targetNamespace, rep .catch(() => { createFlash(s__('ImportProjects|Importing the project failed')); - dispatch('receiveImportError', { repoId: repo.id }); + commit(types.RECEIVE_IMPORT_ERROR, repo.id); }); }; export const receiveJobsSuccess = ({ commit }, updatedProjects) => commit(types.RECEIVE_JOBS_SUCCESS, updatedProjects); -export const fetchJobs = ({ state, dispatch }) => { + +export const fetchJobs = ({ state, commit, dispatch }) => { const { filter } = state; if (eTagPoll) { @@ -95,7 +86,7 @@ export const fetchJobs = ({ state, dispatch }) => { }, method: 'fetchJobs', successCallback: ({ data }) => - dispatch('receiveJobsSuccess', convertObjectPropsToCamelCase(data, { deep: true })), + commit(types.RECEIVE_JOBS_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true })), errorCallback: () => createFlash(s__('ImportProjects|Update of imported projects with realtime changes failed')), data: { filter }, diff --git a/app/assets/javascripts/import_projects/store/getters.js b/app/assets/javascripts/import_projects/store/getters.js index b107c293181..e6eb8f523de 100644 --- a/app/assets/javascripts/import_projects/store/getters.js +++ b/app/assets/javascripts/import_projects/store/getters.js @@ -21,6 +21,8 @@ export const hasProviderRepos = state => state.providerRepos.length > 0; export const hasImportedProjects = state => state.importedProjects.length > 0; +export const hasIncompatibleRepos = state => state.incompatibleRepos.length > 0; + export const reposPathWithFilter = ({ reposPath, filter = '' }) => filter ? `${reposPath}?filter=${filter}` : reposPath; export const jobsPathWithFilter = ({ jobsPath, filter = '' }) => diff --git a/app/assets/javascripts/import_projects/store/index.js b/app/assets/javascripts/import_projects/store/index.js index ff1fd1e598e..29deb7868ba 100644 --- a/app/assets/javascripts/import_projects/store/index.js +++ b/app/assets/javascripts/import_projects/store/index.js @@ -9,9 +9,9 @@ Vue.use(Vuex); export { state, actions, getters, mutations }; -export default () => +export default initialState => new Vuex.Store({ - state: state(), + state: { ...state(), ...initialState }, actions, mutations, getters, diff --git a/app/assets/javascripts/import_projects/store/mutation_types.js b/app/assets/javascripts/import_projects/store/mutation_types.js index 16574f4450f..a23b7eef986 100644 --- a/app/assets/javascripts/import_projects/store/mutation_types.js +++ b/app/assets/javascripts/import_projects/store/mutation_types.js @@ -1,5 +1,3 @@ -export const SET_INITIAL_DATA = 'SET_INITIAL_DATA'; - export const REQUEST_REPOS = 'REQUEST_REPOS'; export const RECEIVE_REPOS_SUCCESS = 'RECEIVE_REPOS_SUCCESS'; export const RECEIVE_REPOS_ERROR = 'RECEIVE_REPOS_ERROR'; diff --git a/app/assets/javascripts/import_projects/store/mutations.js b/app/assets/javascripts/import_projects/store/mutations.js index 6c56cfa8298..ec62d0640ef 100644 --- a/app/assets/javascripts/import_projects/store/mutations.js +++ b/app/assets/javascripts/import_projects/store/mutations.js @@ -2,10 +2,6 @@ import Vue from 'vue'; import * as types from './mutation_types'; export default { - [types.SET_INITIAL_DATA](state, data) { - Object.assign(state, data); - }, - [types.SET_FILTER](state, filter) { state.filter = filter; }, @@ -14,11 +10,15 @@ export default { state.isLoadingRepos = true; }, - [types.RECEIVE_REPOS_SUCCESS](state, { importedProjects, providerRepos, namespaces }) { + [types.RECEIVE_REPOS_SUCCESS]( + state, + { importedProjects, providerRepos, incompatibleRepos, namespaces }, + ) { state.isLoadingRepos = false; state.importedProjects = importedProjects; state.providerRepos = providerRepos; + state.incompatibleRepos = incompatibleRepos ?? []; state.namespaces = namespaces; }, diff --git a/app/assets/javascripts/import_projects/store/state.js b/app/assets/javascripts/import_projects/store/state.js index 829f3aa4fbb..0418d735b1d 100644 --- a/app/assets/javascripts/import_projects/store/state.js +++ b/app/assets/javascripts/import_projects/store/state.js @@ -7,6 +7,7 @@ export default () => ({ currentUsername: '', importedProjects: [], providerRepos: [], + incompatibleRepos: [], namespaces: [], reposBeingImported: [], isLoadingRepos: false, diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 8007ccb91d5..893b32606f8 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -245,11 +245,11 @@ export default { <div class="zen-backdrop"> <slot name="textarea"></slot> <a - class="zen-control zen-control-leave js-zen-leave" + class="zen-control zen-control-leave js-zen-leave gl-text-gray-700" href="#" - :aria-label="__('Enter zen mode')" + :aria-label="__('Leave zen mode')" > - <icon :size="32" name="screen-normal" /> + <icon :size="16" name="screen-normal" /> </a> <markdown-toolbar :markdown-docs-path="markdownDocsPath" diff --git a/app/graphql/types/metrics/dashboard_type.rb b/app/graphql/types/metrics/dashboard_type.rb index d684533ff94..bbcce2d9596 100644 --- a/app/graphql/types/metrics/dashboard_type.rb +++ b/app/graphql/types/metrics/dashboard_type.rb @@ -10,6 +10,9 @@ module Types field :path, GraphQL::STRING_TYPE, null: true, description: 'Path to a file with the dashboard definition' + field :schema_validation_warnings, [GraphQL::STRING_TYPE], null: true, + description: 'Dashboard schema validation warnings' + field :annotations, Types::Metrics::Dashboards::AnnotationType.connection_type, null: true, description: 'Annotations added to the dashboard', resolver: Resolvers::Metrics::Dashboards::AnnotationResolver diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 33ba2af00e9..f87f97722fb 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -39,7 +39,8 @@ module Ci dotenv: '.env', cobertura: 'cobertura-coverage.xml', terraform: 'tfplan.json', - cluster_applications: 'gl-cluster-applications.json' + cluster_applications: 'gl-cluster-applications.json', + requirements: 'requirements.json' }.freeze INTERNAL_TYPES = { @@ -71,7 +72,8 @@ module Ci license_management: :raw, license_scanning: :raw, performance: :raw, - terraform: :raw + terraform: :raw, + requirements: :raw }.freeze DOWNLOADABLE_TYPES = %w[ @@ -90,6 +92,7 @@ module Ci metrics performance sast + requirements ].freeze TYPE_AND_FORMAT_PAIRS = INTERNAL_TYPES.merge(REPORT_TYPES).freeze @@ -182,7 +185,8 @@ module Ci terraform: 18, # Transformed json accessibility: 19, cluster_applications: 20, - secret_detection: 21 ## EE-specific + secret_detection: 21, ## EE-specific + requirements: 22 ## EE-specific } enum file_format: { diff --git a/app/models/performance_monitoring/prometheus_dashboard.rb b/app/models/performance_monitoring/prometheus_dashboard.rb index f64ca3c66a1..b04e7e689cd 100644 --- a/app/models/performance_monitoring/prometheus_dashboard.rb +++ b/app/models/performance_monitoring/prometheus_dashboard.rb @@ -15,15 +15,17 @@ module PerformanceMonitoring end def find_for(project:, user:, path:, options: {}) - dashboard_response = Gitlab::Metrics::Dashboard::Finder.find(project, user, options.merge(dashboard_path: path)) - return unless dashboard_response[:status] == :success - - new( - { - path: path, - environment: options[:environment] - }.merge(dashboard_response[:dashboard]) - ) + template = { path: path, environment: options[:environment] } + rsp = Gitlab::Metrics::Dashboard::Finder.find(project, user, options.merge(dashboard_path: path)) + + case rsp[:http_status] || rsp[:status] + when :success + new(template.merge(rsp[:dashboard] || {})) # when there is empty dashboard file returned rsp is still a success + when :unprocessable_entity + new(template) # validation error + else + nil # any other error + end end private @@ -42,6 +44,15 @@ module PerformanceMonitoring self.as_json(only: yaml_valid_attributes).to_yaml end + # This method is planned to be refactored as a part of https://gitlab.com/gitlab-org/gitlab/-/issues/219398 + # implementation. For new existing logic was reused to faster deliver MVC + def schema_validation_warnings + self.class.from_json(self.as_json) + nil + rescue ActiveModel::ValidationError => exception + exception.model.errors.map { |attr, error| "#{attr}: #{error}" } + end + private def yaml_valid_attributes diff --git a/app/views/layouts/nav/breadcrumbs/_collapsed_dropdown.html.haml b/app/views/layouts/nav/breadcrumbs/_collapsed_dropdown.html.haml index ad0d51d28f9..8ea75087fed 100644 --- a/app/views/layouts/nav/breadcrumbs/_collapsed_dropdown.html.haml +++ b/app/views/layouts/nav/breadcrumbs/_collapsed_dropdown.html.haml @@ -3,7 +3,7 @@ - if defined?(@breadcrumb_dropdown_links) && @breadcrumb_dropdown_links.key?(dropdown_location) %li.dropdown %button.text-expander.has-tooltip.js-breadcrumbs-collapsed-expander{ type: "button", data: { toggle: "dropdown", container: "body" }, "aria-label": button_tooltip, title: button_tooltip } - = icon("ellipsis-h") + = sprite_icon("ellipsis_h", size: 12) = sprite_icon("angle-right", size: 8, css_class: "breadcrumbs-list-angle") .dropdown-menu %ul diff --git a/app/views/projects/_zen.html.haml b/app/views/projects/_zen.html.haml index 744aef3cad4..8dd0e5a92a7 100644 --- a/app/views/projects/_zen.html.haml +++ b/app/views/projects/_zen.html.haml @@ -15,5 +15,5 @@ qa_selector: qa_selector } - else = text_area_tag attr, current_text, class: classes, placeholder: placeholder - %a.zen-control.zen-control-leave.js-zen-leave{ href: "#" } - = icon('compress') + %a.zen-control.zen-control-leave.js-zen-leave.gl-text-gray-700{ href: "#" } + = sprite_icon('compress', size: 16) diff --git a/changelogs/unreleased/mwaw-210289-add-metrics-dashboard-validation-to-grapql.yml b/changelogs/unreleased/mwaw-210289-add-metrics-dashboard-validation-to-grapql.yml new file mode 100644 index 00000000000..675f08ffe27 --- /dev/null +++ b/changelogs/unreleased/mwaw-210289-add-metrics-dashboard-validation-to-grapql.yml @@ -0,0 +1,5 @@ +--- +title: Add dashboard schema validation warnings as metrics dashboard GraphQL field +merge_request: 33592 +author: +type: added diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index 0fa165554d9..8764320959b 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -242,6 +242,8 @@ - 1 - - repository_update_remote_mirror - 1 +- - requirements_management_process_requirements_reports + - 1 - - security_scans - 2 - - self_monitoring_project_create diff --git a/db/post_migrate/20200518114540_schedule_fix_ruby_object_in_audit_events.rb b/db/post_migrate/20200518114540_schedule_fix_ruby_object_in_audit_events.rb new file mode 100644 index 00000000000..e4335089540 --- /dev/null +++ b/db/post_migrate/20200518114540_schedule_fix_ruby_object_in_audit_events.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class ScheduleFixRubyObjectInAuditEvents < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + INDEX_NAME = 'index_audit_events_on_ruby_object_in_details' + INTERVAL = 2.minutes.to_i + BATCH_SIZE = 1_000 + MIGRATION = 'FixRubyObjectInAuditEvents' + + disable_ddl_transaction! + + class AuditEvent < ActiveRecord::Base + self.table_name = 'audit_events' + + include ::EachBatch + end + + def up + return unless Gitlab.ee? + + # create temporary index for audit_events with ruby/object in details field, may take well over 1h + add_concurrent_index(:audit_events, :id, where: "details ~~ '%ruby/object%'", name: INDEX_NAME) + + relation = AuditEvent.where("details ~~ '%ruby/object%'") + + queue_background_migration_jobs_by_range_at_intervals( + relation, + MIGRATION, + INTERVAL, + batch_size: BATCH_SIZE + ) + end + + def down + # temporary index is to be dropped in a different migration in an upcoming release + # https://gitlab.com/gitlab-org/gitlab/issues/196842 + remove_concurrent_index_by_name(:audit_events, INDEX_NAME) + end +end diff --git a/db/structure.sql b/db/structure.sql index 7acc76ab99b..85f8e278ccf 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -9229,6 +9229,8 @@ CREATE INDEX index_approvers_on_user_id ON public.approvers USING btree (user_id CREATE INDEX index_audit_events_on_entity_id_and_entity_type_and_id_desc ON public.audit_events USING btree (entity_id, entity_type, id DESC); +CREATE INDEX index_audit_events_on_ruby_object_in_details ON public.audit_events USING btree (id) WHERE (details ~~ '%ruby/object%'::text); + CREATE INDEX index_award_emoji_on_awardable_type_and_awardable_id ON public.award_emoji USING btree (awardable_type, awardable_id); CREATE INDEX index_award_emoji_on_user_id_and_name ON public.award_emoji USING btree (user_id, name); @@ -13764,6 +13766,7 @@ COPY "schema_migrations" (version) FROM STDIN; 20200514000340 20200515155620 20200518091745 +20200518114540 20200518133123 20200519074709 20200519101002 diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index e70f28d4b02..9a70d71a202 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -1725,6 +1725,13 @@ type CreateSnippetPayload { snippet: Snippet } +enum DastScanTypeEnum { + """ + Passive DAST scan. This scan will not make active attacks against the target site. + """ + PASSIVE +} + """ Autogenerated input type of DeleteAnnotation """ @@ -7040,6 +7047,11 @@ type MetricsDashboard { Path to a file with the dashboard definition """ path: String + + """ + Dashboard schema validation warnings + """ + schemaValidationWarnings: [String!] } type MetricsDashboardAnnotation { @@ -7269,6 +7281,7 @@ type Mutation { mergeRequestSetWip(input: MergeRequestSetWipInput!): MergeRequestSetWipPayload removeAwardEmoji(input: RemoveAwardEmojiInput!): RemoveAwardEmojiPayload removeProjectFromSecurityDashboard(input: RemoveProjectFromSecurityDashboardInput!): RemoveProjectFromSecurityDashboardPayload + runDastScan(input: RunDASTScanInput!): RunDASTScanPayload todoMarkDone(input: TodoMarkDoneInput!): TodoMarkDonePayload todoRestore(input: TodoRestoreInput!): TodoRestorePayload todoRestoreMany(input: TodoRestoreManyInput!): TodoRestoreManyPayload @@ -10298,6 +10311,56 @@ type RootStorageStatistics { } """ +Autogenerated input type of RunDASTScan +""" +input RunDASTScanInput { + """ + The branch to be associated with the scan. + """ + branch: String! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The project the DAST scan belongs to. + """ + projectPath: ID! + + """ + The type of scan to be run. + """ + scanType: DastScanTypeEnum! + + """ + The URL of the target to be scanned. + """ + targetUrl: String! +} + +""" +Autogenerated return type of RunDASTScan +""" +type RunDASTScanPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Errors encountered during execution of the mutation. + """ + errors: [String!]! + + """ + URL of the pipeline that was created. + """ + pipelineUrl: String +} + +""" A Sentry error. """ type SentryDetailedError { diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index ed2631363a0..7c31d5ab41c 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -4571,6 +4571,23 @@ "possibleTypes": null }, { + "kind": "ENUM", + "name": "DastScanTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "PASSIVE", + "description": "Passive DAST scan. This scan will not make active attacks against the target site.", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { "kind": "INPUT_OBJECT", "name": "DeleteAnnotationInput", "description": "Autogenerated input type of DeleteAnnotation", @@ -19616,6 +19633,28 @@ }, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "schemaValidationWarnings", + "description": "Dashboard schema validation warnings", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null } ], "inputFields": null, @@ -21285,6 +21324,33 @@ "deprecationReason": null }, { + "name": "runDastScan", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "RunDASTScanInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "RunDASTScanPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "todoMarkDone", "description": null, "args": [ @@ -30139,6 +30205,150 @@ "possibleTypes": null }, { + "kind": "INPUT_OBJECT", + "name": "RunDASTScanInput", + "description": "Autogenerated input type of RunDASTScan", + "fields": null, + "inputFields": [ + { + "name": "projectPath", + "description": "The project the DAST scan belongs to.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "targetUrl", + "description": "The URL of the target to be scanned.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "branch", + "description": "The branch to be associated with the scan.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "scanType", + "description": "The type of scan to be run.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "DastScanTypeEnum", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "RunDASTScanPayload", + "description": "Autogenerated return type of RunDASTScan", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Errors encountered during execution of the mutation.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pipelineUrl", + "description": "URL of the pipeline that was created.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { "kind": "OBJECT", "name": "SentryDetailedError", "description": "A Sentry error.", diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 42a907f8845..00429423bb4 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -1060,6 +1060,7 @@ Autogenerated return type of MergeRequestSetWip | Name | Type | Description | | --- | ---- | ---------- | | `path` | String | Path to a file with the dashboard definition | +| `schemaValidationWarnings` | String! => Array | Dashboard schema validation warnings | ## MetricsDashboardAnnotation @@ -1450,6 +1451,16 @@ Counts of requirements by their state. | `storageSize` | Float! | The total storage in bytes | | `wikiSize` | Float! | The wiki size in bytes | +## RunDASTScanPayload + +Autogenerated return type of RunDASTScan + +| Name | Type | Description | +| --- | ---- | ---------- | +| `clientMutationId` | String | A unique identifier for the client performing the mutation. | +| `errors` | String! => Array | Errors encountered during execution of the mutation. | +| `pipelineUrl` | String | URL of the pipeline that was created. | + ## SentryDetailedError A Sentry error. diff --git a/doc/update/upgrading_from_source.md b/doc/update/upgrading_from_source.md index 9f8d4901cde..031984143da 100644 --- a/doc/update/upgrading_from_source.md +++ b/doc/update/upgrading_from_source.md @@ -383,6 +383,18 @@ Example: Additional instructions here. --> +### 13.0.1 + +As part of [deprecating Rack Attack throttles on Omnibus GitLab](https://gitlab.com/gitlab-org/omnibus-gitlab/-/issues/4750), Rack Attack initializer on GitLab +was renamed from [`config/initializers/rack_attack_new.rb` to `config/initializers/rack_attack.rb`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/33072). +If this file exists on your installation, consider creating a backup before updating: + +```shell +cd /home/git/gitlab + +cp config/initializers/rack_attack.rb config/initializers/rack_attack_backup.rb +``` + ## Troubleshooting ### 1. Revert the code to the previous version diff --git a/doc/user/application_security/threat_monitoring/index.md b/doc/user/application_security/threat_monitoring/index.md index 6c04dd63dbe..434048896fe 100644 --- a/doc/user/application_security/threat_monitoring/index.md +++ b/doc/user/application_security/threat_monitoring/index.md @@ -9,9 +9,10 @@ info: To determine the technical writer assigned to the Stage/Group associated w > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/14707) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 12.9. -The **Threat Monitoring** page provides metrics for the GitLab -application runtime security features. You can access these metrics by -navigating to your project's **Security & Compliance > Threat Monitoring** page. +The **Threat Monitoring** page provides metrics and policy management +for the GitLab application runtime security features. You can access +these by navigating to your project's **Security & Compliance > Threat +Monitoring** page. GitLab supports statistics for the following security features: @@ -77,3 +78,41 @@ about your packet flow: If a significant percentage of packets is dropped, you should investigate it for potential threats by [examining the Cilium logs](../../clusters/applications.md#install-cilium-using-gitlab-cicd). + +## Container Network Policy management + +> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/3328) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 13.1. + +The **Threat Monitoring** page's **Policy** tab displays deployed +network policies for all available environments. You can check a +network policy's `yaml` manifest and toggle the policy's enforcement +status. This section has the following prerequisites: + +- Your project contains at least one [environment](../../../ci/environments/index.md) +- You've [installed Cilium](../../clusters/applications.md#install-cilium-using-gitlab-cicd) + +Network policies are fetched directly from the selected environment's +deployment platform. Changes performed outside of this tab are +reflected upon refresh. Enforcement status changes are deployed +directly to a deployment namespace of the selected environment. + +NOTE: **Note:** +If you're using [Auto DevOps](../../../topics/autodevops/index.md) and +change a policy in this section, your `auto-deploy-values.yaml` file +doesn't update. Auto DevOps users must make changes by following +the [Container Network Policy documentation](../../../topics/autodevops/stages.md#network-policy). + +### Changing enforcement status + +To change a network policy's enforcement status: + +- Click the network policy you want to update. +- Click the **Enforcement status** toggle to update the selected policy. +- Click the **Apply changes** button to deploy network policy changes. + +NOTE: **Note:** +Disabled network policies have the +`network-policy.gitlab.com/disabled_by: gitlab` selector inside the +`podSelector` block. This narrows the scope of such a policy and as a +result it doesn't affect any pods. The policy itself is still deployed +to the corresponding deployment namespace. diff --git a/doc/user/project/code_owners.md b/doc/user/project/code_owners.md index 81bdf1dfb2f..9b0512830c7 100644 --- a/doc/user/project/code_owners.md +++ b/doc/user/project/code_owners.md @@ -80,6 +80,14 @@ Once set, Code Owners are displayed in merge requests widgets: NOTE: **Note**: While the`CODEOWNERS` file can be used in addition to Merge Request [Approval Rules](merge_requests/merge_request_approvals.md#approval-rules) it can also be used as the sole driver of a Merge Request approval (without using [Approval Rules](merge_requests/merge_request_approvals.md#approval-rules)) by simply creating the file in one of the three locations specified above, configuring the Code Owners to be required approvers for [protected branches](protected_branches.md#protected-branches-approval-by-code-owners-premium) and then using [the syntax of Code Owners files](code_owners.md#the-syntax-of-code-owners-files) to specify the actual owners and granular permissions. +NOTE: **Note**: + Using Code Owners in conjunction with [Protected Branches Approvals](protected_branches.md#protected-branches-approval-by-code-owners-premium) + will prevent any user who is not specified in the `CODEOWNERS` file from pushing changes +for the specified files/paths, even if their role is included in the **Allowed to push** column. +This allows for a more inclusive push strategy, as administrators don't have to restrict developers + from pushing directly to the protected branch, but can restrict pushing to certain +files where a review by Code Owners is required. + ## The syntax of Code Owners files Files can be specified using the same kind of patterns you would use diff --git a/doc/user/project/integrations/prometheus.md b/doc/user/project/integrations/prometheus.md index 2eae2bc9915..215894e137b 100644 --- a/doc/user/project/integrations/prometheus.md +++ b/doc/user/project/integrations/prometheus.md @@ -362,6 +362,8 @@ When **Metrics Dashboard YAML definition is invalid** at least one of the follow 1. `query_range: can't be blank` [learn more](#metrics-metrics-properties) 1. `unit: can't be blank` [learn more](#metrics-metrics-properties) +Metrics Dashboard YAML definition validation information is also available as a [GraphQL API field](../../../api/graphql/reference/index.md#metricsdashboard) + #### Dashboard YAML properties Dashboards have several components: diff --git a/lib/gitlab/background_migration/fix_ruby_object_in_audit_events.rb b/lib/gitlab/background_migration/fix_ruby_object_in_audit_events.rb new file mode 100644 index 00000000000..46921a070c3 --- /dev/null +++ b/lib/gitlab/background_migration/fix_ruby_object_in_audit_events.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + # Remove serialized Ruby object in audit_events + class FixRubyObjectInAuditEvents + def perform(start_id, stop_id) + end + end + end +end + +Gitlab::BackgroundMigration::FixRubyObjectInAuditEvents.prepend_if_ee('EE::Gitlab::BackgroundMigration::FixRubyObjectInAuditEvents') diff --git a/lib/gitlab/ci/config/entry/reports.rb b/lib/gitlab/ci/config/entry/reports.rb index 20bd2856b07..74736b24d73 100644 --- a/lib/gitlab/ci/config/entry/reports.rb +++ b/lib/gitlab/ci/config/entry/reports.rb @@ -14,7 +14,8 @@ module Gitlab ALLOWED_KEYS = %i[junit codequality sast secret_detection dependency_scanning container_scanning dast performance license_management license_scanning metrics lsif - dotenv cobertura terraform accessibility cluster_applications].freeze + dotenv cobertura terraform accessibility cluster_applications + requirements].freeze attributes ALLOWED_KEYS @@ -40,6 +41,7 @@ module Gitlab validates :terraform, array_of_strings_or_string: true validates :accessibility, array_of_strings_or_string: true validates :cluster_applications, array_of_strings_or_string: true + validates :requirements, array_of_strings_or_string: true end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 0d65df72a02..11b4be0f766 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -3091,9 +3091,6 @@ msgstr "" msgid "Auto DevOps, runners and job artifacts" msgstr "" -msgid "Auto License Compliance" -msgstr "" - msgid "Auto stop successfully canceled." msgstr "" @@ -8417,9 +8414,6 @@ msgstr "" msgid "Enter your password to approve" msgstr "" -msgid "Enter zen mode" -msgstr "" - msgid "Environment" msgstr "" @@ -10637,9 +10631,6 @@ msgstr "" msgid "Gitea Import" msgstr "" -msgid "Gitlab CI/CD" -msgstr "" - msgid "Gitlab Pages" msgstr "" @@ -11700,6 +11691,9 @@ msgstr "" msgid "Import all compatible projects" msgstr "" +msgid "Import all compatible repositories" +msgstr "" + msgid "Import all projects" msgstr "" @@ -11889,6 +11883,9 @@ msgstr "" msgid "Incompatible options set!" msgstr "" +msgid "Incompatible project" +msgstr "" + msgid "Indent" msgstr "" @@ -12886,6 +12883,9 @@ msgstr "" msgid "Leave the \"File type\" and \"Delivery method\" options on their default values." msgstr "" +msgid "Leave zen mode" +msgstr "" + msgid "Let's Encrypt does not accept emails on example.com" msgstr "" @@ -12925,15 +12925,9 @@ msgstr "" msgid "LicenseCompliance|Deny" msgstr "" -msgid "LicenseCompliance|Here you can allow or deny licenses for this project. Using %{ci} or %{license} will allow you to see if there are any unmanaged licenses and allow or deny them in merge request." -msgstr "" - msgid "LicenseCompliance|License" msgstr "" -msgid "LicenseCompliance|License Compliance" -msgstr "" - msgid "LicenseCompliance|License Compliance detected %d license and policy violation for the source branch only; approval required" msgid_plural "LicenseCompliance|License Compliance detected %d licenses and policy violations for the source branch only; approval required" msgstr[0] "" diff --git a/package.json b/package.json index 8e60064f29f..44abf908abd 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "@babel/preset-env": "^7.8.4", "@gitlab/at.js": "1.5.5", "@gitlab/svgs": "1.137.0", - "@gitlab/ui": "16.2.1", + "@gitlab/ui": "16.3.0", "@gitlab/visual-review-tools": "1.6.1", "@rails/actioncable": "^6.0.3-1", "@sentry/browser": "^5.10.2", diff --git a/qa/qa/page/project/settings/ci_cd.rb b/qa/qa/page/project/settings/ci_cd.rb index aa27c030b78..a7a0f6f57b6 100644 --- a/qa/qa/page/project/settings/ci_cd.rb +++ b/qa/qa/page/project/settings/ci_cd.rb @@ -42,5 +42,3 @@ module QA end end end - -QA::Page::Project::Settings::CICD.prepend_if_ee('QA::EE::Page::Project::Settings::CICD') diff --git a/spec/frontend/import_projects/components/import_projects_table_spec.js b/spec/frontend/import_projects/components/import_projects_table_spec.js index 9491b52c888..978f6c3ea41 100644 --- a/spec/frontend/import_projects/components/import_projects_table_spec.js +++ b/spec/frontend/import_projects/components/import_projects_table_spec.js @@ -1,11 +1,21 @@ +import { nextTick } from 'vue'; import Vuex from 'vuex'; -import { createLocalVue, mount } from '@vue/test-utils'; -import { state, actions, getters, mutations } from '~/import_projects/store'; -import importProjectsTable from '~/import_projects/components/import_projects_table.vue'; -import STATUS_MAP from '~/import_projects/constants'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon, GlButton } from '@gitlab/ui'; +import { state, getters } from '~/import_projects/store'; +import eventHub from '~/import_projects/event_hub'; +import ImportProjectsTable from '~/import_projects/components/import_projects_table.vue'; +import ImportedProjectTableRow from '~/import_projects/components/imported_project_table_row.vue'; +import ProviderRepoTableRow from '~/import_projects/components/provider_repo_table_row.vue'; +import IncompatibleRepoTableRow from '~/import_projects/components/incompatible_repo_table_row.vue'; + +jest.mock('~/import_projects/event_hub', () => ({ + $emit: jest.fn(), +})); describe('ImportProjectsTable', () => { - let vm; + let wrapper; + const providerTitle = 'THE PROVIDER'; const providerRepo = { id: 10, sanitizedName: 'sanitizedName', fullName: 'fullName' }; const importedProject = { @@ -16,176 +26,164 @@ describe('ImportProjectsTable', () => { importSource: 'importSource', }; - function initStore() { - const stubbedActions = { - ...actions, - fetchJobs: jest.fn(), - fetchRepos: jest.fn(actions.requestRepos), - fetchImport: jest.fn(actions.requestImport), - }; - - const store = new Vuex.Store({ - state: state(), - actions: stubbedActions, - mutations, - getters, - }); - - return store; - } + const findImportAllButton = () => + wrapper + .findAll(GlButton) + .filter(w => w.props().variant === 'success') + .at(0); - function mountComponent() { + function createComponent({ state: initialState, getters: customGetters, slots } = {}) { const localVue = createLocalVue(); localVue.use(Vuex); - const store = initStore(); + const store = new Vuex.Store({ + state: { ...state(), ...initialState }, + getters: { + ...getters, + ...customGetters, + }, + actions: { + fetchRepos: jest.fn(), + fetchReposFiltered: jest.fn(), + fetchJobs: jest.fn(), + stopJobsPolling: jest.fn(), + clearJobsEtagPoll: jest.fn(), + setFilter: jest.fn(), + }, + }); - const component = mount(importProjectsTable, { + wrapper = shallowMount(ImportProjectsTable, { localVue, store, propsData: { providerTitle, }, + slots, }); - - return component.vm; } - beforeEach(() => { - vm = mountComponent(); - }); - afterEach(() => { - vm.$destroy(); + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } }); - it('renders a loading icon while repos are loading', () => - vm.$nextTick().then(() => { - expect(vm.$el.querySelector('.js-loading-button-icon')).not.toBeNull(); - })); - - it('renders a table with imported projects and provider repos', () => { - vm.$store.dispatch('receiveReposSuccess', { - importedProjects: [importedProject], - providerRepos: [providerRepo], - namespaces: [{ path: 'path' }], + it('renders a loading icon while repos are loading', () => { + createComponent({ + state: { + isLoadingRepos: true, + }, }); - return vm.$nextTick().then(() => { - expect(vm.$el.querySelector('.js-loading-button-icon')).toBeNull(); - expect(vm.$el.querySelector('.table')).not.toBeNull(); - expect(vm.$el.querySelector('.import-jobs-from-col').innerText).toMatch( - `From ${providerTitle}`, - ); - - expect(vm.$el.querySelector('.js-imported-project')).not.toBeNull(); - expect(vm.$el.querySelector('.js-provider-repo')).not.toBeNull(); - }); + expect(wrapper.contains(GlLoadingIcon)).toBe(true); }); - it('renders an empty state if there are no imported projects or provider repos', () => { - vm.$store.dispatch('receiveReposSuccess', { - importedProjects: [], - providerRepos: [], - namespaces: [], + it('renders a table with imported projects and provider repos', () => { + createComponent({ + state: { + importedProjects: [importedProject], + providerRepos: [providerRepo], + incompatibleRepos: [{ ...providerRepo, id: 11 }], + namespaces: [{ path: 'path' }], + }, }); - return vm.$nextTick().then(() => { - expect(vm.$el.querySelector('.js-loading-button-icon')).toBeNull(); - expect(vm.$el.querySelector('.table')).toBeNull(); - expect(vm.$el.innerText).toMatch(`No ${providerTitle} repositories found`); - }); + expect(wrapper.contains(GlLoadingIcon)).toBe(false); + expect(wrapper.contains('table')).toBe(true); + expect( + wrapper + .findAll('th') + .filter(w => w.text() === `From ${providerTitle}`) + .isEmpty(), + ).toBe(false); + + expect(wrapper.contains(ProviderRepoTableRow)).toBe(true); + expect(wrapper.contains(ImportedProjectTableRow)).toBe(true); + expect(wrapper.contains(IncompatibleRepoTableRow)).toBe(true); }); - it('shows loading spinner when bulk import button is clicked', () => { - vm.$store.dispatch('receiveReposSuccess', { - importedProjects: [], - providerRepos: [providerRepo], - namespaces: [{ path: 'path' }], - }); - - return vm - .$nextTick() - .then(() => { - expect(vm.$el.querySelector('.js-imported-project')).toBeNull(); - expect(vm.$el.querySelector('.js-provider-repo')).not.toBeNull(); - - vm.$el.querySelector('.js-import-all').click(); - }) - .then(() => vm.$nextTick()) - .then(() => { - expect(vm.$el.querySelector('.js-import-all .js-loading-button-icon')).not.toBeNull(); + it.each` + hasIncompatibleRepos | buttonText + ${false} | ${'Import all repositories'} + ${true} | ${'Import all compatible repositories'} + `( + 'import all button has "$buttonText" text when hasIncompatibleRepos is $hasIncompatibleRepos', + ({ hasIncompatibleRepos, buttonText }) => { + createComponent({ + state: { + providerRepos: [providerRepo], + }, + getters: { + hasIncompatibleRepos: () => hasIncompatibleRepos, + }, }); - }); - it('imports provider repos if bulk import button is clicked', () => { - mountComponent(); + expect(findImportAllButton().text()).toBe(buttonText); + }, + ); - vm.$store.dispatch('receiveReposSuccess', { - importedProjects: [], - providerRepos: [providerRepo], - namespaces: [{ path: 'path' }], + it('renders an empty state if there are no projects available', () => { + createComponent({ + state: { + importedProjects: [], + providerRepos: [], + incompatibleProjects: [], + }, }); - return vm - .$nextTick() - .then(() => { - expect(vm.$el.querySelector('.js-imported-project')).toBeNull(); - expect(vm.$el.querySelector('.js-provider-repo')).not.toBeNull(); - - vm.$store.dispatch('receiveImportSuccess', { importedProject, repoId: providerRepo.id }); - }) - .then(() => vm.$nextTick()) - .then(() => { - expect(vm.$el.querySelector('.js-imported-project')).not.toBeNull(); - expect(vm.$el.querySelector('.js-provider-repo')).toBeNull(); - }); + expect(wrapper.contains(ProviderRepoTableRow)).toBe(false); + expect(wrapper.contains(ImportedProjectTableRow)).toBe(false); + expect(wrapper.text()).toContain(`No ${providerTitle} repositories found`); }); - it('polls to update the status of imported projects', () => { - const updatedProjects = [ - { - id: importedProject.id, - importStatus: 'finished', + it('sends importAll event when import button is clicked', async () => { + createComponent({ + state: { + providerRepos: [providerRepo], }, - ]; - - vm.$store.dispatch('receiveReposSuccess', { - importedProjects: [importedProject], - providerRepos: [], - namespaces: [{ path: 'path' }], }); - return vm - .$nextTick() - .then(() => { - const statusObject = STATUS_MAP[importedProject.importStatus]; - - expect(vm.$el.querySelector('.js-imported-project')).not.toBeNull(); - expect(vm.$el.querySelector(`.${statusObject.textClass}`).textContent).toMatch( - statusObject.text, - ); - - expect(vm.$el.querySelector(`.ic-status_${statusObject.icon}`)).not.toBeNull(); - - vm.$store.dispatch('receiveJobsSuccess', updatedProjects); - }) - .then(() => vm.$nextTick()) - .then(() => { - const statusObject = STATUS_MAP[updatedProjects[0].importStatus]; + findImportAllButton().vm.$emit('click'); + await nextTick(); + expect(eventHub.$emit).toHaveBeenCalledWith('importAll'); + }); - expect(vm.$el.querySelector('.js-imported-project')).not.toBeNull(); - expect(vm.$el.querySelector(`.${statusObject.textClass}`).textContent).toMatch( - statusObject.text, - ); + it('shows loading spinner when import is in progress', () => { + createComponent({ + getters: { + isImportingAnyRepo: () => true, + }, + }); - expect(vm.$el.querySelector(`.ic-status_${statusObject.icon}`)).not.toBeNull(); - }); + expect(findImportAllButton().props().loading).toBe(true); }); it('renders filtering input field', () => { - expect( - vm.$el.querySelector('input[data-qa-selector="githubish_import_filter_field"]'), - ).not.toBeNull(); + createComponent(); + expect(wrapper.contains('input[data-qa-selector="githubish_import_filter_field"]')).toBe(true); }); + + it.each` + hasIncompatibleRepos | shouldRenderSlot | action + ${false} | ${false} | ${'does not render'} + ${true} | ${true} | ${'render'} + `( + '$action incompatible-repos-warning slot if hasIncompatibleRepos is $hasIncompatibleRepos', + ({ hasIncompatibleRepos, shouldRenderSlot }) => { + const INCOMPATIBLE_TEXT = 'INCOMPATIBLE!'; + + createComponent({ + getters: { + hasIncompatibleRepos: () => hasIncompatibleRepos, + }, + + slots: { + 'incompatible-repos-warning': INCOMPATIBLE_TEXT, + }, + }); + + expect(wrapper.text().includes(INCOMPATIBLE_TEXT)).toBe(shouldRenderSlot); + }, + ); }); diff --git a/spec/frontend/import_projects/components/provider_repo_table_row_spec.js b/spec/frontend/import_projects/components/provider_repo_table_row_spec.js index 8be645c496f..f5e5141eac8 100644 --- a/spec/frontend/import_projects/components/provider_repo_table_row_spec.js +++ b/spec/frontend/import_projects/components/provider_repo_table_row_spec.js @@ -6,7 +6,7 @@ import STATUS_MAP, { STATUSES } from '~/import_projects/constants'; describe('ProviderRepoTableRow', () => { let vm; - const fetchImport = jest.fn((context, data) => actions.requestImport(context, data)); + const fetchImport = jest.fn(); const importPath = '/import-path'; const defaultTargetNamespace = 'user'; const ciCdOnly = true; @@ -17,11 +17,11 @@ describe('ProviderRepoTableRow', () => { providerLink: 'providerLink', }; - function initStore() { + function initStore(initialState) { const stubbedActions = { ...actions, fetchImport }; const store = new Vuex.Store({ - state: state(), + state: { ...state(), ...initialState }, actions: stubbedActions, mutations, getters, @@ -30,12 +30,11 @@ describe('ProviderRepoTableRow', () => { return store; } - function mountComponent() { + function mountComponent(initialState) { const localVue = createLocalVue(); localVue.use(Vuex); - const store = initStore(); - store.dispatch('setInitialData', { importPath, defaultTargetNamespace, ciCdOnly }); + const store = initStore({ importPath, defaultTargetNamespace, ciCdOnly, ...initialState }); const component = mount(providerRepoTableRow, { localVue, diff --git a/spec/frontend/import_projects/store/actions_spec.js b/spec/frontend/import_projects/store/actions_spec.js index 4954513715e..1f2882a2532 100644 --- a/spec/frontend/import_projects/store/actions_spec.js +++ b/spec/frontend/import_projects/store/actions_spec.js @@ -4,7 +4,6 @@ import { TEST_HOST } from 'helpers/test_constants'; import axios from '~/lib/utils/axios_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { - SET_INITIAL_DATA, REQUEST_REPOS, RECEIVE_REPOS_SUCCESS, RECEIVE_REPOS_ERROR, @@ -14,14 +13,7 @@ import { RECEIVE_JOBS_SUCCESS, } from '~/import_projects/store/mutation_types'; import { - setInitialData, - requestRepos, - receiveReposSuccess, - receiveReposError, fetchRepos, - requestImport, - receiveImportSuccess, - receiveImportError, fetchImport, receiveJobsSuccess, fetchJobs, @@ -32,7 +24,6 @@ import state from '~/import_projects/store/state'; describe('import_projects store actions', () => { let localState; - const repoId = 1; const repos = [{ id: 1 }, { id: 2 }]; const importPayload = { newName: 'newName', targetNamespace: 'targetNamespace', repo: { id: 1 } }; @@ -40,61 +31,6 @@ describe('import_projects store actions', () => { localState = state(); }); - describe('setInitialData', () => { - it(`commits ${SET_INITIAL_DATA} mutation`, done => { - const initialData = { - reposPath: 'reposPath', - provider: 'provider', - jobsPath: 'jobsPath', - importPath: 'impapp/assets/javascripts/vue_shared/components/select2_select.vueortPath', - defaultTargetNamespace: 'defaultTargetNamespace', - ciCdOnly: 'ciCdOnly', - canSelectNamespace: 'canSelectNamespace', - }; - - testAction( - setInitialData, - initialData, - localState, - [{ type: SET_INITIAL_DATA, payload: initialData }], - [], - done, - ); - }); - }); - - describe('requestRepos', () => { - it(`requestRepos commits ${REQUEST_REPOS} mutation`, done => { - testAction( - requestRepos, - null, - localState, - [{ type: REQUEST_REPOS, payload: null }], - [], - done, - ); - }); - }); - - describe('receiveReposSuccess', () => { - it(`commits ${RECEIVE_REPOS_SUCCESS} mutation`, done => { - testAction( - receiveReposSuccess, - repos, - localState, - [{ type: RECEIVE_REPOS_SUCCESS, payload: repos }], - [], - done, - ); - }); - }); - - describe('receiveReposError', () => { - it(`commits ${RECEIVE_REPOS_ERROR} mutation`, done => { - testAction(receiveReposError, repos, localState, [{ type: RECEIVE_REPOS_ERROR }], [], done); - }); - }); - describe('fetchRepos', () => { let mock; const payload = { imported_projects: [{}], provider_repos: [{}], namespaces: [{}] }; @@ -106,39 +42,33 @@ describe('import_projects store actions', () => { afterEach(() => mock.restore()); - it('dispatches stopJobsPolling, requestRepos and receiveReposSuccess actions on a successful request', done => { + it('dispatches stopJobsPolling actions and commits REQUEST_REPOS, RECEIVE_REPOS_SUCCESS mutations on a successful request', () => { mock.onGet(`${TEST_HOST}/endpoint.json`).reply(200, payload); - testAction( + return testAction( fetchRepos, null, localState, - [], [ - { type: 'stopJobsPolling' }, - { type: 'requestRepos' }, + { type: REQUEST_REPOS }, { - type: 'receiveReposSuccess', + type: RECEIVE_REPOS_SUCCESS, payload: convertObjectPropsToCamelCase(payload, { deep: true }), }, - { - type: 'fetchJobs', - }, ], - done, + [{ type: 'stopJobsPolling' }, { type: 'fetchJobs' }], ); }); - it('dispatches stopJobsPolling, requestRepos and receiveReposError actions on an unsuccessful request', done => { + it('dispatches stopJobsPolling action and commits REQUEST_REPOS, RECEIVE_REPOS_ERROR mutations on an unsuccessful request', () => { mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500); - testAction( + return testAction( fetchRepos, null, localState, - [], - [{ type: 'stopJobsPolling' }, { type: 'requestRepos' }, { type: 'receiveReposError' }], - done, + [{ type: REQUEST_REPOS }, { type: RECEIVE_REPOS_ERROR }], + [{ type: 'stopJobsPolling' }], ); }); @@ -147,72 +77,26 @@ describe('import_projects store actions', () => { localState.filter = 'filter'; }); - it('fetches repos with filter applied', done => { + it('fetches repos with filter applied', () => { mock.onGet(`${TEST_HOST}/endpoint.json?filter=filter`).reply(200, payload); - testAction( + return testAction( fetchRepos, null, localState, - [], [ - { type: 'stopJobsPolling' }, - { type: 'requestRepos' }, + { type: REQUEST_REPOS }, { - type: 'receiveReposSuccess', + type: RECEIVE_REPOS_SUCCESS, payload: convertObjectPropsToCamelCase(payload, { deep: true }), }, - { - type: 'fetchJobs', - }, ], - done, + [{ type: 'stopJobsPolling' }, { type: 'fetchJobs' }], ); }); }); }); - describe('requestImport', () => { - it(`commits ${REQUEST_IMPORT} mutation`, done => { - testAction( - requestImport, - repoId, - localState, - [{ type: REQUEST_IMPORT, payload: repoId }], - [], - done, - ); - }); - }); - - describe('receiveImportSuccess', () => { - it(`commits ${RECEIVE_IMPORT_SUCCESS} mutation`, done => { - const payload = { importedProject: { name: 'imported/project' }, repoId: 2 }; - - testAction( - receiveImportSuccess, - payload, - localState, - [{ type: RECEIVE_IMPORT_SUCCESS, payload }], - [], - done, - ); - }); - }); - - describe('receiveImportError', () => { - it(`commits ${RECEIVE_IMPORT_ERROR} mutation`, done => { - testAction( - receiveImportError, - repoId, - localState, - [{ type: RECEIVE_IMPORT_ERROR, payload: repoId }], - [], - done, - ); - }); - }); - describe('fetchImport', () => { let mock; @@ -223,56 +107,53 @@ describe('import_projects store actions', () => { afterEach(() => mock.restore()); - it('dispatches requestImport and receiveImportSuccess actions on a successful request', done => { + it('commits REQUEST_IMPORT and REQUEST_IMPORT_SUCCESS mutations on a successful request', () => { const importedProject = { name: 'imported/project' }; const importRepoId = importPayload.repo.id; mock.onPost(`${TEST_HOST}/endpoint.json`).reply(200, importedProject); - testAction( + return testAction( fetchImport, importPayload, localState, - [], [ - { type: 'requestImport', payload: importRepoId }, + { type: REQUEST_IMPORT, payload: importRepoId }, { - type: 'receiveImportSuccess', + type: RECEIVE_IMPORT_SUCCESS, payload: { importedProject: convertObjectPropsToCamelCase(importedProject, { deep: true }), repoId: importRepoId, }, }, ], - done, + [], ); }); - it('dispatches requestImport and receiveImportSuccess actions on an unsuccessful request', done => { + it('commits REQUEST_IMPORT and RECEIVE_IMPORT_ERROR on an unsuccessful request', () => { mock.onPost(`${TEST_HOST}/endpoint.json`).reply(500); - testAction( + return testAction( fetchImport, importPayload, localState, - [], [ - { type: 'requestImport', payload: importPayload.repo.id }, - { type: 'receiveImportError', payload: { repoId: importPayload.repo.id } }, + { type: REQUEST_IMPORT, payload: importPayload.repo.id }, + { type: RECEIVE_IMPORT_ERROR, payload: importPayload.repo.id }, ], - done, + [], ); }); }); describe('receiveJobsSuccess', () => { - it(`commits ${RECEIVE_JOBS_SUCCESS} mutation`, done => { - testAction( + it(`commits ${RECEIVE_JOBS_SUCCESS} mutation`, () => { + return testAction( receiveJobsSuccess, repos, localState, [{ type: RECEIVE_JOBS_SUCCESS, payload: repos }], [], - done, ); }); }); @@ -293,21 +174,20 @@ describe('import_projects store actions', () => { afterEach(() => mock.restore()); - it('dispatches requestJobs and receiveJobsSuccess actions on a successful request', done => { + it('commits RECEIVE_JOBS_SUCCESS mutation on a successful request', async () => { mock.onGet(`${TEST_HOST}/endpoint.json`).reply(200, updatedProjects); - testAction( + await testAction( fetchJobs, null, localState, - [], [ { - type: 'receiveJobsSuccess', + type: RECEIVE_JOBS_SUCCESS, payload: convertObjectPropsToCamelCase(updatedProjects, { deep: true }), }, ], - done, + [], ); }); @@ -316,21 +196,20 @@ describe('import_projects store actions', () => { localState.filter = 'filter'; }); - it('fetches realtime changes with filter applied', done => { + it('fetches realtime changes with filter applied', () => { mock.onGet(`${TEST_HOST}/endpoint.json?filter=filter`).reply(200, updatedProjects); - testAction( + return testAction( fetchJobs, null, localState, - [], [ { - type: 'receiveJobsSuccess', + type: RECEIVE_JOBS_SUCCESS, payload: convertObjectPropsToCamelCase(updatedProjects, { deep: true }), }, ], - done, + [], ); }); }); diff --git a/spec/frontend/import_projects/store/getters_spec.js b/spec/frontend/import_projects/store/getters_spec.js index e5e4a95f473..93d1ed89783 100644 --- a/spec/frontend/import_projects/store/getters_spec.js +++ b/spec/frontend/import_projects/store/getters_spec.js @@ -2,6 +2,7 @@ import { namespaceSelectOptions, isImportingAnyRepo, hasProviderRepos, + hasIncompatibleRepos, hasImportedProjects, } from '~/import_projects/store/getters'; import state from '~/import_projects/store/state'; @@ -80,4 +81,18 @@ describe('import_projects store getters', () => { expect(hasImportedProjects(localState)).toBe(false); }); }); + + describe('hasIncompatibleRepos', () => { + it('returns true if there are any incompatibleProjects', () => { + localState.incompatibleRepos = new Array(1); + + expect(hasIncompatibleRepos(localState)).toBe(true); + }); + + it('returns false if there are no incompatibleProjects', () => { + localState.incompatibleRepos = []; + + expect(hasIncompatibleRepos(localState)).toBe(false); + }); + }); }); diff --git a/spec/javascripts/vue_shared/components/deprecated_modal_2_spec.js b/spec/frontend/vue_shared/components/deprecated_modal_2_spec.js index e031583b43a..b201a9acdd4 100644 --- a/spec/javascripts/vue_shared/components/deprecated_modal_2_spec.js +++ b/spec/frontend/vue_shared/components/deprecated_modal_2_spec.js @@ -1,6 +1,5 @@ -import $ from 'jquery'; import Vue from 'vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import mountComponent from 'helpers/vue_mount_component_helper'; import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; const modalComponent = Vue.extend(DeprecatedModal2); @@ -86,7 +85,7 @@ describe('DeprecatedModal2', () => { }); }); - it('works with data-toggle="modal"', done => { + it('works with data-toggle="modal"', () => { setFixtures(` <button id="modal-button" data-toggle="modal" data-target="#my-modal"></button> <div id="modal-container"></div> @@ -101,9 +100,16 @@ describe('DeprecatedModal2', () => { }, modalContainer, ); - $(vm.$el).on('shown.bs.modal', () => done()); + const modalElement = document.getElementById('my-modal'); modalButton.click(); + + expect(modalElement).not.toHaveClass('show'); + + // let the modal fade in + jest.runOnlyPendingTimers(); + + expect(modalElement).toHaveClass('show'); }); describe('methods', () => { @@ -111,7 +117,7 @@ describe('DeprecatedModal2', () => { beforeEach(() => { vm = mountComponent(modalComponent, {}); - spyOn(vm, '$emit'); + jest.spyOn(vm, '$emit').mockImplementation(() => {}); }); describe('emitCancel', () => { @@ -149,23 +155,14 @@ describe('DeprecatedModal2', () => { describe('slots', () => { const slotContent = 'this should go into the slot'; - const modalWithSlot = slotName => { - let template; - if (slotName) { - template = ` - <deprecated-modal-2> - <template slot="${slotName}">${slotContent}</template> - </deprecated-modal-2> - `; - } else { - template = `<deprecated-modal-2>${slotContent}</deprecated-modal-2>`; - } + const modalWithSlot = slot => { return Vue.extend({ components: { DeprecatedModal2, }, - template, + render: h => + h('deprecated-modal-2', [slot ? h('template', { slot }, slotContent) : slotContent]), }); }; diff --git a/spec/javascripts/vue_shared/components/deprecated_modal_spec.js b/spec/frontend/vue_shared/components/deprecated_modal_spec.js index d6c10e32794..b9793ce2d80 100644 --- a/spec/javascripts/vue_shared/components/deprecated_modal_spec.js +++ b/spec/frontend/vue_shared/components/deprecated_modal_spec.js @@ -1,6 +1,5 @@ -import $ from 'jquery'; import Vue from 'vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import mountComponent from 'helpers/vue_mount_component_helper'; import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; const modalComponent = Vue.extend(DeprecatedModal); @@ -47,7 +46,7 @@ describe('DeprecatedModal', () => { }); }); - it('works with data-toggle="modal"', done => { + it('works with data-toggle="modal"', () => { setFixtures(` <button id="modal-button" data-toggle="modal" data-target="#my-modal"></button> <div id="modal-container"></div> @@ -63,9 +62,12 @@ describe('DeprecatedModal', () => { modalContainer, ); const modalElement = vm.$el.querySelector('#my-modal'); - $(modalElement).on('shown.bs.modal', () => done()); + + expect(modalElement).not.toHaveClass('show'); modalButton.click(); + + expect(modalElement).toHaveClass('show'); }); }); }); diff --git a/spec/frontend/vue_shared/directives/tooltip_spec.js b/spec/frontend/vue_shared/directives/tooltip_spec.js new file mode 100644 index 00000000000..9d3dd3c5f75 --- /dev/null +++ b/spec/frontend/vue_shared/directives/tooltip_spec.js @@ -0,0 +1,98 @@ +import $ from 'jquery'; +import { mount } from '@vue/test-utils'; +import tooltip from '~/vue_shared/directives/tooltip'; + +describe('Tooltip directive', () => { + let vm; + + afterEach(() => { + if (vm) { + vm.$destroy(); + } + }); + + describe('with a single tooltip', () => { + beforeEach(() => { + const wrapper = mount( + { + directives: { + tooltip, + }, + data() { + return { + tooltip: 'some text', + }; + }, + template: '<div v-tooltip :title="tooltip"></div>', + }, + { attachToDocument: true }, + ); + + vm = wrapper.vm; + }); + + it('should have tooltip plugin applied', () => { + expect($(vm.$el).data('bs.tooltip')).toBeDefined(); + }); + + it('displays the title as tooltip', () => { + $(vm.$el).tooltip('show'); + jest.runOnlyPendingTimers(); + + const tooltipElement = document.querySelector('.tooltip-inner'); + + expect(tooltipElement.textContent).toContain('some text'); + }); + + it('updates a visible tooltip', () => { + $(vm.$el).tooltip('show'); + jest.runOnlyPendingTimers(); + + const tooltipElement = document.querySelector('.tooltip-inner'); + + vm.tooltip = 'other text'; + + jest.runOnlyPendingTimers(); + + return vm.$nextTick().then(() => { + expect(tooltipElement.textContent).toContain('other text'); + }); + }); + }); + + describe('with multiple tooltips', () => { + beforeEach(() => { + const wrapper = mount( + { + directives: { + tooltip, + }, + template: ` + <div> + <div + v-tooltip + class="js-look-for-tooltip" + title="foo"> + </div> + <div + v-tooltip + title="bar"> + </div> + </div> + `, + }, + { attachToDocument: true }, + ); + + vm = wrapper.vm; + }); + + it('should have tooltip plugin applied to all instances', () => { + expect( + $(vm.$el) + .find('.js-look-for-tooltip') + .data('bs.tooltip'), + ).toBeDefined(); + }); + }); +}); diff --git a/spec/frontend/vue_shared/translate_spec.js b/spec/frontend/vue_shared/translate_spec.js new file mode 100644 index 00000000000..42aa28a6309 --- /dev/null +++ b/spec/frontend/vue_shared/translate_spec.js @@ -0,0 +1,214 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import locale from '~/locale'; +import Translate from '~/vue_shared/translate'; + +const localVue = createLocalVue(); +localVue.use(Translate); + +describe('Vue translate filter', () => { + const createTranslationMock = (key, ...translations) => { + locale.textdomain('app'); + + locale.options.locale_data = { + app: { + '': { + domain: 'app', + lang: 'vo', + plural_forms: 'nplurals=2; plural=(n != 1);', + }, + [key]: translations, + }, + }; + }; + + it('translate singular text (`__`)', () => { + const key = 'singular'; + const translation = 'singular_translated'; + createTranslationMock(key, translation); + + const wrapper = mount( + { + template: ` + <span> + {{ __('${key}') }} + </span> + `, + }, + { localVue }, + ); + + expect(wrapper.text()).toBe(translation); + }); + + it('translate plural text (`n__`) without any substituting text', () => { + const key = 'plural'; + const translationPlural = 'plural_multiple translation'; + createTranslationMock(key, 'plural_singular translation', translationPlural); + + const wrapper = mount( + { + template: ` + <span> + {{ n__('${key}', 'plurals', 2) }} + </span> + `, + }, + { localVue }, + ); + + expect(wrapper.text()).toBe(translationPlural); + }); + + describe('translate plural text (`n__`) with substituting %d', () => { + const key = '%d day'; + + beforeEach(() => { + createTranslationMock(key, '%d singular translated', '%d plural translated'); + }); + + it('and n === 1', () => { + const wrapper = mount( + { + template: ` + <span> + {{ n__('${key}', '%d days', 1) }} + </span> + `, + }, + { localVue }, + ); + + expect(wrapper.text()).toBe('1 singular translated'); + }); + + it('and n > 1', () => { + const wrapper = mount( + { + template: ` + <span> + {{ n__('${key}', '%d days', 2) }} + </span> + `, + }, + { localVue }, + ); + + expect(wrapper.text()).toBe('2 plural translated'); + }); + }); + + describe('translates text with context `s__`', () => { + const key = 'Context|Foobar'; + const translation = 'Context|Foobar translated'; + const expectation = 'Foobar translated'; + + beforeEach(() => { + createTranslationMock(key, translation); + }); + + it('and using two parameters', () => { + const wrapper = mount( + { + template: ` + <span> + {{ s__('Context', 'Foobar') }} + </span> + `, + }, + { localVue }, + ); + + expect(wrapper.text()).toBe(expectation); + }); + + it('and using the pipe syntax', () => { + const wrapper = mount( + { + template: ` + <span> + {{ s__('${key}') }} + </span> + `, + }, + { localVue }, + ); + + expect(wrapper.text()).toBe(expectation); + }); + }); + + it('translate multi line text', () => { + const translation = 'multiline string translated'; + createTranslationMock('multiline string', translation); + + const wrapper = mount( + { + template: ` + <span> + {{ __(\` + multiline + string + \`) }} + </span> + `, + }, + { localVue }, + ); + + expect(wrapper.text()).toBe(translation); + }); + + it('translate pluralized multi line text', () => { + const translation = 'multiline string plural'; + + createTranslationMock('multiline string', 'multiline string singular', translation); + + const wrapper = mount( + { + template: ` + <span> + {{ n__( + \` + multiline + string + \`, + \` + multiline + strings + \`, + 2 + ) }} + </span> + `, + }, + { localVue }, + ); + + expect(wrapper.text()).toBe(translation); + }); + + it('translate pluralized multi line text with context', () => { + const translation = 'multiline string with context'; + + createTranslationMock('Context| multiline string', translation); + + const wrapper = mount( + { + template: ` + <span> + {{ s__( + \` + Context| + multiline + string + \` + ) }} + </span> + `, + }, + { localVue }, + ); + + expect(wrapper.text()).toBe(translation); + }); +}); diff --git a/spec/graphql/types/metrics/dashboard_type_spec.rb b/spec/graphql/types/metrics/dashboard_type_spec.rb index 81219c596a7..0dbd0d8b38d 100644 --- a/spec/graphql/types/metrics/dashboard_type_spec.rb +++ b/spec/graphql/types/metrics/dashboard_type_spec.rb @@ -7,7 +7,7 @@ describe GitlabSchema.types['MetricsDashboard'] do it 'has the expected fields' do expected_fields = %w[ - path annotations + path annotations schema_validation_warnings ] expect(described_class).to have_graphql_fields(*expected_fields) diff --git a/spec/javascripts/vue_shared/directives/tooltip_spec.js b/spec/javascripts/vue_shared/directives/tooltip_spec.js deleted file mode 100644 index 1d516a280b0..00000000000 --- a/spec/javascripts/vue_shared/directives/tooltip_spec.js +++ /dev/null @@ -1,89 +0,0 @@ -import $ from 'jquery'; -import Vue from 'vue'; -import tooltip from '~/vue_shared/directives/tooltip'; - -describe('Tooltip directive', () => { - let vm; - - afterEach(() => { - if (vm) { - vm.$destroy(); - } - }); - - describe('with a single tooltip', () => { - beforeEach(() => { - setFixtures('<div id="dummy-element"></div>'); - vm = new Vue({ - el: '#dummy-element', - directives: { - tooltip, - }, - data() { - return { - tooltip: 'some text', - }; - }, - template: '<div v-tooltip :title="tooltip"></div>', - }); - }); - - it('should have tooltip plugin applied', () => { - expect($(vm.$el).data('bs.tooltip')).toBeDefined(); - }); - - it('displays the title as tooltip', () => { - $(vm.$el).tooltip('show'); - const tooltipElement = document.querySelector('.tooltip-inner'); - - expect(tooltipElement.innerText).toContain('some text'); - }); - - it('updates a visible tooltip', done => { - $(vm.$el).tooltip('show'); - const tooltipElement = document.querySelector('.tooltip-inner'); - - vm.tooltip = 'other text'; - - Vue.nextTick() - .then(() => { - expect(tooltipElement).toContainText('other text'); - done(); - }) - .catch(done.fail); - }); - }); - - describe('with multiple tooltips', () => { - beforeEach(() => { - const SomeComponent = Vue.extend({ - directives: { - tooltip, - }, - template: ` - <div> - <div - v-tooltip - class="js-look-for-tooltip" - title="foo"> - </div> - <div - v-tooltip - title="bar"> - </div> - </div> - `, - }); - - vm = new SomeComponent().$mount(); - }); - - it('should have tooltip plugin applied to all instances', () => { - expect( - $(vm.$el) - .find('.js-look-for-tooltip') - .data('bs.tooltip'), - ).toBeDefined(); - }); - }); -}); diff --git a/spec/javascripts/vue_shared/translate_spec.js b/spec/javascripts/vue_shared/translate_spec.js deleted file mode 100644 index adca7cd64a1..00000000000 --- a/spec/javascripts/vue_shared/translate_spec.js +++ /dev/null @@ -1,251 +0,0 @@ -import Vue from 'vue'; -import Jed from 'jed'; - -import { trimText } from 'spec/helpers/text_helper'; -import locale from '~/locale'; -import Translate from '~/vue_shared/translate'; - -describe('Vue translate filter', () => { - let el; - - const createTranslationMock = (key, ...translations) => { - const fakeLocale = new Jed({ - domain: 'app', - locale_data: { - app: { - '': { - domain: 'app', - lang: 'vo', - plural_forms: 'nplurals=2; plural=(n != 1);', - }, - [key]: translations, - }, - }, - }); - - // eslint-disable-next-line no-underscore-dangle - locale.__Rewire__('locale', fakeLocale); - }; - - afterEach(() => { - // eslint-disable-next-line no-underscore-dangle - locale.__ResetDependency__('locale'); - }); - - beforeEach(() => { - Vue.use(Translate); - - el = document.createElement('div'); - - document.body.appendChild(el); - }); - - it('translate singular text (`__`)', done => { - const key = 'singular'; - const translation = 'singular_translated'; - createTranslationMock(key, translation); - - const vm = new Vue({ - el, - template: ` - <span> - {{ __('${key}') }} - </span> - `, - }).$mount(); - - Vue.nextTick(() => { - expect(trimText(vm.$el.textContent)).toBe(translation); - - done(); - }); - }); - - it('translate plural text (`n__`) without any substituting text', done => { - const key = 'plural'; - const translationPlural = 'plural_multiple translation'; - createTranslationMock(key, 'plural_singular translation', translationPlural); - - const vm = new Vue({ - el, - template: ` - <span> - {{ n__('${key}', 'plurals', 2) }} - </span> - `, - }).$mount(); - - Vue.nextTick(() => { - expect(trimText(vm.$el.textContent)).toBe(translationPlural); - - done(); - }); - }); - - describe('translate plural text (`n__`) with substituting %d', () => { - const key = '%d day'; - - beforeEach(() => { - createTranslationMock(key, '%d singular translated', '%d plural translated'); - }); - - it('and n === 1', done => { - const vm = new Vue({ - el, - template: ` - <span> - {{ n__('${key}', '%d days', 1) }} - </span> - `, - }).$mount(); - - Vue.nextTick(() => { - expect(trimText(vm.$el.textContent)).toBe('1 singular translated'); - - done(); - }); - }); - - it('and n > 1', done => { - const vm = new Vue({ - el, - template: ` - <span> - {{ n__('${key}', '%d days', 2) }} - </span> - `, - }).$mount(); - - Vue.nextTick(() => { - expect(trimText(vm.$el.textContent)).toBe('2 plural translated'); - - done(); - }); - }); - }); - - describe('translates text with context `s__`', () => { - const key = 'Context|Foobar'; - const translation = 'Context|Foobar translated'; - const expectation = 'Foobar translated'; - - beforeEach(() => { - createTranslationMock(key, translation); - }); - - it('and using two parameters', done => { - const vm = new Vue({ - el, - template: ` - <span> - {{ s__('Context', 'Foobar') }} - </span> - `, - }).$mount(); - - Vue.nextTick(() => { - expect(trimText(vm.$el.textContent)).toBe(expectation); - - done(); - }); - }); - - it('and using the pipe syntax', done => { - const vm = new Vue({ - el, - template: ` - <span> - {{ s__('${key}') }} - </span> - `, - }).$mount(); - - Vue.nextTick(() => { - expect(trimText(vm.$el.textContent)).toBe(expectation); - - done(); - }); - }); - }); - - it('translate multi line text', done => { - const translation = 'multiline string translated'; - createTranslationMock('multiline string', translation); - - const vm = new Vue({ - el, - template: ` - <span> - {{ __(\` - multiline - string - \`) }} - </span> - `, - }).$mount(); - - Vue.nextTick(() => { - expect(trimText(vm.$el.textContent)).toBe(translation); - - done(); - }); - }); - - it('translate pluralized multi line text', done => { - const translation = 'multiline string plural'; - - createTranslationMock('multiline string', 'multiline string singular', translation); - - const vm = new Vue({ - el, - template: ` - <span> - {{ n__( - \` - multiline - string - \`, - \` - multiline - strings - \`, - 2 - ) }} - </span> - `, - }).$mount(); - - Vue.nextTick(() => { - expect(trimText(vm.$el.textContent)).toBe(translation); - - done(); - }); - }); - - it('translate pluralized multi line text with context', done => { - const translation = 'multiline string with context'; - - createTranslationMock('Context| multiline string', translation); - - const vm = new Vue({ - el, - template: ` - <span> - {{ s__( - \` - Context| - multiline - string - \` - ) }} - </span> - `, - }).$mount(); - - Vue.nextTick(() => { - expect(trimText(vm.$el.textContent)).toBe(translation); - - done(); - }); - }); -}); diff --git a/spec/models/performance_monitoring/prometheus_dashboard_spec.rb b/spec/models/performance_monitoring/prometheus_dashboard_spec.rb index 0213712bd8c..ef7298c3d8c 100644 --- a/spec/models/performance_monitoring/prometheus_dashboard_spec.rb +++ b/spec/models/performance_monitoring/prometheus_dashboard_spec.rb @@ -42,7 +42,7 @@ describe PerformanceMonitoring::PrometheusDashboard do it 'raises error with corresponding messages', :aggregate_failures do expect { subject }.to raise_error do |error| expect(error).to be_kind_of(ActiveModel::ValidationError) - expect(error.model.errors.messages).to eql(errors_messages) + expect(error.model.errors.messages).to eq(errors_messages) end end end @@ -190,20 +190,51 @@ describe PerformanceMonitoring::PrometheusDashboard do dashboard_instance = described_class.find_for(project: project, user: user, path: path, options: { environment: environment }) expect(dashboard_instance).to be_instance_of described_class - expect(dashboard_instance.environment).to be environment - expect(dashboard_instance.path).to be path + expect(dashboard_instance.environment).to eq environment + expect(dashboard_instance.path).to eq path end end context 'dashboard has NOT been found' do it 'returns nil' do - allow(Gitlab::Metrics::Dashboard::Finder).to receive(:find).and_return(status: :error) + allow(Gitlab::Metrics::Dashboard::Finder).to receive(:find).and_return(http_status: :not_found) dashboard_instance = described_class.find_for(project: project, user: user, path: path, options: { environment: environment }) expect(dashboard_instance).to be_nil end end + + context 'dashboard has invalid schema', :aggregate_failures do + it 'still returns dashboard object' do + expect(Gitlab::Metrics::Dashboard::Finder).to receive(:find).and_return(http_status: :unprocessable_entity) + + dashboard_instance = described_class.find_for(project: project, user: user, path: path, options: { environment: environment }) + + expect(dashboard_instance).to be_instance_of described_class + expect(dashboard_instance.environment).to eq environment + expect(dashboard_instance.path).to eq path + end + end + end + + describe '#schema_validation_warnings' do + context 'when schema is valid' do + it 'returns nil' do + expect(described_class).to receive(:from_json) + expect(described_class.new.schema_validation_warnings).to be_nil + end + end + + context 'when schema is invalid' do + it 'returns array with errors messages' do + instance = described_class.new + instance.errors.add(:test, 'test error') + + expect(described_class).to receive(:from_json).and_raise(ActiveModel::ValidationError.new(instance)) + expect(described_class.new.schema_validation_warnings).to eq ['test: test error'] + end + end end describe '#to_yaml' do diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index d7c4695203d..787d778b483 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -3726,7 +3726,7 @@ describe Project do context 'when feature is private' do let(:project) { create(:project, :public, :merge_requests_private) } - context 'when user does not has access to the feature' do + context 'when user does not have access to the feature' do it 'does not return projects with the project feature private' do is_expected.not_to include(project) end diff --git a/spec/requests/api/graphql/metrics/dashboard_query_spec.rb b/spec/requests/api/graphql/metrics/dashboard_query_spec.rb index 8b0965a815b..d9d9ea9ad61 100644 --- a/spec/requests/api/graphql/metrics/dashboard_query_spec.rb +++ b/spec/requests/api/graphql/metrics/dashboard_query_spec.rb @@ -9,25 +9,19 @@ describe 'Getting Metrics Dashboard' do let(:project) { create(:project) } let!(:environment) { create(:environment, project: project) } - let(:fields) do - <<~QUERY - #{all_graphql_fields_for('MetricsDashboard'.classify)} - QUERY - end - let(:query) do - %( - query { - project(fullPath:"#{project.full_path}") { - environments(name: "#{environment.name}") { - nodes { - metricsDashboard(path: "#{path}"){ - #{fields} - } - } - } - } - } + graphql_query_for( + 'project', { 'fullPath' => project.full_path }, + query_graphql_field( + :environments, { 'name' => environment.name }, + query_graphql_field( + :nodes, nil, + query_graphql_field( + :metricsDashboard, { 'path' => path }, + all_graphql_fields_for('MetricsDashboard'.classify) + ) + ) + ) ) end @@ -63,7 +57,29 @@ describe 'Getting Metrics Dashboard' do it 'returns metrics dashboard' do dashboard = graphql_data.dig('project', 'environments', 'nodes')[0]['metricsDashboard'] - expect(dashboard).to eql("path" => path) + expect(dashboard).to eql("path" => path, "schemaValidationWarnings" => nil) + end + + context 'invalid dashboard' do + let(:path) { '.gitlab/dashboards/metrics.yml' } + let(:project) { create(:project, :repository, :custom_repo, namespace: current_user.namespace, files: { path => "---\ndasboard: ''" }) } + + it 'returns metrics dashboard' do + dashboard = graphql_data.dig('project', 'environments', 'nodes', 0, 'metricsDashboard') + + expect(dashboard).to eql("path" => path, "schemaValidationWarnings" => ["dashboard: can't be blank", "panel_groups: can't be blank"]) + end + end + + context 'empty dashboard' do + let(:path) { '.gitlab/dashboards/metrics.yml' } + let(:project) { create(:project, :repository, :custom_repo, namespace: current_user.namespace, files: { path => "" }) } + + it 'returns metrics dashboard' do + dashboard = graphql_data.dig('project', 'environments', 'nodes', 0, 'metricsDashboard') + + expect(dashboard).to eql("path" => path, "schemaValidationWarnings" => ["dashboard: can't be blank", "panel_groups: can't be blank"]) + end end end @@ -72,7 +88,7 @@ describe 'Getting Metrics Dashboard' do it_behaves_like 'a working graphql query' - it 'return snil' do + it 'returns nil' do dashboard = graphql_data.dig('project', 'environments', 'nodes')[0]['metricsDashboard'] expect(dashboard).to be_nil diff --git a/spec/serializers/provider_repo_serializer_spec.rb b/spec/serializers/provider_repo_serializer_spec.rb deleted file mode 100644 index f2be30c36d9..00000000000 --- a/spec/serializers/provider_repo_serializer_spec.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe ProviderRepoSerializer do - it 'represents ProviderRepoEntity entities' do - expect(described_class.entity_class).to eq(ProviderRepoEntity) - end -end diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb index bc24de9e1d3..90c53d4a346 100644 --- a/spec/services/ci/retry_build_service_spec.rb +++ b/spec/services/ci/retry_build_service_spec.rb @@ -38,7 +38,8 @@ describe Ci::RetryBuildService do job_artifacts_codequality job_artifacts_metrics scheduled_at job_variables waiting_for_resource_at job_artifacts_metrics_referee job_artifacts_network_referee job_artifacts_dotenv - job_artifacts_cobertura needs job_artifacts_accessibility].freeze + job_artifacts_cobertura needs job_artifacts_accessibility + job_artifacts_requirements].freeze ignore_accessors = %i[type lock_version target_url base_tags trace_sections diff --git a/yarn.lock b/yarn.lock index d1452bd6f0a..dac814f277a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -787,10 +787,10 @@ resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.137.0.tgz#49fb1f33340cfdf0a47c83b7a613e3c7306dd53c" integrity sha512-dhyiedyTKYJt/mXV+PjfY2pivAAPh3BAOHpVzNCZj6HmJ9VZFIJDzOAQTTxlxRz4UyPmHPuCiaal63q+JfLzcQ== -"@gitlab/ui@16.2.1": - version "16.2.1" - resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-16.2.1.tgz#52d1a7e21b36e8cdb2766a945d928c8815956449" - integrity sha512-JPkJUp9iyhS+u465qEMcVn9ZhZC7LqXjFBsTpF+plY9LuQWxNSEV9LU1WJ6lR1UsyNAQCJJ04qt9VgkxG52S7Q== +"@gitlab/ui@16.3.0": + version "16.3.0" + resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-16.3.0.tgz#033864b3e5c3305a2e1663d2d818388fb700dedb" + integrity sha512-iUMKS4AMY7cBP/BHACNWA5dFcOtHL2oFu4jgeCwpI+WtJGELkGcCRKYmmErjSZ+j4nYYMT+FZnyl+Qj7xBn6+g== dependencies: "@babel/standalone" "^7.0.0" "@gitlab/vue-toasted" "^1.3.0" |