diff options
154 files changed, 2001 insertions, 1233 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4d30efccb5c..f1573dba32a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -28,6 +28,8 @@ stages: - prepare - merge - test + - review + - qa - post-test - pages - post-cleanup diff --git a/.gitlab/ci/cng.gitlab-ci.yml b/.gitlab/ci/cng.gitlab-ci.yml index e15f8ed91e0..c384bcdcdfc 100644 --- a/.gitlab/ci/cng.gitlab-ci.yml +++ b/.gitlab/ci/cng.gitlab-ci.yml @@ -9,7 +9,7 @@ cloud-native-image: cache: {} when: manual script: - - gem install gitlab --no-document + - install_gitlab_gem - CNG_PROJECT_PATH="gitlab-org/build/CNG" BUILD_TRIGGER_TOKEN=$CI_JOB_TOKEN ./scripts/trigger-build cng only: - tags@gitlab-org/gitlab-ce diff --git a/.gitlab/ci/frontend.gitlab-ci.yml b/.gitlab/ci/frontend.gitlab-ci.yml index fd179f77e26..bfefd42c52d 100644 --- a/.gitlab/ci/frontend.gitlab-ci.yml +++ b/.gitlab/ci/frontend.gitlab-ci.yml @@ -38,6 +38,10 @@ gitlab:assets:compile: - bundle exec rake gitlab:assets:compile - time scripts/build_assets_image - scripts/clean-old-cached-assets + # Play dependent manual jobs + - install_api_client_dependencies_with_apt + - play_job "review-build-cng" || true # this job might not exist so ignore the failure if it cannot be played + - play_job "schedule:review-build-cng" || true # this job might not exist so ignore the failure if it cannot be played artifacts: name: webpack-report expire_in: 31d diff --git a/.gitlab/ci/qa.gitlab-ci.yml b/.gitlab/ci/qa.gitlab-ci.yml index 07b38c9aa85..85c6409186e 100644 --- a/.gitlab/ci/qa.gitlab-ci.yml +++ b/.gitlab/ci/qa.gitlab-ci.yml @@ -1,20 +1,17 @@ package-and-qa: image: ruby:2.5-alpine - stage: test + stage: qa + when: manual before_script: [] dependencies: [] cache: {} variables: GIT_DEPTH: "1" - API_TOKEN: "${GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN}" retry: 0 script: - - apk add --update openssl curl jq - - gem install gitlab --no-document - - source ./scripts/review_apps/review-apps.sh - - wait_for_job_to_be_done "gitlab:assets:compile" + - source scripts/utils.sh + - install_gitlab_gem - ./scripts/trigger-build omnibus - when: manual only: - /.+/@gitlab-org/gitlab-ce - /.+/@gitlab-org/gitlab-ee diff --git a/.gitlab/ci/review.gitlab-ci.yml b/.gitlab/ci/review.gitlab-ci.yml index 9cfb50eeefc..f5b131cf6b2 100644 --- a/.gitlab/ci/review.gitlab-ci.yml +++ b/.gitlab/ci/review.gitlab-ci.yml @@ -26,12 +26,10 @@ extends: .dedicated-runner <<: *review-only image: registry.gitlab.com/gitlab-org/gitlab-build-images:gitlab-charts-build-base - stage: test cache: {} dependencies: [] - environment: &review-environment - name: review/${CI_COMMIT_REF_NAME} - url: https://gitlab-${CI_ENVIRONMENT_SLUG}.${REVIEW_APPS_DOMAIN} + before_script: + - source scripts/utils.sh .review-docker: &review-docker <<: *review-base @@ -42,18 +40,13 @@ - gitlab-org - docker variables: &review-docker-variables - GIT_DEPTH: "1" DOCKER_DRIVER: overlay2 DOCKER_HOST: tcp://docker:2375 LATEST_QA_IMAGE: "gitlab/${CI_PROJECT_NAME}-qa:nightly" QA_IMAGE: "${CI_REGISTRY}/${CI_PROJECT_PATH}/gitlab/${CI_PROJECT_NAME}-qa:${CI_COMMIT_REF_SLUG}" - before_script: [] build-qa-image: <<: *review-docker - variables: - <<: *review-docker-variables - GIT_DEPTH: "20" stage: prepare script: - time docker build --cache-from ${LATEST_QA_IMAGE} --tag ${QA_IMAGE} ./qa/ @@ -63,16 +56,14 @@ build-qa-image: .review-build-cng-base: &review-build-cng-base image: ruby:2.5-alpine stage: test - before_script: [] + when: manual + before_script: + - source scripts/utils.sh + - install_api_client_dependencies_with_apk + - install_gitlab_gem dependencies: [] cache: {} - variables: - API_TOKEN: "${GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN}" script: - - apk add --update openssl curl jq - - gem install gitlab --no-document - - source ./scripts/review_apps/review-apps.sh - - wait_for_job_to_be_done "gitlab:assets:compile" - BUILD_TRIGGER_TOKEN=$REVIEW_APPS_BUILD_TRIGGER_TOKEN ./scripts/trigger-build cng review-build-cng: @@ -85,26 +76,32 @@ schedule:review-build-cng: .review-deploy-base: &review-deploy-base <<: *review-base + stage: review retry: 2 allow_failure: true variables: HOST_SUFFIX: "${CI_ENVIRONMENT_SLUG}" DOMAIN: "-${CI_ENVIRONMENT_SLUG}.${REVIEW_APPS_DOMAIN}" GITLAB_HELM_CHART_REF: "master" - API_TOKEN: "${GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN}" - environment: - <<: *review-environment + environment: &review-environment + name: review/${CI_COMMIT_REF_NAME} + url: https://gitlab-${CI_ENVIRONMENT_SLUG}.${REVIEW_APPS_DOMAIN} on_stop: review-stop before_script: - export GITLAB_SHELL_VERSION=$(<GITLAB_SHELL_VERSION) - export GITALY_VERSION=$(<GITALY_SERVER_VERSION) - export GITLAB_WORKHORSE_VERSION=$(<GITLAB_WORKHORSE_VERSION) - - apk update && apk add jq - - gem install gitlab --no-document - - source ./scripts/review_apps/review-apps.sh + - echo "${CI_ENVIRONMENT_URL}" > review_app_url.txt + - source scripts/utils.sh + - install_api_client_dependencies_with_apk + - source scripts/review_apps/review-apps.sh script: - - wait_for_job_to_be_done "review-build-cng" - perform_review_app_deployment + artifacts: + paths: + - review_app_url.txt + expire_in: 2 days + when: always review-deploy: <<: *review-deploy-base @@ -113,15 +110,29 @@ schedule:review-deploy: <<: *review-deploy-base <<: *review-schedules-only script: - - wait_for_job_to_be_done "schedule:review-build-cng" - perform_review_app_deployment +review-stop: + <<: *review-base + stage: review + when: manual + allow_failure: true + variables: + GIT_DEPTH: "1" + environment: + <<: *review-environment + action: stop + script: + - source scripts/review_apps/review-apps.sh + - delete + - cleanup + .review-qa-base: &review-qa-base <<: *review-docker + stage: qa allow_failure: true variables: <<: *review-docker-variables - API_TOKEN: "${GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN}" QA_ARTIFACTS_DIR: "${CI_PROJECT_DIR}/qa" QA_CAN_TEST_GIT_PROTOCOL_V2: "false" GITLAB_USERNAME: "root" @@ -131,40 +142,45 @@ schedule:review-deploy: GITHUB_ACCESS_TOKEN: "${REVIEW_APPS_QA_GITHUB_ACCESS_TOKEN}" EE_LICENSE: "${REVIEW_APPS_EE_LICENSE}" QA_DEBUG: "true" + dependencies: + - review-deploy artifacts: paths: - ./qa/gitlab-qa-run-* expire_in: 7 days when: always before_script: - - echo "${QA_IMAGE}" + - export CI_ENVIRONMENT_URL="$(cat review_app_url.txt)" - echo "${CI_ENVIRONMENT_URL}" - - apk update && apk add curl jq - - source ./scripts/review_apps/review-apps.sh + - echo "${QA_IMAGE}" + - source scripts/utils.sh + - install_api_client_dependencies_with_apk - gem install gitlab-qa --no-document ${GITLAB_QA_VERSION:+ --version ${GITLAB_QA_VERSION}} review-qa-smoke: <<: *review-qa-base retry: 2 script: - - wait_for_job_to_be_done "review-deploy" - gitlab-qa Test::Instance::Smoke "${QA_IMAGE}" "${CI_ENVIRONMENT_URL}" review-qa-all: <<: *review-qa-base + when: manual script: - - wait_for_job_to_be_done "review-deploy" - gitlab-qa Test::Instance::Any "${QA_IMAGE}" "${CI_ENVIRONMENT_URL}" - when: manual .review-performance-base: &review-performance-base <<: *review-qa-base - script: - - wait_for_job_to_be_done "review-deploy" + stage: qa + before_script: + - export CI_ENVIRONMENT_URL="$(cat review_app_url.txt)" + - echo "${CI_ENVIRONMENT_URL}" - mkdir -p gitlab-exporter - wget -O ./gitlab-exporter/index.js https://gitlab.com/gitlab-org/gl-performance/raw/master/index.js - - mkdir sitespeed-results - - docker run --shm-size=1g --rm -v "$(pwd)":/sitespeed.io sitespeedio/sitespeed.io:6.3.1 --plugins.add ./gitlab-exporter --outputFolder sitespeed-results "$CI_ENVIRONMENT_URL" + - mkdir -p sitespeed-results + script: + - docker run --shm-size=1g --rm -v "$(pwd)":/sitespeed.io sitespeedio/sitespeed.io:6.3.1 --plugins.add ./gitlab-exporter --outputFolder sitespeed-results "${CI_ENVIRONMENT_URL}" + after_script: - mv sitespeed-results/data/performance.json performance.json artifacts: paths: @@ -175,42 +191,26 @@ review-qa-all: review-performance: <<: *review-performance-base -review-stop: - <<: *review-base - extends: .single-script-job-dedicated-runner - image: registry.gitlab.com/gitlab-org/gitlab-build-images:gitlab-charts-build-base - allow_failure: true - variables: - SCRIPT_NAME: "review_apps/review-apps.sh" - when: manual - environment: - <<: *review-environment - action: stop - script: - - source $(basename "${SCRIPT_NAME}") - - delete - - cleanup +schedule:review-performance: + <<: *review-performance-base + <<: *review-schedules-only + dependencies: + - schedule:review-deploy schedule:review-cleanup: <<: *review-base <<: *review-schedules-only stage: build allow_failure: true - variables: - GIT_DEPTH: "1" environment: name: review/auto-cleanup + action: stop before_script: - - gem install gitlab --no-document + - source scripts/utils.sh + - install_gitlab_gem script: - ruby -rrubygems scripts/review_apps/automated_cleanup.rb -schedule:review-performance: - <<: *review-performance-base - <<: *review-schedules-only - script: - - wait_for_job_to_be_done "schedule:review-deploy" - danger-review: extends: .dedicated-pull-cache-job image: registry.gitlab.com/gitlab-org/gitlab-build-images:danger diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 2b17ffd5042..a2d87226ac2 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -1.34.0 +1.35.0
\ No newline at end of file diff --git a/GITLAB_WORKHORSE_VERSION b/GITLAB_WORKHORSE_VERSION index acd405b1d62..df5119ec64e 100644 --- a/GITLAB_WORKHORSE_VERSION +++ b/GITLAB_WORKHORSE_VERSION @@ -1 +1 @@ -8.6.0 +8.7.0 @@ -416,7 +416,7 @@ group :ed25519 do end # Gitaly GRPC client -gem 'gitaly-proto', '~> 1.19.0', require: 'gitaly' +gem 'gitaly-proto', '~> 1.22.0', require: 'gitaly' gem 'grpc', '~> 1.19.0' diff --git a/Gemfile.lock b/Gemfile.lock index da8f8db9528..64f2f78a4f8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -280,7 +280,7 @@ GEM gettext_i18n_rails (>= 0.7.1) po_to_json (>= 1.0.0) rails (>= 3.2.0) - gitaly-proto (1.19.0) + gitaly-proto (1.22.0) grpc (~> 1.0) github-markup (1.7.0) gitlab-default_value_for (3.1.1) @@ -1052,7 +1052,7 @@ DEPENDENCIES gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails_js (~> 1.3) - gitaly-proto (~> 1.19.0) + gitaly-proto (~> 1.22.0) github-markup (~> 1.7.0) gitlab-default_value_for (~> 3.1.1) gitlab-labkit (~> 0.1.2) diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 8754c253881..e583a8affd4 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import _ from 'underscore'; import axios from './lib/utils/axios_utils'; +import { joinPaths } from './lib/utils/url_utility'; const Api = { groupsPath: '/api/:version/groups.json', @@ -339,11 +340,7 @@ const Api = { }, buildUrl(url) { - let urlRoot = ''; - if (gon.relative_url_root != null) { - urlRoot = gon.relative_url_root; - } - return urlRoot + url.replace(':version', gon.api_version); + return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version)); }, }; diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index 6aa689b4056..17de7b2cf1e 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -168,7 +168,7 @@ export default { </script> <template> <div> - <div class="d-flex board-card-header"> + <div class="d-flex board-card-header" dir="auto"> <h4 class="board-card-title append-bottom-0 prepend-top-0"> <icon v-if="issue.confidential" diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index df855261b3c..4f47f1b6550 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -1,25 +1,20 @@ import Visibility from 'visibilityjs'; import Vue from 'vue'; +import { GlToast } from '@gitlab/ui'; import PersistentUserCallout from '../persistent_user_callout'; import { s__, sprintf } from '../locale'; import Flash from '../flash'; import Poll from '../lib/utils/poll'; import initSettingsPanels from '../settings_panels'; import eventHub from './event_hub'; -import { - APPLICATION_STATUS, - REQUEST_SUBMITTED, - REQUEST_FAILURE, - UPGRADE_REQUESTED, - UPGRADE_REQUEST_FAILURE, - INGRESS, - INGRESS_DOMAIN_SUFFIX, -} from './constants'; +import { APPLICATION_STATUS, INGRESS, INGRESS_DOMAIN_SUFFIX } from './constants'; import ClustersService from './services/clusters_service'; import ClustersStore from './stores/clusters_store'; import Applications from './components/applications.vue'; import setupToggleButtons from '../toggle_buttons'; +Vue.use(GlToast); + /** * Cluster page has 2 separate parts: * Toggle button and applications section @@ -134,7 +129,6 @@ export default class Clusters { if (this.showTokenButton) this.showTokenButton.addEventListener('click', this.showToken); eventHub.$on('installApplication', this.installApplication); eventHub.$on('upgradeApplication', data => this.upgradeApplication(data)); - eventHub.$on('upgradeFailed', appId => this.upgradeFailed(appId)); eventHub.$on('dismissUpgradeSuccess', appId => this.dismissUpgradeSuccess(appId)); eventHub.$on('saveKnativeDomain', data => this.saveKnativeDomain(data)); eventHub.$on('setKnativeHostname', data => this.setKnativeHostname(data)); @@ -144,7 +138,6 @@ export default class Clusters { if (this.showTokenButton) this.showTokenButton.removeEventListener('click', this.showToken); eventHub.$off('installApplication', this.installApplication); eventHub.$off('upgradeApplication', this.upgradeApplication); - eventHub.$off('upgradeFailed', this.upgradeFailed); eventHub.$off('dismissUpgradeSuccess', this.dismissUpgradeSuccess); eventHub.$off('saveKnativeDomain'); eventHub.$off('setKnativeHostname'); @@ -258,12 +251,13 @@ export default class Clusters { installApplication(data) { const appId = data.id; - this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUBMITTED); this.store.updateAppProperty(appId, 'requestReason', null); this.store.updateAppProperty(appId, 'statusReason', null); + this.store.installApplication(appId); + return this.service.installApplication(appId, data.params).catch(() => { - this.store.updateAppProperty(appId, 'requestStatus', REQUEST_FAILURE); + this.store.notifyInstallFailure(appId); this.store.updateAppProperty( appId, 'requestReason', @@ -274,17 +268,15 @@ export default class Clusters { upgradeApplication(data) { const appId = data.id; - this.store.updateAppProperty(appId, 'requestStatus', UPGRADE_REQUESTED); - this.store.updateAppProperty(appId, 'status', APPLICATION_STATUS.UPDATING); - this.service.installApplication(appId, data.params).catch(() => this.upgradeFailed(appId)); - } - upgradeFailed(appId) { - this.store.updateAppProperty(appId, 'requestStatus', UPGRADE_REQUEST_FAILURE); + this.store.updateApplication(appId); + this.service.installApplication(appId, data.params).catch(() => { + this.store.notifyUpdateFailure(appId); + }); } dismissUpgradeSuccess(appId) { - this.store.updateAppProperty(appId, 'requestStatus', null); + this.store.acknowledgeSuccessfulUpdate(appId); } toggleIngressDomainHelpText(ingressPreviousState, ingressNewState) { diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index 937e4c3bfc3..a351916942e 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -8,12 +8,7 @@ import identicon from '../../vue_shared/components/identicon.vue'; import loadingButton from '../../vue_shared/components/loading_button.vue'; import UninstallApplicationButton from './uninstall_application_button.vue'; -import { - APPLICATION_STATUS, - REQUEST_SUBMITTED, - REQUEST_FAILURE, - UPGRADE_REQUESTED, -} from '../constants'; +import { APPLICATION_STATUS } from '../constants'; export default { components: { @@ -63,10 +58,6 @@ export default { type: String, required: false, }, - requestStatus: { - type: String, - required: false, - }, requestReason: { type: String, required: false, @@ -76,6 +67,11 @@ export default { required: false, default: false, }, + installFailed: { + type: Boolean, + required: false, + default: false, + }, version: { type: String, required: false, @@ -88,6 +84,21 @@ export default { type: Boolean, required: false, }, + updateSuccessful: { + type: Boolean, + required: false, + default: false, + }, + updateFailed: { + type: Boolean, + required: false, + default: false, + }, + updateAcknowledged: { + type: Boolean, + required: false, + default: true, + }, installApplicationRequestParams: { type: Object, required: false, @@ -102,21 +113,12 @@ export default { return Object.values(APPLICATION_STATUS).includes(this.status); }, isInstalling() { - return ( - this.status === APPLICATION_STATUS.SCHEDULED || - this.status === APPLICATION_STATUS.INSTALLING || - (this.requestStatus === REQUEST_SUBMITTED && !this.statusReason && !this.installed) - ); + return this.status === APPLICATION_STATUS.INSTALLING; }, canInstall() { - if (this.isInstalling) { - return false; - } - return ( this.status === APPLICATION_STATUS.NOT_INSTALLABLE || this.status === APPLICATION_STATUS.INSTALLABLE || - this.status === APPLICATION_STATUS.ERROR || this.isUnknownStatus ); }, @@ -137,7 +139,7 @@ export default { return !this.installed || !this.uninstallable; }, installButtonLoading() { - return !this.status || this.status === APPLICATION_STATUS.SCHEDULED || this.isInstalling; + return !this.status || this.isInstalling; }, installButtonDisabled() { // Avoid the potential for the real-time data to say APPLICATION_STATUS.INSTALLABLE but @@ -168,19 +170,13 @@ export default { manageButtonLabel() { return s__('ClusterIntegration|Manage'); }, - hasError() { - return ( - !this.isInstalling && - (this.status === APPLICATION_STATUS.ERROR || this.requestStatus === REQUEST_FAILURE) - ); - }, generalErrorDescription() { return sprintf(s__('ClusterIntegration|Something went wrong while installing %{title}'), { title: this.title, }); }, versionLabel() { - if (this.upgradeFailed) { + if (this.updateFailed) { return s__('ClusterIntegration|Upgrade failed'); } else if (this.isUpgrading) { return s__('ClusterIntegration|Upgrading'); @@ -188,19 +184,6 @@ export default { return s__('ClusterIntegration|Upgraded'); }, - upgradeRequested() { - return this.requestStatus === UPGRADE_REQUESTED; - }, - upgradeSuccessful() { - return this.status === APPLICATION_STATUS.UPDATED; - }, - upgradeFailed() { - if (this.isUpgrading) { - return false; - } - - return this.status === APPLICATION_STATUS.UPDATE_ERRORED; - }, upgradeFailureDescription() { return s__('ClusterIntegration|Update failed. Please check the logs and try again.'); }, @@ -211,11 +194,11 @@ export default { }, upgradeButtonLabel() { let label; - if (this.upgradeAvailable && !this.upgradeFailed && !this.isUpgrading) { + if (this.upgradeAvailable && !this.updateFailed && !this.isUpgrading) { label = s__('ClusterIntegration|Upgrade'); } else if (this.isUpgrading) { label = s__('ClusterIntegration|Updating'); - } else if (this.upgradeFailed) { + } else if (this.updateFailed) { label = s__('ClusterIntegration|Retry update'); } @@ -223,24 +206,19 @@ export default { }, isUpgrading() { // Since upgrading is handled asynchronously on the backend we need this check to prevent any delay on the frontend - return ( - this.status === APPLICATION_STATUS.UPDATING || - (this.upgradeRequested && !this.upgradeSuccessful) - ); + return this.status === APPLICATION_STATUS.UPDATING; }, shouldShowUpgradeDetails() { // This method only returns true when; // Upgrade was successful OR Upgrade failed // AND new upgrade is unavailable AND version information is present. - return ( - (this.upgradeSuccessful || this.upgradeFailed) && !this.upgradeAvailable && this.version - ); + return (this.updateSuccessful || this.updateFailed) && !this.upgradeAvailable && this.version; }, }, watch: { - status() { - if (this.status === APPLICATION_STATUS.UPDATE_ERRORED) { - eventHub.$emit('upgradeFailed', this.id); + updateSuccessful() { + if (this.updateSuccessful) { + this.$toast.show(this.upgradeSuccessDescription); } }, }, @@ -257,9 +235,6 @@ export default { params: this.installApplicationRequestParams, }); }, - dismissUpgradeSuccess() { - eventHub.$emit('dismissUpgradeSuccess', this.id); - }, }, }; </script> @@ -297,7 +272,7 @@ export default { </strong> <slot name="description"></slot> <div - v-if="hasError || isUnknownStatus" + v-if="installFailed || isUnknownStatus" class="cluster-application-error text-danger prepend-top-10" > <p class="js-cluster-application-general-error-message append-bottom-0"> @@ -318,10 +293,10 @@ export default { class="form-text text-muted label p-0 js-cluster-application-upgrade-details" > {{ versionLabel }} - <span v-if="upgradeSuccessful">to</span> + <span v-if="updateSuccessful">to</span> <gl-link - v-if="upgradeSuccessful" + v-if="updateSuccessful" :href="chartRepo" target="_blank" class="js-cluster-application-upgrade-version" @@ -330,24 +305,13 @@ export default { </div> <div - v-if="upgradeFailed && !isUpgrading" + v-if="updateFailed && !isUpgrading" class="bs-callout bs-callout-danger cluster-application-banner mt-2 mb-0 js-cluster-application-upgrade-failure-message" > {{ upgradeFailureDescription }} </div> - - <div - v-if="upgradeRequested && upgradeSuccessful" - class="bs-callout bs-callout-success cluster-application-banner mt-2 mb-0 p-0 pl-3" - > - {{ upgradeSuccessDescription }} - <button class="close cluster-application-banner-close" @click="dismissUpgradeSuccess"> - × - </button> - </div> - <loading-button - v-if="upgradeAvailable || upgradeFailed || isUpgrading" + v-if="upgradeAvailable || updateFailed || isUpgrading" class="btn btn-primary js-cluster-application-upgrade-button mt-2" :loading="isUpgrading" :disabled="isUpgrading" @@ -361,9 +325,9 @@ export default { role="gridcell" > <div v-if="showManageButton" class="btn-group table-action-buttons"> - <a :href="manageLink" :class="{ disabled: disabled }" class="btn">{{ - manageButtonLabel - }}</a> + <a :href="manageLink" :class="{ disabled: disabled }" class="btn"> + {{ manageButtonLabel }} + </a> </div> <div class="btn-group table-action-buttons"> <loading-button diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index ae4fe11c6ae..dfc2069f131 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -224,9 +224,9 @@ export default { <p class="append-bottom-0"> {{ s__(`ClusterIntegration|Choose which applications to install on your Kubernetes cluster. - Helm Tiller is required to install any of the following applications.`) + Helm Tiller is required to install any of the following applications.`) }} - <a :href="helpPath"> {{ __('More information') }} </a> + <a :href="helpPath">{{ __('More information') }}</a> </p> <div class="cluster-application-list prepend-top-10"> @@ -239,15 +239,16 @@ export default { :request-status="applications.helm.requestStatus" :request-reason="applications.helm.requestReason" :installed="applications.helm.installed" + :install-failed="applications.helm.installFailed" class="rounded-top" title-link="https://docs.helm.sh/" > <div slot="description"> {{ s__(`ClusterIntegration|Helm streamlines installing - and managing Kubernetes applications. - Tiller runs inside of your Kubernetes Cluster, - and manages releases of your charts.`) + and managing Kubernetes applications. + Tiller runs inside of your Kubernetes Cluster, + and manages releases of your charts.`) }} </div> </application-row> @@ -255,7 +256,7 @@ export default { <div class="svg-container" v-html="helmInstallIllustration"></div> {{ s__(`ClusterIntegration|You must first install Helm Tiller before - installing the applications below`) + installing the applications below`) }} </div> <application-row @@ -267,6 +268,7 @@ export default { :request-status="applications.ingress.requestStatus" :request-reason="applications.ingress.requestReason" :installed="applications.ingress.installed" + :install-failed="applications.ingress.installFailed" :disabled="!helmInstalled" title-link="https://kubernetes.io/docs/concepts/services-networking/ingress/" > @@ -274,16 +276,14 @@ export default { <p> {{ s__(`ClusterIntegration|Ingress gives you a way to route - requests to services based on the request host or path, - centralizing a number of services into a single entrypoint.`) + requests to services based on the request host or path, + centralizing a number of services into a single entrypoint.`) }} </p> <template v-if="ingressInstalled"> <div class="form-group"> - <label for="ingress-endpoint"> - {{ s__('ClusterIntegration|Ingress Endpoint') }} - </label> + <label for="ingress-endpoint">{{ s__('ClusterIntegration|Ingress Endpoint') }}</label> <div v-if="ingressExternalEndpoint" class="input-group"> <input id="ingress-endpoint" @@ -309,8 +309,8 @@ export default { <p class="form-text text-muted"> {{ s__(`ClusterIntegration|Point a wildcard DNS to this - generated endpoint in order to access - your application after it has been deployed.`) + generated endpoint in order to access + your application after it has been deployed.`) }} <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> {{ __('More information') }} @@ -321,10 +321,9 @@ export default { <p v-if="!ingressExternalEndpoint" class="settings-message js-no-endpoint-message"> {{ s__(`ClusterIntegration|The endpoint is in - the process of being assigned. Please check your Kubernetes - cluster or Quotas on Google Kubernetes Engine if it takes a long time.`) + the process of being assigned. Please check your Kubernetes + cluster or Quotas on Google Kubernetes Engine if it takes a long time.`) }} - <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> {{ __('More information') }} </a> @@ -344,6 +343,7 @@ export default { :request-status="applications.cert_manager.requestStatus" :request-reason="applications.cert_manager.requestReason" :installed="applications.cert_manager.installed" + :install-failed="applications.cert_manager.installFailed" :install-application-request-params="{ email: applications.cert_manager.email }" :disabled="!helmInstalled" title-link="https://cert-manager.readthedocs.io/en/latest/#" @@ -366,15 +366,14 @@ export default { <p class="form-text text-muted"> {{ s__(`ClusterIntegration|Issuers represent a certificate authority. - You must provide an email address for your Issuer. `) + You must provide an email address for your Issuer. `) }} <a href="http://docs.cert-manager.io/en/latest/reference/issuers.html?highlight=email" target="_blank" rel="noopener noreferrer" + >{{ __('More information') }}</a > - {{ __('More information') }} - </a> </p> </div> </div> @@ -391,6 +390,7 @@ export default { :request-status="applications.prometheus.requestStatus" :request-reason="applications.prometheus.requestReason" :installed="applications.prometheus.installed" + :install-failed="applications.prometheus.installFailed" :disabled="!helmInstalled" title-link="https://prometheus.io/docs/introduction/overview/" > @@ -408,15 +408,18 @@ export default { :chart-repo="applications.runner.chartRepo" :upgrade-available="applications.runner.upgradeAvailable" :installed="applications.runner.installed" + :install-failed="applications.runner.installFailed" + :update-successful="applications.runner.updateSuccessful" + :update-failed="applications.runner.updateFailed" :disabled="!helmInstalled" title-link="https://docs.gitlab.com/runner/" > <div slot="description"> {{ s__(`ClusterIntegration|GitLab Runner connects to the - repository and executes CI/CD jobs, - pushing results back and deploying - applications to production.`) + repository and executes CI/CD jobs, + pushing results back and deploying + applications to production.`) }} </div> </application-row> @@ -430,6 +433,7 @@ export default { :request-status="applications.jupyter.requestStatus" :request-reason="applications.jupyter.requestReason" :installed="applications.jupyter.installed" + :install-failed="applications.jupyter.installFailed" :install-application-request-params="{ hostname: applications.jupyter.hostname }" :disabled="!helmInstalled" title-link="https://jupyterhub.readthedocs.io/en/stable/" @@ -438,18 +442,16 @@ export default { <p> {{ s__(`ClusterIntegration|JupyterHub, a multi-user Hub, spawns, - manages, and proxies multiple instances of the single-user - Jupyter notebook server. JupyterHub can be used to serve - notebooks to a class of students, a corporate data science group, - or a scientific research group.`) + manages, and proxies multiple instances of the single-user + Jupyter notebook server. JupyterHub can be used to serve + notebooks to a class of students, a corporate data science group, + or a scientific research group.`) }} </p> <template v-if="ingressExternalEndpoint"> <div class="form-group"> - <label for="jupyter-hostname"> - {{ s__('ClusterIntegration|Jupyter Hostname') }} - </label> + <label for="jupyter-hostname">{{ s__('ClusterIntegration|Jupyter Hostname') }}</label> <div class="input-group"> <input @@ -470,7 +472,7 @@ export default { <p v-if="ingressInstalled" class="form-text text-muted"> {{ s__(`ClusterIntegration|Replace this with your own hostname if you want. - If you do so, point hostname to Ingress IP Address from above.`) + If you do so, point hostname to Ingress IP Address from above.`) }} <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> {{ __('More information') }} @@ -490,8 +492,10 @@ export default { :request-status="applications.knative.requestStatus" :request-reason="applications.knative.requestReason" :installed="applications.knative.installed" + :install-failed="applications.knative.installFailed" :install-application-request-params="{ hostname: applications.knative.hostname }" :disabled="!helmInstalled" + v-bind="applications.knative" title-link="https://github.com/knative/docs" > <div slot="description"> @@ -499,7 +503,7 @@ export default { <p v-if="!rbac" class="rbac-notice bs-callout bs-callout-info append-bottom-0"> {{ s__(`ClusterIntegration|You must have an RBAC-enabled cluster - to install Knative.`) + to install Knative.`) }} <a :href="helpPath" target="_blank" rel="noopener noreferrer"> {{ __('More information') }} @@ -510,9 +514,9 @@ export default { <p> {{ s__(`ClusterIntegration|Knative extends Kubernetes to provide - a set of middleware components that are essential to build modern, - source-centric, and container-based applications that can run - anywhere: on premises, in the cloud, or even in a third-party data center.`) + a set of middleware components that are essential to build modern, + source-centric, and container-based applications that can run + anywhere: on premises, in the cloud, or even in a third-party data center.`) }} </p> @@ -523,9 +527,7 @@ export default { class="form-group col-sm-12 mb-0" > <label for="knative-domainname"> - <strong> - {{ s__('ClusterIntegration|Knative Domain Name:') }} - </strong> + <strong>{{ s__('ClusterIntegration|Knative Domain Name:') }}</strong> </label> <input id="knative-domainname" @@ -538,9 +540,7 @@ export default { <template v-if="knativeInstalled"> <div class="form-group col-sm-12 col-md-6 pl-md-0 mb-0 mt-3 mt-md-0"> <label for="knative-endpoint"> - <strong> - {{ s__('ClusterIntegration|Knative Endpoint:') }} - </strong> + <strong>{{ s__('ClusterIntegration|Knative Endpoint:') }}</strong> </label> <div v-if="knativeExternalEndpoint" class="input-group"> <input @@ -583,8 +583,8 @@ export default { > {{ s__(`ClusterIntegration|The endpoint is in - the process of being assigned. Please check your Kubernetes - cluster or Quotas on Google Kubernetes Engine if it takes a long time.`) + the process of being assigned. Please check your Kubernetes + cluster or Quotas on Google Kubernetes Engine if it takes a long time.`) }} </p> diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js index 17849497c87..48dbce9676e 100644 --- a/app/assets/javascripts/clusters/constants.js +++ b/app/assets/javascripts/clusters/constants.js @@ -7,6 +7,7 @@ export const CLUSTER_TYPE = { // These need to match what is returned from the server export const APPLICATION_STATUS = { + NO_STATUS: null, NOT_INSTALLABLE: 'not_installable', INSTALLABLE: 'installable', SCHEDULED: 'scheduled', @@ -27,17 +28,13 @@ export const APPLICATION_STATUS = { export const APPLICATION_INSTALLED_STATUSES = [ APPLICATION_STATUS.INSTALLED, APPLICATION_STATUS.UPDATING, - APPLICATION_STATUS.UPDATED, - APPLICATION_STATUS.UPDATE_ERRORED, - APPLICATION_STATUS.UNINSTALLING, - APPLICATION_STATUS.UNINSTALL_ERRORED, ]; // These are only used client-side -export const REQUEST_SUBMITTED = 'request-submitted'; -export const REQUEST_FAILURE = 'request-failure'; -export const UPGRADE_REQUESTED = 'upgrade-requested'; -export const UPGRADE_REQUEST_FAILURE = 'upgrade-request-failure'; + +export const UPDATE_EVENT = 'update'; +export const INSTALL_EVENT = 'install'; + export const INGRESS = 'ingress'; export const JUPYTER = 'jupyter'; export const KNATIVE = 'knative'; diff --git a/app/assets/javascripts/clusters/services/application_state_machine.js b/app/assets/javascripts/clusters/services/application_state_machine.js new file mode 100644 index 00000000000..aafb2350ae4 --- /dev/null +++ b/app/assets/javascripts/clusters/services/application_state_machine.js @@ -0,0 +1,141 @@ +import { APPLICATION_STATUS, UPDATE_EVENT, INSTALL_EVENT } from '../constants'; + +const { + NO_STATUS, + SCHEDULED, + NOT_INSTALLABLE, + INSTALLABLE, + INSTALLING, + INSTALLED, + ERROR, + UPDATING, + UPDATED, + UPDATE_ERRORED, +} = APPLICATION_STATUS; + +const applicationStateMachine = { + /* When the application initially loads, it will have `NO_STATUS` + * It will transition from `NO_STATUS` once the async backend call is completed + */ + [NO_STATUS]: { + on: { + [SCHEDULED]: { + target: INSTALLING, + }, + [NOT_INSTALLABLE]: { + target: NOT_INSTALLABLE, + }, + [INSTALLABLE]: { + target: INSTALLABLE, + }, + [INSTALLING]: { + target: INSTALLING, + }, + [INSTALLED]: { + target: INSTALLED, + }, + [ERROR]: { + target: INSTALLABLE, + effects: { + installFailed: true, + }, + }, + [UPDATING]: { + target: UPDATING, + }, + [UPDATED]: { + target: INSTALLED, + }, + [UPDATE_ERRORED]: { + target: INSTALLED, + effects: { + updateFailed: true, + }, + }, + }, + }, + [NOT_INSTALLABLE]: { + on: { + [INSTALLABLE]: { + target: INSTALLABLE, + }, + }, + }, + [INSTALLABLE]: { + on: { + [INSTALL_EVENT]: { + target: INSTALLING, + effects: { + installFailed: false, + }, + }, + // This is possible in artificial environments for E2E testing + [INSTALLED]: { + target: INSTALLED, + }, + }, + }, + [INSTALLING]: { + on: { + [INSTALLED]: { + target: INSTALLED, + }, + [ERROR]: { + target: INSTALLABLE, + effects: { + installFailed: true, + }, + }, + }, + }, + [INSTALLED]: { + on: { + [UPDATE_EVENT]: { + target: UPDATING, + effects: { + updateFailed: false, + updateSuccessful: false, + }, + }, + }, + }, + [UPDATING]: { + on: { + [UPDATED]: { + target: INSTALLED, + effects: { + updateSuccessful: true, + updateAcknowledged: false, + }, + }, + [UPDATE_ERRORED]: { + target: INSTALLED, + effects: { + updateFailed: true, + }, + }, + }, + }, +}; + +/** + * Determines an application new state based on the application current state + * and an event. If the application current state cannot handle a given event, + * the current state is returned. + * + * @param {*} application + * @param {*} event + */ +const transitionApplicationState = (application, event) => { + const newState = applicationStateMachine[application.status].on[event]; + + return newState + ? { + ...application, + status: newState.target, + ...newState.effects, + } + : application; +}; + +export default transitionApplicationState; diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js index 38512ac28c2..c2e30960659 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -7,7 +7,11 @@ import { CERT_MANAGER, RUNNER, APPLICATION_INSTALLED_STATUSES, + APPLICATION_STATUS, + INSTALL_EVENT, + UPDATE_EVENT, } from '../constants'; +import transitionApplicationState from '../services/application_state_machine'; const isApplicationInstalled = appStatus => APPLICATION_INSTALLED_STATUSES.includes(appStatus); @@ -15,8 +19,8 @@ const applicationInitialState = { status: null, statusReason: null, requestReason: null, - requestStatus: null, installed: false, + installFailed: false, }; export default class ClusterStore { @@ -49,6 +53,9 @@ export default class ClusterStore { version: null, chartRepo: 'https://gitlab.com/charts/gitlab-runner', upgradeAvailable: null, + updateAcknowledged: true, + updateSuccessful: false, + updateFailed: false, }, prometheus: { ...applicationInitialState, @@ -93,6 +100,32 @@ export default class ClusterStore { this.state.statusReason = reason; } + installApplication(appId) { + this.handleApplicationEvent(appId, INSTALL_EVENT); + } + + notifyInstallFailure(appId) { + this.handleApplicationEvent(appId, APPLICATION_STATUS.ERROR); + } + + updateApplication(appId) { + this.handleApplicationEvent(appId, UPDATE_EVENT); + } + + notifyUpdateFailure(appId) { + this.handleApplicationEvent(appId, APPLICATION_STATUS.UPDATE_ERRORED); + } + + handleApplicationEvent(appId, event) { + const currentAppState = this.state.applications[appId]; + + this.state.applications[appId] = transitionApplicationState(currentAppState, event); + } + + acknowledgeSuccessfulUpdate(appId) { + this.state.applications[appId].updateAcknowledged = true; + } + updateAppProperty(appId, prop, value) { this.state.applications[appId][prop] = value; } @@ -109,12 +142,16 @@ export default class ClusterStore { version, update_available: upgradeAvailable, } = serverAppEntry; + const currentApplicationState = this.state.applications[appId] || {}; + const nextApplicationState = transitionApplicationState(currentApplicationState, status); this.state.applications[appId] = { - ...(this.state.applications[appId] || {}), - status, + ...currentApplicationState, + ...nextApplicationState, statusReason, - installed: isApplicationInstalled(status), + installed: isApplicationInstalled(nextApplicationState.status), + // Make sure uninstallable is always false until this feature is unflagged + uninstallable: false, }; if (appId === INGRESS) { diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index f1e26cdfa21..f437954881c 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -477,6 +477,16 @@ class GfmAutoComplete { } return null; }, + highlighter(li, query) { + // override default behaviour to escape dot character + // see https://github.com/ichord/At.js/pull/576 + if (!query) { + return li; + } + const escapedQuery = query.replace(/[.+]/, '\\$&'); + const regexp = new RegExp(`>\\s*([^<]*?)(${escapedQuery})([^<]*)\\s*<`, 'ig'); + return li.replace(regexp, (str, $1, $2, $3) => `> ${$1}<strong>${$2}</strong>${$3} <`); + }, }; } diff --git a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue index 00b2d236da3..6b0aa5b2b2b 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue @@ -108,6 +108,7 @@ export default { :placeholder="placeholder" :value="text" class="note-textarea ide-commit-message-textarea" + dir="auto" name="commit-message" @scroll="handleScroll" @input="onInput" diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js index 229ef168926..518a9cf7a0f 100644 --- a/app/assets/javascripts/ide/ide_router.js +++ b/app/assets/javascripts/ide/ide_router.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import VueRouter from 'vue-router'; -import { join as joinPath } from 'path'; +import { joinPaths } from '~/lib/utils/url_utility'; import flash from '~/flash'; import store from './stores'; @@ -34,7 +34,7 @@ const EmptyRouterComponent = { const router = new VueRouter({ mode: 'history', - base: `${gon.relative_url_root}/-/ide/`, + base: joinPaths(gon.relative_url_root || '', '/-/ide/'), routes: [ { path: '/project/:namespace+/:project', @@ -46,11 +46,11 @@ const router = new VueRouter({ }, { path: ':targetmode(edit|tree|blob)/:branchid+/', - redirect: to => joinPath(to.path, '/-/'), + redirect: to => joinPaths(to.path, '/-/'), }, { path: ':targetmode(edit|tree|blob)', - redirect: to => joinPath(to.path, '/master/-/'), + redirect: to => joinPaths(to.path, '/master/-/'), }, { path: 'merge_requests/:mrid', @@ -58,7 +58,7 @@ const router = new VueRouter({ }, { path: '', - redirect: to => joinPath(to.path, '/edit/master/-/'), + redirect: to => joinPaths(to.path, '/edit/master/-/'), }, ], }, 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 777f8fa6691..00eb0afb3bf 100644 --- a/app/assets/javascripts/import_projects/components/import_projects_table.vue +++ b/app/assets/javascripts/import_projects/components/import_projects_table.vue @@ -74,7 +74,7 @@ export default { <gl-loading-icon v-if="isLoadingRepos" class="js-loading-button-icon import-projects-loading-icon" - :size="4" + size="md" /> <div v-else-if="hasProviderRepos || hasImportedProjects" class="table-responsive"> <table class="table import-table"> diff --git a/app/assets/javascripts/import_projects/store/index.js b/app/assets/javascripts/import_projects/store/index.js index f666e2ebf33..ff1fd1e598e 100644 --- a/app/assets/javascripts/import_projects/store/index.js +++ b/app/assets/javascripts/import_projects/store/index.js @@ -7,6 +7,8 @@ import mutations from './mutations'; Vue.use(Vuex); +export { state, actions, getters, mutations }; + export default () => new Vuex.Store({ state: state(), diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue index 385e9543973..f2462e50093 100644 --- a/app/assets/javascripts/issue_show/components/description.vue +++ b/app/assets/javascripts/issue_show/components/description.vue @@ -149,6 +149,7 @@ export default { v-model="descriptionText" :data-update-url="updateUrl" class="hidden js-task-list-field" + dir="auto" > </textarea> diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue index 299130e56ae..d27dd873125 100644 --- a/app/assets/javascripts/issue_show/components/fields/description.vue +++ b/app/assets/javascripts/issue_show/components/fields/description.vue @@ -53,6 +53,7 @@ export default { v-model="formState.description" class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea" + dir="auto" data-supports-quick-actions="false" aria-label="Description" placeholder="Write a comment or drag your files here…" diff --git a/app/assets/javascripts/issue_show/components/fields/title.vue b/app/assets/javascripts/issue_show/components/fields/title.vue index a3371cb9614..ce4baf17d09 100644 --- a/app/assets/javascripts/issue_show/components/fields/title.vue +++ b/app/assets/javascripts/issue_show/components/fields/title.vue @@ -20,6 +20,7 @@ export default { ref="input" v-model="formState.title" class="form-control qa-title-input" + dir="auto" type="text" placeholder="Title" aria-label="Title" diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue index 3b5c95ccded..d2f33dc31a7 100644 --- a/app/assets/javascripts/issue_show/components/title.vue +++ b/app/assets/javascripts/issue_show/components/title.vue @@ -72,6 +72,7 @@ export default { 'issue-realtime-trigger-pulse': pulseAnimation, }" class="title" + dir="auto" v-html="titleHtml" ></h2> <button diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index ae02559415c..498c2348ca2 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -3,10 +3,17 @@ import { InMemoryCache } from 'apollo-cache-inmemory'; import { createUploadLink } from 'apollo-upload-client'; import csrf from '~/lib/utils/csrf'; -export default (resolvers = {}) => - new ApolloClient({ +export default (resolvers = {}, baseUrl = '') => { + let uri = `${gon.relative_url_root}/api/graphql`; + + if (baseUrl) { + // Prepend baseUrl and ensure that `///` are replaced with `/` + uri = `${baseUrl}${uri}`.replace(/\/{3,}/g, '/'); + } + + return new ApolloClient({ link: createUploadLink({ - uri: `${gon.relative_url_root}/api/graphql`, + uri, headers: { [csrf.headerKey]: csrf.token, }, @@ -14,3 +21,4 @@ export default (resolvers = {}) => cache: new InMemoryCache(), resolvers, }); +}; diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index 4ba84589705..bdfd06fc250 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -120,3 +120,5 @@ export function webIDEUrl(route = undefined) { } return returnUrl; } + +export { join as joinPaths } from 'path'; diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 1b722c0505a..a2ca4b07a66 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -135,6 +135,12 @@ function deferredInitialisation() { }); loadAwardsHandler(); + + // Toggle Canary Badge + if (Cookies.get('gitlab_canary') && Cookies.get('gitlab_canary') === 'true') { + document.querySelector('.js-canary-badge').classList.remove('hidden'); + document.querySelector('.js-canary-link').classList.add('hidden'); + } } document.addEventListener('DOMContentLoaded', () => { diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index f2bd4150b6d..00547abd7bc 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -221,6 +221,7 @@ export default { <gl-dropdown-item v-for="environment in store.environmentsData" :key="environment.id" + :href="environment.metrics_path" :active="environment.name === currentEnvironmentName" active-class="is-active" >{{ environment.name }}</gl-dropdown-item diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js index ed794779ff2..08dc57d545c 100644 --- a/app/assets/javascripts/monitoring/monitoring_bundle.js +++ b/app/assets/javascripts/monitoring/monitoring_bundle.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; -import Dashboard from './components/dashboard.vue'; +import Dashboard from 'ee_else_ce/monitoring/components/dashboard.vue'; export default (props = {}) => { const el = document.getElementById('prometheus-graphs'); diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index caf22f71bf9..688c06878ac 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -351,6 +351,7 @@ Please check your network connection and try again.`; ref="textarea" slot="textarea" v-model="note" + dir="auto" :disabled="isSubmitting" name="note[note]" class="note-textarea js-vue-comment-form js-note-text diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index fbf75ed0e41..8ddd5b8514a 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -122,6 +122,7 @@ export default { v-model="note.note" :data-update-url="note.path" class="hidden js-task-list-field" + dir="auto" ></textarea> <note-edited-text v-if="note.last_edited_at" diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 471323bfc83..fb098095cf3 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -268,6 +268,7 @@ export default { :data-supports-quick-actions="!isEditing" name="note[note]" class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form js-vue-textarea qa-reply-input" + dir="auto" aria-label="Description" placeholder="Write a comment or drag your files here…" @keydown.meta.enter="handleKeySubmit()" diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js index 61204c37307..4a20753e7ae 100644 --- a/app/assets/javascripts/pages/users/activity_calendar.js +++ b/app/assets/javascripts/pages/users/activity_calendar.js @@ -65,18 +65,18 @@ export default class ActivityCalendar { this.daySize = 15; this.daySizeWithSpace = this.daySize + this.daySpace * 2; this.monthNames = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', + __('Jan'), + __('Feb'), + __('Mar'), + __('Apr'), + __('May'), + __('Jun'), + __('Jul'), + __('Aug'), + __('Sep'), + __('Oct'), + __('Nov'), + __('Dec'), ]; this.months = []; this.firstDayOfWeek = firstDayOfWeek; diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 6660f8120f8..b8976f77bac 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -34,6 +34,7 @@ export default () => { props: { isLoading: this.mediator.state.isLoading, pipeline: this.mediator.store.state.pipeline, + mediator: this.mediator, }, on: { refreshPipelineGraph: this.requestRefreshPipelineGraph, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue index a38f25cce35..acd8037cfb2 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue @@ -30,6 +30,7 @@ export default { :id="inputId" :value="value" class="form-control js-gfm-input append-bottom-default commit-message-edit" + dir="auto" required="required" rows="7" @input="$emit('input', $event.target.value)" diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index aa4ecb0aac3..705ee05e29f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -119,7 +119,8 @@ export default { }, showTargetBranchAdvancedError() { return Boolean( - this.mr.pipeline && + this.mr.isOpen && + this.mr.pipeline && this.mr.pipeline.target_sha && this.mr.pipeline.target_sha !== this.mr.targetBranchSha, ); diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue b/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue index 1f9670cf2fc..53e6efa6ea3 100644 --- a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue +++ b/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue @@ -17,15 +17,13 @@ export default { required: true, }, }, - data() { - return { - milestoneDue: this.milestone.due_date ? parsePikadayDate(this.milestone.due_date) : null, - milestoneStart: this.milestone.start_date - ? parsePikadayDate(this.milestone.start_date) - : null, - }; - }, computed: { + milestoneDue() { + return this.milestone.due_date ? parsePikadayDate(this.milestone.due_date) : null; + }, + milestoneStart() { + return this.milestone.start_date ? parsePikadayDate(this.milestone.start_date) : null; + }, isMilestoneStarted() { if (!this.milestoneStart) { return false; diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue index ffde55bf083..b807a35b421 100644 --- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue +++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue @@ -1,4 +1,5 @@ <script> +import '~/commons/bootstrap'; import { GlTooltipDirective } from '@gitlab/ui'; import { sprintf } from '~/locale'; import IssueMilestone from '../../components/issue/issue_milestone.vue'; diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue index cafd3a515ea..c09bdfec250 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue @@ -17,10 +17,10 @@ export default { <template> <tr class="line_holder" :class="lineType"> - <td class="diff-line-num old_line" :class="lineType"> + <td class="diff-line-num old_line border-top-0 border-bottom-0" :class="lineType"> {{ line.old_line }} </td> - <td class="diff-line-num new_line" :class="lineType"> + <td class="diff-line-num new_line border-top-0 border-bottom-0" :class="lineType"> {{ line.new_line }} </td> <td class="line_content" :class="lineType"> diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index acc179b3834..3c86b7e4c61 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -22,6 +22,7 @@ import noteHeader from '~/notes/components/note_header.vue'; import Icon from '~/vue_shared/components/icon.vue'; import TimelineEntryItem from './timeline_entry_item.vue'; import { spriteIcon } from '../../../lib/utils/common_utils'; +import initMRPopovers from '~/mr_popover/'; const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; @@ -71,6 +72,9 @@ export default { ); }, }, + mounted() { + initMRPopovers(this.$el.querySelectorAll('.gfm-merge_request')); + }, }; </script> diff --git a/app/assets/javascripts/vue_shared/components/select2_select.vue b/app/assets/javascripts/vue_shared/components/select2_select.vue index 3074ea859cc..6d2612556ff 100644 --- a/app/assets/javascripts/vue_shared/components/select2_select.vue +++ b/app/assets/javascripts/vue_shared/components/select2_select.vue @@ -1,6 +1,6 @@ <script> import $ from 'jquery'; -import 'select2/select2'; +import 'select2'; export default { name: 'Select2Select', diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index f9773622001..4dbfd1ba6f4 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -1,11 +1,13 @@ <script> import { GlPopover, GlSkeletonLoading } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; import UserAvatarImage from '../user_avatar/user_avatar_image.vue'; import { glEmojiTag } from '../../../emoji'; export default { name: 'UserPopover', components: { + Icon, GlPopover, GlSkeletonLoading, UserAvatarImage, @@ -68,16 +70,27 @@ export default { <gl-skeleton-loading v-else :lines="1" class="animation-container-small mb-1" /> </div> <div class="text-secondary"> - <div v-if="user.bio" class="js-bio">{{ user.bio }}</div> - <div v-if="user.organization" class="js-organization">{{ user.organization }}</div> + <div v-if="user.bio" class="js-bio d-flex mb-1"> + <icon name="profile" css-classes="category-icon" /> + <span class="ml-1">{{ user.bio }}</span> + </div> + <div v-if="user.organization" class="js-organization d-flex mb-1"> + <icon v-show="!jobInfoIsLoading" name="work" css-classes="category-icon" /> + <span class="ml-1">{{ user.organization }}</span> + </div> <gl-skeleton-loading v-if="jobInfoIsLoading" :lines="1" class="animation-container-small mb-1" /> </div> - <div class="text-secondary"> - {{ user.location }} + <div class="js-location text-secondary d-flex"> + <icon + v-show="!locationIsLoading && user.location" + name="location" + css-classes="category-icon" + /> + <span class="ml-1">{{ user.location }}</span> <gl-skeleton-loading v-if="locationIsLoading" :lines="1" diff --git a/app/assets/stylesheets/components/popover.scss b/app/assets/stylesheets/components/popover.scss index 7d46b262a69..838bf5d343b 100644 --- a/app/assets/stylesheets/components/popover.scss +++ b/app/assets/stylesheets/components/popover.scss @@ -5,6 +5,10 @@ padding: $gl-padding-8; font-size: $gl-font-size-small; line-height: $gl-line-height; + + .category-icon { + color: $gray-600; + } } } diff --git a/app/assets/stylesheets/components/toast.scss b/app/assets/stylesheets/components/toast.scss new file mode 100644 index 00000000000..91d16c8e98d --- /dev/null +++ b/app/assets/stylesheets/components/toast.scss @@ -0,0 +1,3 @@ +.toast-close { + font-size: $default-icon-size !important; +} diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 1e025b3a67d..a6179e2a96e 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -447,30 +447,29 @@ } } +.title-container, .navbar-nav { - li { - .badge.badge-pill { - position: inherit; - font-weight: $gl-font-weight-normal; - margin-left: -6px; - font-size: 11px; - color: $white-light; - padding: 0 5px; - line-height: 12px; - border-radius: 7px; - box-shadow: 0 1px 0 rgba($gl-header-color, 0.2); - - &.issues-count { - background-color: $green-500; - } + .badge.badge-pill { + position: inherit; + font-weight: $gl-font-weight-normal; + margin-left: -6px; + font-size: 11px; + color: $white-light; + padding: 0 5px; + line-height: 12px; + border-radius: 7px; + box-shadow: 0 1px 0 rgba($gl-header-color, 0.2); + + &.green-badge { + background-color: $green-500; + } - &.merge-requests-count { - background-color: $orange-600; - } + &.merge-requests-count { + background-color: $orange-600; + } - &.todos-count { - background-color: $blue-500; - } + &.todos-count { + background-color: $blue-500; } } } diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 61fab445793..02a95c04b8b 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -256,6 +256,10 @@ } } +.board-card-header { + text-align: initial; +} + .board-card-assignee { margin-top: -$gl-padding-4; margin-bottom: -$gl-padding-4; diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index f8620eec46d..04c66006027 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -60,6 +60,7 @@ overflow-wrap: break-word; min-width: 0; width: 100%; + text-align: initial; } .btn-edit { diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 60a840aac1b..13288d8bad1 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -456,7 +456,9 @@ // Don't hide the overflow in system messages .system-note-message, -.issuable-detail { +.issuable-detail, +.md-preview-holder, +.note-body { .scoped-label-wrapper { .badge { overflow: initial; diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index d1c5cef76fa..c4dff95a4b9 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -19,7 +19,7 @@ module Projects redirect_to project_settings_ci_cd_path(@project) else - render 'show' + redirect_to project_settings_ci_cd_path(@project), alert: result[:message] end end end diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index 88910c91763..fa5bdbc7d49 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -17,7 +17,7 @@ class Projects::WikisController < Projects::ApplicationController def pages @wiki_pages = Kaminari.paginate_array( - @project_wiki.pages(sort: params[:sort], direction: params[:direction]) + @project_wiki.list_pages(sort: params[:sort], direction: params[:direction]) ).page(params[:page]) @wiki_entries = WikiPage.group_by_directory(@wiki_pages) @@ -118,7 +118,7 @@ class Projects::WikisController < Projects::ApplicationController @sidebar_page = @project_wiki.find_sidebar(params[:version_id]) unless @sidebar_page # Fallback to default sidebar - @sidebar_wiki_entries = WikiPage.group_by_directory(@project_wiki.pages(limit: 15)) + @sidebar_wiki_entries = WikiPage.group_by_directory(@project_wiki.list_pages(limit: 15)) end rescue ProjectWiki::CouldNotCreateWikiError flash[:notice] = _("Could not create Wiki Repository at this time. Please try again later.") diff --git a/app/helpers/broadcast_messages_helper.rb b/app/helpers/broadcast_messages_helper.rb index 289cb44f1e8..495c29d3e24 100644 --- a/app/helpers/broadcast_messages_helper.rb +++ b/app/helpers/broadcast_messages_helper.rb @@ -4,7 +4,7 @@ module BroadcastMessagesHelper def broadcast_message(message) return unless message.present? - content_tag :div, class: 'broadcast-message', style: broadcast_message_style(message) do + content_tag :div, dir: 'auto', class: 'broadcast-message', style: broadcast_message_style(message) do icon('bullhorn') << ' ' << render_broadcast_message(message) end end diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index 28ea51d6769..f90cd1ea690 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -15,7 +15,7 @@ module CacheMarkdownField # Increment this number every time the renderer changes its output CACHE_COMMONMARK_VERSION_START = 10 - CACHE_COMMONMARK_VERSION = 15 + CACHE_COMMONMARK_VERSION = 16 # changes to these attributes cause the cache to be invalidates INVALIDATED_BY = %w[author project].freeze diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index 23ddd708396..c91add6439f 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -82,17 +82,25 @@ class ProjectWiki end def empty? - pages(limit: 1).empty? + list_pages(limit: 1).empty? end + # Lists wiki pages of the repository. + # + # limit - max number of pages returned by the method. + # sort - criterion by which the pages are sorted. + # direction - order of the sorted pages. + # load_content - option, which specifies whether the content inside the page + # will be loaded. + # # Returns an Array of GitLab WikiPage instances or an # empty Array if this Wiki has no pages. - def pages(limit: 0, sort: nil, direction: DIRECTION_ASC) - sort ||= TITLE_ORDER - direction_desc = direction == DIRECTION_DESC - - wiki.pages( - limit: limit, sort: sort, direction_desc: direction_desc + def list_pages(limit: 0, sort: nil, direction: DIRECTION_ASC, load_content: false) + wiki.list_pages( + limit: limit, + sort: sort, + direction_desc: direction == DIRECTION_DESC, + load_content: load_content ).map do |page| WikiPage.new(self, page, true) end diff --git a/app/services/test_hooks/project_service.rb b/app/services/test_hooks/project_service.rb index 6607f5b2418..a71278e8b8b 100644 --- a/app/services/test_hooks/project_service.rb +++ b/app/services/test_hooks/project_service.rb @@ -56,7 +56,7 @@ module TestHooks end def wiki_page_events_data - page = project.wiki.pages.first + page = project.wiki.list_pages(limit: 1).first if !project.wiki_enabled? || page.blank? throw(:validation_error, s_('TestHooks|Ensure the wiki is enabled and has pages.')) end diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml index c465d9f51d6..46beca0465e 100644 --- a/app/views/admin/broadcast_messages/_form.html.haml +++ b/app/views/admin/broadcast_messages/_form.html.haml @@ -14,6 +14,7 @@ .col-sm-10 = f.text_area :message, class: "form-control js-autosize", required: true, + dir: 'auto', data: { preview_path: preview_admin_broadcast_messages_path } .form-group.row.js-toggle-colors-container .col-sm-10.offset-sm-2 diff --git a/app/views/events/event/_common.html.haml b/app/views/events/event/_common.html.haml index 96d6553a2ac..b02fdb4b638 100644 --- a/app/views/events/event/_common.html.haml +++ b/app/views/events/event/_common.html.haml @@ -11,7 +11,8 @@ = link_to [event.project.namespace.becomes(Namespace), event.project, event.target], class: 'has-tooltip event-target-link append-right-4', title: event.target_title do = event.target.reference_link_text - unless event.milestone? - %span.event-target-title.append-right-4= """.html_safe + event.target.title + """.html_safe + %span.event-target-title.append-right-4{ dir: "auto" } + = """.html_safe + event.target.title + """.html_safe - else %span.event-type.d-inline-block.append-right-4{ class: event.action_name } = event_action_name(event) diff --git a/app/views/events/event/_note.html.haml b/app/views/events/event/_note.html.haml index 90ed8e41d32..7e2103287f7 100644 --- a/app/views/events/event/_note.html.haml +++ b/app/views/events/event/_note.html.haml @@ -7,7 +7,8 @@ %span.event-type.d-inline-block.append-right-4{ class: event.action_name } = event.action_name = event_note_title_html(event) - %span.event-target-title.append-right-4= """.html_safe + event.target.title + """.html_safe + %span.event-target-title.append-right-4{ dir: "auto" } + = """.html_safe + event.target.title + """.html_safe = render "events/event_scope", event: event diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index a9b85889846..319d0307f78 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -17,6 +17,8 @@ - if logo_text.present? %span.logo-text.d-none.d-lg-block.prepend-left-8 = logo_text + %span.js-canary-badge.badge.badge-pill.green-badge.align-self-center + = _('Next') - if current_user = render "layouts/nav/dashboard" @@ -38,7 +40,7 @@ = link_to assigned_issues_dashboard_path, title: _('Issues'), class: 'dashboard-shortcuts-issues', aria: { label: _('Issues') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = sprite_icon('issues', size: 16) - issues_count = assigned_issuables_count(:issues) - %span.badge.badge-pill.issues-count{ class: ('hidden' if issues_count.zero?) } + %span.badge.badge-pill.issues-count.green-badge{ class: ('hidden' if issues_count.zero?) } = number_with_delimiter(issues_count) - if header_link?(:merge_requests) = nav_link(path: 'dashboard#merge_requests', html_options: { class: "user-counter" }) do diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml index cd9128c452b..c53bfd8a85d 100644 --- a/app/views/layouts/header/_help_dropdown.html.haml +++ b/app/views/layouts/header/_help_dropdown.html.haml @@ -7,3 +7,6 @@ = link_to _("Submit feedback"), "https://about.gitlab.com/submit-feedback" - if current_user_menu?(:help) || current_user_menu?(:settings) || current_user_menu?(:profile) = render 'shared/user_dropdown_contributing_link' + - if Gitlab.com? + %li.js-canary-link + = link_to _("Switch to GitLab Next"), "https://next.gitlab.com/" diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 50adc19f524..3ca4abddbb8 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -80,6 +80,8 @@ - deleted_message = s_('ForkedFromProjectPath|Forked from %{project_name} (deleted)') = deleted_message % { project_name: fork_source_name(@project) } + = render_if_exists "projects/home_mirror" + - if @project.badges.present? .project-badges.mb-2 - @project.badges.each do |badge| diff --git a/app/views/projects/_zen.html.haml b/app/views/projects/_zen.html.haml index afc40ca4eab..c502b392384 100644 --- a/app/views/projects/_zen.html.haml +++ b/app/views/projects/_zen.html.haml @@ -8,6 +8,7 @@ = f.text_area attr, class: classes, placeholder: placeholder, + dir: 'auto', data: { supports_quick_actions: supports_quick_actions, supports_autocomplete: supports_autocomplete } - else diff --git a/app/views/projects/forks/error.html.haml b/app/views/projects/forks/error.html.haml index e8a89b8c6fc..b37dba8b35d 100644 --- a/app/views/projects/forks/error.html.haml +++ b/app/views/projects/forks/error.html.haml @@ -1,24 +1,20 @@ -- page_title "Fork project" +- page_title _("Fork project") - if @forked_project && !@forked_project.saved? .alert.alert-danger.alert-block %h4 = sprite_icon('fork', size: 16) - Fork Error! + = _("Fork Error!") %p - You tried to fork - = link_to_project @project - but it failed for the following reason: - + = _("You tried to fork %{link_to_the_project} but it failed for the following reason:").html_safe % { link_to_the_project: link_to_project(@project) } - if @forked_project && @forked_project.errors.any? %p – - error = @forked_project.errors.full_messages.first - if error.include?("already been taken") - Name has already been taken + = _("Name has already been taken") - else = error %p - = link_to new_project_fork_path(@project), title: "Fork", class: "btn" do - Try to fork again + = link_to _("Try to fork again"), new_project_fork_path(@project), title: _("Fork"), class: "btn" diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml index c63c34c4ebb..0397a7034c7 100644 --- a/app/views/projects/forks/index.html.haml +++ b/app/views/projects/forks/index.html.haml @@ -5,12 +5,12 @@ .nav-controls = form_tag request.original_url, method: :get, class: 'project-filter-form', id: 'project-filter-form' do |f| - = search_field_tag :filter_projects, nil, placeholder: 'Search forks', class: 'projects-list-filter project-filter-form-field form-control input-short', + = search_field_tag :filter_projects, nil, placeholder: _('Search forks'), class: 'projects-list-filter project-filter-form-field form-control input-short', spellcheck: false, data: { 'filter-selector' => 'span.namespace-name' } .dropdown %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' } - %span.light sort: + %span.light= _("sort:") - if @sort.present? = sort_options_hash[@sort] - else @@ -30,13 +30,12 @@ - if current_user && can?(current_user, :fork_project, @project) - if current_user.already_forked?(@project) && current_user.manageable_namespaces.size < 2 - = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: 'Go to your fork', class: 'btn btn-success' do + = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: _('Go to your fork'), class: 'btn btn-success' do = sprite_icon('fork', size: 12) - %span Fork + %span= _('Fork') - else - = link_to new_project_fork_path(@project), title: "Fork project", class: 'btn btn-success' do + = link_to new_project_fork_path(@project), title: _("Fork project"), class: 'btn btn-success' do = sprite_icon('fork', size: 12) - %span Fork - + %span= _('Fork') = render 'projects', projects: @forks diff --git a/app/views/projects/forks/new.html.haml b/app/views/projects/forks/new.html.haml index a603b1024eb..bf03353a565 100644 --- a/app/views/projects/forks/new.html.haml +++ b/app/views/projects/forks/new.html.haml @@ -1,13 +1,11 @@ -- page_title "Fork project" +- page_title _("Fork project") .row.prepend-top-default .col-lg-3 %h4.prepend-top-0 - Fork project + = _("Fork project") %p - A fork is a copy of a project. - %br - Forking a repository allows you to make changes without affecting the original project. + = _("A fork is a copy of a project.<br />Forking a repository allows you to make changes without affecting the original project.").html_safe .col-lg-9 - if @namespaces.present? .fork-thumbnail-container.js-fork-content @@ -17,13 +15,13 @@ = render 'fork_button', namespace: namespace - else %strong - No available namespaces to fork the project. + = _("No available namespaces to fork the project.") %p.prepend-top-default - You must have permission to create a project in a namespace before forking. + = _("You must have permission to create a project in a namespace before forking.") .save-project-loader.hide.js-fork-content %h2.text-center = icon('spinner spin') - Forking repository + = _("Forking repository") %p.text-center - Please wait a moment, this page will automatically refresh when ready. + = _("Please wait a moment, this page will automatically refresh when ready.") diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index 945d1b00b08..0d8d7123a01 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -6,7 +6,7 @@ .issuable-info-container .issuable-main-info .issue-title.title - %span.issue-title-text + %span.issue-title-text{ dir: "auto" } - if issue.confidential? %span.has-tooltip{ title: _('Confidential') } = confidential_icon(issue) diff --git a/app/views/projects/issues/new.html.haml b/app/views/projects/issues/new.html.haml index c6ff0d50ef4..d1601d7fd10 100644 --- a/app/views/projects/issues/new.html.haml +++ b/app/views/projects/issues/new.html.haml @@ -2,8 +2,7 @@ - breadcrumb_title _("New") - page_title _("New Issue") -%h3.page-title - _("New Issue") +%h3.page-title= _("New Issue") %hr = render "form" diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index 79c586eef73..05aeb5d972b 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -37,21 +37,21 @@ %ul.merge-request-tabs.nav-tabs.nav.nav-links.scrolling-tabs %li.notes-tab.qa-notes-tab = tab_link_for @merge_request, :show, force_link: @commit.present? do - Discussion + = _("Discussion") %span.badge.badge-pill= @merge_request.related_notes.user.count - if @merge_request.source_project %li.commits-tab = tab_link_for @merge_request, :commits do - Commits + = _("Commits") %span.badge.badge-pill= @commits_count - if @pipelines.any? %li.pipelines-tab = tab_link_for @merge_request, :pipelines do - Pipelines + = _("Pipelines") %span.badge.badge-pill.js-pipelines-mr-count= @pipelines.size %li.diffs-tab.qa-diffs-tab = tab_link_for @merge_request, :diffs do - Changes + = _("Changes") %span.badge.badge-pill= @merge_request.diff_size .d-inline-flex.flex-wrap #js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(@merge_request), diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml index c031815200b..a1ec2c887c2 100644 --- a/app/views/projects/mirrors/_mirror_repos.html.haml +++ b/app/views/projects/mirrors/_mirror_repos.html.haml @@ -11,7 +11,7 @@ = link_to _('Read more'), help_page_path('workflow/repository_mirroring'), target: '_blank' .settings-content - = form_for @project, url: project_mirror_path(@project), html: { class: 'gl-show-field-errors js-mirror-form', autocomplete: 'false', data: mirrors_form_data_attributes } do |f| + = form_for @project, url: project_mirror_path(@project), html: { class: 'gl-show-field-errors js-mirror-form', autocomplete: 'new-password', data: mirrors_form_data_attributes } do |f| .panel.panel-default .panel-heading %h3.panel-title= _('Mirror a repository') @@ -20,7 +20,7 @@ .form-group.has-feedback = label_tag :url, _('Git repository URL'), class: 'label-light' - = text_field_tag :url, nil, class: 'form-control js-mirror-url js-repo-url qa-mirror-repository-url-input', placeholder: _('Input your repository URL'), required: true, pattern: "(#{protocols}):\/\/.+" + = text_field_tag :url, nil, class: 'form-control js-mirror-url js-repo-url qa-mirror-repository-url-input', placeholder: _('Input your repository URL'), required: true, pattern: "(#{protocols}):\/\/.+", autocomplete: 'new-password' = render 'projects/mirrors/instructions' diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml index 5b25a67bc87..8ae2807729b 100644 --- a/app/views/search/_results.html.haml +++ b/app/views/search/_results.html.haml @@ -21,10 +21,9 @@ - if @scope == 'projects' .term = render 'shared/projects/list', projects: @search_objects, pipeline_status: false - - elsif %w[blobs wiki_blobs].include?(@scope) - = render partial: 'search/results/blob', collection: @search_objects, locals: { projects: blob_projects(@search_objects) } - else - = render partial: "search/results/#{@scope.singularize}", collection: @search_objects + - locals = { projects: blob_projects(@search_objects) } if %w[blobs wiki_blobs].include?(@scope) + = render partial: "search/results/#{@scope.singularize}", collection: @search_objects, locals: locals - if @scope != 'projects' = paginate_collection(@search_objects) diff --git a/app/views/shared/_sidebar_toggle_button.html.haml b/app/views/shared/_sidebar_toggle_button.html.haml index aa428f9fe73..d90a6d43761 100644 --- a/app/views/shared/_sidebar_toggle_button.html.haml +++ b/app/views/shared/_sidebar_toggle_button.html.haml @@ -1,8 +1,8 @@ %a.toggle-sidebar-button.js-toggle-sidebar{ role: "button", type: "button", title: "Toggle sidebar" } = sprite_icon('angle-double-left', css_class: 'icon-angle-double-left') = sprite_icon('angle-double-right', css_class: 'icon-angle-double-right') - %span.collapse-text _("Collapse sidebar") + %span.collapse-text= _("Collapse sidebar") = button_tag class: 'close-nav-button', type: 'button' do = sprite_icon('close', size: 16) - %span.collapse-text _("Close sidebar") + %span.collapse-text= _("Close sidebar") diff --git a/app/views/shared/issuable/form/_title.html.haml b/app/views/shared/issuable/form/_title.html.haml index 56c4b021eab..75e9ab547ce 100644 --- a/app/views/shared/issuable/form/_title.html.haml +++ b/app/views/shared/issuable/form/_title.html.haml @@ -6,7 +6,7 @@ %div{ class: div_class } = form.text_field :title, required: true, maxlength: 255, autofocus: true, - autocomplete: 'off', class: 'form-control pad qa-issuable-form-title', placeholder: _('Title') + autocomplete: 'off', class: 'form-control pad qa-issuable-form-title', placeholder: _('Title'), dir: 'auto' - if issuable.respond_to?(:work_in_progress?) .form-text.text-muted diff --git a/changelogs/unreleased/10327-enable-reliable-fetcher-by-default.yml b/changelogs/unreleased/10327-enable-reliable-fetcher-by-default.yml new file mode 100644 index 00000000000..89d2fced6e1 --- /dev/null +++ b/changelogs/unreleased/10327-enable-reliable-fetcher-by-default.yml @@ -0,0 +1,5 @@ +--- +title: Enable Sidekiq Reliable Fetcher for background jobs by default +merge_request: 27530 +author: +type: added diff --git a/changelogs/unreleased/11254-overflow-ce.yml b/changelogs/unreleased/11254-overflow-ce.yml new file mode 100644 index 00000000000..dcac46000ac --- /dev/null +++ b/changelogs/unreleased/11254-overflow-ce.yml @@ -0,0 +1,5 @@ +--- +title: Hide ScopedBadge overflow notes +merge_request: 27651 +author: +type: fixed diff --git a/changelogs/unreleased/46048-canary-next.yml b/changelogs/unreleased/46048-canary-next.yml new file mode 100644 index 00000000000..1a702cccff9 --- /dev/null +++ b/changelogs/unreleased/46048-canary-next.yml @@ -0,0 +1,5 @@ +--- +title: Adds badge for Canary environment and help link +merge_request: +author: +type: added diff --git a/changelogs/unreleased/48479-auto-direction-for-issue-title.yml b/changelogs/unreleased/48479-auto-direction-for-issue-title.yml new file mode 100644 index 00000000000..0571f58ab4d --- /dev/null +++ b/changelogs/unreleased/48479-auto-direction-for-issue-title.yml @@ -0,0 +1,5 @@ +--- +title: Add auto direction for issue title +merge_request: 27378 +author: Ahmad Haghighi +type: fixed diff --git a/changelogs/unreleased/54656-500-error-on-save-of-general-pipeline-settings-timeout.yml b/changelogs/unreleased/54656-500-error-on-save-of-general-pipeline-settings-timeout.yml new file mode 100644 index 00000000000..8b4f4894048 --- /dev/null +++ b/changelogs/unreleased/54656-500-error-on-save-of-general-pipeline-settings-timeout.yml @@ -0,0 +1,5 @@ +--- +title: Fix 500 in general pipeline settings when passing an invalid build timeout. +merge_request: 27416 +author: +type: fixed diff --git a/changelogs/unreleased/57017-add-toast-success-message.yml b/changelogs/unreleased/57017-add-toast-success-message.yml new file mode 100644 index 00000000000..931e7755591 --- /dev/null +++ b/changelogs/unreleased/57017-add-toast-success-message.yml @@ -0,0 +1,5 @@ +--- +title: Display a toast message when the Kubernetes runner has successfully upgraded. +merge_request: 27206 +author: +type: changed diff --git a/changelogs/unreleased/60387-use-icons-in-user-popovers.yml b/changelogs/unreleased/60387-use-icons-in-user-popovers.yml new file mode 100644 index 00000000000..100d33690b3 --- /dev/null +++ b/changelogs/unreleased/60387-use-icons-in-user-popovers.yml @@ -0,0 +1,5 @@ +--- +title: Show category icons in user popover +merge_request: +author: +type: added diff --git a/changelogs/unreleased/60552-period-dropdown.yml b/changelogs/unreleased/60552-period-dropdown.yml new file mode 100644 index 00000000000..e1b4a098ab0 --- /dev/null +++ b/changelogs/unreleased/60552-period-dropdown.yml @@ -0,0 +1,5 @@ +--- +title: Fix autocomplete dropdown for usernames starting with period +merge_request: 27533 +author: Jan Beckmann +type: fixed diff --git a/changelogs/unreleased/60687-enviro-dropdown.yml b/changelogs/unreleased/60687-enviro-dropdown.yml new file mode 100644 index 00000000000..1fc5a7dd6f5 --- /dev/null +++ b/changelogs/unreleased/60687-enviro-dropdown.yml @@ -0,0 +1,5 @@ +--- +title: Fix Metrics Environments dropdown +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/60808-only-show-target-branch-advanced-error-before-merge.yml b/changelogs/unreleased/60808-only-show-target-branch-advanced-error-before-merge.yml new file mode 100644 index 00000000000..b340f8408f3 --- /dev/null +++ b/changelogs/unreleased/60808-only-show-target-branch-advanced-error-before-merge.yml @@ -0,0 +1,6 @@ +--- +title: Only show the "target branch has advanced" message when the merge request is + open +merge_request: 27588 +author: +type: fixed diff --git a/changelogs/unreleased/60855-mr-popover-is-not-attached-in-system-notes.yml b/changelogs/unreleased/60855-mr-popover-is-not-attached-in-system-notes.yml new file mode 100644 index 00000000000..f7017ddf3dd --- /dev/null +++ b/changelogs/unreleased/60855-mr-popover-is-not-attached-in-system-notes.yml @@ -0,0 +1,5 @@ +--- +title: Fix bug where system note MR has no popover +merge_request: 27589 +author: +type: fixed diff --git a/changelogs/unreleased/60874-fix-suggestion-misalignment.yml b/changelogs/unreleased/60874-fix-suggestion-misalignment.yml new file mode 100644 index 00000000000..f5717ac19fd --- /dev/null +++ b/changelogs/unreleased/60874-fix-suggestion-misalignment.yml @@ -0,0 +1,5 @@ +--- +title: Resolve Misalignment on suggested changes diff table +merge_request: 27612 +author: +type: fixed diff --git a/changelogs/unreleased/60906-fix-wiki-links.yml b/changelogs/unreleased/60906-fix-wiki-links.yml new file mode 100644 index 00000000000..cc65a1382bf --- /dev/null +++ b/changelogs/unreleased/60906-fix-wiki-links.yml @@ -0,0 +1,5 @@ +--- +title: Show proper wiki links in search results +merge_request: 27634 +author: +type: fixed diff --git a/changelogs/unreleased/autodevops_remote_private_helm_repository.yml b/changelogs/unreleased/autodevops_remote_private_helm_repository.yml new file mode 100644 index 00000000000..5341abb1095 --- /dev/null +++ b/changelogs/unreleased/autodevops_remote_private_helm_repository.yml @@ -0,0 +1,6 @@ +--- +title: Allow linking to a private helm repository by providing credentials, and customisation + of repository name +merge_request: 27123 +author: Stuart Moore @stjm-cc +type: added diff --git a/changelogs/unreleased/fix-api-ide-relative-url-root.yml b/changelogs/unreleased/fix-api-ide-relative-url-root.yml new file mode 100644 index 00000000000..8c058645f3e --- /dev/null +++ b/changelogs/unreleased/fix-api-ide-relative-url-root.yml @@ -0,0 +1,5 @@ +--- +title: Fix FE API and IDE handling of '/' relative_url_root +merge_request: 27635 +author: +type: fixed diff --git a/changelogs/unreleased/fix-lazy-blobs-requesting-all-previous-blobs.yml b/changelogs/unreleased/fix-lazy-blobs-requesting-all-previous-blobs.yml new file mode 100644 index 00000000000..58f5a9c943c --- /dev/null +++ b/changelogs/unreleased/fix-lazy-blobs-requesting-all-previous-blobs.yml @@ -0,0 +1,6 @@ +--- +title: Fix Blob.lazy always loading all previously-requested blobs when a new request + is made +merge_request: +author: +type: performance diff --git a/changelogs/unreleased/fj-53523-add-option-avoid-loading-wiki-page-content.yml b/changelogs/unreleased/fj-53523-add-option-avoid-loading-wiki-page-content.yml new file mode 100644 index 00000000000..49eaff52e5a --- /dev/null +++ b/changelogs/unreleased/fj-53523-add-option-avoid-loading-wiki-page-content.yml @@ -0,0 +1,5 @@ +--- +title: Added list_pages method to avoid loading all wiki pages content +merge_request: 22801 +author: +type: performance diff --git a/changelogs/unreleased/jc-update-list-last-commits.yml b/changelogs/unreleased/jc-update-list-last-commits.yml new file mode 100644 index 00000000000..0e72c4255ae --- /dev/null +++ b/changelogs/unreleased/jc-update-list-last-commits.yml @@ -0,0 +1,5 @@ +--- +title: Client side changes for ListLastCommitsForTree response update +merge_request: 26880 +author: +type: fixed diff --git a/changelogs/unreleased/refactor-58827-migrate-issue-spec-to-jest.yml b/changelogs/unreleased/refactor-58827-migrate-issue-spec-to-jest.yml new file mode 100644 index 00000000000..03d94c39c10 --- /dev/null +++ b/changelogs/unreleased/refactor-58827-migrate-issue-spec-to-jest.yml @@ -0,0 +1,5 @@ +--- +title: 'refactor(issue): Refactored issue tests from Karma to Jest' +merge_request: 27673 +author: Martin Hobert +type: other diff --git a/changelogs/unreleased/refactor-58829-migrate-notes-spec-to-jest.yml b/changelogs/unreleased/refactor-58829-migrate-notes-spec-to-jest.yml new file mode 100644 index 00000000000..9a1886797da --- /dev/null +++ b/changelogs/unreleased/refactor-58829-migrate-notes-spec-to-jest.yml @@ -0,0 +1,5 @@ +--- +title: 'Refactored notes tests from Karma to Jest' +merge_request: 27648 +author: Martin Hobert +type: other diff --git a/changelogs/unreleased/sh-fix-autocomplete-mirror-repo.yml b/changelogs/unreleased/sh-fix-autocomplete-mirror-repo.yml new file mode 100644 index 00000000000..e855684bab1 --- /dev/null +++ b/changelogs/unreleased/sh-fix-autocomplete-mirror-repo.yml @@ -0,0 +1,5 @@ +--- +title: Disable password autocomplete in mirror repository form +merge_request: 27542 +author: +type: fixed diff --git a/changelogs/unreleased/update-workhorse-master.yml b/changelogs/unreleased/update-workhorse-master.yml new file mode 100644 index 00000000000..97e2e891ab1 --- /dev/null +++ b/changelogs/unreleased/update-workhorse-master.yml @@ -0,0 +1,5 @@ +--- +title: Update Workhorse to v8.7.0 +merge_request: 27630 +author: +type: fixed diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 2e4aa9c1053..7b69cf11288 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -1,5 +1,17 @@ require 'sidekiq/web' +def enable_reliable_fetch? + return true unless Feature::FlipperFeature.table_exists? + + Feature.enabled?(:gitlab_sidekiq_reliable_fetcher, default_enabled: true) +end + +def enable_semi_reliable_fetch_mode? + return true unless Feature::FlipperFeature.table_exists? + + Feature.enabled?(:gitlab_sidekiq_enable_semi_reliable_fetcher, default_enabled: true) +end + # Disable the Sidekiq Rack session since GitLab already has its own session store. # CSRF protection still works (https://github.com/mperham/sidekiq/commit/315504e766c4fd88a29b7772169060afc4c40329). Sidekiq::Web.set :sessions, false @@ -45,9 +57,8 @@ Sidekiq.configure_server do |config| ActiveRecord::Base.clear_all_connections! end - if Feature::FlipperFeature.table_exists? && Feature.enabled?(:gitlab_sidekiq_reliable_fetcher) - # By default we're going to use Semi Reliable Fetch - config.options[:semi_reliable_fetch] = Feature.enabled?(:gitlab_sidekiq_enable_semi_reliable_fetcher, default_enabled: true) + if enable_reliable_fetch? + config.options[:semi_reliable_fetch] = enable_semi_reliable_fetch_mode? Sidekiq::ReliableFetch.setup_reliable_fetch!(config) end diff --git a/doc/development/i18n/externalization.md b/doc/development/i18n/externalization.md index 223585ebb55..38d56322de8 100644 --- a/doc/development/i18n/externalization.md +++ b/doc/development/i18n/externalization.md @@ -300,7 +300,7 @@ file in. Once the changes are on master, they will be picked up by [Crowdin](http://translate.gitlab.com) and be presented for translation. If there are merge conflicts in the `gitlab.pot` file, you can delete the file -and regenerate it using the same command. Confirm that you are not deleting any strings accidentally by looking over the diff. +and regenerate it using the same command. ### Validating PO files diff --git a/doc/development/testing_guide/img/review_apps_cicd_architecture.png b/doc/development/testing_guide/img/review_apps_cicd_architecture.png Binary files differindex 87e472076f3..1ee28d3db91 100644 --- a/doc/development/testing_guide/img/review_apps_cicd_architecture.png +++ b/doc/development/testing_guide/img/review_apps_cicd_architecture.png diff --git a/doc/development/testing_guide/review_apps.md b/doc/development/testing_guide/review_apps.md index 55ca502f84a..7ad33f05f77 100644 --- a/doc/development/testing_guide/review_apps.md +++ b/doc/development/testing_guide/review_apps.md @@ -6,7 +6,7 @@ Review Apps are automatically deployed by each pipeline, both in ## How does it work? -### CD/CD architecture diagram +### CI/CD architecture diagram ![Review Apps CI/CD architecture](img/review_apps_cicd_architecture.png) @@ -14,23 +14,29 @@ Review Apps are automatically deployed by each pipeline, both in <summary>Show mermaid source</summary> <pre> graph TD - B1 -.->|2. once gitlab:assets:compile is done,<br />triggers a CNG-mirror pipeline and wait for it to be done| A2 - C1 -.->|2. once review-build-cng is done,<br />Helm deploys the Review App using the Cloud<br/>Native images built by the CNG-mirror pipeline| A3 - -subgraph gitlab-ce/ee `test` stage - A1[gitlab:assets:compile] - B1[review-build-cng] -->|1. wait for| A1 - C1[review-deploy] -->|1. wait for| B1 - D1[review-qa-smoke] -->|1. wait for| C1 - D1[review-qa-smoke] -.->|2. once review-deploy is done| E1>gitlab-qa runs the smoke<br/>suite against the Review App] + build-qa-image -.->|once the `prepare` stage is done| gitlab:assets:compile + review-build-cng -->|triggers a CNG-mirror pipeline and wait for it to be done| CNG-mirror + review-build-cng -.->|once the `test` stage is done| review-deploy + review-deploy -.->|once the `review` stage is done| review-qa-smoke + +subgraph 1. gitlab-ce/ee `prepare` stage + build-qa-image end -subgraph CNG-mirror pipeline - A2>Cloud Native images are built]; +subgraph 2. gitlab-ce/ee `test` stage + gitlab:assets:compile -->|plays dependent job once done| review-build-cng end -subgraph GCP `gitlab-review-apps` project - A3>"Cloud Native images are deployed to the<br />`review-apps-ce` or `review-apps-ee` Kubernetes (GKE) cluster"]; +subgraph 3. gitlab-ce/ee `review` stage + review-deploy["review-deploy<br /><br />Helm deploys the Review App using the Cloud<br/>Native images built by the CNG-mirror pipeline.<br /><br />Cloud Native images are deployed to the `review-apps-ce` or `review-apps-ee`<br />Kubernetes (GKE) cluster, in the GCP `gitlab-review-apps` project."] + end + +subgraph 4. gitlab-ce/ee `qa` stage + review-qa-smoke[review-qa-smoke<br /><br />gitlab-qa runs the smoke suite against the Review App.] + end + +subgraph CNG-mirror pipeline + CNG-mirror>Cloud Native images are built]; end </pre> </details> @@ -38,40 +44,37 @@ subgraph GCP `gitlab-review-apps` project ### Detailed explanation 1. On every [pipeline][gitlab-pipeline] during the `test` stage, the - [`review-build-cng`][review-build-cng] and - [`review-deploy`][review-deploy] jobs are automatically started. - - The [`review-deploy`][review-deploy] job waits for the - [`review-build-cng`][review-build-cng] job to finish. - - The [`review-build-cng`][review-build-cng] job waits for the - [`gitlab:assets:compile`][gitlab:assets:compile] job to finish since the - [`CNG-mirror`][cng-mirror] pipeline triggered in the following step depends on it. -1. Once the [`gitlab:assets:compile`][gitlab:assets:compile] job is done, - [`review-build-cng`][review-build-cng] [triggers a pipeline][cng-pipeline] - in the [`CNG-mirror`][cng-mirror] project. - - The [`CNG-mirror`][cng-pipeline] pipeline creates the Docker images of - each component (e.g. `gitlab-rails-ee`, `gitlab-shell`, `gitaly` etc.) - based on the commit from the [GitLab pipeline][gitlab-pipeline] and store - them in its [registry][cng-mirror-registry]. - - We use the [`CNG-mirror`][cng-mirror] project so that the `CNG`, (**C**loud - **N**ative **G**itLab), project's registry is not overloaded with a - lot of transient Docker images. -1. Once the [`review-build-cng`][review-build-cng] job is done, the - [`review-deploy`][review-deploy] job deploys the Review App using - [the official GitLab Helm chart][helm-chart] to the - [`review-apps-ce`][review-apps-ce] / [`review-apps-ee`][review-apps-ee] - Kubernetes cluster on GCP. - - The actual scripts used to deploy the Review App can be found at - [`scripts/review_apps/review-apps.sh`][review-apps.sh]. - - These scripts are basically - [our official Auto DevOps scripts][Auto-DevOps.gitlab-ci.yml] where the - default CNG images are overridden with the images built and stored in the - [`CNG-mirror` project's registry][cng-mirror-registry]. - - Since we're using [the official GitLab Helm chart][helm-chart], this means - you get a dedicated environment for your branch that's very close to what - it would look in production. + [`gitlab:assets:compile`][gitlab:assets:compile] job is automatically started. + - Once it's done, it starts the [`review-build-cng`][review-build-cng] + manual job since the [`CNG-mirror`][cng-mirror] pipeline triggered in the + following step depends on it. +1. The [`review-build-cng`][review-build-cng] job [triggers a pipeline][cng-mirror-pipeline] + in the [`CNG-mirror`][cng-mirror] project. + - The [`CNG-mirror`][cng-mirror-pipeline] pipeline creates the Docker images of + each component (e.g. `gitlab-rails-ee`, `gitlab-shell`, `gitaly` etc.) + based on the commit from the [GitLab pipeline][gitlab-pipeline] and stores + them in its [registry][cng-mirror-registry]. + - We use the [`CNG-mirror`][cng-mirror] project so that the `CNG`, (**C**loud + **N**ative **G**itLab), project's registry is not overloaded with a + lot of transient Docker images. + - Note that the official CNG images are built by the `cloud-native-image` + job, which runs only for tags, and triggers itself a [`CNG`][cng] pipeline. +1. Once the `test` stage is done, the [`review-deploy`][review-deploy] job + deploys the Review App using [the official GitLab Helm chart][helm-chart] to + the [`review-apps-ce`][review-apps-ce] / [`review-apps-ee`][review-apps-ee] + Kubernetes cluster on GCP. + - The actual scripts used to deploy the Review App can be found at + [`scripts/review_apps/review-apps.sh`][review-apps.sh]. + - These scripts are basically + [our official Auto DevOps scripts][Auto-DevOps.gitlab-ci.yml] where the + default CNG images are overridden with the images built and stored in the + [`CNG-mirror` project's registry][cng-mirror-registry]. + - Since we're using [the official GitLab Helm chart][helm-chart], this means + you get a dedicated environment for your branch that's very close to what + it would look in production. 1. Once the [`review-deploy`][review-deploy] job succeeds, you should be able to - use your Review App thanks to the direct link to it from the MR widget. To log - into the Review App, see "Log into my Review App?" below. + use your Review App thanks to the direct link to it from the MR widget. To log + into the Review App, see "Log into my Review App?" below. **Additional notes:** @@ -82,71 +85,69 @@ subgraph GCP `gitlab-review-apps` project - If the Review App deployment fails, you can simply retry it (there's no need to run the [`review-stop`][gitlab-ci-yml] job first). - The manual [`review-stop`][gitlab-ci-yml] in the `test` stage can be used to - stop a Review App manually, and is also started by GitLab once a branch is - deleted. -- Review Apps are cleaned up regularly using a pipeline schedule that runs + stop a Review App manually, and is also started by GitLab once a merge + request's branch is deleted after being merged. +- Review Apps are cleaned up regularly via a pipeline schedule that runs the [`schedule:review-cleanup`][gitlab-ci-yml] job. ## QA runs -On every [pipeline][gitlab-pipeline] during the `test` stage, the -`review-qa-smoke` job is automatically started: it runs the QA smoke suite. -You can also manually start the `review-qa-all`: it runs the QA full suite. +On every [pipeline][gitlab-pipeline] in the `qa` stage (which comes after the +`review` stage), the `review-qa-smoke` job is automatically started and it runs +the QA smoke suite. -Note that both jobs first wait for the `review-deploy` job to be finished. +You can also manually start the `review-qa-all`: it runs the full QA suite. ## Performance Metrics -On every [pipeline][gitlab-pipeline] during the `test` stage, the +On every [pipeline][gitlab-pipeline] in the `qa` stage, the `review-performance` job is automatically started: this job does basic -browser performance testing using [Sitespeed.io Container](https://docs.gitlab.com/ee/user/project/merge_requests/browser_performance_testing.html) . +browser performance testing using a +[Sitespeed.io Container](https://docs.gitlab.com/ee/user/project/merge_requests/browser_performance_testing.html). -This job waits for the `review-deploy` job to be finished. +## How to: -## How to? - -### Log into my Review App? +### Log into my Review App The default username is `root` and its password can be found in the 1Password secure note named **gitlab-{ce,ee} Review App's root password**. -### Enable a feature flag for my Review App? +### Enable a feature flag for my Review App 1. Open your Review App and log in as documented above. 1. Create a personal access token. 1. Enable the feature flag using the [Feature flag API](../../api/features.md). -### Find my Review App slug? +### Find my Review App slug 1. Open the `review-deploy` job. 1. Look for `Checking for previous deployment of review-*`. 1. For instance for `Checking for previous deployment of review-qa-raise-e-12chm0`, - your Review App slug would be `review-qa-raise-e-12chm0` in this case. + your Review App slug would be `review-qa-raise-e-12chm0` in this case. -### Run a Rails console? +### Run a Rails console 1. [Filter Workloads by your Review App slug](https://console.cloud.google.com/kubernetes/workload?project=gitlab-review-apps) - , e.g. `review-29951-issu-id2qax`. -1. Find and open the `task-runner` Deployment, e.g. `review-29951-issu-id2qax-task-runner`. -1. Click on the Pod in the "Managed pods" section, e.g. `review-29951-issu-id2qax-task-runner-d5455cc8-2lsvz`. + , e.g. `review-qa-raise-e-12chm0`. +1. Find and open the `task-runner` Deployment, e.g. `review-qa-raise-e-12chm0-task-runner`. +1. Click on the Pod in the "Managed pods" section, e.g. `review-qa-raise-e-12chm0-task-runner-d5455cc8-2lsvz`. 1. Click on the `KUBECTL` dropdown, then `Exec` -> `task-runner`. 1. Replace `-c task-runner -- ls` with `-it -- gitlab-rails console` from the - default command or - - Run `kubectl exec --namespace review-apps-ce review-29951-issu-id2qax-task-runner-d5455cc8-2lsvz -it -- gitlab-rails console` - and - - Replace `review-apps-ce` with `review-apps-ee` if the Review App - is running EE, and - - Replace `review-29951-issu-id2qax-task-runner-d5455cc8-2lsvz` - with your Pod's name. + default command or + - Run `kubectl exec --namespace review-apps-ce review-qa-raise-e-12chm0-task-runner-d5455cc8-2lsvz -it -- gitlab-rails console` and + - Replace `review-apps-ce` with `review-apps-ee` if the Review App + is running EE, and + - Replace `review-qa-raise-e-12chm0-task-runner-d5455cc8-2lsvz` + with your Pod's name. -### Dig into a Pod's logs? +### Dig into a Pod's logs -1. [Filter Workloads by your Review App slug](https://console.cloud.google.com/kubernetes/workload?project=gitlab-review-apps) - , e.g. `review-1979-1-mul-dnvlhv`. +1. [Filter Workloads by your Review App slug](https://console.cloud.google.com/kubernetes/workload?project=gitlab-review-apps), + e.g. `review-qa-raise-e-12chm0`. 1. Find and open the `migrations` Deployment, e.g. - `review-1979-1-mul-dnvlhv-migrations.1`. + `review-qa-raise-e-12chm0-migrations.1`. 1. Click on the Pod in the "Managed pods" section, e.g. - `review-1979-1-mul-dnvlhv-migrations.1-nqwtx`. + `review-qa-raise-e-12chm0-migrations.1-nqwtx`. 1. Click on the `Container logs` link. ## Frequently Asked Questions @@ -182,7 +183,8 @@ find a way to limit it to only us.** [review-build-cng]: https://gitlab.com/gitlab-org/gitlab-ce/-/jobs/149511623 [review-deploy]: https://gitlab.com/gitlab-org/gitlab-ce/-/jobs/149511624 [cng-mirror]: https://gitlab.com/gitlab-org/build/CNG-mirror -[cng-pipeline]: https://gitlab.com/gitlab-org/build/CNG-mirror/pipelines/44364657 +[cng]: https://gitlab.com/gitlab-org/build/CNG +[cng-mirror-pipeline]: https://gitlab.com/gitlab-org/build/CNG-mirror/pipelines/44364657 [cng-mirror-registry]: https://gitlab.com/gitlab-org/build/CNG-mirror/container_registry [helm-chart]: https://gitlab.com/charts/gitlab/ [review-apps-ce]: https://console.cloud.google.com/kubernetes/clusters/details/us-central1-a/review-apps-ce?project=gitlab-review-apps diff --git a/doc/topics/autodevops/index.md b/doc/topics/autodevops/index.md index 0ab9406c681..1c559b5263c 100644 --- a/doc/topics/autodevops/index.md +++ b/doc/topics/autodevops/index.md @@ -737,6 +737,9 @@ also be customized, and you can easily use a [custom buildpack](#custom-buildpac | `AUTO_DEVOPS_DOMAIN` | The [Auto DevOps domain](#auto-devops-base-domain). By default, set automatically by the [Auto DevOps setting](#enablingdisabling-auto-devops). This variable is deprecated and [is scheduled to be removed](https://gitlab.com/gitlab-org/gitlab-ce/issues/56959). Use `KUBE_INGRESS_BASE_DOMAIN` instead. | | `AUTO_DEVOPS_CHART` | The Helm Chart used to deploy your apps; defaults to the one [provided by GitLab](https://gitlab.com/gitlab-org/charts/auto-deploy-app). | | `AUTO_DEVOPS_CHART_REPOSITORY` | The Helm Chart repository used to search for charts; defaults to `https://charts.gitlab.io`. | +| `AUTO_DEVOPS_CHART_REPOSITORY_NAME` | From Gitlab 11.11, this variable can be used to set the name of the helm repository; defaults to "gitlab" | +| `AUTO_DEVOPS_CHART_REPOSITORY_USERNAME` | From Gitlab 11.11, this variable can be used to set a username to connect to the helm repository. Defaults to no credentials. (Also set AUTO_DEVOPS_CHART_REPOSITORY_PASSWORD) | +| `AUTO_DEVOPS_CHART_REPOSITORY_PASSWORD` | From Gitlab 11.11, this variable can be used to set a password to connect to the helm repository. Defaults to no credentials. (Also set AUTO_DEVOPS_CHART_REPOSITORY_USERNAME) | | `REPLICAS` | The number of replicas to deploy; defaults to 1. | | `PRODUCTION_REPLICAS` | The number of replicas to deploy in the production environment. This takes precedence over `REPLICAS`; defaults to 1. | | `CANARY_REPLICAS` | The number of canary replicas to deploy for [Canary Deployments](https://docs.gitlab.com/ee/user/project/canary_deployments.html); defaults to 1 | diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index 8a21d44b4bf..7e4539d0419 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -67,10 +67,6 @@ module API initial_current_user != current_user end - def user_namespace - @user_namespace ||= find_namespace!(params[:id]) - end - def user_group @group ||= find_group!(params[:id]) end diff --git a/lib/api/namespaces.rb b/lib/api/namespaces.rb index 3cc09f6ac3f..77ecb3e7cde 100644 --- a/lib/api/namespaces.rb +++ b/lib/api/namespaces.rb @@ -44,6 +44,8 @@ module API requires :id, type: String, desc: "Namespace's ID or path" end get ':id', requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + user_namespace = find_namespace!(params[:id]) + present user_namespace, with: Entities::Namespace, current_user: current_user end end diff --git a/lib/api/wikis.rb b/lib/api/wikis.rb index 994074ddc67..5724adb2c40 100644 --- a/lib/api/wikis.rb +++ b/lib/api/wikis.rb @@ -33,7 +33,8 @@ module API authorize! :read_wiki, user_project entity = params[:with_content] ? Entities::WikiPage : Entities::WikiPageBasic - present user_project.wiki.pages, with: entity + + present user_project.wiki.list_pages(load_content: params[:with_content]), with: entity end desc 'Get a wiki page' do diff --git a/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml b/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml index d36576fe39f..9d99d04d263 100644 --- a/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml @@ -490,7 +490,7 @@ rollout 100%: fi helm init --client-only - helm repo add gitlab ${AUTO_DEVOPS_CHART_REPOSITORY:-https://charts.gitlab.io} + helm repo add ${AUTO_DEVOPS_CHART_REPOSITORY_NAME:-gitlab} ${AUTO_DEVOPS_CHART_REPOSITORY:-https://charts.gitlab.io} ${AUTO_DEVOPS_CHART_REPOSITORY_USERNAME:+"--username" "$AUTO_DEVOPS_CHART_REPOSITORY_USERNAME"} ${AUTO_DEVOPS_CHART_REPOSITORY_PASSWORD:+"--password" "$AUTO_DEVOPS_CHART_REPOSITORY_PASSWORD"} if [[ ! -d "$auto_chart" ]]; then helm fetch ${auto_chart} --untar fi diff --git a/lib/gitlab/git/wiki.rb b/lib/gitlab/git/wiki.rb index a0dd4a24363..c1bcd8e934a 100644 --- a/lib/gitlab/git/wiki.rb +++ b/lib/gitlab/git/wiki.rb @@ -86,9 +86,14 @@ module Gitlab end end - def pages(limit: 0, sort: nil, direction_desc: false) + def list_pages(limit: 0, sort: nil, direction_desc: false, load_content: false) wrapped_gitaly_errors do - gitaly_get_all_pages(limit: limit, sort: sort, direction_desc: direction_desc) + gitaly_list_pages( + limit: limit, + sort: sort, + direction_desc: direction_desc, + load_content: load_content + ) end end @@ -168,10 +173,17 @@ module Gitlab Gitlab::Git::WikiFile.new(wiki_file) end - def gitaly_get_all_pages(limit: 0, sort: nil, direction_desc: false) - gitaly_wiki_client.get_all_pages( - limit: limit, sort: sort, direction_desc: direction_desc - ).map do |wiki_page, version| + def gitaly_list_pages(limit: 0, sort: nil, direction_desc: false, load_content: false) + params = { limit: limit, sort: sort, direction_desc: direction_desc } + + gitaly_pages = + if load_content + gitaly_wiki_client.load_all_pages(params) + else + gitaly_wiki_client.list_all_pages(params) + end + + gitaly_pages.map do |wiki_page, version| Gitlab::Git::WikiPage.new(wiki_page, version) end end diff --git a/lib/gitlab/gitaly_client/blob_service.rb b/lib/gitlab/gitaly_client/blob_service.rb index 6b8e58e6199..8ccefb00d20 100644 --- a/lib/gitlab/gitaly_client/blob_service.rb +++ b/lib/gitlab/gitaly_client/blob_service.rb @@ -55,13 +55,13 @@ module Gitlab def get_blobs(revision_paths, limit = -1) return [] if revision_paths.empty? - revision_paths.map! do |rev, path| + request_revision_paths = revision_paths.map do |rev, path| Gitaly::GetBlobsRequest::RevisionPath.new(revision: rev, path: encode_binary(path)) end request = Gitaly::GetBlobsRequest.new( repository: @gitaly_repo, - revision_paths: revision_paths, + revision_paths: request_revision_paths, limit: limit ) diff --git a/lib/gitlab/gitaly_client/commit_service.rb b/lib/gitlab/gitaly_client/commit_service.rb index 0d5debfcd01..2896b7e1ce0 100644 --- a/lib/gitlab/gitaly_client/commit_service.rb +++ b/lib/gitlab/gitaly_client/commit_service.rb @@ -174,7 +174,7 @@ module Gitlab response.each_with_object({}) do |gitaly_response, hsh| gitaly_response.commits.each do |commit_for_tree| - hsh[commit_for_tree.path] = Gitlab::Git::Commit.new(@repository, commit_for_tree.commit) + hsh[commit_for_tree.path_bytes] = Gitlab::Git::Commit.new(@repository, commit_for_tree.commit) end end end diff --git a/lib/gitlab/gitaly_client/wiki_service.rb b/lib/gitlab/gitaly_client/wiki_service.rb index e036cdcd800..ce9faad825c 100644 --- a/lib/gitlab/gitaly_client/wiki_service.rb +++ b/lib/gitlab/gitaly_client/wiki_service.rb @@ -87,7 +87,27 @@ module Gitlab wiki_page_from_iterator(response) end - def get_all_pages(limit: 0, sort: nil, direction_desc: false) + def list_all_pages(limit: 0, sort: nil, direction_desc: false) + sort_value = Gitaly::WikiListPagesRequest::SortBy.resolve(sort.to_s.upcase.to_sym) + + params = { repository: @gitaly_repo, limit: limit, direction_desc: direction_desc } + params[:sort] = sort_value if sort_value + + request = Gitaly::WikiListPagesRequest.new(params) + stream = GitalyClient.call(@repository.storage, :wiki_service, :wiki_list_pages, request, timeout: GitalyClient.medium_timeout) + stream.each_with_object([]) do |message, pages| + page = message.page + + next unless page + + wiki_page = GitalyClient::WikiPage.new(page.to_h) + version = new_wiki_page_version(page.version) + + pages << [wiki_page, version] + end + end + + def load_all_pages(limit: 0, sort: nil, direction_desc: false) sort_value = Gitaly::WikiGetAllPagesRequest::SortBy.resolve(sort.to_s.upcase.to_sym) params = { repository: @gitaly_repo, limit: limit, direction_desc: direction_desc } @@ -95,6 +115,7 @@ module Gitlab request = Gitaly::WikiGetAllPagesRequest.new(params) response = GitalyClient.call(@repository.storage, :wiki_service, :wiki_get_all_pages, request, timeout: GitalyClient.medium_timeout) + pages = [] loop do diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 54c40e48084..5bc9bb3434d 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -372,6 +372,9 @@ msgstr "" msgid "A deleted user" msgstr "" +msgid "A fork is a copy of a project.<br />Forking a repository allows you to make changes without affecting the original project." +msgstr "" + msgid "A member of GitLab's abuse team will review your report as soon as possible." msgstr "" @@ -1887,6 +1890,9 @@ msgstr "" msgid "Close milestone" msgstr "" +msgid "Close sidebar" +msgstr "" + msgid "Closed" msgstr "" @@ -3256,6 +3262,9 @@ msgstr "" msgid "Discuss a specific suggestion or question that needs to be resolved" msgstr "" +msgid "Discussion" +msgstr "" + msgid "Dismiss" msgstr "" @@ -4140,6 +4149,15 @@ msgstr "" msgid "For public projects, anyone can view pipelines and access job details (output logs and artifacts)" msgstr "" +msgid "Fork" +msgstr "" + +msgid "Fork Error!" +msgstr "" + +msgid "Fork project" +msgstr "" + msgid "ForkedFromProjectPath|Forked from" msgstr "" @@ -4149,6 +4167,9 @@ msgstr "" msgid "Forking in progress" msgstr "" +msgid "Forking repository" +msgstr "" + msgid "Forks" msgstr "" @@ -4311,6 +4332,9 @@ msgstr "" msgid "Go to project" msgstr "" +msgid "Go to your fork" +msgstr "" + msgid "Google Code import" msgstr "" @@ -5775,6 +5799,9 @@ msgstr "" msgid "Name" msgstr "" +msgid "Name has already been taken" +msgstr "" + msgid "Name new label" msgstr "" @@ -5915,6 +5942,9 @@ msgstr "" msgid "Newly registered users will by default be external" msgstr "" +msgid "Next" +msgstr "" + msgid "No" msgstr "" @@ -5927,6 +5957,9 @@ msgstr "" msgid "No activities found" msgstr "" +msgid "No available namespaces to fork the project." +msgstr "" + msgid "No branches found" msgstr "" @@ -7835,6 +7868,9 @@ msgstr "" msgid "Search for projects, issues, etc." msgstr "" +msgid "Search forks" +msgstr "" + msgid "Search groups" msgstr "" @@ -8644,6 +8680,9 @@ msgstr "" msgid "Switch branch/tag" msgstr "" +msgid "Switch to GitLab Next" +msgstr "" + msgid "System Hooks" msgstr "" @@ -9709,6 +9748,9 @@ msgstr "" msgid "Try again?" msgstr "" +msgid "Try to fork again" +msgstr "" + msgid "Trying to communicate with your device. Plug it in (if you haven't already) and press the button on the device now." msgstr "" @@ -10533,6 +10575,9 @@ msgstr "" msgid "You must have maintainer access to force delete a lock" msgstr "" +msgid "You must have permission to create a project in a namespace before forking." +msgstr "" + msgid "You need permission." msgstr "" @@ -10548,6 +10593,9 @@ msgstr "" msgid "You need to upload a Google Takeout archive." msgstr "" +msgid "You tried to fork %{link_to_the_project} but it failed for the following reason:" +msgstr "" + msgid "You will lose all changes you've made to this file. This action cannot be undone." msgstr "" @@ -11199,6 +11247,9 @@ msgstr "" msgid "sign in" msgstr "" +msgid "sort:" +msgstr "" + msgid "source" msgstr "" diff --git a/package.json b/package.json index e04470109be..d2ee29fe06d 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@babel/preset-env": "^7.3.1", "@gitlab/csslab": "^1.9.0", "@gitlab/svgs": "^1.59.0", - "@gitlab/ui": "^3.4.0", + "@gitlab/ui": "^3.5.0", "apollo-cache-inmemory": "^1.5.1", "apollo-client": "^2.5.1", "apollo-upload-client": "^10.0.0", diff --git a/scripts/review_apps/review-apps.sh b/scripts/review_apps/review-apps.sh index b55ce1af55e..8be22dc0278 100755 --- a/scripts/review_apps/review-apps.sh +++ b/scripts/review_apps/review-apps.sh @@ -1,26 +1,6 @@ [[ "$TRACE" ]] && set -x export TILLER_NAMESPACE="$KUBE_NAMESPACE" -function echoerr() { - local header="${2}" - - if [ -n "${header}" ]; then - printf "\n\033[0;31m** %s **\n\033[0m" "${1}" >&2; - else - printf "\033[0;31m%s\n\033[0m" "${1}" >&2; - fi -} - -function echoinfo() { - local header="${2}" - - if [ -n "${header}" ]; then - printf "\n\033[0;33m** %s **\n\033[0m" "${1}" >&2; - else - printf "\033[0;33m%s\n\033[0m" "${1}" >&2; - fi -} - function deployExists() { local namespace="${1}" local deploy="${2}" @@ -328,80 +308,3 @@ function add_license() { puts "License added"; ' } - -function get_job_id() { - local job_name="${1}" - local query_string="${2:+&${2}}" - - local max_page=3 - local page=1 - - while true; do - local url="https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/pipelines/${CI_PIPELINE_ID}/jobs?per_page=100&page=${page}${query_string}" - echoinfo "GET ${url}" - - local job_id - job_id=$(curl --silent --show-error --header "PRIVATE-TOKEN: ${API_TOKEN}" "${url}" | jq "map(select(.name == \"${job_name}\")) | map(.id) | last") - [[ "${job_id}" == "null" && "${page}" -lt "$max_page" ]] || break - - let "page++" - done - - if [[ "${job_id}" == "" ]]; then - echoerr "The '${job_name}' job ID couldn't be retrieved!" - else - echoinfo "The '${job_name}' job ID is ${job_id}" - echo "${job_id}" - fi -} - -function play_job() { - local job_name="${1}" - local job_id - job_id=$(get_job_id "${job_name}" "scope=manual"); - if [ -z "${job_id}" ]; then return; fi - - local url="https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/jobs/${job_id}/play" - echoinfo "POST ${url}" - - local job_url - job_url=$(curl --silent --show-error --request POST --header "PRIVATE-TOKEN: ${API_TOKEN}" "${url}" | jq ".web_url") - echoinfo "Manual job '${job_name}' started at: ${job_url}" -} - -function wait_for_job_to_be_done() { - local job_name="${1}" - local query_string="${2}" - local job_id - job_id=$(get_job_id "${job_name}" "${query_string}") - if [ -z "${job_id}" ]; then return; fi - - echoinfo "Waiting for the '${job_name}' job to finish..." - - local url="https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/jobs/${job_id}" - echoinfo "GET ${url}" - - # In case the job hasn't finished yet. Keep trying until the job times out. - local interval=30 - local elapsed_seconds=0 - while true; do - local job_status - job_status=$(curl --silent --show-error --header "PRIVATE-TOKEN: ${API_TOKEN}" "${url}" | jq ".status" | sed -e s/\"//g) - [[ "${job_status}" == "pending" || "${job_status}" == "running" ]] || break - - printf "." - let "elapsed_seconds+=interval" - sleep ${interval} - done - - local elapsed_minutes=$((elapsed_seconds / 60)) - echoinfo "Waited '${job_name}' for ${elapsed_minutes} minutes." - - if [[ "${job_status}" == "failed" ]]; then - echoerr "The '${job_name}' failed." - elif [[ "${job_status}" == "manual" ]]; then - echoinfo "The '${job_name}' is manual." - else - echoinfo "The '${job_name}' passed." - fi -} diff --git a/scripts/utils.sh b/scripts/utils.sh index 2d2ba115563..4a6567b8a62 100644 --- a/scripts/utils.sh +++ b/scripts/utils.sh @@ -1,4 +1,4 @@ -retry() { +function retry() { if eval "$@"; then return 0 fi @@ -13,15 +13,15 @@ retry() { return 1 } -setup_db_user_only() { +function setup_db_user_only() { if [ "$GITLAB_DATABASE" = "postgresql" ]; then - . scripts/create_postgres_user.sh + source scripts/create_postgres_user.sh else - . scripts/create_mysql_user.sh + source scripts/create_mysql_user.sh fi } -setup_db() { +function setup_db() { setup_db_user_only bundle exec rake db:drop db:create db:schema:load db:migrate @@ -30,3 +30,129 @@ setup_db() { bundle exec rake add_limits_mysql fi } + +function install_api_client_dependencies_with_apk() { + apk add --update openssl curl jq +} + +function install_api_client_dependencies_with_apt() { + apt update && apt install jq -y +} + +function install_gitlab_gem() { + gem install gitlab --no-document +} + +function echoerr() { + local header="${2}" + + if [ -n "${header}" ]; then + printf "\n\033[0;31m** %s **\n\033[0m" "${1}" >&2; + else + printf "\033[0;31m%s\n\033[0m" "${1}" >&2; + fi +} + +function echoinfo() { + local header="${2}" + + if [ -n "${header}" ]; then + printf "\n\033[0;33m** %s **\n\033[0m" "${1}" >&2; + else + printf "\033[0;33m%s\n\033[0m" "${1}" >&2; + fi +} + +function get_job_id() { + local job_name="${1}" + local query_string="${2:+&${2}}" + local api_token="${API_TOKEN-${GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN}}" + if [ -z "${api_token}" ]; then + echoerr "Please provide an API token with \$API_TOKEN or \$GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN." + return + fi + + local max_page=3 + local page=1 + + while true; do + local url="https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/pipelines/${CI_PIPELINE_ID}/jobs?per_page=100&page=${page}${query_string}" + echoinfo "GET ${url}" + + local job_id + job_id=$(curl --silent --show-error --header "PRIVATE-TOKEN: ${api_token}" "${url}" | jq "map(select(.name == \"${job_name}\")) | map(.id) | last") + [[ "${job_id}" == "null" && "${page}" -lt "$max_page" ]] || break + + let "page++" + done + + if [[ "${job_id}" == "" ]]; then + echoerr "The '${job_name}' job ID couldn't be retrieved!" + else + echoinfo "The '${job_name}' job ID is ${job_id}" + echo "${job_id}" + fi +} + +function play_job() { + local job_name="${1}" + local job_id + job_id=$(get_job_id "${job_name}" "scope=manual"); + if [ -z "${job_id}" ]; then return; fi + + local api_token="${API_TOKEN-${GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN}}" + if [ -z "${api_token}" ]; then + echoerr "Please provide an API token with \$API_TOKEN or \$GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN." + return + fi + + local url="https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/jobs/${job_id}/play" + echoinfo "POST ${url}" + + local job_url + job_url=$(curl --silent --show-error --request POST --header "PRIVATE-TOKEN: ${api_token}" "${url}" | jq ".web_url") + echoinfo "Manual job '${job_name}' started at: ${job_url}" +} + +function wait_for_job_to_be_done() { + local job_name="${1}" + local query_string="${2}" + local job_id + job_id=$(get_job_id "${job_name}" "${query_string}") + if [ -z "${job_id}" ]; then return; fi + + local api_token="${API_TOKEN-${GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN}}" + if [ -z "${api_token}" ]; then + echoerr "Please provide an API token with \$API_TOKEN or \$GITLAB_BOT_MULTI_PROJECT_PIPELINE_POLLING_TOKEN." + return + fi + + echoinfo "Waiting for the '${job_name}' job to finish..." + + local url="https://gitlab.com/api/v4/projects/${CI_PROJECT_ID}/jobs/${job_id}" + echoinfo "GET ${url}" + + # In case the job hasn't finished yet. Keep trying until the job times out. + local interval=30 + local elapsed_seconds=0 + while true; do + local job_status + job_status=$(curl --silent --show-error --header "PRIVATE-TOKEN: ${api_token}" "${url}" | jq ".status" | sed -e s/\"//g) + [[ "${job_status}" == "pending" || "${job_status}" == "running" ]] || break + + printf "." + let "elapsed_seconds+=interval" + sleep ${interval} + done + + local elapsed_minutes=$((elapsed_seconds / 60)) + echoinfo "Waited '${job_name}' for ${elapsed_minutes} minutes." + + if [[ "${job_status}" == "failed" ]]; then + echoerr "The '${job_name}' failed." + elif [[ "${job_status}" == "manual" ]]; then + echoinfo "The '${job_name}' is manual." + else + echoinfo "The '${job_name}' passed." + fi +} diff --git a/spec/controllers/projects/settings/ci_cd_controller_spec.rb b/spec/controllers/projects/settings/ci_cd_controller_spec.rb index fc9a0adeed2..db53e5bc8a4 100644 --- a/spec/controllers/projects/settings/ci_cd_controller_spec.rb +++ b/spec/controllers/projects/settings/ci_cd_controller_spec.rb @@ -191,6 +191,15 @@ describe Projects::Settings::CiCdController do expect(project.build_timeout).to eq(5400) end end + + context 'when build_timeout_human_readable is invalid' do + let(:params) { { build_timeout_human_readable: '5m' } } + + it 'set specified timeout' do + expect(subject).to set_flash[:alert] + expect(response).to redirect_to(namespace_project_settings_ci_cd_path) + end + end end end end diff --git a/spec/controllers/projects/wikis_controller_spec.rb b/spec/controllers/projects/wikis_controller_spec.rb index e0a6fc52ee9..f2e0b5e5c1d 100644 --- a/spec/controllers/projects/wikis_controller_spec.rb +++ b/spec/controllers/projects/wikis_controller_spec.rb @@ -19,6 +19,18 @@ describe Projects::WikisController do destroy_page(wiki_title) end + describe 'GET #pages' do + subject { get :pages, params: { namespace_id: project.namespace, project_id: project, id: wiki_title } } + + it 'does not load the pages content' do + expect(controller).to receive(:load_wiki).and_return(project_wiki) + + expect(project_wiki).to receive(:list_pages).twice.and_call_original + + subject + end + end + describe 'GET #show' do render_views @@ -28,9 +40,9 @@ describe Projects::WikisController do expect(controller).to receive(:load_wiki).and_return(project_wiki) # empty? call - expect(project_wiki).to receive(:pages).with(limit: 1).and_call_original + expect(project_wiki).to receive(:list_pages).with(limit: 1).and_call_original # Sidebar entries - expect(project_wiki).to receive(:pages).with(limit: 15).and_call_original + expect(project_wiki).to receive(:list_pages).with(limit: 15).and_call_original subject @@ -104,7 +116,7 @@ describe Projects::WikisController do subject - expect(response).to redirect_to(project_wiki_path(project, project_wiki.pages.first)) + expect(response).to redirect_to(project_wiki_path(project, project_wiki.list_pages.first)) end end @@ -138,7 +150,7 @@ describe Projects::WikisController do allow(controller).to receive(:valid_encoding?).and_return(false) subject - expect(response).to redirect_to(project_wiki_path(project, project_wiki.pages.first)) + expect(response).to redirect_to(project_wiki_path(project, project_wiki.list_pages.first)) end end @@ -148,7 +160,7 @@ describe Projects::WikisController do it 'updates the page' do subject - wiki_page = project_wiki.pages.first + wiki_page = project_wiki.list_pages(load_content: true).first expect(wiki_page.title).to eq new_title expect(wiki_page.content).to eq new_content diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb index 9c60f0fcd4d..4634d1d4bb3 100644 --- a/spec/controllers/search_controller_spec.rb +++ b/spec/controllers/search_controller_spec.rb @@ -11,6 +11,30 @@ describe SearchController do sign_in(user) end + context 'uses the right partials depending on scope' do + using RSpec::Parameterized::TableSyntax + render_views + + set(:project) { create(:project, :public, :repository, :wiki_repo) } + + subject { get(:show, params: { project_id: project.id, scope: scope, search: 'merge' }) } + + where(:partial, :scope) do + '_blob' | :blobs + '_wiki_blob' | :wiki_blobs + '_commit' | :commits + end + + with_them do + it do + project_wiki = create(:project_wiki, project: project, user: user) + create(:wiki_page, wiki: project_wiki, attrs: { title: 'merge', content: 'merge' }) + + expect(subject).to render_template("search/results/#{partial}") + end + end + end + it 'finds issue comments' do project = create(:project, :public) note = create(:note_on_issue, project: project) diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb index b1c6f308bc6..29545779a34 100644 --- a/spec/features/admin/admin_users_spec.rb +++ b/spec/features/admin/admin_users_spec.rb @@ -34,14 +34,11 @@ describe "Admin::Users" do expect(page).to have_button('Delete user and contributions') end - describe "view extra user information", :js do - it 'does not have the user popover open' do + describe "view extra user information" do + it 'shows the user popover on hover', :js, :quarantine do expect(page).not_to have_selector('#__BV_popover_1__') - end - it 'shows the user popover on hover' do first_user_link = page.first('.js-user-link') - first_user_link.hover expect(page).to have_selector('#__BV_popover_1__') diff --git a/spec/features/issuables/markdown_references/jira_spec.rb b/spec/features/issuables/markdown_references/jira_spec.rb index 8eaccfc0949..cc04798248c 100644 --- a/spec/features/issuables/markdown_references/jira_spec.rb +++ b/spec/features/issuables/markdown_references/jira_spec.rb @@ -1,6 +1,6 @@ require "rails_helper" -describe "Jira", :js do +describe "Jira", :js, :quarantine do let(:user) { create(:user) } let(:actual_project) { create(:project, :public, :repository) } let(:merge_request) { create(:merge_request, target_project: actual_project, source_project: actual_project) } diff --git a/spec/features/protected_branches_spec.rb b/spec/features/protected_branches_spec.rb index 0aff916ec83..0dbff5a2701 100644 --- a/spec/features/protected_branches_spec.rb +++ b/spec/features/protected_branches_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe 'Protected Branches', :js do + include ProtectedBranchHelpers + let(:user) { create(:user) } let(:admin) { create(:admin) } let(:project) { create(:project, :repository) } @@ -150,27 +152,11 @@ describe 'Protected Branches', :js do end describe "access control" do - include_examples "protected branches > access control > CE" - end - end - - def set_protected_branch_name(branch_name) - find(".js-protected-branch-select").click - find(".dropdown-input-field").set(branch_name) - click_on("Create wildcard #{branch_name}") - end - - def set_defaults - find(".js-allowed-to-merge").click - within('.qa-allowed-to-merge-dropdown') do - expect(first("li")).to have_content("Roles") - find(:link, 'No one').click - end + before do + stub_licensed_features(protected_refs_for_users: false) + end - find(".js-allowed-to-push").click - within('.qa-allowed-to-push-dropdown') do - expect(first("li")).to have_content("Roles") - find(:link, 'No one').click + include_examples "protected branches > access control > CE" end end end diff --git a/spec/features/protected_tags_spec.rb b/spec/features/protected_tags_spec.rb index c8e92cd1c07..652542b1719 100644 --- a/spec/features/protected_tags_spec.rb +++ b/spec/features/protected_tags_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe 'Protected Tags', :js do + include ProtectedTagHelpers + let(:user) { create(:user, :admin) } let(:project) { create(:project, :repository) } @@ -8,13 +10,6 @@ describe 'Protected Tags', :js do sign_in(user) end - def set_protected_tag_name(tag_name) - find(".js-protected-tag-select").click - find(".dropdown-input-field").set(tag_name) - click_on("Create wildcard #{tag_name}") - find('.protected-tags-dropdown .dropdown-menu', visible: false) - end - describe "explicit protected tags" do it "allows creating explicit protected tags" do visit project_protected_tags_path(project) @@ -92,6 +87,10 @@ describe 'Protected Tags', :js do end describe "access control" do + before do + stub_licensed_features(protected_refs_for_users: false) + end + include_examples "protected tags > access control > CE" end end diff --git a/spec/frontend/clusters/clusters_bundle_spec.js b/spec/frontend/clusters/clusters_bundle_spec.js index 33a35069004..5103cb4f69f 100644 --- a/spec/frontend/clusters/clusters_bundle_spec.js +++ b/spec/frontend/clusters/clusters_bundle_spec.js @@ -1,16 +1,13 @@ import Clusters from '~/clusters/clusters_bundle'; -import { - REQUEST_SUBMITTED, - REQUEST_FAILURE, - APPLICATION_STATUS, - INGRESS_DOMAIN_SUFFIX, -} from '~/clusters/constants'; +import { APPLICATION_STATUS, INGRESS_DOMAIN_SUFFIX } from '~/clusters/constants'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import { loadHTMLFixture } from 'helpers/fixtures'; import { setTestTimeout } from 'helpers/timeout'; import $ from 'jquery'; +const { INSTALLING, INSTALLABLE, INSTALLED, NOT_INSTALLABLE } = APPLICATION_STATUS; + describe('Clusters', () => { setTestTimeout(1000); @@ -93,7 +90,7 @@ describe('Clusters', () => { it('does not show alert when things transition from initial null state to something', () => { cluster.checkForNewInstalls(INITIAL_APP_MAP, { ...INITIAL_APP_MAP, - helm: { status: APPLICATION_STATUS.INSTALLABLE, title: 'Helm Tiller' }, + helm: { status: INSTALLABLE, title: 'Helm Tiller' }, }); const flashMessage = document.querySelector('.js-cluster-application-notice .flash-text'); @@ -105,11 +102,11 @@ describe('Clusters', () => { cluster.checkForNewInstalls( { ...INITIAL_APP_MAP, - helm: { status: APPLICATION_STATUS.INSTALLING, title: 'Helm Tiller' }, + helm: { status: INSTALLING, title: 'Helm Tiller' }, }, { ...INITIAL_APP_MAP, - helm: { status: APPLICATION_STATUS.INSTALLED, title: 'Helm Tiller' }, + helm: { status: INSTALLED, title: 'Helm Tiller' }, }, ); @@ -125,13 +122,13 @@ describe('Clusters', () => { cluster.checkForNewInstalls( { ...INITIAL_APP_MAP, - helm: { status: APPLICATION_STATUS.INSTALLING, title: 'Helm Tiller' }, - ingress: { status: APPLICATION_STATUS.INSTALLABLE, title: 'Ingress' }, + helm: { status: INSTALLING, title: 'Helm Tiller' }, + ingress: { status: INSTALLABLE, title: 'Ingress' }, }, { ...INITIAL_APP_MAP, - helm: { status: APPLICATION_STATUS.INSTALLED, title: 'Helm Tiller' }, - ingress: { status: APPLICATION_STATUS.INSTALLED, title: 'Ingress' }, + helm: { status: INSTALLED, title: 'Helm Tiller' }, + ingress: { status: INSTALLED, title: 'Ingress' }, }, ); @@ -218,11 +215,11 @@ describe('Clusters', () => { it('tries to install helm', () => { jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce(); - expect(cluster.store.state.applications.helm.requestStatus).toEqual(null); + cluster.store.state.applications.helm.status = INSTALLABLE; cluster.installApplication({ id: 'helm' }); - expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_SUBMITTED); + expect(cluster.store.state.applications.helm.status).toEqual(INSTALLING); expect(cluster.store.state.applications.helm.requestReason).toEqual(null); expect(cluster.service.installApplication).toHaveBeenCalledWith('helm', undefined); }); @@ -230,11 +227,11 @@ describe('Clusters', () => { it('tries to install ingress', () => { jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce(); - expect(cluster.store.state.applications.ingress.requestStatus).toEqual(null); + cluster.store.state.applications.ingress.status = INSTALLABLE; cluster.installApplication({ id: 'ingress' }); - expect(cluster.store.state.applications.ingress.requestStatus).toEqual(REQUEST_SUBMITTED); + expect(cluster.store.state.applications.ingress.status).toEqual(INSTALLING); expect(cluster.store.state.applications.ingress.requestReason).toEqual(null); expect(cluster.service.installApplication).toHaveBeenCalledWith('ingress', undefined); }); @@ -242,11 +239,11 @@ describe('Clusters', () => { it('tries to install runner', () => { jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce(); - expect(cluster.store.state.applications.runner.requestStatus).toEqual(null); + cluster.store.state.applications.runner.status = INSTALLABLE; cluster.installApplication({ id: 'runner' }); - expect(cluster.store.state.applications.runner.requestStatus).toEqual(REQUEST_SUBMITTED); + expect(cluster.store.state.applications.runner.status).toEqual(INSTALLING); expect(cluster.store.state.applications.runner.requestReason).toEqual(null); expect(cluster.service.installApplication).toHaveBeenCalledWith('runner', undefined); }); @@ -254,13 +251,12 @@ describe('Clusters', () => { it('tries to install jupyter', () => { jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce(); - expect(cluster.store.state.applications.jupyter.requestStatus).toEqual(null); cluster.installApplication({ id: 'jupyter', params: { hostname: cluster.store.state.applications.jupyter.hostname }, }); - expect(cluster.store.state.applications.jupyter.requestStatus).toEqual(REQUEST_SUBMITTED); + cluster.store.state.applications.jupyter.status = INSTALLABLE; expect(cluster.store.state.applications.jupyter.requestReason).toEqual(null); expect(cluster.service.installApplication).toHaveBeenCalledWith('jupyter', { hostname: cluster.store.state.applications.jupyter.hostname, @@ -272,16 +268,18 @@ describe('Clusters', () => { .spyOn(cluster.service, 'installApplication') .mockRejectedValueOnce(new Error('STUBBED ERROR')); - expect(cluster.store.state.applications.helm.requestStatus).toEqual(null); + cluster.store.state.applications.helm.status = INSTALLABLE; const promise = cluster.installApplication({ id: 'helm' }); - expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_SUBMITTED); + expect(cluster.store.state.applications.helm.status).toEqual(INSTALLING); expect(cluster.store.state.applications.helm.requestReason).toEqual(null); expect(cluster.service.installApplication).toHaveBeenCalled(); return promise.then(() => { - expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_FAILURE); + expect(cluster.store.state.applications.helm.status).toEqual(INSTALLABLE); + expect(cluster.store.state.applications.helm.installFailed).toBe(true); + expect(cluster.store.state.applications.helm.requestReason).toBeDefined(); }); }); @@ -315,7 +313,6 @@ describe('Clusters', () => { }); describe('toggleIngressDomainHelpText', () => { - const { INSTALLED, INSTALLABLE, NOT_INSTALLABLE } = APPLICATION_STATUS; let ingressPreviousState; let ingressNewState; diff --git a/spec/frontend/clusters/components/application_row_spec.js b/spec/frontend/clusters/components/application_row_spec.js index 038d2be9e98..17273b7d5b1 100644 --- a/spec/frontend/clusters/components/application_row_spec.js +++ b/spec/frontend/clusters/components/application_row_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import eventHub from '~/clusters/event_hub'; -import { APPLICATION_STATUS, REQUEST_SUBMITTED, REQUEST_FAILURE } from '~/clusters/constants'; +import { APPLICATION_STATUS } from '~/clusters/constants'; import applicationRow from '~/clusters/components/application_row.vue'; import mountComponent from 'helpers/vue_mount_component_helper'; import { DEFAULT_APPLICATION_STATE } from '../services/mock_data'; @@ -80,17 +80,6 @@ describe('Application Row', () => { expect(vm.installButtonDisabled).toEqual(false); }); - it('has loading "Installing" when APPLICATION_STATUS.SCHEDULED', () => { - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_STATUS.SCHEDULED, - }); - - expect(vm.installButtonLabel).toEqual('Installing'); - expect(vm.installButtonLoading).toEqual(true); - expect(vm.installButtonDisabled).toEqual(true); - }); - it('has loading "Installing" when APPLICATION_STATUS.INSTALLING', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, @@ -102,18 +91,6 @@ describe('Application Row', () => { expect(vm.installButtonDisabled).toEqual(true); }); - it('has loading "Installing" when REQUEST_SUBMITTED', () => { - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_STATUS.INSTALLABLE, - requestStatus: REQUEST_SUBMITTED, - }); - - expect(vm.installButtonLabel).toEqual('Installing'); - expect(vm.installButtonLoading).toEqual(true); - expect(vm.installButtonDisabled).toEqual(true); - }); - it('has disabled "Installed" when application is installed and not uninstallable', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, @@ -139,10 +116,11 @@ describe('Application Row', () => { expect(installBtn).toBe(null); }); - it('has enabled "Install" when APPLICATION_STATUS.ERROR', () => { + it('has enabled "Install" when install fails', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_STATUS.ERROR, + status: APPLICATION_STATUS.INSTALLABLE, + installFailed: true, }); expect(vm.installButtonLabel).toEqual('Install'); @@ -154,7 +132,6 @@ describe('Application Row', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, status: APPLICATION_STATUS.INSTALLABLE, - requestStatus: REQUEST_FAILURE, }); expect(vm.installButtonLabel).toEqual('Install'); @@ -246,15 +223,15 @@ describe('Application Row', () => { expect(upgradeBtn.innerHTML).toContain('Upgrade'); }); - it('has enabled "Retry update" when APPLICATION_STATUS.UPDATE_ERRORED', () => { + it('has enabled "Retry update" when update process fails', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_STATUS.UPDATE_ERRORED, + status: APPLICATION_STATUS.INSTALLED, + updateFailed: true, }); const upgradeBtn = vm.$el.querySelector('.js-cluster-application-upgrade-button'); expect(upgradeBtn).not.toBe(null); - expect(vm.upgradeFailed).toBe(true); expect(upgradeBtn.innerHTML).toContain('Retry update'); }); @@ -274,7 +251,8 @@ describe('Application Row', () => { jest.spyOn(eventHub, '$emit'); vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_STATUS.UPDATE_ERRORED, + status: APPLICATION_STATUS.INSTALLED, + upgradeAvailable: true, }); const upgradeBtn = vm.$el.querySelector('.js-cluster-application-upgrade-button'); @@ -303,7 +281,8 @@ describe('Application Row', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, title: 'GitLab Runner', - status: APPLICATION_STATUS.UPDATE_ERRORED, + status: APPLICATION_STATUS.INSTALLED, + updateFailed: true, }); const failureMessage = vm.$el.querySelector( '.js-cluster-application-upgrade-failure-message', @@ -314,6 +293,21 @@ describe('Application Row', () => { 'Update failed. Please check the logs and try again.', ); }); + + it('displays a success toast message if application upgrade was successful', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + title: 'GitLab Runner', + updateSuccessful: false, + }); + + vm.$toast = { show: jest.fn() }; + vm.updateSuccessful = true; + + vm.$nextTick(() => { + expect(vm.$toast.show).toHaveBeenCalledWith('GitLab Runner upgraded successfully.'); + }); + }); }); describe('Version', () => { @@ -321,7 +315,8 @@ describe('Application Row', () => { const version = '0.1.45'; vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_STATUS.UPDATED, + status: APPLICATION_STATUS.INSTALLED, + updateSuccessful: true, version, }); const upgradeDetails = vm.$el.querySelector('.js-cluster-application-upgrade-details'); @@ -337,7 +332,8 @@ describe('Application Row', () => { const chartRepo = 'https://gitlab.com/charts/gitlab-runner'; vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_STATUS.UPDATED, + status: APPLICATION_STATUS.INSTALLED, + updateSuccessful: true, chartRepo, version, }); @@ -351,7 +347,8 @@ describe('Application Row', () => { const version = '0.1.45'; vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_STATUS.UPDATE_ERRORED, + status: APPLICATION_STATUS.INSTALLED, + updateFailed: true, version, }); const upgradeDetails = vm.$el.querySelector('.js-cluster-application-upgrade-details'); @@ -367,7 +364,6 @@ describe('Application Row', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, status: null, - requestStatus: null, }); const generalErrorMessage = vm.$el.querySelector( '.js-cluster-application-general-error-message', @@ -376,12 +372,13 @@ describe('Application Row', () => { expect(generalErrorMessage).toBeNull(); }); - it('shows status reason when APPLICATION_STATUS.ERROR', () => { + it('shows status reason when install fails', () => { const statusReason = 'We broke it 0.0'; vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, status: APPLICATION_STATUS.ERROR, statusReason, + installFailed: true, }); const generalErrorMessage = vm.$el.querySelector( '.js-cluster-application-general-error-message', @@ -402,7 +399,7 @@ describe('Application Row', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, status: APPLICATION_STATUS.INSTALLABLE, - requestStatus: REQUEST_FAILURE, + installFailed: true, requestReason, }); const generalErrorMessage = vm.$el.querySelector( diff --git a/spec/frontend/clusters/services/application_state_machine_spec.js b/spec/frontend/clusters/services/application_state_machine_spec.js new file mode 100644 index 00000000000..e74b7910572 --- /dev/null +++ b/spec/frontend/clusters/services/application_state_machine_spec.js @@ -0,0 +1,134 @@ +import transitionApplicationState from '~/clusters/services/application_state_machine'; +import { APPLICATION_STATUS, UPDATE_EVENT, INSTALL_EVENT } from '~/clusters/constants'; + +const { + NO_STATUS, + SCHEDULED, + NOT_INSTALLABLE, + INSTALLABLE, + INSTALLING, + INSTALLED, + ERROR, + UPDATING, + UPDATED, + UPDATE_ERRORED, +} = APPLICATION_STATUS; + +const NO_EFFECTS = 'no effects'; + +describe('applicationStateMachine', () => { + const noEffectsToEmptyObject = effects => (typeof effects === 'string' ? {} : effects); + + describe(`current state is ${NO_STATUS}`, () => { + it.each` + expectedState | event | effects + ${INSTALLING} | ${SCHEDULED} | ${NO_EFFECTS} + ${NOT_INSTALLABLE} | ${NOT_INSTALLABLE} | ${NO_EFFECTS} + ${INSTALLABLE} | ${INSTALLABLE} | ${NO_EFFECTS} + ${INSTALLING} | ${INSTALLING} | ${NO_EFFECTS} + ${INSTALLED} | ${INSTALLED} | ${NO_EFFECTS} + ${INSTALLABLE} | ${ERROR} | ${{ installFailed: true }} + ${UPDATING} | ${UPDATING} | ${NO_EFFECTS} + ${INSTALLED} | ${UPDATED} | ${NO_EFFECTS} + ${INSTALLED} | ${UPDATE_ERRORED} | ${{ updateFailed: true }} + `(`transitions to $expectedState on $event event and applies $effects`, data => { + const { expectedState, event, effects } = data; + const currentAppState = { + status: NO_STATUS, + }; + + expect(transitionApplicationState(currentAppState, event)).toEqual({ + status: expectedState, + ...noEffectsToEmptyObject(effects), + }); + }); + }); + + describe(`current state is ${NOT_INSTALLABLE}`, () => { + it.each` + expectedState | event | effects + ${INSTALLABLE} | ${INSTALLABLE} | ${NO_EFFECTS} + `(`transitions to $expectedState on $event event and applies $effects`, data => { + const { expectedState, event, effects } = data; + const currentAppState = { + status: NOT_INSTALLABLE, + }; + + expect(transitionApplicationState(currentAppState, event)).toEqual({ + status: expectedState, + ...noEffectsToEmptyObject(effects), + }); + }); + }); + + describe(`current state is ${INSTALLABLE}`, () => { + it.each` + expectedState | event | effects + ${INSTALLING} | ${INSTALL_EVENT} | ${{ installFailed: false }} + ${INSTALLED} | ${INSTALLED} | ${NO_EFFECTS} + `(`transitions to $expectedState on $event event and applies $effects`, data => { + const { expectedState, event, effects } = data; + const currentAppState = { + status: INSTALLABLE, + }; + + expect(transitionApplicationState(currentAppState, event)).toEqual({ + status: expectedState, + ...noEffectsToEmptyObject(effects), + }); + }); + }); + + describe(`current state is ${INSTALLING}`, () => { + it.each` + expectedState | event | effects + ${INSTALLED} | ${INSTALLED} | ${NO_EFFECTS} + ${INSTALLABLE} | ${ERROR} | ${{ installFailed: true }} + `(`transitions to $expectedState on $event event and applies $effects`, data => { + const { expectedState, event, effects } = data; + const currentAppState = { + status: INSTALLING, + }; + + expect(transitionApplicationState(currentAppState, event)).toEqual({ + status: expectedState, + ...noEffectsToEmptyObject(effects), + }); + }); + }); + + describe(`current state is ${INSTALLED}`, () => { + it.each` + expectedState | event | effects + ${UPDATING} | ${UPDATE_EVENT} | ${{ updateFailed: false, updateSuccessful: false }} + `(`transitions to $expectedState on $event event and applies $effects`, data => { + const { expectedState, event, effects } = data; + const currentAppState = { + status: INSTALLED, + }; + + expect(transitionApplicationState(currentAppState, event)).toEqual({ + status: expectedState, + ...effects, + }); + }); + }); + + describe(`current state is ${UPDATING}`, () => { + it.each` + expectedState | event | effects + ${INSTALLED} | ${UPDATED} | ${{ updateSuccessful: true, updateAcknowledged: false }} + ${INSTALLED} | ${UPDATE_ERRORED} | ${{ updateFailed: true }} + `(`transitions to $expectedState on $event event and applies $effects`, data => { + const { expectedState, event, effects } = data; + const currentAppState = { + status: UPDATING, + }; + + expect(transitionApplicationState(currentAppState, event)).toEqual({ + status: expectedState, + ...effects, + }); + }); + }); +}); diff --git a/spec/frontend/clusters/services/mock_data.js b/spec/frontend/clusters/services/mock_data.js index b4d1bb710e0..1e896af1c7d 100644 --- a/spec/frontend/clusters/services/mock_data.js +++ b/spec/frontend/clusters/services/mock_data.js @@ -113,7 +113,6 @@ const DEFAULT_APPLICATION_STATE = { description: 'Some description about this interesting application!', status: null, statusReason: null, - requestStatus: null, requestReason: null, }; diff --git a/spec/frontend/clusters/stores/clusters_store_spec.js b/spec/frontend/clusters/stores/clusters_store_spec.js index c0e8b737ea2..a20e0439555 100644 --- a/spec/frontend/clusters/stores/clusters_store_spec.js +++ b/spec/frontend/clusters/stores/clusters_store_spec.js @@ -32,15 +32,6 @@ describe('Clusters Store', () => { }); describe('updateAppProperty', () => { - it('should store new request status', () => { - expect(store.state.applications.helm.requestStatus).toEqual(null); - - const newStatus = APPLICATION_STATUS.INSTALLING; - store.updateAppProperty('helm', 'requestStatus', newStatus); - - expect(store.state.applications.helm.requestStatus).toEqual(newStatus); - }); - it('should store new request reason', () => { expect(store.state.applications.helm.requestReason).toEqual(null); @@ -68,80 +59,90 @@ describe('Clusters Store', () => { title: 'Helm Tiller', status: mockResponseData.applications[0].status, statusReason: mockResponseData.applications[0].status_reason, - requestStatus: null, requestReason: null, installed: false, + installFailed: false, + uninstallable: false, }, ingress: { title: 'Ingress', - status: mockResponseData.applications[1].status, + status: APPLICATION_STATUS.INSTALLABLE, statusReason: mockResponseData.applications[1].status_reason, - requestStatus: null, requestReason: null, externalIp: null, externalHostname: null, installed: false, + installFailed: true, + uninstallable: false, }, runner: { title: 'GitLab Runner', status: mockResponseData.applications[2].status, statusReason: mockResponseData.applications[2].status_reason, - requestStatus: null, requestReason: null, version: mockResponseData.applications[2].version, upgradeAvailable: mockResponseData.applications[2].update_available, chartRepo: 'https://gitlab.com/charts/gitlab-runner', installed: false, + installFailed: false, + updateAcknowledged: true, + updateFailed: false, + updateSuccessful: false, + uninstallable: false, }, prometheus: { title: 'Prometheus', - status: mockResponseData.applications[3].status, + status: APPLICATION_STATUS.INSTALLABLE, statusReason: mockResponseData.applications[3].status_reason, - requestStatus: null, requestReason: null, installed: false, + installFailed: true, + uninstallable: false, }, jupyter: { title: 'JupyterHub', status: mockResponseData.applications[4].status, statusReason: mockResponseData.applications[4].status_reason, - requestStatus: null, requestReason: null, hostname: '', installed: false, + installFailed: false, + uninstallable: false, }, knative: { title: 'Knative', status: mockResponseData.applications[5].status, statusReason: mockResponseData.applications[5].status_reason, - requestStatus: null, requestReason: null, hostname: null, isEditingHostName: false, externalIp: null, externalHostname: null, installed: false, + installFailed: false, + uninstallable: false, }, cert_manager: { title: 'Cert-Manager', - status: mockResponseData.applications[6].status, + status: APPLICATION_STATUS.INSTALLABLE, + installFailed: true, statusReason: mockResponseData.applications[6].status_reason, - requestStatus: null, requestReason: null, email: mockResponseData.applications[6].email, installed: false, + uninstallable: false, }, }, }); }); - describe.each(APPLICATION_INSTALLED_STATUSES)('given the current app status is %s', () => { + describe.each(APPLICATION_INSTALLED_STATUSES)('given the current app status is %s', status => { it('marks application as installed', () => { const mockResponseData = CLUSTERS_MOCK_DATA.GET['/gitlab-org/gitlab-shell/clusters/2/status.json'].data; const runnerAppIndex = 2; - mockResponseData.applications[runnerAppIndex].status = APPLICATION_STATUS.INSTALLED; + mockResponseData.applications[runnerAppIndex].status = status; store.updateStateFromServer(mockResponseData); diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index fba7c41df94..3886853f3c1 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -209,6 +209,38 @@ describe('GfmAutoComplete', () => { }); }); + describe('DefaultOptions.highlighter', () => { + beforeEach(() => { + atwhoInstance = { setting: {} }; + }); + + it('should return li if no query is given', () => { + const liTag = '<li></li>'; + + const highlightedTag = gfmAutoCompleteCallbacks.highlighter.call(atwhoInstance, liTag); + + expect(highlightedTag).toEqual(liTag); + }); + + it('should highlight search query in li element', () => { + const liTag = '<li><img src="" />string</li>'; + const query = 's'; + + const highlightedTag = gfmAutoCompleteCallbacks.highlighter.call(atwhoInstance, liTag, query); + + expect(highlightedTag).toEqual('<li><img src="" /> <strong>s</strong>tring </li>'); + }); + + it('should highlight search query with special char in li element', () => { + const liTag = '<li><img src="" />te.st</li>'; + const query = '.'; + + const highlightedTag = gfmAutoCompleteCallbacks.highlighter.call(atwhoInstance, liTag, query); + + expect(highlightedTag).toEqual('<li><img src="" /> te<strong>.</strong>st </li>'); + }); + }); + describe('isLoading', () => { it('should be true with loading data object item', () => { expect(GfmAutoComplete.isLoading({ name: 'loading' })).toBe(true); diff --git a/spec/frontend/import_projects/components/import_projects_table_spec.js b/spec/frontend/import_projects/components/import_projects_table_spec.js new file mode 100644 index 00000000000..17a998d0174 --- /dev/null +++ b/spec/frontend/import_projects/components/import_projects_table_spec.js @@ -0,0 +1,185 @@ +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'; + +describe('ImportProjectsTable', () => { + let vm; + const providerTitle = 'THE PROVIDER'; + const providerRepo = { id: 10, sanitizedName: 'sanitizedName', fullName: 'fullName' }; + const importedProject = { + id: 1, + fullPath: 'fullPath', + importStatus: 'started', + providerLink: 'providerLink', + importSource: 'importSource', + }; + + function initStore() { + const stubbedActions = Object.assign({}, 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; + } + + function mountComponent() { + const localVue = createLocalVue(); + localVue.use(Vuex); + + const store = initStore(); + + const component = mount(importProjectsTable, { + localVue, + store, + propsData: { + providerTitle, + }, + sync: false, + }); + + return component.vm; + } + + beforeEach(() => { + vm = mountComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders a loading icon whilst 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' }], + }); + + 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(); + }); + }); + + it('renders an empty state if there are no imported projects or provider repos', () => { + vm.$store.dispatch('receiveReposSuccess', { + importedProjects: [], + providerRepos: [], + namespaces: [], + }); + + 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 available to import`); + }); + }); + + 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('imports provider repos if bulk import button is clicked', () => { + mountComponent(); + + 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.$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(); + }); + }); + + it('polls to update the status of imported projects', () => { + const updatedProjects = [ + { + id: importedProject.id, + importStatus: 'finished', + }, + ]; + + 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]; + + 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(); + }); + }); +}); diff --git a/spec/javascripts/import_projects/components/imported_project_table_row_spec.js b/spec/frontend/import_projects/components/imported_project_table_row_spec.js index 7dac7e9ccc1..f95acc1edd7 100644 --- a/spec/javascripts/import_projects/components/imported_project_table_row_spec.js +++ b/spec/frontend/import_projects/components/imported_project_table_row_spec.js @@ -1,5 +1,6 @@ -import Vue from 'vue'; +import Vuex from 'vuex'; import createStore from '~/import_projects/store'; +import { createLocalVue, mount } from '@vue/test-utils'; import importedProjectTableRow from '~/import_projects/components/imported_project_table_row.vue'; import STATUS_MAP from '~/import_projects/constants'; @@ -13,27 +14,33 @@ describe('ImportedProjectTableRow', () => { importSource: 'importSource', }; - function createComponent() { - const ImportedProjectTableRow = Vue.extend(importedProjectTableRow); + function mountComponent() { + const localVue = createLocalVue(); + localVue.use(Vuex); - const store = createStore(); - return new ImportedProjectTableRow({ - store, + const component = mount(importedProjectTableRow, { + localVue, + store: createStore(), propsData: { project: { ...project, }, }, - }).$mount(); + sync: false, + }); + + return component.vm; } + beforeEach(() => { + vm = mountComponent(); + }); + afterEach(() => { vm.$destroy(); }); it('renders an imported project table row', () => { - vm = createComponent(); - const providerLink = vm.$el.querySelector('.js-provider-link'); const statusObject = STATUS_MAP[project.importStatus]; diff --git a/spec/javascripts/import_projects/components/provider_repo_table_row_spec.js b/spec/frontend/import_projects/components/provider_repo_table_row_spec.js index 4d2bacd2ad0..02c786d8d0b 100644 --- a/spec/javascripts/import_projects/components/provider_repo_table_row_spec.js +++ b/spec/frontend/import_projects/components/provider_repo_table_row_spec.js @@ -1,14 +1,15 @@ -import Vue from 'vue'; -import MockAdapter from 'axios-mock-adapter'; -import axios from '~/lib/utils/axios_utils'; -import createStore from '~/import_projects/store'; +import Vuex from 'vuex'; +import { createLocalVue, mount } from '@vue/test-utils'; +import { state, actions, getters, mutations } from '~/import_projects/store'; import providerRepoTableRow from '~/import_projects/components/provider_repo_table_row.vue'; import STATUS_MAP, { STATUSES } from '~/import_projects/constants'; -import setTimeoutPromise from '../../helpers/set_timeout_promise_helper'; describe('ProviderRepoTableRow', () => { - let store; let vm; + const fetchImport = jest.fn((context, data) => actions.requestImport(context, data)); + const importPath = '/import-path'; + const defaultTargetNamespace = 'user'; + const ciCdOnly = true; const repo = { id: 10, sanitizedName: 'sanitizedName', @@ -16,21 +17,42 @@ describe('ProviderRepoTableRow', () => { providerLink: 'providerLink', }; - function createComponent() { - const ProviderRepoTableRow = Vue.extend(providerRepoTableRow); + function initStore() { + const stubbedActions = Object.assign({}, actions, { + fetchImport, + }); - return new ProviderRepoTableRow({ + const store = new Vuex.Store({ + state: state(), + actions: stubbedActions, + mutations, + getters, + }); + + return store; + } + + function mountComponent() { + const localVue = createLocalVue(); + localVue.use(Vuex); + + const store = initStore(); + store.dispatch('setInitialData', { importPath, defaultTargetNamespace, ciCdOnly }); + + const component = mount(providerRepoTableRow, { + localVue, store, propsData: { - repo: { - ...repo, - }, + repo, }, - }).$mount(); + sync: false, + }); + + return component.vm; } beforeEach(() => { - store = createStore(); + vm = mountComponent(); }); afterEach(() => { @@ -38,8 +60,6 @@ describe('ProviderRepoTableRow', () => { }); it('renders a provider repo table row', () => { - vm = createComponent(); - const providerLink = vm.$el.querySelector('.js-provider-link'); const statusObject = STATUS_MAP[STATUSES.NONE]; @@ -55,8 +75,6 @@ describe('ProviderRepoTableRow', () => { }); it('renders a select2 namespace select', () => { - vm = createComponent(); - const dropdownTrigger = vm.$el.querySelector('.js-namespace-select'); expect(dropdownTrigger).not.toBeNull(); @@ -67,30 +85,20 @@ describe('ProviderRepoTableRow', () => { expect(vm.$el.querySelector('.select2-drop')).not.toBeNull(); }); - it('imports repo when clicking import button', done => { - const importPath = '/import-path'; - const defaultTargetNamespace = 'user'; - const ciCdOnly = true; - const mock = new MockAdapter(axios); - - store.dispatch('setInitialData', { importPath, defaultTargetNamespace, ciCdOnly }); - mock.onPost(importPath).replyOnce(200); - spyOn(store, 'dispatch').and.returnValue(new Promise(() => {})); - - vm = createComponent(); - + it('imports repo when clicking import button', () => { vm.$el.querySelector('.js-import-button').click(); - setTimeoutPromise() - .then(() => { - expect(store.dispatch).toHaveBeenCalledWith('fetchImport', { - repo, - newName: repo.sanitizedName, - targetNamespace: defaultTargetNamespace, - }); - }) - .then(() => mock.restore()) - .then(done) - .catch(done.fail); + return vm.$nextTick().then(() => { + const { calls } = fetchImport.mock; + + // Not using .toBeCalledWith because it expects + // an unmatchable and undefined 3rd argument. + expect(calls.length).toBe(1); + expect(calls[0][1]).toEqual({ + repo, + newName: repo.sanitizedName, + targetNamespace: defaultTargetNamespace, + }); + }); }); }); diff --git a/spec/javascripts/import_projects/store/actions_spec.js b/spec/frontend/import_projects/store/actions_spec.js index 77850ee3283..6a7b90788dd 100644 --- a/spec/javascripts/import_projects/store/actions_spec.js +++ b/spec/frontend/import_projects/store/actions_spec.js @@ -27,8 +27,8 @@ import { stopJobsPolling, } from '~/import_projects/store/actions'; import state from '~/import_projects/store/state'; -import testAction from 'spec/helpers/vuex_action_helper'; -import { TEST_HOST } from 'spec/test_constants'; +import testAction from 'helpers/vuex_action_helper'; +import { TEST_HOST } from 'helpers/test_constants'; describe('import_projects store actions', () => { let localState; diff --git a/spec/javascripts/vue_shared/components/issue/issue_assignees_spec.js b/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js index 9eac75fac96..d1de98f4a15 100644 --- a/spec/javascripts/vue_shared/components/issue/issue_assignees_spec.js +++ b/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js @@ -2,8 +2,8 @@ import Vue from 'vue'; import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import { mockAssigneesList } from 'spec/boards/mock_data'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import { mockAssigneesList } from '../../../../javascripts/boards/mock_data'; const createComponent = (assignees = mockAssigneesList, cssClass = '') => { const Component = Vue.extend(IssueAssignees); diff --git a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js b/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js new file mode 100644 index 00000000000..2e93ec412b9 --- /dev/null +++ b/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js @@ -0,0 +1,172 @@ +import Vue from 'vue'; +import { mount } from '@vue/test-utils'; + +import IssueMilestone from '~/vue_shared/components/issue/issue_milestone.vue'; + +import { mockMilestone } from '../../../../javascripts/boards/mock_data'; + +const createComponent = (milestone = mockMilestone) => { + const Component = Vue.extend(IssueMilestone); + + return mount(Component, { + propsData: { + milestone, + }, + sync: false, + }); +}; + +describe('IssueMilestoneComponent', () => { + let wrapper; + let vm; + + beforeEach(done => { + wrapper = createComponent(); + + ({ vm } = wrapper); + + Vue.nextTick(done); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('computed', () => { + describe('isMilestoneStarted', () => { + it('should return `false` when milestoneStart prop is not defined', () => { + wrapper.setProps({ + milestone: Object.assign({}, mockMilestone, { + start_date: '', + }), + }); + + expect(wrapper.vm.isMilestoneStarted).toBe(false); + }); + + it('should return `true` when milestone start date is past current date', () => { + wrapper.setProps({ + milestone: Object.assign({}, mockMilestone, { + start_date: '1990-07-22', + }), + }); + + expect(wrapper.vm.isMilestoneStarted).toBe(true); + }); + }); + + describe('isMilestonePastDue', () => { + it('should return `false` when milestoneDue prop is not defined', () => { + wrapper.setProps({ + milestone: Object.assign({}, mockMilestone, { + due_date: '', + }), + }); + + expect(wrapper.vm.isMilestonePastDue).toBe(false); + }); + + it('should return `true` when milestone due is past current date', () => { + wrapper.setProps({ + milestone: Object.assign({}, mockMilestone, { + due_date: '1990-07-22', + }), + }); + + expect(wrapper.vm.isMilestonePastDue).toBe(true); + }); + }); + + describe('milestoneDatesAbsolute', () => { + it('returns string containing absolute milestone due date', () => { + expect(vm.milestoneDatesAbsolute).toBe('(December 31, 2019)'); + }); + + it('returns string containing absolute milestone start date when due date is not present', () => { + wrapper.setProps({ + milestone: Object.assign({}, mockMilestone, { + due_date: '', + }), + }); + + expect(wrapper.vm.milestoneDatesAbsolute).toBe('(January 1, 2018)'); + }); + + it('returns empty string when both milestone start and due dates are not present', () => { + wrapper.setProps({ + milestone: Object.assign({}, mockMilestone, { + start_date: '', + due_date: '', + }), + }); + + expect(wrapper.vm.milestoneDatesAbsolute).toBe(''); + }); + }); + + describe('milestoneDatesHuman', () => { + it('returns string containing milestone due date when date is yet to be due', () => { + wrapper.setProps({ + milestone: Object.assign({}, mockMilestone, { + due_date: `${new Date().getFullYear() + 10}-01-01`, + }), + }); + + expect(wrapper.vm.milestoneDatesHuman).toContain('years remaining'); + }); + + it('returns string containing milestone start date when date has already started and due date is not present', () => { + wrapper.setProps({ + milestone: Object.assign({}, mockMilestone, { + start_date: '1990-07-22', + due_date: '', + }), + }); + + expect(wrapper.vm.milestoneDatesHuman).toContain('Started'); + }); + + it('returns string containing milestone start date when date is yet to start and due date is not present', () => { + wrapper.setProps({ + milestone: Object.assign({}, mockMilestone, { + start_date: `${new Date().getFullYear() + 10}-01-01`, + due_date: '', + }), + }); + + expect(wrapper.vm.milestoneDatesHuman).toContain('Starts'); + }); + + it('returns empty string when milestone start and due dates are not present', () => { + wrapper.setProps({ + milestone: Object.assign({}, mockMilestone, { + start_date: '', + due_date: '', + }), + }); + + expect(wrapper.vm.milestoneDatesHuman).toBe(''); + }); + }); + }); + + describe('template', () => { + it('renders component root element with class `issue-milestone-details`', () => { + expect(vm.$el.classList.contains('issue-milestone-details')).toBe(true); + }); + + it('renders milestone icon', () => { + expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('clock'); + }); + + it('renders milestone title', () => { + expect(vm.$el.querySelector('.milestone-title').innerText.trim()).toBe(mockMilestone.title); + }); + + it('renders milestone tooltip', () => { + expect(vm.$el.querySelector('.js-item-milestone').innerText.trim()).toContain( + mockMilestone.title, + ); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/issue/issue_warning_spec.js b/spec/frontend/vue_shared/components/issue/issue_warning_spec.js index aa7d6ea2e34..4a8de5fc4f1 100644 --- a/spec/javascripts/vue_shared/components/issue/issue_warning_spec.js +++ b/spec/frontend/vue_shared/components/issue/issue_warning_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import issueWarning from '~/vue_shared/components/issue/issue_warning.vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import mountComponent from 'helpers/vue_mount_component_helper'; const IssueWarning = Vue.extend(issueWarning); @@ -19,7 +19,9 @@ describe('Issue Warning Component', () => { isLocked: true, }); - expect(vm.$el.querySelector('.icon use').href.baseVal).toMatch(/lock$/); + expect( + vm.$el.querySelector('.icon use').getAttributeNS('http://www.w3.org/1999/xlink', 'href'), + ).toMatch(/lock$/); expect(formatWarning(vm.$el.querySelector('span').textContent)).toEqual( 'This issue is locked. Only project members can comment.', ); @@ -32,7 +34,9 @@ describe('Issue Warning Component', () => { isConfidential: true, }); - expect(vm.$el.querySelector('.icon use').href.baseVal).toMatch(/eye-slash$/); + expect( + vm.$el.querySelector('.icon use').getAttributeNS('http://www.w3.org/1999/xlink', 'href'), + ).toMatch(/eye-slash$/); expect(formatWarning(vm.$el.querySelector('span').textContent)).toEqual( 'This is a confidential issue. Your comment will not be visible to the public.', ); diff --git a/spec/javascripts/vue_shared/components/issue/related_issuable_item_spec.js b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js index 42198e92eea..e43d5301a50 100644 --- a/spec/javascripts/vue_shared/components/issue/related_issuable_item_spec.js +++ b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js @@ -1,7 +1,11 @@ import Vue from 'vue'; +import { formatDate } from '~/lib/utils/datetime_utility'; import { mount, createLocalVue } from '@vue/test-utils'; import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue'; -import { defaultMilestone, defaultAssignees } from './related_issuable_mock_data'; +import { + defaultAssignees, + defaultMilestone, +} from '../../../../javascripts/vue_shared/components/issue/related_issuable_mock_data'; describe('RelatedIssuableItem', () => { let wrapper; @@ -85,11 +89,11 @@ describe('RelatedIssuableItem', () => { it('renders state title', () => { const stateTitle = tokenState.attributes('data-original-title'); + const formatedCreateDate = formatDate(props.createdAt); expect(stateTitle).toContain('<span class="bold">Opened</span>'); - expect(stateTitle).toContain( - '<span class="text-tertiary">Dec 1, 2018 12:00am GMT+0000</span>', - ); + + expect(stateTitle).toContain(`<span class="text-tertiary">${formatedCreateDate}</span>`); }); it('renders aria label', () => { diff --git a/spec/javascripts/vue_shared/components/notes/placeholder_note_spec.js b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js index 45f131194ca..eafff7f681e 100644 --- a/spec/javascripts/vue_shared/components/notes/placeholder_note_spec.js +++ b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import issuePlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue'; import createStore from '~/notes/stores'; -import { userDataMock } from '../../../notes/mock_data'; +import { userDataMock } from '../../../../javascripts/notes/mock_data'; describe('issue placeholder system note component', () => { let store; diff --git a/spec/javascripts/vue_shared/components/notes/placeholder_system_note_spec.js b/spec/frontend/vue_shared/components/notes/placeholder_system_note_spec.js index 6013e85811a..976e38c15ee 100644 --- a/spec/javascripts/vue_shared/components/notes/placeholder_system_note_spec.js +++ b/spec/frontend/vue_shared/components/notes/placeholder_system_note_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import placeholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import mountComponent from 'helpers/vue_mount_component_helper'; describe('placeholder system note component', () => { let PlaceholderSystemNote; diff --git a/spec/javascripts/vue_shared/components/notes/system_note_spec.js b/spec/frontend/vue_shared/components/notes/system_note_spec.js index adcb1c858aa..5b4ca20940a 100644 --- a/spec/javascripts/vue_shared/components/notes/system_note_spec.js +++ b/spec/frontend/vue_shared/components/notes/system_note_spec.js @@ -5,8 +5,10 @@ import createStore from '~/notes/stores'; describe('system note component', () => { let vm; let props; + let initMRPopoversSpy; beforeEach(() => { + initMRPopoversSpy = spyOnDependency(issueSystemNote, 'initMRPopovers'); props = { note: { id: '1424', @@ -56,4 +58,8 @@ describe('system note component', () => { it('removes wrapping paragraph from note HTML', () => { expect(vm.$el.querySelector('.system-note-message').innerHTML).toEqual('<span>closed</span>'); }); + + it('should initMRPopovers onMount', () => { + expect(initMRPopoversSpy).toHaveBeenCalled(); + }); }); diff --git a/spec/javascripts/api_spec.js b/spec/javascripts/api_spec.js index e537e0e8afc..494b3b934a8 100644 --- a/spec/javascripts/api_spec.js +++ b/spec/javascripts/api_spec.js @@ -4,7 +4,7 @@ import Api from '~/api'; describe('Api', () => { const dummyApiVersion = 'v3000'; - const dummyUrlRoot = 'http://host.invalid'; + const dummyUrlRoot = '/gitlab'; const dummyGon = { api_version: dummyApiVersion, relative_url_root: dummyUrlRoot, @@ -32,6 +32,18 @@ describe('Api', () => { expect(builtUrl).toEqual(expectedOutput); }); + + [null, '', '/'].forEach(root => { + it(`works when relative_url_root is ${root}`, () => { + window.gon.relative_url_root = root; + const input = '/api/:version/foo/bar'; + const expectedOutput = `/api/${dummyApiVersion}/foo/bar`; + + const builtUrl = Api.buildUrl(input); + + expect(builtUrl).toEqual(expectedOutput); + }); + }); }); describe('group', () => { diff --git a/spec/javascripts/import_projects/components/import_projects_table_spec.js b/spec/javascripts/import_projects/components/import_projects_table_spec.js deleted file mode 100644 index ab8642bf0dd..00000000000 --- a/spec/javascripts/import_projects/components/import_projects_table_spec.js +++ /dev/null @@ -1,188 +0,0 @@ -import Vue from 'vue'; -import MockAdapter from 'axios-mock-adapter'; -import axios from '~/lib/utils/axios_utils'; -import createStore from '~/import_projects/store'; -import importProjectsTable from '~/import_projects/components/import_projects_table.vue'; -import STATUS_MAP from '~/import_projects/constants'; -import setTimeoutPromise from '../../helpers/set_timeout_promise_helper'; - -describe('ImportProjectsTable', () => { - let vm; - let mock; - let store; - const reposPath = '/repos-path'; - const jobsPath = '/jobs-path'; - const providerTitle = 'THE PROVIDER'; - const providerRepo = { id: 10, sanitizedName: 'sanitizedName', fullName: 'fullName' }; - const importedProject = { - id: 1, - fullPath: 'fullPath', - importStatus: 'started', - providerLink: 'providerLink', - importSource: 'importSource', - }; - - function createComponent() { - const ImportProjectsTable = Vue.extend(importProjectsTable); - - const component = new ImportProjectsTable({ - store, - propsData: { - providerTitle, - }, - }).$mount(); - - store.dispatch('stopJobsPolling'); - - return component; - } - - beforeEach(() => { - store = createStore(); - store.dispatch('setInitialData', { reposPath }); - mock = new MockAdapter(axios); - }); - - afterEach(() => { - vm.$destroy(); - mock.restore(); - }); - - it('renders a loading icon whilst repos are loading', done => { - mock.restore(); // Stop the mock adapter from responding to the request, keeping the spinner up - - vm = createComponent(); - - setTimeoutPromise() - .then(() => { - expect(vm.$el.querySelector('.js-loading-button-icon')).not.toBeNull(); - }) - .then(() => done()) - .catch(() => done.fail()); - }); - - it('renders a table with imported projects and provider repos', done => { - const response = { - importedProjects: [importedProject], - providerRepos: [providerRepo], - namespaces: [{ path: 'path' }], - }; - mock.onGet(reposPath).reply(200, response); - - vm = createComponent(); - - setTimeoutPromise() - .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(); - }) - .then(() => done()) - .catch(() => done.fail()); - }); - - it('renders an empty state if there are no imported projects or provider repos', done => { - const response = { - importedProjects: [], - providerRepos: [], - namespaces: [], - }; - mock.onGet(reposPath).reply(200, response); - - vm = createComponent(); - - setTimeoutPromise() - .then(() => { - expect(vm.$el.querySelector('.js-loading-button-icon')).toBeNull(); - expect(vm.$el.querySelector('.table')).toBeNull(); - expect(vm.$el.innerText).toMatch(`No ${providerTitle} repositories available to import`); - }) - .then(() => done()) - .catch(() => done.fail()); - }); - - it('imports provider repos if bulk import button is clicked', done => { - const importPath = '/import-path'; - const response = { - importedProjects: [], - providerRepos: [providerRepo], - namespaces: [{ path: 'path' }], - }; - - mock.onGet(reposPath).replyOnce(200, response); - mock.onPost(importPath).replyOnce(200, importedProject); - - store.dispatch('setInitialData', { importPath }); - - vm = createComponent(); - - setTimeoutPromise() - .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(() => setTimeoutPromise()) - .then(() => { - expect(vm.$el.querySelector('.js-imported-project')).not.toBeNull(); - expect(vm.$el.querySelector('.js-provider-repo')).toBeNull(); - }) - .then(() => done()) - .catch(() => done.fail()); - }); - - it('polls to update the status of imported projects', done => { - const importPath = '/import-path'; - const response = { - importedProjects: [importedProject], - providerRepos: [], - namespaces: [{ path: 'path' }], - }; - const updatedProjects = [ - { - id: importedProject.id, - importStatus: 'finished', - }, - ]; - - mock.onGet(reposPath).replyOnce(200, response); - - store.dispatch('setInitialData', { importPath, jobsPath }); - - vm = createComponent(); - - setTimeoutPromise() - .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(); - - mock.onGet(jobsPath).replyOnce(200, updatedProjects); - return store.dispatch('restartJobsPolling'); - }) - .then(() => setTimeoutPromise()) - .then(() => { - const statusObject = STATUS_MAP[updatedProjects[0].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(); - }) - .then(() => done()) - .catch(() => done.fail()); - }); -}); diff --git a/spec/javascripts/monitoring/dashboard_spec.js b/spec/javascripts/monitoring/dashboard_spec.js index ce2c6c43c0f..16dc0084a10 100644 --- a/spec/javascripts/monitoring/dashboard_spec.js +++ b/spec/javascripts/monitoring/dashboard_spec.js @@ -175,7 +175,7 @@ describe('Dashboard', () => { setTimeout(() => { const dropdownItems = component.$el.querySelectorAll( - '.js-environments-dropdown .dropdown-item[active="true"]', + '.js-environments-dropdown .dropdown-item.is-active', ); expect(dropdownItems.length).toEqual(1); diff --git a/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js index 690fcd3e224..a0628fdcebe 100644 --- a/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js @@ -228,6 +228,7 @@ describe('mrWidgetOptions', () => { describe('showTargetBranchAdvancedError', () => { describe(`when the pipeline's target_sha property doesn't exist`, () => { beforeEach(done => { + Vue.set(vm.mr, 'isOpen', true); Vue.set(vm.mr.pipeline, 'target_sha', undefined); Vue.set(vm.mr, 'targetBranchSha', 'abcd'); vm.$nextTick(done); @@ -240,6 +241,7 @@ describe('mrWidgetOptions', () => { describe(`when the pipeline's target_sha matches the target branch's sha`, () => { beforeEach(done => { + Vue.set(vm.mr, 'isOpen', true); Vue.set(vm.mr.pipeline, 'target_sha', 'abcd'); Vue.set(vm.mr, 'targetBranchSha', 'abcd'); vm.$nextTick(done); @@ -250,8 +252,22 @@ describe('mrWidgetOptions', () => { }); }); + describe(`when the merge request is not open`, () => { + beforeEach(done => { + Vue.set(vm.mr, 'isOpen', false); + Vue.set(vm.mr.pipeline, 'target_sha', 'abcd'); + Vue.set(vm.mr, 'targetBranchSha', 'bcde'); + vm.$nextTick(done); + }); + + it('should be false', () => { + expect(vm.showTargetBranchAdvancedError).toEqual(false); + }); + }); + describe(`when the pipeline's target_sha does not match the target branch's sha`, () => { beforeEach(done => { + Vue.set(vm.mr, 'isOpen', true); Vue.set(vm.mr.pipeline, 'target_sha', 'abcd'); Vue.set(vm.mr, 'targetBranchSha', 'bcde'); vm.$nextTick(done); diff --git a/spec/javascripts/vue_shared/components/issue/issue_milestone_spec.js b/spec/javascripts/vue_shared/components/issue/issue_milestone_spec.js deleted file mode 100644 index 8fca2637326..00000000000 --- a/spec/javascripts/vue_shared/components/issue/issue_milestone_spec.js +++ /dev/null @@ -1,234 +0,0 @@ -import Vue from 'vue'; - -import IssueMilestone from '~/vue_shared/components/issue/issue_milestone.vue'; - -import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import { mockMilestone } from 'spec/boards/mock_data'; - -const createComponent = (milestone = mockMilestone) => { - const Component = Vue.extend(IssueMilestone); - - return mountComponent(Component, { - milestone, - }); -}; - -describe('IssueMilestoneComponent', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('computed', () => { - describe('isMilestoneStarted', () => { - it('should return `false` when milestoneStart prop is not defined', done => { - const vmStartUndefined = createComponent( - Object.assign({}, mockMilestone, { - start_date: '', - }), - ); - - Vue.nextTick() - .then(() => { - expect(vmStartUndefined.isMilestoneStarted).toBe(false); - }) - .then(done) - .catch(done.fail); - - vmStartUndefined.$destroy(); - }); - - it('should return `true` when milestone start date is past current date', done => { - const vmStarted = createComponent( - Object.assign({}, mockMilestone, { - start_date: '1990-07-22', - }), - ); - - Vue.nextTick() - .then(() => { - expect(vmStarted.isMilestoneStarted).toBe(true); - }) - .then(done) - .catch(done.fail); - - vmStarted.$destroy(); - }); - }); - - describe('isMilestonePastDue', () => { - it('should return `false` when milestoneDue prop is not defined', done => { - const vmDueUndefined = createComponent( - Object.assign({}, mockMilestone, { - due_date: '', - }), - ); - - Vue.nextTick() - .then(() => { - expect(vmDueUndefined.isMilestonePastDue).toBe(false); - }) - .then(done) - .catch(done.fail); - - vmDueUndefined.$destroy(); - }); - - it('should return `true` when milestone due is past current date', done => { - const vmPastDue = createComponent( - Object.assign({}, mockMilestone, { - due_date: '1990-07-22', - }), - ); - - Vue.nextTick() - .then(() => { - expect(vmPastDue.isMilestonePastDue).toBe(true); - }) - .then(done) - .catch(done.fail); - - vmPastDue.$destroy(); - }); - }); - - describe('milestoneDatesAbsolute', () => { - it('returns string containing absolute milestone due date', () => { - expect(vm.milestoneDatesAbsolute).toBe('(December 31, 2019)'); - }); - - it('returns string containing absolute milestone start date when due date is not present', done => { - const vmDueUndefined = createComponent( - Object.assign({}, mockMilestone, { - due_date: '', - }), - ); - - Vue.nextTick() - .then(() => { - expect(vmDueUndefined.milestoneDatesAbsolute).toBe('(January 1, 2018)'); - }) - .then(done) - .catch(done.fail); - - vmDueUndefined.$destroy(); - }); - - it('returns empty string when both milestone start and due dates are not present', done => { - const vmDatesUndefined = createComponent( - Object.assign({}, mockMilestone, { - start_date: '', - due_date: '', - }), - ); - - Vue.nextTick() - .then(() => { - expect(vmDatesUndefined.milestoneDatesAbsolute).toBe(''); - }) - .then(done) - .catch(done.fail); - - vmDatesUndefined.$destroy(); - }); - }); - - describe('milestoneDatesHuman', () => { - it('returns string containing milestone due date when date is yet to be due', done => { - const vmFuture = createComponent( - Object.assign({}, mockMilestone, { - due_date: `${new Date().getFullYear() + 10}-01-01`, - }), - ); - - Vue.nextTick() - .then(() => { - expect(vmFuture.milestoneDatesHuman).toContain('years remaining'); - }) - .then(done) - .catch(done.fail); - - vmFuture.$destroy(); - }); - - it('returns string containing milestone start date when date has already started and due date is not present', done => { - const vmStarted = createComponent( - Object.assign({}, mockMilestone, { - start_date: '1990-07-22', - due_date: '', - }), - ); - - Vue.nextTick() - .then(() => { - expect(vmStarted.milestoneDatesHuman).toContain('Started'); - }) - .then(done) - .catch(done.fail); - - vmStarted.$destroy(); - }); - - it('returns string containing milestone start date when date is yet to start and due date is not present', done => { - const vmStarts = createComponent( - Object.assign({}, mockMilestone, { - start_date: `${new Date().getFullYear() + 10}-01-01`, - due_date: '', - }), - ); - - Vue.nextTick() - .then(() => { - expect(vmStarts.milestoneDatesHuman).toContain('Starts'); - }) - .then(done) - .catch(done.fail); - - vmStarts.$destroy(); - }); - - it('returns empty string when milestone start and due dates are not present', done => { - const vmDatesUndefined = createComponent( - Object.assign({}, mockMilestone, { - start_date: '', - due_date: '', - }), - ); - - Vue.nextTick() - .then(() => { - expect(vmDatesUndefined.milestoneDatesHuman).toBe(''); - }) - .then(done) - .catch(done.fail); - - vmDatesUndefined.$destroy(); - }); - }); - }); - - describe('template', () => { - it('renders component root element with class `issue-milestone-details`', () => { - expect(vm.$el.classList.contains('issue-milestone-details')).toBe(true); - }); - - it('renders milestone icon', () => { - expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('clock'); - }); - - it('renders milestone title', () => { - expect(vm.$el.querySelector('.milestone-title').innerText.trim()).toBe(mockMilestone.title); - }); - - it('renders milestone tooltip', () => { - expect(vm.$el.querySelector('.js-item-milestone').innerText.trim()).toContain( - mockMilestone.title, - ); - }); - }); -}); diff --git a/spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js b/spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js index 852558a83bc..c7e0d806d80 100644 --- a/spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js +++ b/spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js @@ -61,6 +61,12 @@ describe('User Popover Component', () => { expect(vm.$el.textContent).toContain(DEFAULT_PROPS.user.username); expect(vm.$el.textContent).toContain(DEFAULT_PROPS.user.location); }); + + it('shows icon for location', () => { + const iconEl = vm.$el.querySelector('.js-location svg'); + + expect(iconEl.querySelector('use').getAttribute('xlink:href')).toContain('location'); + }); }); describe('job data', () => { @@ -117,6 +123,18 @@ describe('User Popover Component', () => { 'Me & my <funky> Company', ); }); + + it('shows icon for bio', () => { + const iconEl = vm.$el.querySelector('.js-bio svg'); + + expect(iconEl.querySelector('use').getAttribute('xlink:href')).toContain('profile'); + }); + + it('shows icon for organization', () => { + const iconEl = vm.$el.querySelector('.js-organization svg'); + + expect(iconEl.querySelector('use').getAttribute('xlink:href')).toContain('work'); + }); }); describe('status data', () => { diff --git a/spec/lib/api/helpers_spec.rb b/spec/lib/api/helpers_spec.rb index 08165f147bb..00916f80784 100644 --- a/spec/lib/api/helpers_spec.rb +++ b/spec/lib/api/helpers_spec.rb @@ -137,18 +137,6 @@ describe API::Helpers do it_behaves_like 'user namespace finder' end - describe '#user_namespace' do - let(:namespace_finder) do - subject.user_namespace - end - - before do - allow(subject).to receive(:params).and_return({ id: namespace.id }) - end - - it_behaves_like 'user namespace finder' - end - describe '#send_git_blob' do let(:repository) { double } let(:blob) { double(name: 'foobar') } diff --git a/spec/lib/gitlab/git/blob_spec.rb b/spec/lib/gitlab/git/blob_spec.rb index 10bc82e24d1..1c24244c3a6 100644 --- a/spec/lib/gitlab/git/blob_spec.rb +++ b/spec/lib/gitlab/git/blob_spec.rb @@ -341,7 +341,7 @@ describe Gitlab::Git::Blob, :seed_helper do it { expect(blob.mode).to eq("100755") } end - context 'file with Chinese text' do + context 'file with Japanese text' do let(:blob) { Gitlab::Git::Blob.find(repository, SeedRepo::Commit::ID, "encoding/テスト.txt") } it { expect(blob.name).to eq("テスト.txt") } diff --git a/spec/lib/gitlab/git/wiki_spec.rb b/spec/lib/gitlab/git/wiki_spec.rb index ded5d7576df..1e577392949 100644 --- a/spec/lib/gitlab/git/wiki_spec.rb +++ b/spec/lib/gitlab/git/wiki_spec.rb @@ -21,13 +21,13 @@ describe Gitlab::Git::Wiki do end it 'returns all the pages' do - expect(subject.pages.count).to eq(2) - expect(subject.pages.first.title).to eq 'page1' - expect(subject.pages.last.title).to eq 'page2' + expect(subject.list_pages.count).to eq(2) + expect(subject.list_pages.first.title).to eq 'page1' + expect(subject.list_pages.last.title).to eq 'page2' end it 'returns only one page' do - pages = subject.pages(limit: 1) + pages = subject.list_pages(limit: 1) expect(pages.count).to eq(1) expect(pages.first.title).to eq 'page1' @@ -62,8 +62,8 @@ describe Gitlab::Git::Wiki do subject.delete_page('*', commit_details('whatever')) - expect(subject.pages.count).to eq 1 - expect(subject.pages.first.title).to eq 'page1' + expect(subject.list_pages.count).to eq 1 + expect(subject.list_pages.first.title).to eq 'page1' end end @@ -87,7 +87,7 @@ describe Gitlab::Git::Wiki do with_them do subject { wiki.preview_slug(title, format) } - let(:gitaly_slug) { wiki.pages.first } + let(:gitaly_slug) { wiki.list_pages.first } it { is_expected.to eq(expected_slug) } @@ -96,7 +96,7 @@ describe Gitlab::Git::Wiki do create_page(title, 'content', format: format) - gitaly_slug = wiki.pages.first.url_path + gitaly_slug = wiki.list_pages.first.url_path is_expected.to eq(gitaly_slug) end diff --git a/spec/lib/gitlab/gitaly_client/wiki_service_spec.rb b/spec/lib/gitlab/gitaly_client/wiki_service_spec.rb index d82c9c28da0..4fa8e97aca0 100644 --- a/spec/lib/gitlab/gitaly_client/wiki_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/wiki_service_spec.rb @@ -46,7 +46,7 @@ describe Gitlab::GitalyClient::WikiService do end end - describe '#get_all_pages' do + describe '#load_all_pages' do let(:page_2_info) { { title: 'My Page 2', raw_data: 'c', version: page_version } } let(:response) do [ @@ -63,7 +63,7 @@ describe Gitlab::GitalyClient::WikiService do let(:wiki_page_2) { subject[1].first } let(:wiki_page_2_version) { subject[1].last } - subject { client.get_all_pages } + subject { client.load_all_pages } it 'sends a wiki_get_all_pages message' do expect_any_instance_of(Gitaly::WikiService::Stub) @@ -99,7 +99,7 @@ describe Gitlab::GitalyClient::WikiService do end context 'with limits' do - subject { client.get_all_pages(limit: 1) } + subject { client.load_all_pages(limit: 1) } it 'sends a request with the limit' do expect_any_instance_of(Gitaly::WikiService::Stub) diff --git a/spec/models/blob_spec.rb b/spec/models/blob_spec.rb index d0e1688cce3..8364293b908 100644 --- a/spec/models/blob_spec.rb +++ b/spec/models/blob_spec.rb @@ -43,6 +43,21 @@ describe Blob do changelog.id contributing.id end + + it 'does not include blobs from previous requests in later requests' do + changelog = described_class.lazy(project, commit_id, 'CHANGELOG') + contributing = described_class.lazy(same_project, commit_id, 'CONTRIBUTING.md') + + # Access property so the values are loaded + changelog.id + contributing.id + + readme = described_class.lazy(project, commit_id, 'README.md') + + expect(project.repository).to receive(:blobs_at).with([[commit_id, 'README.md']]).once.and_call_original + + readme.id + end end describe '#data' do diff --git a/spec/models/project_wiki_spec.rb b/spec/models/project_wiki_spec.rb index 2525a6aebe0..d12dd97bb9e 100644 --- a/spec/models/project_wiki_spec.rb +++ b/spec/models/project_wiki_spec.rb @@ -109,8 +109,7 @@ describe ProjectWiki do subject { super().empty? } it { is_expected.to be_falsey } - # Re-enable this when https://gitlab.com/gitlab-org/gitaly/issues/1204 is fixed - xit 'only instantiates a Wiki page once' do + it 'only instantiates a Wiki page once' do expect(WikiPage).to receive(:new).once.and_call_original subject @@ -119,22 +118,65 @@ describe ProjectWiki do end end - describe "#pages" do + describe "#list_pages" do + let(:wiki_pages) { subject.list_pages } + before do - create_page("index", "This is an awesome new Gollum Wiki") - @pages = subject.pages + create_page("index", "This is an index") + create_page("index2", "This is an index2") + create_page("an index3", "This is an index3") end after do - destroy_page(@pages.first.page) + wiki_pages.each do |wiki_page| + destroy_page(wiki_page.page) + end end it "returns an array of WikiPage instances" do - expect(@pages.first).to be_a WikiPage + expect(wiki_pages.first).to be_a WikiPage + end + + it 'does not load WikiPage content by default' do + wiki_pages.each do |page| + expect(page.content).to be_empty + end + end + + it 'returns all pages by default' do + expect(wiki_pages.count).to eq(3) + end + + context "with limit option" do + it 'returns limited set of pages' do + expect(subject.list_pages(limit: 1).count).to eq(1) + end end - it "returns the correct number of pages" do - expect(@pages.count).to eq(1) + context "with sorting options" do + it 'returns pages sorted by title by default' do + pages = ['an index3', 'index', 'index2'] + + expect(subject.list_pages.map(&:title)).to eq(pages) + expect(subject.list_pages(direction: "desc").map(&:title)).to eq(pages.reverse) + end + + it 'returns pages sorted by created_at' do + pages = ['index', 'index2', 'an index3'] + + expect(subject.list_pages(sort: 'created_at').map(&:title)).to eq(pages) + expect(subject.list_pages(sort: 'created_at', direction: "desc").map(&:title)).to eq(pages.reverse) + end + end + + context "with load_content option" do + let(:pages) { subject.list_pages(load_content: true) } + + it 'loads WikiPage content' do + expect(pages.first.content).to eq("This is an index3") + expect(pages.second.content).to eq("This is an index") + expect(pages.third.content).to eq("This is an index2") + end end end @@ -144,7 +186,7 @@ describe ProjectWiki do end after do - subject.pages.each { |page| destroy_page(page.page) } + subject.list_pages.each { |page| destroy_page(page.page) } end it "returns the latest version of the page if it exists" do @@ -195,7 +237,7 @@ describe ProjectWiki do end after do - subject.pages.each { |page| destroy_page(page.page) } + subject.list_pages.each { |page| destroy_page(page.page) } end it 'finds the page defined as _sidebar' do @@ -242,12 +284,12 @@ describe ProjectWiki do describe "#create_page" do after do - destroy_page(subject.pages.first.page) + destroy_page(subject.list_pages.first.page) end it "creates a new wiki page" do expect(subject.create_page("test page", "this is content")).not_to eq(false) - expect(subject.pages.count).to eq(1) + expect(subject.list_pages.count).to eq(1) end it "returns false when a duplicate page exists" do @@ -262,7 +304,7 @@ describe ProjectWiki do it "sets the correct commit message" do subject.create_page("test page", "some content", :markdown, "commit message") - expect(subject.pages.first.page.version.message).to eq("commit message") + expect(subject.list_pages.first.page.version.message).to eq("commit message") end it 'sets the correct commit email' do @@ -293,7 +335,7 @@ describe ProjectWiki do format: :markdown, message: "updated page" ) - @page = subject.pages.first.page + @page = subject.list_pages(load_content: true).first.page end after do @@ -337,7 +379,7 @@ describe ProjectWiki do it "deletes the page" do subject.delete_page(@page) - expect(subject.pages.count).to eq(0) + expect(subject.list_pages.count).to eq(0) end it 'sets the correct commit email' do diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 3f5d285bc2c..4c354593b57 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -217,6 +217,25 @@ describe Repository do expect(result.size).to eq(0) end + + context 'with a commit with invalid UTF-8 path' do + def create_commit_with_invalid_utf8_path + rugged = rugged_repo(repository) + blob_id = Rugged::Blob.from_buffer(rugged, "some contents") + tree_builder = Rugged::Tree::Builder.new(rugged) + tree_builder.insert({ oid: blob_id, name: "hello\x80world", filemode: 0100644 }) + tree_id = tree_builder.write + user = { email: "jcai@gitlab.com", time: Time.now, name: "John Cai" } + + Rugged::Commit.create(rugged, message: 'some commit message', parents: [rugged.head.target.oid], tree: tree_id, committer: user, author: user) + end + + it 'does not raise an error' do + commit = create_commit_with_invalid_utf8_path + + expect { repository.list_last_commits_for_tree(commit, '.', offset: 0) }.not_to raise_error + end + end end describe '#last_commit_for_path' do diff --git a/spec/models/wiki_page_spec.rb b/spec/models/wiki_page_spec.rb index d5c85c11195..520a06e138e 100644 --- a/spec/models/wiki_page_spec.rb +++ b/spec/models/wiki_page_spec.rb @@ -44,47 +44,49 @@ describe WikiPage do WikiDirectory.new('dir_2', pages) end - context 'sort by title' do - let(:grouped_entries) { described_class.group_by_directory(wiki.pages) } - let(:expected_grouped_entries) { [dir_1_1, dir_1, page_dir_2, dir_2, page_1, page_6] } - - it 'returns an array with pages and directories' do - grouped_entries.each_with_index do |page_or_dir, i| - expected_page_or_dir = expected_grouped_entries[i] - expected_slugs = get_slugs(expected_page_or_dir) - slugs = get_slugs(page_or_dir) - - expect(slugs).to match_array(expected_slugs) + context "#list_pages" do + context 'sort by title' do + let(:grouped_entries) { described_class.group_by_directory(wiki.list_pages) } + let(:expected_grouped_entries) { [dir_1_1, dir_1, page_dir_2, dir_2, page_1, page_6] } + + it 'returns an array with pages and directories' do + grouped_entries.each_with_index do |page_or_dir, i| + expected_page_or_dir = expected_grouped_entries[i] + expected_slugs = get_slugs(expected_page_or_dir) + slugs = get_slugs(page_or_dir) + + expect(slugs).to match_array(expected_slugs) + end end end - end - context 'sort by created_at' do - let(:grouped_entries) { described_class.group_by_directory(wiki.pages(sort: 'created_at')) } - let(:expected_grouped_entries) { [dir_1_1, page_1, dir_1, page_dir_2, dir_2, page_6] } + context 'sort by created_at' do + let(:grouped_entries) { described_class.group_by_directory(wiki.list_pages(sort: 'created_at')) } + let(:expected_grouped_entries) { [dir_1_1, page_1, dir_1, page_dir_2, dir_2, page_6] } - it 'returns an array with pages and directories' do - grouped_entries.each_with_index do |page_or_dir, i| - expected_page_or_dir = expected_grouped_entries[i] - expected_slugs = get_slugs(expected_page_or_dir) - slugs = get_slugs(page_or_dir) + it 'returns an array with pages and directories' do + grouped_entries.each_with_index do |page_or_dir, i| + expected_page_or_dir = expected_grouped_entries[i] + expected_slugs = get_slugs(expected_page_or_dir) + slugs = get_slugs(page_or_dir) - expect(slugs).to match_array(expected_slugs) + expect(slugs).to match_array(expected_slugs) + end end end - end - it 'returns an array with retained order with directories at the top' do - expected_order = ['dir_1/dir_1_1/page_3', 'dir_1/page_2', 'dir_2', 'dir_2/page_4', 'dir_2/page_5', 'page_1', 'page_6'] + it 'returns an array with retained order with directories at the top' do + expected_order = ['dir_1/dir_1_1/page_3', 'dir_1/page_2', 'dir_2', 'dir_2/page_4', 'dir_2/page_5', 'page_1', 'page_6'] - grouped_entries = described_class.group_by_directory(wiki.pages) + grouped_entries = described_class.group_by_directory(wiki.list_pages) - actual_order = - grouped_entries.map do |page_or_dir| - get_slugs(page_or_dir) - end - .flatten - expect(actual_order).to eq(expected_order) + actual_order = + grouped_entries.map do |page_or_dir| + get_slugs(page_or_dir) + end + .flatten + expect(actual_order).to eq(expected_order) + end end end end @@ -386,7 +388,7 @@ describe WikiPage do it "deletes the page" do @page.delete - expect(wiki.pages).to be_empty + expect(wiki.list_pages).to be_empty end it "returns true" do diff --git a/spec/services/merge_requests/merge_to_ref_service_spec.rb b/spec/services/merge_requests/merge_to_ref_service_spec.rb index 24d09c1fd00..0ac23050caf 100644 --- a/spec/services/merge_requests/merge_to_ref_service_spec.rb +++ b/spec/services/merge_requests/merge_to_ref_service_spec.rb @@ -104,7 +104,7 @@ describe MergeRequests::MergeToRefService do it_behaves_like 'MergeService for target ref' end - context 'when merge commit with squash' do + context 'when merge commit with squash', :quarantine do before do merge_request.update!(squash: true, source_branch: 'master', target_branch: 'feature') end diff --git a/spec/support/protected_branch_helpers.rb b/spec/support/protected_branch_helpers.rb new file mode 100644 index 00000000000..ede16d1c1e2 --- /dev/null +++ b/spec/support/protected_branch_helpers.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module ProtectedBranchHelpers + def set_allowed_to(operation, option = 'Maintainers', form: '.js-new-protected-branch') + within form do + select_elem = find(".js-allowed-to-#{operation}") + select_elem.click + + wait_for_requests + + within('.dropdown-content') do + Array(option).each { |opt| click_on(opt) } + end + + # Enhanced select is used in EE, therefore an extra click is needed. + select_elem.click if select_elem['aria-expanded'] == 'true' + end + end + + def set_protected_branch_name(branch_name) + find('.js-protected-branch-select').click + find('.dropdown-input-field').set(branch_name) + click_on("Create wildcard #{branch_name}") + end + + def set_defaults + set_allowed_to('merge') + set_allowed_to('push') + end +end diff --git a/spec/support/protected_tag_helpers.rb b/spec/support/protected_tag_helpers.rb new file mode 100644 index 00000000000..fe9be856286 --- /dev/null +++ b/spec/support/protected_tag_helpers.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require_relative 'protected_branch_helpers' + +module ProtectedTagHelpers + include ::ProtectedBranchHelpers + + def set_allowed_to(operation, option = 'Maintainers', form: '.new-protected-tag') + super + end + + def set_protected_tag_name(tag_name) + find('.js-protected-tag-select').click + find('.dropdown-input-field').set(tag_name) + click_on("Create wildcard #{tag_name}") + find('.protected-tags-dropdown .dropdown-menu', visible: false) + end +end diff --git a/yarn.lock b/yarn.lock index 8a23aabba20..a0446b652ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -663,10 +663,10 @@ resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.59.0.tgz#affcf9596d736836d37469bb4aea2226ac03e087" integrity sha512-dokGyyLRRsoBKO70KP1g+ZsDGyTK/RIHWDmvWI6Bx5AxQ3UqAzVXn2OIb3owjJAexyRG1uBmJrriiVVyHznQ4g== -"@gitlab/ui@^3.4.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-3.4.0.tgz#174681f210eb16c3d101a36968d5e4d163c0d014" - integrity sha512-joXNz80IHMQxEGrqcNUTEKofjfZtkOKUe34HAFI71NEeYT6H0r/lYmJ5Gcz+MmwM1CvZOVbB3DnKzxQPDbN/hQ== +"@gitlab/ui@^3.5.0": + version "3.5.0" + resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-3.5.0.tgz#31ecfc16e3f7663545f31ddf07e02bba96a6d138" + integrity sha512-eDD++hhGJuH59g2QcGshuou9/NLcLfse4Abm9KOIWIaYI3NPWW2KRGwLHPB6H0d5W0/X5pyWYQvXgF7JE2ZXbA== dependencies: "@babel/standalone" "^7.0.0" bootstrap-vue "^2.0.0-rc.11" @@ -678,6 +678,7 @@ url-search-params-polyfill "^5.0.0" vue "^2.5.21" vue-loader "^15.4.2" + vue-toasted "^1.1.26" "@mrmlnc/readdir-enhanced@^2.2.1": version "2.2.1" @@ -10982,6 +10983,11 @@ vue-template-es2015-compiler@^1.9.0: resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz#1ee3bc9a16ecbf5118be334bb15f9c46f82f5825" integrity sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw== +vue-toasted@^1.1.26: + version "1.1.26" + resolved "https://registry.yarnpkg.com/vue-toasted/-/vue-toasted-1.1.26.tgz#1333d1a42157ab78389c3810023a49ba94e69c7b" + integrity sha512-Z4/gfPcqdzsRvif7UITrZOkh3C6jm0yQKJyr9kX31IGWXor5dNipE1Sc5SnlL5RLmY7vlLa+SqIjc9Gbpy7V0g== + vue-virtual-scroll-list@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/vue-virtual-scroll-list/-/vue-virtual-scroll-list-1.3.1.tgz#efcb83d3a3dcc69cd886fa4de1130a65493e8f76" |