diff options
60 files changed, 1218 insertions, 2001 deletions
diff --git a/.rubocop_todo/gitlab/avoid_gitlab_instance_checks.yml b/.rubocop_todo/gitlab/avoid_gitlab_instance_checks.yml index 2ada4dfdaac..6e193518af1 100644 --- a/.rubocop_todo/gitlab/avoid_gitlab_instance_checks.yml +++ b/.rubocop_todo/gitlab/avoid_gitlab_instance_checks.yml @@ -114,7 +114,6 @@ Gitlab/AvoidGitlabInstanceChecks: - 'ee/lib/ee/gitlab/background_migration/create_compliance_standards_adherence.rb' - 'ee/lib/ee/gitlab/background_migration/migrate_shared_vulnerability_identifiers.rb' - 'ee/lib/ee/gitlab/gon_helper.rb' - - 'ee/lib/ee/gitlab/saas.rb' - 'ee/lib/ee/gitlab/scim/base_provisioning_service.rb' - 'ee/lib/ee/gitlab/snippet_search_results.rb' - 'ee/lib/ee/gitlab/tracking/standard_context.rb' diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/commit_block.vue b/app/assets/javascripts/ci/job_details/components/sidebar/commit_block.vue index 95616a4c706..5e826efbefb 100644 --- a/app/assets/javascripts/ci/job_details/components/sidebar/commit_block.vue +++ b/app/assets/javascripts/ci/job_details/components/sidebar/commit_block.vue @@ -25,11 +25,7 @@ export default { <p class="gl-display-flex gl-flex-wrap gl-align-items-baseline gl-gap-2 gl-mb-0"> <span class="gl-display-flex gl-font-weight-bold">{{ __('Commit') }}</span> - <gl-link - :href="commit.commit_path" - class="gl-text-blue-500! gl-font-monospace" - data-testid="commit-sha" - > + <gl-link :href="commit.commit_path" class="commit-sha-container" data-testid="commit-sha"> {{ commit.short_id }} </gl-link> diff --git a/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue b/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue index 6fd0d6b66d2..51a68f6619a 100644 --- a/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue +++ b/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue @@ -419,7 +419,7 @@ export default { <template #link="{ content }"> <gl-link :href="commitPath" - class="gl-bg-blue-50 gl-rounded-base gl-px-2 gl-mx-2" + class="commit-sha-container" data-testid="commit-link" target="_blank" > diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue index c993b65f6c0..386835d21d4 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue @@ -4,6 +4,7 @@ import { GlBadge, GlButton, GlLoadingIcon, + GlPagination, GlTabs, GlTab, GlSprintf, @@ -16,12 +17,20 @@ import deletePipelineScheduleMutation from '../graphql/mutations/delete_pipeline import playPipelineScheduleMutation from '../graphql/mutations/play_pipeline_schedule.mutation.graphql'; import takeOwnershipMutation from '../graphql/mutations/take_ownership.mutation.graphql'; import getPipelineSchedulesQuery from '../graphql/queries/get_pipeline_schedules.query.graphql'; -import { ALL_SCOPE } from '../constants'; +import { ALL_SCOPE, SCHEDULES_PER_PAGE } from '../constants'; import PipelineSchedulesTable from './table/pipeline_schedules_table.vue'; import TakeOwnershipModal from './take_ownership_modal.vue'; import DeletePipelineScheduleModal from './delete_pipeline_schedule_modal.vue'; import PipelineScheduleEmptyState from './pipeline_schedules_empty_state.vue'; +const defaultPagination = { + first: SCHEDULES_PER_PAGE, + last: null, + prevPageCursor: '', + nextPageCursor: '', + currentPage: 1, +}; + export default { i18n: { schedulesFetchError: s__('PipelineSchedules|There was a problem fetching pipeline schedules.'), @@ -44,6 +53,7 @@ export default { GlBadge, GlButton, GlLoadingIcon, + GlPagination, GlTabs, GlTab, GlSprintf, @@ -72,16 +82,22 @@ export default { // we need to ensure we send null to the API when // the scope is 'ALL' status: this.scope === ALL_SCOPE ? null : this.scope, + first: this.pagination.first, + last: this.pagination.last, + prevPageCursor: this.pagination.prevPageCursor, + nextPageCursor: this.pagination.nextPageCursor, }; }, update(data) { - const { pipelineSchedules: { nodes: list = [], count } = {} } = data.project || {}; + const { pipelineSchedules: { nodes: list = [], count, pageInfo = {} } = {} } = + data.project || {}; const currentUser = data.currentUser || {}; return { list, count, currentUser, + pageInfo, }; }, error() { @@ -104,6 +120,9 @@ export default { showDeleteModal: false, showTakeOwnershipModal: false, count: 0, + pagination: { + ...defaultPagination, + }, }; }, computed: { @@ -144,6 +163,15 @@ export default { showEmptyState() { return !this.isLoading && this.schedulesCount === 0 && this.onAllTab; }, + showPagination() { + return this.schedules?.pageInfo?.hasNextPage || this.schedules?.pageInfo?.hasPreviousPage; + }, + prevPage() { + return Number(this.schedules?.pageInfo?.hasPreviousPage); + }, + nextPage() { + return Number(this.schedules?.pageInfo?.hasNextPage); + }, }, watch: { // this watcher ensures that the count on the all tab @@ -245,10 +273,36 @@ export default { this.reportError(this.$options.i18n.schedulePlayError); } }, + resetPagination() { + this.pagination = { + ...defaultPagination, + }; + }, fetchPipelineSchedulesByStatus(scope) { this.scope = scope; + this.resetPagination(); this.$apollo.queries.schedules.refetch(); }, + handlePageChange(page) { + const { startCursor, endCursor } = this.schedules.pageInfo; + + if (page > this.pagination.currentPage) { + this.pagination = { + first: SCHEDULES_PER_PAGE, + last: null, + prevPageCursor: '', + nextPageCursor: endCursor, + currentPage: page, + }; + } else { + this.pagination = { + last: SCHEDULES_PER_PAGE, + first: null, + prevPageCursor: startCursor, + currentPage: page, + }; + } + }, }, }; </script> @@ -296,14 +350,25 @@ export default { <gl-loading-icon v-if="isLoading" size="lg" /> - <pipeline-schedules-table - v-else - :schedules="schedules.list" - :current-user="schedules.currentUser" - @showTakeOwnershipModal="setTakeOwnershipModal" - @showDeleteModal="setDeleteModal" - @playPipelineSchedule="playPipelineSchedule" - /> + <template v-else> + <pipeline-schedules-table + :schedules="schedules.list" + :current-user="schedules.currentUser" + @showTakeOwnershipModal="setTakeOwnershipModal" + @showDeleteModal="setDeleteModal" + @playPipelineSchedule="playPipelineSchedule" + /> + + <gl-pagination + v-if="showPagination" + :value="pagination.currentPage" + :prev-page="prevPage" + :next-page="nextPage" + align="center" + class="gl-mt-5" + @input="handlePageChange" + /> + </template> </gl-tab> <template #tabs-end> diff --git a/app/assets/javascripts/ci/pipeline_schedules/constants.js b/app/assets/javascripts/ci/pipeline_schedules/constants.js index 16dab33ce29..be3feeb6623 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/constants.js +++ b/app/assets/javascripts/ci/pipeline_schedules/constants.js @@ -1,3 +1,4 @@ export const VARIABLE_TYPE = 'ENV_VAR'; export const FILE_TYPE = 'FILE'; export const ALL_SCOPE = 'ALL'; +export const SCHEDULES_PER_PAGE = 50; diff --git a/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql b/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql index 29a26be0344..8fe9fbc5e24 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql +++ b/app/assets/javascripts/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql @@ -1,7 +1,13 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" + query getPipelineSchedulesQuery( $projectPath: ID! $status: PipelineScheduleStatus $ids: [ID!] = null + $first: Int + $last: Int + $prevPageCursor: String = "" + $nextPageCursor: String = "" ) { currentUser { id @@ -9,7 +15,14 @@ query getPipelineSchedulesQuery( } project(fullPath: $projectPath) { id - pipelineSchedules(status: $status, ids: $ids) { + pipelineSchedules( + status: $status + ids: $ids + first: $first + last: $last + after: $nextPageCursor + before: $prevPageCursor + ) { count nodes { id @@ -56,6 +69,9 @@ query getPipelineSchedulesQuery( adminPipelineSchedule } } + pageInfo { + ...PageInfo + } } } } diff --git a/app/assets/javascripts/google_tag_manager/index.js b/app/assets/javascripts/google_tag_manager/index.js index a9ae9a5af82..0cb25fbaeb5 100644 --- a/app/assets/javascripts/google_tag_manager/index.js +++ b/app/assets/javascripts/google_tag_manager/index.js @@ -1,310 +1 @@ -import { v4 as uuidv4 } from 'uuid'; -import { logError } from '~/lib/logger'; - -const SKU_PREMIUM = '2c92a00d76f0d5060176f2fb0a5029ff'; -const SKU_ULTIMATE = '2c92a0ff76f0d5250176f2f8c86f305a'; -const PRODUCT_INFO = { - [SKU_PREMIUM]: { - // eslint-disable-next-line @gitlab/require-i18n-strings - name: 'Premium', - id: '0002', - price: '228', - variant: 'SaaS', - }, - [SKU_ULTIMATE]: { - // eslint-disable-next-line @gitlab/require-i18n-strings - name: 'Ultimate', - id: '0001', - price: '1188', - variant: 'SaaS', - }, -}; -const EMPTY_NAMESPACE_ID_VALUE = 'not available'; - -const generateProductInfo = (sku, quantity) => { - const product = PRODUCT_INFO[sku]; - - if (!product) { - logError('Unexpected product sku provided to generateProductInfo'); - return {}; - } - - const productInfo = { - ...product, - brand: 'GitLab', - category: 'DevOps', - quantity, - }; - - return productInfo; -}; - -const isSupported = () => Boolean(window.dataLayer) && gon.features?.gitlabGtmDatalayer; -// gon.features.gitlabGtmDatalayer is set by writing -// `push_frontend_feature_flag(:gitlab_gtm_datalayer, type: :ops)` -// to the appropriate controller -// window.dataLayer is set by adding partials to the appropriate view found in -// views/layouts/_google_tag_manager_body.html.haml and _google_tag_manager_head.html.haml - -const pushEvent = (event, args = {}) => { - if (!window.dataLayer) { - return; - } - - try { - window.dataLayer.push({ - event, - ...args, - }); - } catch (e) { - logError('Unexpected error while pushing to dataLayer', e); - } -}; - -const pushEnhancedEcommerceEvent = (event, args = {}) => { - if (!window.dataLayer) { - return; - } - - try { - window.dataLayer.push({ ecommerce: null }); // Clear the previous ecommerce object - window.dataLayer.push({ - event, - ...args, - }); - } catch (e) { - logError('Unexpected error while pushing to dataLayer', e); - } -}; - -const pushAccountSubmit = (accountType, accountMethod) => - pushEvent('accountSubmit', { accountType, accountMethod }); - -const trackFormSubmission = (accountType) => { - const form = document.getElementById('new_new_user'); - form.addEventListener('submit', () => { - pushAccountSubmit(accountType, 'form'); - }); -}; - -const trackOmniAuthSubmission = (accountType) => { - const links = document.querySelectorAll('.js-oauth-login'); - links.forEach((link) => { - const { provider } = link.dataset; - link.addEventListener('click', () => { - pushAccountSubmit(accountType, provider); - }); - }); -}; - -export const trackFreeTrialAccountSubmissions = () => { - if (!isSupported()) { - return; - } - - trackFormSubmission('freeThirtyDayTrial'); - trackOmniAuthSubmission('freeThirtyDayTrial'); -}; - -export const trackNewRegistrations = () => { - if (!isSupported()) { - return; - } - - trackFormSubmission('standardSignUp'); - trackOmniAuthSubmission('standardSignUp'); -}; - -export const trackSaasTrialSubmit = () => { - if (!isSupported()) { - return; - } - - pushEvent('saasTrialSubmit'); -}; - -export const trackSaasTrialGroup = () => { - if (!isSupported()) { - return; - } - - const form = document.querySelector('.js-saas-trial-group'); - - if (!form) return; - - form.addEventListener('submit', () => { - pushEvent('saasTrialGroup'); - }); -}; - -export const trackProjectImport = () => { - if (!isSupported()) { - return; - } - - const importButtons = document.querySelectorAll('.js-import-project-btn'); - importButtons.forEach((button) => { - button.addEventListener('click', () => { - const { platform } = button.dataset; - pushEvent('projectImport', { platform }); - }); - }); -}; - -export const trackSaasTrialGetStarted = () => { - if (!isSupported()) { - return; - } - - const getStartedButton = document.querySelector('.js-get-started-btn'); - getStartedButton.addEventListener('click', () => { - pushEvent('saasTrialGetStarted'); - }); -}; - -export const trackTrialAcceptTerms = () => { - if (!isSupported()) { - return; - } - - pushEvent('saasTrialAcceptTerms'); -}; - -export const trackCheckout = (selectedPlan, quantity) => { - if (!isSupported()) { - return; - } - - const product = generateProductInfo(selectedPlan, quantity); - - if (Object.keys(product).length === 0) { - return; - } - - const eventData = { - ecommerce: { - currencyCode: 'USD', - checkout: { - actionField: { step: 1 }, - products: [product], - }, - }, - }; - - // eslint-disable-next-line @gitlab/require-i18n-strings - pushEnhancedEcommerceEvent('EECCheckout', eventData); -}; - -export const getNamespaceId = () => { - return window.gl.snowplowStandardContext?.data?.namespace_id || EMPTY_NAMESPACE_ID_VALUE; -}; - -export const trackTransaction = (transactionDetails) => { - if (!isSupported()) { - return; - } - - const transactionId = uuidv4(); - const { paymentOption, revenue, tax, selectedPlan, quantity } = transactionDetails; - const product = generateProductInfo(selectedPlan, quantity); - const namespaceId = getNamespaceId(); - - if (Object.keys(product).length === 0) { - return; - } - - const eventData = { - ecommerce: { - currencyCode: 'USD', - purchase: { - actionField: { - id: transactionId, - affiliation: 'GitLab', - option: paymentOption, - revenue: revenue.toString(), - tax: tax.toString(), - }, - products: [{ ...product, dimension36: namespaceId }], - }, - }, - }; - - pushEnhancedEcommerceEvent('EECtransactionSuccess', eventData); -}; - -export const pushEECproductAddToCartEvent = () => { - if (!isSupported()) { - return; - } - - window.dataLayer.push({ - event: 'EECproductAddToCart', - ecommerce: { - currencyCode: 'USD', - add: { - products: [ - { - name: 'CI/CD Minutes', - id: '0003', - price: '10', - brand: 'GitLab', - category: 'DevOps', - variant: 'add-on', - quantity: 1, - }, - ], - }, - }, - }); -}; - -export const trackAddToCartUsageTab = () => { - const getStartedButton = document.querySelector('.js-buy-additional-minutes'); - if (!getStartedButton) { - return; - } - getStartedButton.addEventListener('click', pushEECproductAddToCartEvent); -}; - -export const trackCombinedGroupProjectForm = () => { - if (!isSupported()) { - return; - } - - const form = document.querySelector('.js-groups-projects-form'); - form.addEventListener('submit', () => { - pushEvent('combinedGroupProjectFormSubmit'); - }); -}; - -export const trackCompanyForm = (aboutYourCompanyType) => { - if (!isSupported()) { - return; - } - - pushEvent('aboutYourCompanyFormSubmit', { aboutYourCompanyType }); -}; - -export const saasTrialWelcome = () => { - if (!isSupported()) { - return; - } - - const saasTrialWelcomeButton = document.querySelector('.js-trial-welcome-btn'); - - saasTrialWelcomeButton?.addEventListener('click', () => { - pushEvent('saasTrialWelcome'); - }); -}; - -export const saasTrialContinuousOnboarding = () => { - if (!isSupported()) { - return; - } - - const getStartedButton = document.querySelector('.js-get-started-btn'); - - getStartedButton?.addEventListener('click', () => { - pushEvent('saasTrialContinuousOnboarding'); - }); -}; +export const trackTrialAcceptTerms = () => {}; diff --git a/app/assets/javascripts/pages/registrations/new/index.js b/app/assets/javascripts/pages/registrations/new/index.js index 84050c3cb0f..90a9c9e7279 100644 --- a/app/assets/javascripts/pages/registrations/new/index.js +++ b/app/assets/javascripts/pages/registrations/new/index.js @@ -1,5 +1,3 @@ -import { trackNewRegistrations } from '~/google_tag_manager'; - import NoEmojiValidator from '~/emoji/no_emoji_validator'; import LengthValidator from '~/validators/length_validator'; import UsernameValidator from '~/pages/sessions/new/username_validator'; @@ -13,8 +11,6 @@ new LengthValidator(); // eslint-disable-line no-new new NoEmojiValidator(); // eslint-disable-line no-new new EmailFormatValidator(); // eslint-disable-line no-new -trackNewRegistrations(); - Tracking.enableFormTracking({ forms: { allow: ['new_user'] }, }); diff --git a/app/assets/javascripts/terms/components/app.vue b/app/assets/javascripts/terms/components/app.vue index 0ae97a47170..29099bcc366 100644 --- a/app/assets/javascripts/terms/components/app.vue +++ b/app/assets/javascripts/terms/components/app.vue @@ -5,7 +5,7 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import csrf from '~/lib/utils/csrf'; -import { trackTrialAcceptTerms } from '~/google_tag_manager'; +import { trackTrialAcceptTerms } from 'ee_else_ce/google_tag_manager'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; export default { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index 110412fa13e..2e104f2b93b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -239,7 +239,7 @@ export default { {{ s__('Pipeline|for') }} <gl-link :href="pipeline.commit.commit_path" - class="commit-sha gl-font-weight-normal" + class="commit-sha-container" data-testid="commit-link" >{{ pipeline.commit.short_id }}</gl-link > @@ -250,7 +250,7 @@ export default { v-safe-html="sourceBranchLink" :title="sourceBranch" truncate-target="child" - class="label-branch label-truncate gl-font-weight-normal" + class="label-branch label-truncate ref-container" /> </template> <template v-if="finishedAt"> diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_cli_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_cli_instructions.vue index 36e608a068b..f59664e8d1d 100644 --- a/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_cli_instructions.vue +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_cli_instructions.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlCollapsibleListbox, GlLoadingIcon } from '@gitlab/ui'; import { s__ } from '~/locale'; import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; import { REGISTRATION_TOKEN_PLACEHOLDER } from '../constants'; @@ -8,8 +8,7 @@ import getRunnerSetupInstructionsQuery from '../graphql/get_runner_setup.query.g export default { components: { GlButton, - GlDropdown, - GlDropdownItem, + GlCollapsibleListbox, GlLoadingIcon, ModalCopyButton, }, @@ -27,7 +26,7 @@ export default { }, data() { return { - selectedArchitecture: this.platform?.architectures[0] || null, + selectedArchName: this.platform?.architectures[0]?.name || null, instructions: null, }; }, @@ -55,6 +54,9 @@ export default { architectures() { return this.platform?.architectures || []; }, + selectedArchitecture() { + return this.architectures.find(({ name }) => name === this.selectedArchName) || null; + }, binaryUrl() { return this.selectedArchitecture?.downloadLocation; }, @@ -69,20 +71,22 @@ export default { } return registerInstructions; }, + listboxItems() { + return this.architectures.map(({ name }) => { + return { text: name, value: name }; + }); + }, }, watch: { platform() { // reset selection if architecture is not in this list - const arch = this.architectures.find(({ name }) => name === this.selectedArchitecture.name); + const arch = this.architectures.find(({ name }) => name === this.selectedArchName); if (!arch) { - this.selectArchitecture(this.architectures[0]); + this.selectedArchName = this.architectures[0]?.name || null; } }, }, methods: { - selectArchitecture(architecture) { - this.selectedArchitecture = architecture; - }, onClose() { this.$emit('close'); }, @@ -104,18 +108,7 @@ export default { <gl-loading-icon v-if="$apollo.loading" size="sm" inline /> </h5> - <gl-dropdown class="gl-mb-3" :text="selectedArchitecture.name"> - <gl-dropdown-item - v-for="architecture in architectures" - :key="architecture.name" - is-check-item - :is-checked="selectedArchitecture.name === architecture.name" - data-testid="architecture-dropdown-item" - @click="selectArchitecture(architecture)" - > - {{ architecture.name }} - </gl-dropdown-item> - </gl-dropdown> + <gl-collapsible-listbox v-model="selectedArchName" class="gl-mb-3" :items="listboxItems" /> <div class="gl-sm-display-flex gl-align-items-center gl-mb-3"> <h5>{{ $options.i18n.downloadInstallBinary }}</h5> <gl-button diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 5dae64653ac..21c252038af 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -592,3 +592,23 @@ See https://gitlab.com/gitlab-org/gitlab/issues/36857 for more details. box-shadow: 0 0 0 1px inset; } } + +.ref-container, +.commit-sha-container { + font-family: $gl-monospace-font; + font-variant-ligatures: none; + font-size: $gl-font-size-sm; + padding-left: $gl-spacing-scale-2; + padding-right: $gl-spacing-scale-2; + border-radius: $gl-border-radius-base; +} + +.ref-container { + color: var(--blue-500, $blue-500) !important; + background-color: var(--blue-50, $blue-50); +} + +.commit-sha-container { + color: var(--gray-700, $gray-700) !important; + background-color: var(--gray-50, $gray-50); +} diff --git a/app/components/pajamas/alert_component.html.haml b/app/components/pajamas/alert_component.html.haml index a7be57311bb..ee7d5552455 100644 --- a/app/components/pajamas/alert_component.html.haml +++ b/app/components/pajamas/alert_component.html.haml @@ -1,6 +1,7 @@ .gl-alert{ @alert_options, role: 'alert', class: base_class } - if @show_icon - = sprite_icon(icon, css_class: icon_classes) + .gl-alert-icon-container + = sprite_icon(icon, css_class: icon_classes) - if @dismissible = render Pajamas::ButtonComponent.new(category: :tertiary, icon: 'close', @@ -8,7 +9,7 @@ button_options: dismissible_button_options) .gl-alert-content{ role: 'alert' } - if @title - %h4.gl-alert-title + %h2.gl-alert-title = @title - if body? .gl-alert-body diff --git a/app/components/pajamas/alert_component.rb b/app/components/pajamas/alert_component.rb index 008d624b7e2..c9397ca56cc 100644 --- a/app/components/pajamas/alert_component.rb +++ b/app/components/pajamas/alert_component.rb @@ -24,7 +24,7 @@ module Pajamas classes = ["gl-alert-#{@variant}"] classes.push('gl-alert-not-dismissible') unless @dismissible classes.push('gl-alert-no-icon') unless @show_icon - + classes.push('gl-alert-has-title') if @title classes.join(' ') end diff --git a/app/controllers/concerns/google_analytics_csp.rb b/app/controllers/concerns/google_analytics_csp.rb deleted file mode 100644 index 4fffe298803..00000000000 --- a/app/controllers/concerns/google_analytics_csp.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -module GoogleAnalyticsCSP - extend ActiveSupport::Concern - - included do - content_security_policy do |policy| - next unless helpers.google_tag_manager_enabled? || policy.directives.present? - - # Tag Manager with a Content Security Policy for Google Analytics 4 - # https://developers.google.com/tag-platform/security/guides/csp#google_analytics_4_google_analytics - - default_script_src = policy.directives['script-src'] || policy.directives['default-src'] - script_src_values = Array.wrap(default_script_src) | ['*.googletagmanager.com'] - policy.script_src(*script_src_values) - - default_img_src = policy.directives['img-src'] || policy.directives['default-src'] - img_src_values = - Array.wrap(default_img_src) | - [ - '*.google-analytics.com', - '*.analytics.google.com', - '*.googletagmanager.com', - '*.g.doubleclick.net' - ] - policy.img_src(*img_src_values) - - default_connect_src = policy.directives['connect-src'] || policy.directives['default-src'] - connect_src_values = - Array.wrap(default_connect_src) | - [ - '*.google-analytics.com', - '*.analytics.google.com', - '*.googletagmanager.com', - '*.g.doubleclick.net' - ] - policy.connect_src(*connect_src_values) - end - end -end diff --git a/app/controllers/concerns/google_syndication_csp.rb b/app/controllers/concerns/google_syndication_csp.rb deleted file mode 100644 index c55debe448b..00000000000 --- a/app/controllers/concerns/google_syndication_csp.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -module GoogleSyndicationCSP - extend ActiveSupport::Concern - - ALLOWED_SRC = ['*.google.com/pagead/landing', 'pagead2.googlesyndication.com/pagead/landing'].freeze - - included do - content_security_policy do |policy| - next unless helpers.google_tag_manager_enabled? || policy.directives.present? - - connect_src_values = Array.wrap( - policy.directives['connect-src'] || policy.directives['default-src'] - ) - - connect_src_values.concat(ALLOWED_SRC) if helpers.google_tag_manager_enabled? - - policy.connect_src(*connect_src_values.uniq) - end - end -end diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb index 5ceabaa734a..db1cf31d349 100644 --- a/app/controllers/confirmations_controller.rb +++ b/app/controllers/confirmations_controller.rb @@ -4,8 +4,6 @@ class ConfirmationsController < Devise::ConfirmationsController include AcceptsPendingInvitations include GitlabRecaptcha include OneTrustCSP - include GoogleAnalyticsCSP - include GoogleSyndicationCSP prepend_before_action :check_recaptcha, only: :create before_action :load_recaptcha, only: :new diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index a61b2af8117..72636a89433 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -7,8 +7,6 @@ class RegistrationsController < Devise::RegistrationsController include InvisibleCaptchaOnSignup include OneTrustCSP include BizibleCSP - include GoogleAnalyticsCSP - include GoogleSyndicationCSP include PreferredLanguageSwitcher include Gitlab::Tracking::Helpers::WeakPasswordErrorEvent include SkipsAlreadySignedInMessage @@ -27,10 +25,6 @@ class RegistrationsController < Devise::RegistrationsController check_rate_limit!(:user_sign_up, scope: request.ip) end - before_action only: [:new] do - push_frontend_feature_flag(:gitlab_gtm_datalayer, type: :ops) - end - feature_category :instance_resiliency helper_method :arkose_labs_enabled? diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index afbadc7f4ac..595d79abcf2 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -12,8 +12,6 @@ class SessionsController < Devise::SessionsController include OneTrustCSP include BizibleCSP include VerifiesWithEmail - include GoogleAnalyticsCSP - include GoogleSyndicationCSP include PreferredLanguageSwitcher include SkipsAlreadySignedInMessage include AcceptsPendingInvitations diff --git a/app/controllers/users/terms_controller.rb b/app/controllers/users/terms_controller.rb index f36b140f3a2..f7eb2aad9dc 100644 --- a/app/controllers/users/terms_controller.rb +++ b/app/controllers/users/terms_controller.rb @@ -4,7 +4,6 @@ module Users class TermsController < ApplicationController include InternalRedirect include OneTrustCSP - include GoogleAnalyticsCSP skip_before_action :authenticate_user!, only: [:index] skip_before_action :enforce_terms! @@ -14,10 +13,6 @@ module Users before_action :terms - before_action only: [:index] do - push_frontend_feature_flag(:gitlab_gtm_datalayer, type: :ops) - end - layout 'terms' feature_category :user_management diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index d09c0d3a542..fc157df3891 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -182,19 +182,6 @@ module AuthHelper current_user.allow_password_authentication_for_web? && !current_user.password_automatically_set? end - def google_tag_manager_enabled? - return false unless Gitlab.com? - - extra_config.has_key?('google_tag_manager_nonce_id') && - extra_config.google_tag_manager_nonce_id.present? - end - - def google_tag_manager_id - return unless google_tag_manager_enabled? - - extra_config.google_tag_manager_nonce_id - end - def auth_app_owner_text(owner) return unless owner diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 12c75d88264..bc1370d7e97 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -267,14 +267,14 @@ module MergeRequestsHelper '' end - link_to branch, branch_path, title: branch_title, class: 'gl-text-blue-500! gl-font-monospace gl-bg-blue-50 gl-rounded-base gl-font-sm gl-px-2 gl-display-inline-block gl-text-truncate gl-max-w-26 gl-mx-2' + link_to branch, branch_path, title: branch_title, class: 'ref-container gl-display-inline-block gl-text-truncate gl-max-w-26 gl-mx-2' end def merge_request_header(project, merge_request) link_to_author = link_to_member(project, merge_request.author, size: 24, extra_class: 'gl-font-weight-bold gl-mr-2', avatar: false) copy_button = clipboard_button(text: merge_request.source_branch, title: _('Copy branch name'), class: 'gl-display-none! gl-md-display-inline-block! js-source-branch-copy') - target_branch = link_to merge_request.target_branch, project_tree_path(merge_request.target_project, merge_request.target_branch), title: merge_request.target_branch, class: 'gl-text-blue-500! gl-font-monospace gl-bg-blue-50 gl-rounded-base gl-font-sm gl-px-2 gl-display-inline-block gl-text-truncate gl-max-w-26 gl-mx-2' + target_branch = link_to merge_request.target_branch, project_tree_path(merge_request.target_project, merge_request.target_branch), title: merge_request.target_branch, class: 'ref-container gl-display-inline-block gl-text-truncate gl-max-w-26 gl-mx-2' _('%{author} requested to merge %{source_branch} %{copy_button} into %{target_branch} %{created_at}').html_safe % { author: link_to_author.html_safe, source_branch: merge_request_source_branch(merge_request).html_safe, copy_button: copy_button.html_safe, target_branch: target_branch.html_safe, created_at: time_ago_with_tooltip(merge_request.created_at, html_class: 'gl-display-inline-block').html_safe } end diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb index 762ee0d92cd..f165bcb7a17 100644 --- a/app/presenters/ci/pipeline_presenter.rb +++ b/app/presenters/ci/pipeline_presenter.rb @@ -90,7 +90,7 @@ module Ci def link_to_pipeline_ref ApplicationController.helpers.link_to(pipeline.ref, project_commits_path(pipeline.project, pipeline.ref), - class: "ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2") + class: "ref-container gl-link") end def link_to_merge_request @@ -98,7 +98,7 @@ module Ci ApplicationController.helpers.link_to(merge_request_presenter.to_reference, project_merge_request_path(merge_request_presenter.project, merge_request_presenter), - class: 'mr-iid') + class: 'mr-iid ref-container') end def link_to_merge_request_source_branch @@ -140,7 +140,7 @@ module Ci all_related_merge_requests.first(limit).map do |merge_request| mr_path = project_merge_request_path(merge_request.project, merge_request) - ApplicationController.helpers.link_to "#{merge_request.to_reference} #{merge_request.title}", mr_path, class: 'mr-iid' + ApplicationController.helpers.link_to "#{merge_request.to_reference} #{merge_request.title}", mr_path, class: 'mr-iid ref-container' end end diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index 5c23af6e821..09845574aa1 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -197,7 +197,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated def source_branch_link if source_branch_exists? - link_to(source_branch, source_branch_commits_path, class: 'ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2') + link_to(source_branch, source_branch_commits_path, class: 'ref-container gl-link') else content_tag(:span, source_branch, class: 'ref-name') end @@ -205,7 +205,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated def target_branch_link if target_branch_exists? - link_to(target_branch, target_branch_commits_path, class: 'ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2') + link_to(target_branch, target_branch_commits_path, class: 'ref-container gl-link') else content_tag(:span, target_branch, class: 'ref-name') end diff --git a/app/views/devise/confirmations/almost_there.haml b/app/views/devise/confirmations/almost_there.haml index 1760e6e0f84..8075697b758 100644 --- a/app/views/devise/confirmations/almost_there.haml +++ b/app/views/devise/confirmations/almost_there.haml @@ -3,10 +3,10 @@ - registration_link_start = '<a href="%{new_user_registration_path}">'.html_safe % { new_user_registration_path: new_user_registration_path } - link_end = '</a>'.html_safe - content_for :page_specific_javascripts do - = render "layouts/google_tag_manager_head" + = render_if_exists "layouts/google_tag_manager_head" = render "layouts/one_trust" = render "layouts/bizible" -= render "layouts/google_tag_manager_body" += render_if_exists "layouts/google_tag_manager_body" = render_if_exists 'devise/shared/delete_unconfirmed_users_flash' diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml index 18856e04eb8..29f1a1f398b 100644 --- a/app/views/devise/registrations/new.html.haml +++ b/app/views/devise/registrations/new.html.haml @@ -2,10 +2,10 @@ - page_description _("Join GitLab today! You and your team can plan, build, and ship secure code all in one application. Get started here for free!") - add_page_specific_style 'page_bundles/signup' - content_for :page_specific_javascripts do - = render "layouts/google_tag_manager_head" + = render_if_exists "layouts/google_tag_manager_head" = render "layouts/one_trust" = render "layouts/bizible" -= render "layouts/google_tag_manager_body" += render_if_exists "layouts/google_tag_manager_body" - content_for :omniauth_providers_bottom do = render 'devise/shared/signup_omniauth_providers' diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml index 0fd27f7f7e7..acfb16b64cd 100644 --- a/app/views/devise/sessions/new.html.haml +++ b/app/views/devise/sessions/new.html.haml @@ -1,13 +1,13 @@ - page_title _("Sign in") - content_for :page_specific_javascripts do - = render "layouts/google_tag_manager_head" + = render_if_exists "layouts/google_tag_manager_head" = render "layouts/one_trust" - content_for :sessions_broadcast do = render "devise/sessions/broadcast" -= render "layouts/google_tag_manager_body" += render_if_exists "layouts/google_tag_manager_body" #signin-container - if any_form_based_providers_enabled? diff --git a/app/views/layouts/_google_tag_manager_body.html.haml b/app/views/layouts/_google_tag_manager_body.html.haml deleted file mode 100644 index 98d7bf5d138..00000000000 --- a/app/views/layouts/_google_tag_manager_body.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -- return unless google_tag_manager_enabled? - -<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=#{google_tag_manager_id}" -height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript> diff --git a/app/views/layouts/_google_tag_manager_head.html.haml b/app/views/layouts/_google_tag_manager_head.html.haml deleted file mode 100644 index 9f16b2c9005..00000000000 --- a/app/views/layouts/_google_tag_manager_head.html.haml +++ /dev/null @@ -1,42 +0,0 @@ -- return unless google_tag_manager_enabled? - -- if Feature.enabled?(:gitlab_gtm_datalayer, type: :ops) - = javascript_tag do - :plain - window.dataLayer = window.dataLayer || []; - function gtag(){dataLayer.push(arguments);} - - gtag('consent', 'default', { - 'analytics_storage': 'granted', - 'ad_storage': 'granted', - 'functionality_storage': 'granted', - 'wait_for_update': 500 - }); - - gtag('consent', 'default', { - 'analytics_storage': 'denied', - 'ad_storage': 'denied', - 'functionality_storage': 'denied', - 'region': ['AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'IS', 'LI', 'NO', 'GB', 'PE', 'RU'], - 'wait_for_update': 500 - }); - - window.geofeed = (options) => { - dataLayer.push({ - 'event' : 'OneTrustCountryLoad', - 'oneTrustCountryId': options.country.toString() - }) - } - - const json = document.createElement('script'); - json.setAttribute('src', 'https://geolocation.onetrust.com/cookieconsentpub/v1/geo/location/geofeed'); - document.head.appendChild(json); - -= javascript_tag nonce: content_security_policy_nonce do - :plain - (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': - new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0], - j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= - 'https://www.googletagmanager.com/gtm.js?id='+i+dl;j.setAttribute('nonce', - '#{content_security_policy_nonce}');f.parentNode.insertBefore(j,f); - })(window,document,'script','dataLayer','#{google_tag_manager_id}'); diff --git a/app/views/users/terms/index.html.haml b/app/views/users/terms/index.html.haml index afe257c2fc2..9f7c17dad9a 100644 --- a/app/views/users/terms/index.html.haml +++ b/app/views/users/terms/index.html.haml @@ -1,7 +1,7 @@ - content_for :page_specific_javascripts do - = render "layouts/google_tag_manager_head" + = render_if_exists "layouts/google_tag_manager_head" = render "layouts/one_trust" = render "layouts/bizible" -= render "layouts/google_tag_manager_body" += render_if_exists "layouts/google_tag_manager_body" #js-terms-of-service{ data: { terms_data: terms_data(@term, @redirect) } } diff --git a/doc/administration/moderate_users.md b/doc/administration/moderate_users.md index 3095f696978..f2a5e25c368 100644 --- a/doc/administration/moderate_users.md +++ b/doc/administration/moderate_users.md @@ -73,31 +73,24 @@ GitLab administrators can block and unblock users. ### Block a user -To completely prevent access of a user to the GitLab instance, -administrators can choose to block the user. +Prerequisite: -Users can be blocked [via an abuse report](../administration/review_abuse_reports.md#blocking-users), -by removing them in LDAP, or directly from the Admin Area. To do this: +- You must be an administrator for the instance. -1. On the left sidebar, select **Search or go to**. -1. Select **Admin Area**. -1. Select **Overview > Users**. -1. Optional. Select a user. -1. Select the **{settings}** **User administration** dropdown list. -1. Select **Block**. +You can block a user's access to the instance. When you block a user, they receive an email notification that their account has been blocked. After this email, they no longer receive notifications. A blocked user: -A blocked user: +- Cannot sign in or access any repositories, but all of their data remains in those repositories. +- Cannot use slash commands. For more information, see [slash commands](../user/project/integrations/gitlab_slack_application.md#slash-commands). +- Does not occupy a seat. For more information, see [billable users](../subscriptions/self_managed/index.md#billable-users). -- Cannot sign in. -- Cannot access Git repositories or the API. -- Does not receive any notifications from GitLab. -- Cannot use [slash commands](../user/project/integrations/gitlab_slack_application.md#slash-commands). -- Does not consume a [seat](../subscriptions/self_managed/index.md#billable-users). +To block a user: -Personal projects, and group and user history of the blocked user are left intact. +1. On the left sidebar, select **Search or go to**. +1. Select **Admin Area**. +1. Select **Overview > Users**. +1. For the user you want to deactivate, select the vertical ellipsis (**{ellipsis_v}**) and then **Block**. -NOTE: -Users can also be blocked using the [GitLab API](../api/users.md#block-user). +To report abuse from other users, see [report abuse](../user/report_abuse.md). For more information on abuse reports in the Admin area, see [resolving abuse reports](../administration/review_abuse_reports.md#resolving-abuse-reports). ### Unblock a user @@ -136,44 +129,27 @@ GitLab administrators can deactivate and activate users. > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/22257) in GitLab 12.4. -To temporarily prevent access by a GitLab user that has no recent activity, -administrators can choose to deactivate the user. - -Deactivating a user is functionally identical to [blocking a user](#block-and-unblock-users), -with the following differences: - -- It does not prohibit the user from logging back in via the UI. -- Once a deactivated user logs back into the GitLab UI, their account is set to active. +You can temporarily deactivate a user who has no recent activity. -A deactivated user: +The user you deactivate must be dormant. When you deactivate a user, their projects, group, and history remain. A deactivated user: -- Cannot access Git repositories or the API. -- Does not receive any notifications from GitLab. -- Cannot use [slash commands](../user/project/integrations/gitlab_slack_application.md#slash-commands). -- Does not consume a [seat](../subscriptions/self_managed/index.md#billable-users). +- Cannot access repositories or the API. +- Cannot use slash commands. For more information, see [slash commands](../user/project/integrations/gitlab_slack_application.md#slash-commands). +- Does not occupy a seat. For more information, see [billable users](../subscriptions/self_managed/index.md#billable-users). -Personal projects, and group and user history of the deactivated user are left intact. +Deactiviation is similar to blocking, but there are a few important differences. Deactivating a user does not prohibit the user from signing into the GitLab UI. A deactivated user can become active again by signing in. -NOTE: -Users are notified about account deactivation if -[user deactivation emails](../administration/settings/email.md#user-deactivation-emails) are enabled. - -A user can be deactivated from the Admin Area. To do this: +To deactivate a user from the Admin Area: 1. On the left sidebar, select **Search or go to**. 1. Select **Admin Area**. 1. Select **Overview > Users**. -1. Optional. Select a user. -1. Select the **{settings}** **User administration** dropdown list. -1. Select **Deactivate**. +1. For the user you want to deactivate, select the vertical ellipsis (**{ellipsis_v}**) and then **Deactivate**. +1. On the dialog, select **Deactivate**. -For the deactivation option to be visible to an administrator, the user: +Email notifications stop after deactivation. GitLab sends email notifications to users when their account has been deactivated. For more information about this feature, see [user deactivation emails](../administration/settings/email.md#user-deactivation-emails). -- Must have a state of active. -- Must be [dormant](#automatically-deactivate-dormant-users). - -NOTE: -Users can also be deactivated using the [GitLab API](../api/users.md#deactivate-user). +To deactivate users with the GitLab API, see [deactivate user](../api/users.md#deactivate-user). For information about permanent user restrictions, see [block and unblock users](#block-and-unblock-users). ### Automatically deactivate dormant users diff --git a/doc/api/releases/index.md b/doc/api/releases/index.md index 0d3ef0531c8..c5d1c596cfd 100644 --- a/doc/api/releases/index.md +++ b/doc/api/releases/index.md @@ -8,12 +8,13 @@ info: To determine the technical writer assigned to the Stage/Group associated w > - Release Evidences were [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/26019) in GitLab 12.5. > - `description_html` became an opt-in field [with GitLab 13.12 for performance reasons](https://gitlab.com/gitlab-org/gitlab/-/issues/299447). - Please pass the `include_html_description` query string parameter if you need it. + You might also pass the `include_html_description` query string as a parameter. > - [The permission model for create, update and delete actions was fixed](https://gitlab.com/gitlab-org/gitlab/-/issues/327505) in GitLab 14.1. - See [Release permissions](../../user/project/releases/index.md#release-permissions) for more information. + For more information, see [Release permissions](../../user/project/releases/index.md#release-permissions). -Use this API to manipulate GitLab [Release](../../user/project/releases/index.md) -entries. For manipulating links as a release asset, see [Release Links API](links.md). +Use this API to manipulate [release entries](../../user/project/releases/index.md). + +To manipulate links as a release asset, see [Release Links API](links.md). ## Authentication diff --git a/doc/api/vulnerabilities.md b/doc/api/vulnerabilities.md index 9c496ba8bdc..dc5e5c5f509 100644 --- a/doc/api/vulnerabilities.md +++ b/doc/api/vulnerabilities.md @@ -17,9 +17,8 @@ This document now describes the new Vulnerabilities API that provides access to WARNING: This API is in the process of being deprecated and considered unstable. The response payload may be subject to change or breakage -across GitLab releases. Please use the -[GraphQL API](graphql/reference/index.md#queryvulnerabilities) -instead. See the [GraphQL examples](#replace-vulnerability-rest-api-with-graphql) to get started. +across GitLab releases. Use the +[GraphQL API](graphql/reference/index.md#queryvulnerabilities) instead. For more information, see [GraphQL examples](#replace-vulnerability-rest-api-with-graphql). Every API call to vulnerabilities must be [authenticated](rest/index.md#authentication). diff --git a/doc/api/vulnerability_findings.md b/doc/api/vulnerability_findings.md index 05ae42d9100..d2960bf17a6 100644 --- a/doc/api/vulnerability_findings.md +++ b/doc/api/vulnerability_findings.md @@ -23,9 +23,8 @@ any request for vulnerability findings of this project returns a `403 Forbidden` WARNING: This API is in the process of being deprecated and considered unstable. The response payload may be subject to change or breakage -across GitLab releases. Please use the -[GraphQL API](graphql/reference/index.md#queryvulnerabilities) -instead. See the [GraphQL examples](#replace-vulnerability-findings-rest-api-with-graphql) to get started. +across GitLab releases. Use the +[GraphQL API](graphql/reference/index.md#queryvulnerabilities) instead. For more information, see [GraphQL examples](../api/vulnerabilities.md#replace-vulnerability-rest-api-with-graphql) ## Vulnerability findings pagination diff --git a/doc/development/contributing/design.md b/doc/development/contributing/design.md index 59e35e658e7..bc6ec96cf49 100644 --- a/doc/development/contributing/design.md +++ b/doc/development/contributing/design.md @@ -105,8 +105,8 @@ Check accessibility using your browser's _accessibility inspector_ ([Chrome](htt - Conform to level AA of the World Wide Web Consortium (W3C) [Web Content Accessibility Guidelines 2.1](https://www.w3.org/TR/WCAG21/), according to our [statement of compliance](https://design.gitlab.com/accessibility/a11y/). -- Follow accessibility [best practices](https://design.gitlab.com/accessibility/best-practices/) - and [checklist](../fe_guide/accessibility.md#quick-checklist). +- Follow accessibility [Pajamas' best practices](https://design.gitlab.com/accessibility/best-practices/) + and read the accessibility developer documentation's [checklist](../fe_guide/accessibility/best_practices.md#quick-checklist). ### Handoff diff --git a/doc/development/fe_guide/accessibility.md b/doc/development/fe_guide/accessibility.md index 7ad0239d726..3915f63f701 100644 --- a/doc/development/fe_guide/accessibility.md +++ b/doc/development/fe_guide/accessibility.md @@ -1,657 +1,11 @@ --- -stage: none -group: unassigned -info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments +redirect_to: 'accessibility/index.md' +remove_date: '2024-01-12' --- -# Accessibility +This document was moved to [another location](accessibility/index.md). -Accessibility is important for users who use screen readers or rely on keyboard-only functionality -to ensure they have an equivalent experience to sighted mouse users. - -This page contains guidelines we should follow. - -## Quick summary - -Since [no ARIA is better than bad ARIA](https://w3c.github.io/aria-practices/#no_aria_better_bad_aria), -review the following recommendations before using `aria-*`, `role`, and `tabindex`. -Use semantic HTML, which has accessibility semantics baked in, and ideally test with -[relevant combinations of screen readers and browsers](https://www.accessibility-developer-guide.com/knowledge/screen-readers/relevant-combinations/). - -In [WebAIM's accessibility analysis of the top million home pages](https://webaim.org/projects/million/#aria), -they found that "ARIA correlated to higher detectable errors". -It is likely that *misuse* of ARIA is a big cause of increased errors, -so when in doubt don't use `aria-*`, `role`, and `tabindex` and stick with semantic HTML. - -## Enable keyboard navigation on macOS - -By default, macOS limits the <kbd>tab</kbd> key to **Text boxes and lists only**. To enable full keyboard navigation: - -1. Open **System Preferences**. -1. Select **Keyboard**. -1. Open the **Shortcuts** tab. -1. Enable the setting **Use keyboard navigation to move focus between controls**. - -You can read more about enabling browser-specific keyboard navigation on [a11yproject](https://www.a11yproject.com/posts/macos-browser-keyboard-navigation/). - -## Quick checklist - -- [Text](#text-inputs-with-accessible-names), - [select](#select-inputs-with-accessible-names), - [checkbox](#checkbox-inputs-with-accessible-names), - [radio](#radio-inputs-with-accessible-names), - [file](#file-inputs-with-accessible-names), - and [toggle](#gltoggle-components-with-an-accessible-names) inputs have accessible names. -- [Buttons](#buttons-and-links-with-descriptive-accessible-names), - [links](#buttons-and-links-with-descriptive-accessible-names), - and [images](#images-with-accessible-names) have descriptive accessible names. -- Icons - - [Non-decorative icons](#icons-that-convey-information) have an `aria-label`. - - [Clickable icons](#icons-that-are-clickable) are buttons, that is, `<gl-button icon="close" />` is used and not `<gl-icon />`. - - Icon-only buttons have an `aria-label`. -- Interactive elements can be [accessed with the Tab key](#support-keyboard-only-use) and have a visible focus state. -- Elements with [tooltips](#tooltips) are focusable using the Tab key. -- Are any `role`, `tabindex` or `aria-*` attributes unnecessary? -- Can any `div` or `span` elements be replaced with a more semantic [HTML element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element) like `p`, `button`, or `time`? - -## Provide a good document outline - -[Headings are the primary mechanism used by screen reader users to navigate content](https://webaim.org/projects/screenreadersurvey8/#finding). -Therefore, the structure of headings on a page should make sense, like a good table of contents. -We should ensure that: - -- There is only one `h1` element on the page. -- Heading levels are not skipped. -- Heading levels are nested correctly. - -## Provide accessible names for screen readers - -To provide markup with accessible names, ensure every: - -- input has an [associated `label`](#examples-of-providing-accessible-names). -- button and link have [visible text](#buttons-and-links-with-descriptive-accessible-names), or `aria-label` when there is no visible text, such as for an icon button with no content. -- image has an [`alt` attribute](#images-with-accessible-names). -- `fieldset` has `legend` as its first child. -- `figure` has `figcaption` as its first child. -- `table` has `caption` as its first child. - -Groups of checkboxes and radio inputs should be grouped together in a `fieldset` with a `legend`. -`legend` gives the group of checkboxes and radio inputs a label. - -If the `label`, child text, or child element is not visually desired, -use `.gl-sr-only` to hide the element from everything but screen readers. - -### Examples of providing accessible names - -The following subsections contain examples of markup that render HTML elements with accessible names. - -Note that [when using `GlFormGroup`](https://bootstrap-vue.org/docs/components/form-group#accessibility): - -- Passing only a `label` prop renders a `fieldset` with a `legend` containing the `label` value. -- Passing both a `label` and a `label-for` prop renders a `label` that points to the form input with the same `label-for` ID. - -#### Text inputs with accessible names - -When using `GlFormGroup`, the `label` prop alone does not give the input an accessible name. -The `label-for` prop must also be provided to give the input an accessible name. - -Text input examples: - -```html -<!-- Input with label --> -<gl-form-group :label="__('Issue title')" label-for="issue-title"> - <gl-form-input id="issue-title" v-model="title" /> -</gl-form-group> - -<!-- Input with hidden label --> -<gl-form-group :label="__('Issue title')" label-for="issue-title" label-sr-only> - <gl-form-input id="issue-title" v-model="title" /> -</gl-form-group> -``` - -`textarea` examples: - -```html -<!-- textarea with label --> -<gl-form-group :label="__('Issue description')" label-for="issue-description"> - <gl-form-textarea id="issue-description" v-model="description" /> -</gl-form-group> - -<!-- textarea with hidden label --> -<gl-form-group :label="__('Issue description')" label-for="issue-description" label-sr-only> - <gl-form-textarea id="issue-description" v-model="description" /> -</gl-form-group> -``` - -Alternatively, you can use a plain `label` element: - -```html -<!-- Input with label using `label` --> -<label for="issue-title">{{ __('Issue title') }}</label> -<gl-form-input id="issue-title" v-model="title" /> - -<!-- Input with hidden label using `label` --> -<label for="issue-title" class="gl-sr-only">{{ __('Issue title') }}</label> -<gl-form-input id="issue-title" v-model="title" /> -``` - -#### Select inputs with accessible names - -Select input examples: - -```html -<!-- Select input with label --> -<gl-form-group :label="__('Issue status')" label-for="issue-status"> - <gl-form-select id="issue-status" v-model="status" :options="options" /> -</gl-form-group> - -<!-- Select input with hidden label --> -<gl-form-group :label="__('Issue status')" label-for="issue-status" label-sr-only> - <gl-form-select id="issue-status" v-model="status" :options="options" /> -</gl-form-group> -``` - -#### Checkbox inputs with accessible names - -Single checkbox: - -```html -<!-- Single checkbox with label --> -<gl-form-checkbox v-model="status" value="task-complete"> - {{ __('Task complete') }} -</gl-form-checkbox> - -<!-- Single checkbox with hidden label --> -<gl-form-checkbox v-model="status" value="task-complete"> - <span class="gl-sr-only">{{ __('Task complete') }}</span> -</gl-form-checkbox> -``` - -Multiple checkboxes: - -```html -<!-- Multiple labeled checkboxes grouped within a fieldset --> -<gl-form-group :label="__('Task list')"> - <gl-form-checkbox value="task-1">{{ __('Task 1') }}</gl-form-checkbox> - <gl-form-checkbox value="task-2">{{ __('Task 2') }}</gl-form-checkbox> -</gl-form-group> - -<!-- Or --> -<gl-form-group :label="__('Task list')"> - <gl-form-checkbox-group v-model="selected" :options="options" /> -</gl-form-group> - -<!-- Multiple labeled checkboxes grouped within a fieldset with hidden legend --> -<gl-form-group :label="__('Task list')" label-sr-only> - <gl-form-checkbox value="task-1">{{ __('Task 1') }}</gl-form-checkbox> - <gl-form-checkbox value="task-2">{{ __('Task 2') }}</gl-form-checkbox> -</gl-form-group> - -<!-- Or --> -<gl-form-group :label="__('Task list')" label-sr-only> - <gl-form-checkbox-group v-model="selected" :options="options" /> -</gl-form-group> -``` - -#### Radio inputs with accessible names - -Single radio input: - -```html -<!-- Single radio with a label --> -<gl-form-radio v-model="status" value="opened"> - {{ __('Opened') }} -</gl-form-radio> - -<!-- Single radio with a hidden label --> -<gl-form-radio v-model="status" value="opened"> - <span class="gl-sr-only">{{ __('Opened') }}</span> -</gl-form-radio> -``` - -Multiple radio inputs: - -```html -<!-- Multiple labeled radio inputs grouped within a fieldset --> -<gl-form-group :label="__('Issue status')"> - <gl-form-radio value="opened">{{ __('Opened') }}</gl-form-radio> - <gl-form-radio value="closed">{{ __('Closed') }}</gl-form-radio> -</gl-form-group> - -<!-- Or --> -<gl-form-group :label="__('Issue status')"> - <gl-form-radio-group v-model="selected" :options="options" /> -</gl-form-group> - -<!-- Multiple labeled radio inputs grouped within a fieldset with hidden legend --> -<gl-form-group :label="__('Issue status')" label-sr-only> - <gl-form-radio value="opened">{{ __('Opened') }}</gl-form-radio> - <gl-form-radio value="closed">{{ __('Closed') }}</gl-form-radio> -</gl-form-group> - -<!-- Or --> -<gl-form-group :label="__('Issue status')" label-sr-only> - <gl-form-radio-group v-model="selected" :options="options" /> -</gl-form-group> -``` - -#### File inputs with accessible names - -File input examples: - -```html -<!-- File input with a label --> -<label for="attach-file">{{ __('Attach a file') }}</label> -<input id="attach-file" type="file" /> - -<!-- File input with a hidden label --> -<label for="attach-file" class="gl-sr-only">{{ __('Attach a file') }}</label> -<input id="attach-file" type="file" /> -``` - -#### GlToggle components with an accessible names - -`GlToggle` examples: - -```html -<!-- GlToggle with label --> -<gl-toggle v-model="notifications" :label="__('Notifications')" /> - -<!-- GlToggle with hidden label --> -<gl-toggle v-model="notifications" :label="__('Notifications')" label-position="hidden" /> -``` - -#### GlFormCombobox components with an accessible names - -`GlFormCombobox` examples: - -```html -<!-- GlFormCombobox with label --> -<gl-form-combobox :label-text="__('Key')" :token-list="$options.tokenList" /> -``` - -#### Images with accessible names - -Image examples: - -```html -<img :src="imagePath" :alt="__('A description of the image')" /> - -<!-- SVGs implicitly have a graphics role so if it is semantically an image we should apply `role="img"` --> -<svg role="img" :alt="__('A description of the image')" /> - -<!-- A decorative image, hidden from screen readers --> -<img :src="imagePath" :alt="" /> -``` - -#### Buttons and links with descriptive accessible names - -Buttons and links should have accessible names that are descriptive enough to be understood in isolation. - -```html -<!-- bad --> -<gl-button @click="handleClick">{{ __('Submit') }}</gl-button> - -<gl-link :href="url">{{ __('page') }}</gl-link> - -<!-- good --> -<gl-button @click="handleClick">{{ __('Submit review') }}</gl-button> - -<gl-link :href="url">{{ __("GitLab's accessibility page") }}</gl-link> -``` - -#### Links styled like buttons - -Links can be styled like buttons using `GlButton`. - -```html - <gl-button :href="url">{{ __('Link styled as a button') }}</gl-button> -``` - -## Role - -In general, avoid using `role`. -Use semantic HTML elements that implicitly have a `role` instead. - -| Bad | Good | -| --- | --- | -| `<div role="button">` | `<button>` | -| `<div role="img">` | `<img>` | -| `<div role="link">` | `<a>` | -| `<div role="header">` | `<h1>` to `<h6>` | -| `<div role="textbox">` | `<input>` or `<textarea>` | -| `<div role="article">` | `<article>` | -| `<div role="list">` | `<ol>` or `<ul>` | -| `<div role="listitem">` | `<li>` | -| `<div role="table">` | `<table>` | -| `<div role="rowgroup">` | `<thead>`, `<tbody>`, or `<tfoot>` | -| `<div role="row">` | `<tr>` | -| `<div role="columnheader">` | `<th>` | -| `<div role="cell">` | `<td>` | - -## Support keyboard-only use - -Keyboard users rely on focus outlines to understand where they are on the page. Therefore, if an -element is interactive you must ensure: - -- It can receive keyboard focus. -- It has a visible focus state. - -Use semantic HTML, such as `a` (`GlLink`) and `button` (`GlButton`), which provides these behaviours by default. - -Keep in mind that: - -- <kbd>Tab</kbd> and <kbd>Shift-Tab</kbd> should only move between interactive elements, not static content. -- When you add `:hover` styles, in most cases you should add `:focus` styles too so that the styling is applied for both mouse **and** keyboard users. -- If you remove an interactive element's `outline`, make sure you maintain visual focus state in another way such as with `box-shadow`. - -See the [Pajamas Keyboard-only page](https://design.gitlab.com/accessibility/keyboard-only) for more detail. - -## `tabindex` - -Prefer **no** `tabindex` to using `tabindex`, since: - -- Using semantic HTML such as `button` (`GlButton`) implicitly provides `tabindex="0"`. -- Tabbing order should match the visual reading order and positive `tabindex`s interfere with this. - -### Avoid using `tabindex="0"` to make an element interactive - -Use interactive elements instead of `div` and `span` tags. -For example: - -- If the element should be clickable, use a `button` (`GlButton`). -- If the element should be text editable, use an [`input` or `textarea`](#text-inputs-with-accessible-names). - -Once the markup is semantically complete, use CSS to update it to its desired visual state. - -```html -<!-- bad --> -<div role="button" tabindex="0" @click="expand">Expand</div> - -<!-- good --> -<gl-button class="gl-p-0!" category="tertiary" @click="expand">Expand</gl-button> -``` - -### Do not use `tabindex="0"` on interactive elements - -Interactive elements are already tab accessible so adding `tabindex` is redundant. - -```html -<!-- bad --> -<gl-link href="help" tabindex="0">Help</gl-link> -<gl-button tabindex="0">Submit</gl-button> - -<!-- good --> -<gl-link href="help">Help</gl-link> -<gl-button>Submit</gl-button> -``` - -### Do not use `tabindex="0"` on elements for screen readers to read - -Screen readers can read text that is not tab accessible. -The use of `tabindex="0"` is unnecessary and can cause problems, -as screen reader users then expect to be able to interact with it. - -```html -<!-- bad --> -<p tabindex="0" :aria-label="message">{{ message }}</p> - -<!-- good --> -<p>{{ message }}</p> -``` - -### Do not use a positive `tabindex` - -[Always avoid using `tabindex="1"`](https://webaim.org/techniques/keyboard/tabindex#overview) -or greater. - -## Icons - -Icons can be split into three different types: - -- Icons that are decorative -- Icons that convey meaning -- Icons that are clickable - -### Icons that are decorative - -Icons are decorative when there's no loss of information to the user when they are removed from the UI. - -As the majority of icons within GitLab are decorative, `GlIcon` automatically hides its rendered icons from screen readers. -Therefore, you do not need to add `aria-hidden="true"` to `GlIcon`, as this is redundant. - -```html -<!-- unnecessary — gl-icon hides icons from screen readers by default --> -<gl-icon name="rocket" aria-hidden="true" />` - -<!-- good --> -<gl-icon name="rocket" />` -``` - -### Icons that convey information - -Icons convey information if there is loss of information to the user when they are removed from the UI. - -An example is a confidential icon that conveys the issue is confidential, and does not have the text "Confidential" next to it. - -Icons that convey information must have an accessible name so that the information is conveyed to screen reader users too. - -```html -<!-- bad --> -<gl-icon name="eye-slash" />` - -<!-- good --> -<gl-icon name="eye-slash" :aria-label="__('Confidential issue')" />` -``` - -### Icons that are clickable - -Icons that are clickable are semantically buttons, so they should be rendered as buttons, with an accessible name. - -```html -<!-- bad --> -<gl-icon name="close" :aria-label="__('Close')" @click="handleClick" /> - -<!-- good --> -<gl-button icon="close" category="tertiary" :aria-label="__('Close')" @click="handleClick" /> -``` - -## Tooltips - -When adding tooltips, we must ensure that the element with the tooltip can receive focus so keyboard users can see the tooltip. -If the element is a static one, such as an icon, we can enclose it in a button, which already is -focusable, so we don't have to add `tabindex=0` to the icon. - -The following code snippet is a good example of an icon with a tooltip. - -- It is automatically focusable, as it is a button. -- It is given an accessible name with `aria-label`, as it is a button with no text. -- We can use the `gl-hover-bg-transparent!` class if we don't want the button's background to become gray on hover. -- We can use the `gl-p-0!` class to remove the button padding, if needed. - -```html -<gl-button - v-gl-tooltip - class="gl-hover-bg-transparent! gl-p-0!" - icon="warning" - category="tertiary" - :title="tooltipText" - :aria-label="__('Warning')" -/> -``` - -## Hiding elements - -Use the following table to hide elements from users, when appropriate. - -| Hide from sighted users | Hide from screen readers | Hide from both sighted and screen reader users | -| --- | --- | --- | -| `.gl-sr-only` | `aria-hidden="true"` | `display: none`, `visibility: hidden`, or `hidden` attribute | - -### Hide decorative images from screen readers - -To reduce noise for screen reader users, hide decorative images using `alt=""`. -If the image is not an `img` element, such as an inline SVG, you can hide it by adding both `role="img"` and `alt=""`. - -`gl-icon` components automatically hide their icons from screen readers so `aria-hidden="true"` is -unnecessary when using `gl-icon`. - -```html -<!-- good - decorative images hidden from screen readers --> - -<img src="decorative.jpg" alt=""> - -<svg role="img" alt="" /> - -<gl-icon name="epic" /> -``` - -## When to use ARIA - -No ARIA is required when using semantic HTML, because it already incorporates accessibility. - -However, there are some UI patterns that do not have semantic HTML equivalents. -General examples of these are dialogs (modals) and tabs. -GitLab-specific examples are assignee and label dropdowns. -Building such widgets require ARIA to make them understandable to screen readers. -Proper research and testing should be done to ensure compliance with [WCAG](https://www.w3.org/WAI/standards-guidelines/wcag/). - -## Automated accessibility testing with axe - -We use [axe-core](https://github.com/dequelabs/axe-core) [gems](https://github.com/dequelabs/axe-core-gems) -to run automated accessibility tests in feature tests. - -[We aim to conform to level AA of the World Wide Web Consortium (W3C) Web Content Accessibility Guidelines 2.1](https://design.gitlab.com/accessibility/a11y). - -### When to add accessibility tests - -When adding a new view to the application, make sure to include the accessibility check in your feature test. -We aim to have full coverage for all the views. - -One of the advantages of testing in feature tests is that we can check different states, not only -single components in isolation. - -You can find some examples on how to approach accessibility checks below. - -#### Empty state - -Some views have an empty state that result in a page structure that's different from the default view. -They may also offer some actions, for example to create a first issue or to enable a feature. -In this case, add assertions for both an empty state and a default view. - -#### Ensure compliance before user interactions - -Often we test against a number of steps we expect our users to perform. -In this case, make sure to include the check early on, before any of them has been simulated. -This way we ensure there are no barriers to what we expect of users. - -#### Ensure compliance after changed page structure - -User interactions may result in significant changes in page structure. For example, a modal is shown, or a new section is rendered. -In that case, add an assertion after any such change. -We want to make sure that users are able to interact with all available components. - -#### Separate file for extensive test suites - -For some views, feature tests span multiple files. -Take a look at our [feature tests for a merge request](https://gitlab.com/gitlab-org/gitlab/-/tree/master/spec/features/merge_request). -The number of user interactions that needs to be covered is too big to fit into one test file. -As a result, multiple feature tests cover one view, with different user privileges, or data sets. -If we were to include accessibility checks in all of them, there is a chance we would cover the same states of a view multiple times and significantly increase the run time. -It would also make it harder to determine the coverage for accessibility, if assertions would be scattered across many files. - -In that case, consider creating one test file dedicated to accessibility. -Place it in the same directory and name it `accessibility_spec.rb`, for example `spec/features/merge_request/accessibility_spec.rb`. -Make it explicit that a feature test has accessibility coverage in a separate file, and -doesn't need additional assertions. Include this comment below the opening of the -top-level block: - -```ruby -# spec/features/merge_request/user_approves_spec.rb - -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'Merge request > User approves', :js, feature_category: :code_review_workflow do -# covered by ./accessibility_spec.rb -``` - -#### Shared examples - -Often feature tests include shared examples for a number of scenarios. -If they differ only by provided data, but are based on the same user interaction, you can check for accessibility compliance outside the shared examples. -This way we only run the check once and save resources. - -### How to add accessibility tests - -Axe provides the custom matcher `be_axe_clean`, which can be used like the following: - -```ruby -# spec/features/settings_spec.rb -it 'passes axe automated accessibility testing', :js do - visit_settings_page - - wait_for_requests # ensures page is fully loaded - - expect(page).to be_axe_clean -end -``` - -If needed, you can scope testing to a specific area of the page by using `within`. - -Axe also provides specific [clauses](https://github.com/dequelabs/axe-core-gems/blob/develop/packages/axe-core-rspec/README.md#clauses), -for example: - -```ruby -expect(page).to be_axe_clean.within '[data-testid="element"]' - -# run only WCAG 2.1 Level AA rules -expect(page).to be_axe_clean.according_to :wcag21aa - -# specifies which rule to skip -expect(page).to be_axe_clean.skipping :'link-in-text-block' - -# clauses can be chained -expect(page).to be_axe_clean.within('[data-testid="element"]') - .according_to(:wcag21aa) -``` - -Axe does not test hidden regions, such as inactive menus or modal windows. To test -hidden regions for accessibility, write tests that activate or render the regions visible -and run the matcher again. - -You can run accessibility tests locally in the same way as you [run any feature tests](../testing_guide/frontend_testing.md#how-to-run-a-feature-test). - -After adding accessibility tests, make sure to fix all possible errors. -For help on how to do it, refer to [this guide](#quick-checklist). -You can also check accessibility sections in [Pajamas components' documentation](https://design.gitlab.com/components/overview). -If any of the errors require global changes, create a follow-up issue and assign these labels: `accessability`, `WG::product accessibility`. - -### Known accessibility violations - -This section documents violations where a recommendation differs with the [design system](https://design.gitlab.com/): - -- `link-in-text-block`: For now, use the `skipping` clause to skip `:'link-in-text-block'` - rule to fix the violation. After this is fixed as part of [issue 1444](https://gitlab.com/gitlab-org/gitlab-services/design.gitlab.com/-/issues/1444) - and underline is added to the `GlLink` component, this clause can be removed. - -## Resources - -### Viewing the browser accessibility tree - -- [Firefox DevTools guide](https://developer.mozilla.org/en-US/docs/Tools/Accessibility_inspector#accessing_the_accessibility_inspector) -- [Chrome DevTools guide](https://developer.chrome.com/docs/devtools/accessibility/reference/#pane) - -### Browser extensions - -We have two options for Web accessibility testing: - -- [axe](https://www.deque.com/axe/) for [Firefox](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/) -- [axe](https://www.deque.com/axe/) for [Chrome](https://chrome.google.com/webstore/detail/axe-devtools-web-accessib/lhdoppojpmngadmnindnejefpokejbdd) - -### Other links - -- [The A11Y Project](https://www.a11yproject.com/) is a good resource for accessibility -- [Awesome Accessibility](https://github.com/brunopulis/awesome-a11y) - is a compilation of accessibility-related material +<!-- This redirect file can be deleted after <2024-01-12>. --> +<!-- Redirects that point to other docs in the same project expire in three months. --> +<!-- Redirects that point to docs in a different project or site (link is not relative and starts with `https:`) expire in one year. --> +<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/redirects.html --> diff --git a/doc/development/fe_guide/accessibility/automated_testing.md b/doc/development/fe_guide/accessibility/automated_testing.md new file mode 100644 index 00000000000..2c0d598dc58 --- /dev/null +++ b/doc/development/fe_guide/accessibility/automated_testing.md @@ -0,0 +1,125 @@ +--- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments +--- + +# Automated accessibility testing + +We use [axe-core](https://github.com/dequelabs/axe-core) [gems](https://github.com/dequelabs/axe-core-gems) +to run automated accessibility tests in feature tests. + +[We aim to conform to level AA of the World Wide Web Consortium (W3C) Web Content Accessibility Guidelines 2.1](https://design.gitlab.com/accessibility/a11y). + +## When to add accessibility tests + +When adding a new view to the application, make sure to include the accessibility check in your feature test. +We aim to have full coverage for all the views. + +One of the advantages of testing in feature tests is that we can check different states, not only +single components in isolation. + +You can find some examples on how to approach accessibility checks below. + +### Empty state + +Some views have an empty state that result in a page structure that's different from the default view. +They may also offer some actions, for example to create a first issue or to enable a feature. +In this case, add assertions for both an empty state and a default view. + +### Ensure compliance before user interactions + +Often we test against a number of steps we expect our users to perform. +In this case, make sure to include the check early on, before any of them has been simulated. +This way we ensure there are no barriers to what we expect of users. + +### Ensure compliance after changed page structure + +User interactions may result in significant changes in page structure. For example, a modal is shown, or a new section is rendered. +In that case, add an assertion after any such change. +We want to make sure that users are able to interact with all available components. + +### Separate file for extensive test suites + +For some views, feature tests span multiple files. +Take a look at our [feature tests for a merge request](https://gitlab.com/gitlab-org/gitlab/-/tree/master/spec/features/merge_request). +The number of user interactions that needs to be covered is too big to fit into one test file. +As a result, multiple feature tests cover one view, with different user privileges, or data sets. +If we were to include accessibility checks in all of them, there is a chance we would cover the same states of a view multiple times and significantly increase the run time. +It would also make it harder to determine the coverage for accessibility, if assertions would be scattered across many files. + +In that case, consider creating one test file dedicated to accessibility. +Place it in the same directory and name it `accessibility_spec.rb`, for example `spec/features/merge_request/accessibility_spec.rb`. +Make it explicit that a feature test has accessibility coverage in a separate file, and +doesn't need additional assertions. Include this comment below the opening of the +top-level block: + +```ruby +# spec/features/merge_request/user_approves_spec.rb + +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Merge request > User approves', :js, feature_category: :code_review_workflow do +# covered by ./accessibility_spec.rb +``` + +### Shared examples + +Often feature tests include shared examples for a number of scenarios. +If they differ only by provided data, but are based on the same user interaction, you can check for accessibility compliance outside the shared examples. +This way we only run the check once and save resources. + +## How to add accessibility tests + +Axe provides the custom matcher `be_axe_clean`, which can be used like the following: + +```ruby +# spec/features/settings_spec.rb +it 'passes axe automated accessibility testing', :js do + visit_settings_page + + wait_for_requests # ensures page is fully loaded + + expect(page).to be_axe_clean +end +``` + +If needed, you can scope testing to a specific area of the page by using `within`. + +Axe also provides specific [clauses](https://github.com/dequelabs/axe-core-gems/blob/develop/packages/axe-core-rspec/README.md#clauses), +for example: + +```ruby +expect(page).to be_axe_clean.within '[data-testid="element"]' + +# run only WCAG 2.1 Level AA rules +expect(page).to be_axe_clean.according_to :wcag21aa + +# specifies which rule to skip +expect(page).to be_axe_clean.skipping :'link-in-text-block' + +# clauses can be chained +expect(page).to be_axe_clean.within('[data-testid="element"]') + .according_to(:wcag21aa) +``` + +Axe does not test hidden regions, such as inactive menus or modal windows. To test +hidden regions for accessibility, write tests that activate or render the regions visible +and run the matcher again. + +You can run accessibility tests locally in the same way as you [run any feature tests](../../testing_guide/frontend_testing.md#how-to-run-a-feature-test). + +After adding accessibility tests, make sure to fix all possible errors. +For help on how to do it, refer to [this guide](best_practices.md#quick-checklist). +You can also check accessibility sections in [Pajamas components' documentation](https://design.gitlab.com/components/overview). +If any of the errors require global changes, create a follow-up issue and assign these labels: `accessability`, `WG::product accessibility`. + +### Known accessibility violations + +This section documents violations where a recommendation differs with the [design system](https://design.gitlab.com/): + +- `link-in-text-block`: For now, use the `skipping` clause to skip `:'link-in-text-block'` + rule to fix the violation. After this is fixed as part of [issue 1444](https://gitlab.com/gitlab-org/gitlab-services/design.gitlab.com/-/issues/1444) + and underline is added to the `GlLink` component, this clause can be removed. diff --git a/doc/development/fe_guide/accessibility/best_practices.md b/doc/development/fe_guide/accessibility/best_practices.md new file mode 100644 index 00000000000..37c28f99116 --- /dev/null +++ b/doc/development/fe_guide/accessibility/best_practices.md @@ -0,0 +1,512 @@ +--- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments +--- + +# Accessibility best practices + +## Quick summary + +Since [no ARIA is better than bad ARIA](https://w3c.github.io/aria-practices/#no_aria_better_bad_aria), +review the following recommendations before using `aria-*`, `role`, and `tabindex`. +Use semantic HTML, which has accessibility semantics baked in, and ideally test with +[relevant combinations of screen readers and browsers](https://www.accessibility-developer-guide.com/knowledge/screen-readers/relevant-combinations/). + +In [WebAIM's accessibility analysis of the top million home pages](https://webaim.org/projects/million/#aria), +they found that "ARIA correlated to higher detectable errors". +It is likely that *misuse* of ARIA is a big cause of increased errors, +so when in doubt don't use `aria-*`, `role`, and `tabindex` and stick with semantic HTML. + +## Enable keyboard navigation on macOS + +By default, macOS limits the <kbd>tab</kbd> key to **Text boxes and lists only**. To enable full keyboard navigation: + +1. Open **System Preferences**. +1. Select **Keyboard**. +1. Open the **Shortcuts** tab. +1. Enable the setting **Use keyboard navigation to move focus between controls**. + +You can read more about enabling browser-specific keyboard navigation on [a11yproject](https://www.a11yproject.com/posts/macos-browser-keyboard-navigation/). + +## Quick checklist + +- [Text](#text-inputs-with-accessible-names), + [select](#select-inputs-with-accessible-names), + [checkbox](#checkbox-inputs-with-accessible-names), + [radio](#radio-inputs-with-accessible-names), + [file](#file-inputs-with-accessible-names), + and [toggle](#gltoggle-components-with-an-accessible-names) inputs have accessible names. +- [Buttons](#buttons-and-links-with-descriptive-accessible-names), + [links](#buttons-and-links-with-descriptive-accessible-names), + and [images](#images-with-accessible-names) have descriptive accessible names. +- Icons + - [Non-decorative icons](#icons-that-convey-information) have an `aria-label`. + - [Clickable icons](#icons-that-are-clickable) are buttons, that is, `<gl-button icon="close" />` is used and not `<gl-icon />`. + - Icon-only buttons have an `aria-label`. +- Interactive elements can be [accessed with the Tab key](#support-keyboard-only-use) and have a visible focus state. +- Elements with [tooltips](#tooltips) are focusable using the Tab key. +- Are any `role`, `tabindex` or `aria-*` attributes unnecessary? +- Can any `div` or `span` elements be replaced with a more semantic [HTML element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element) like `p`, `button`, or `time`? + +## Provide a good document outline + +[Headings are the primary mechanism used by screen reader users to navigate content](https://webaim.org/projects/screenreadersurvey8/#finding). +Therefore, the structure of headings on a page should make sense, like a good table of contents. +We should ensure that: + +- There is only one `h1` element on the page. +- Heading levels are not skipped. +- Heading levels are nested correctly. + +## Provide accessible names for screen readers + +To provide markup with accessible names, ensure every: + +- input has an [associated `label`](#examples-of-providing-accessible-names). +- button and link have [visible text](#buttons-and-links-with-descriptive-accessible-names), or `aria-label` when there is no visible text, such as for an icon button with no content. +- image has an [`alt` attribute](#images-with-accessible-names). +- `fieldset` has `legend` as its first child. +- `figure` has `figcaption` as its first child. +- `table` has `caption` as its first child. + +Groups of checkboxes and radio inputs should be grouped together in a `fieldset` with a `legend`. +`legend` gives the group of checkboxes and radio inputs a label. + +If the `label`, child text, or child element is not visually desired, +use `.gl-sr-only` to hide the element from everything but screen readers. + +### Examples of providing accessible names + +The following subsections contain examples of markup that render HTML elements with accessible names. + +Note that [when using `GlFormGroup`](https://bootstrap-vue.org/docs/components/form-group#accessibility): + +- Passing only a `label` prop renders a `fieldset` with a `legend` containing the `label` value. +- Passing both a `label` and a `label-for` prop renders a `label` that points to the form input with the same `label-for` ID. + +#### Text inputs with accessible names + +When using `GlFormGroup`, the `label` prop alone does not give the input an accessible name. +The `label-for` prop must also be provided to give the input an accessible name. + +Text input examples: + +```html +<!-- Input with label --> +<gl-form-group :label="__('Issue title')" label-for="issue-title"> + <gl-form-input id="issue-title" v-model="title" /> +</gl-form-group> + +<!-- Input with hidden label --> +<gl-form-group :label="__('Issue title')" label-for="issue-title" label-sr-only> + <gl-form-input id="issue-title" v-model="title" /> +</gl-form-group> +``` + +`textarea` examples: + +```html +<!-- textarea with label --> +<gl-form-group :label="__('Issue description')" label-for="issue-description"> + <gl-form-textarea id="issue-description" v-model="description" /> +</gl-form-group> + +<!-- textarea with hidden label --> +<gl-form-group :label="__('Issue description')" label-for="issue-description" label-sr-only> + <gl-form-textarea id="issue-description" v-model="description" /> +</gl-form-group> +``` + +Alternatively, you can use a plain `label` element: + +```html +<!-- Input with label using `label` --> +<label for="issue-title">{{ __('Issue title') }}</label> +<gl-form-input id="issue-title" v-model="title" /> + +<!-- Input with hidden label using `label` --> +<label for="issue-title" class="gl-sr-only">{{ __('Issue title') }}</label> +<gl-form-input id="issue-title" v-model="title" /> +``` + +#### Select inputs with accessible names + +Select input examples: + +```html +<!-- Select input with label --> +<gl-form-group :label="__('Issue status')" label-for="issue-status"> + <gl-form-select id="issue-status" v-model="status" :options="options" /> +</gl-form-group> + +<!-- Select input with hidden label --> +<gl-form-group :label="__('Issue status')" label-for="issue-status" label-sr-only> + <gl-form-select id="issue-status" v-model="status" :options="options" /> +</gl-form-group> +``` + +#### Checkbox inputs with accessible names + +Single checkbox: + +```html +<!-- Single checkbox with label --> +<gl-form-checkbox v-model="status" value="task-complete"> + {{ __('Task complete') }} +</gl-form-checkbox> + +<!-- Single checkbox with hidden label --> +<gl-form-checkbox v-model="status" value="task-complete"> + <span class="gl-sr-only">{{ __('Task complete') }}</span> +</gl-form-checkbox> +``` + +Multiple checkboxes: + +```html +<!-- Multiple labeled checkboxes grouped within a fieldset --> +<gl-form-group :label="__('Task list')"> + <gl-form-checkbox value="task-1">{{ __('Task 1') }}</gl-form-checkbox> + <gl-form-checkbox value="task-2">{{ __('Task 2') }}</gl-form-checkbox> +</gl-form-group> + +<!-- Or --> +<gl-form-group :label="__('Task list')"> + <gl-form-checkbox-group v-model="selected" :options="options" /> +</gl-form-group> + +<!-- Multiple labeled checkboxes grouped within a fieldset with hidden legend --> +<gl-form-group :label="__('Task list')" label-sr-only> + <gl-form-checkbox value="task-1">{{ __('Task 1') }}</gl-form-checkbox> + <gl-form-checkbox value="task-2">{{ __('Task 2') }}</gl-form-checkbox> +</gl-form-group> + +<!-- Or --> +<gl-form-group :label="__('Task list')" label-sr-only> + <gl-form-checkbox-group v-model="selected" :options="options" /> +</gl-form-group> +``` + +#### Radio inputs with accessible names + +Single radio input: + +```html +<!-- Single radio with a label --> +<gl-form-radio v-model="status" value="opened"> + {{ __('Opened') }} +</gl-form-radio> + +<!-- Single radio with a hidden label --> +<gl-form-radio v-model="status" value="opened"> + <span class="gl-sr-only">{{ __('Opened') }}</span> +</gl-form-radio> +``` + +Multiple radio inputs: + +```html +<!-- Multiple labeled radio inputs grouped within a fieldset --> +<gl-form-group :label="__('Issue status')"> + <gl-form-radio value="opened">{{ __('Opened') }}</gl-form-radio> + <gl-form-radio value="closed">{{ __('Closed') }}</gl-form-radio> +</gl-form-group> + +<!-- Or --> +<gl-form-group :label="__('Issue status')"> + <gl-form-radio-group v-model="selected" :options="options" /> +</gl-form-group> + +<!-- Multiple labeled radio inputs grouped within a fieldset with hidden legend --> +<gl-form-group :label="__('Issue status')" label-sr-only> + <gl-form-radio value="opened">{{ __('Opened') }}</gl-form-radio> + <gl-form-radio value="closed">{{ __('Closed') }}</gl-form-radio> +</gl-form-group> + +<!-- Or --> +<gl-form-group :label="__('Issue status')" label-sr-only> + <gl-form-radio-group v-model="selected" :options="options" /> +</gl-form-group> +``` + +#### File inputs with accessible names + +File input examples: + +```html +<!-- File input with a label --> +<label for="attach-file">{{ __('Attach a file') }}</label> +<input id="attach-file" type="file" /> + +<!-- File input with a hidden label --> +<label for="attach-file" class="gl-sr-only">{{ __('Attach a file') }}</label> +<input id="attach-file" type="file" /> +``` + +#### GlToggle components with an accessible names + +`GlToggle` examples: + +```html +<!-- GlToggle with label --> +<gl-toggle v-model="notifications" :label="__('Notifications')" /> + +<!-- GlToggle with hidden label --> +<gl-toggle v-model="notifications" :label="__('Notifications')" label-position="hidden" /> +``` + +#### GlFormCombobox components with an accessible names + +`GlFormCombobox` examples: + +```html +<!-- GlFormCombobox with label --> +<gl-form-combobox :label-text="__('Key')" :token-list="$options.tokenList" /> +``` + +#### Images with accessible names + +Image examples: + +```html +<img :src="imagePath" :alt="__('A description of the image')" /> + +<!-- SVGs implicitly have a graphics role so if it is semantically an image we should apply `role="img"` --> +<svg role="img" :alt="__('A description of the image')" /> + +<!-- A decorative image, hidden from screen readers --> +<img :src="imagePath" :alt="" /> +``` + +#### Buttons and links with descriptive accessible names + +Buttons and links should have accessible names that are descriptive enough to be understood in isolation. + +```html +<!-- bad --> +<gl-button @click="handleClick">{{ __('Submit') }}</gl-button> + +<gl-link :href="url">{{ __('page') }}</gl-link> + +<!-- good --> +<gl-button @click="handleClick">{{ __('Submit review') }}</gl-button> + +<gl-link :href="url">{{ __("GitLab's accessibility page") }}</gl-link> +``` + +#### Links styled like buttons + +Links can be styled like buttons using `GlButton`. + +```html + <gl-button :href="url">{{ __('Link styled as a button') }}</gl-button> +``` + +## Role + +In general, avoid using `role`. +Use semantic HTML elements that implicitly have a `role` instead. + +| Bad | Good | +| --- | --- | +| `<div role="button">` | `<button>` | +| `<div role="img">` | `<img>` | +| `<div role="link">` | `<a>` | +| `<div role="header">` | `<h1>` to `<h6>` | +| `<div role="textbox">` | `<input>` or `<textarea>` | +| `<div role="article">` | `<article>` | +| `<div role="list">` | `<ol>` or `<ul>` | +| `<div role="listitem">` | `<li>` | +| `<div role="table">` | `<table>` | +| `<div role="rowgroup">` | `<thead>`, `<tbody>`, or `<tfoot>` | +| `<div role="row">` | `<tr>` | +| `<div role="columnheader">` | `<th>` | +| `<div role="cell">` | `<td>` | + +## Support keyboard-only use + +Keyboard users rely on focus outlines to understand where they are on the page. Therefore, if an +element is interactive you must ensure: + +- It can receive keyboard focus. +- It has a visible focus state. + +Use semantic HTML, such as `a` (`GlLink`) and `button` (`GlButton`), which provides these behaviours by default. + +Keep in mind that: + +- <kbd>Tab</kbd> and <kbd>Shift-Tab</kbd> should only move between interactive elements, not static content. +- When you add `:hover` styles, in most cases you should add `:focus` styles too so that the styling is applied for both mouse **and** keyboard users. +- If you remove an interactive element's `outline`, make sure you maintain visual focus state in another way such as with `box-shadow`. + +See the [Pajamas Keyboard-only page](https://design.gitlab.com/accessibility/keyboard-only) for more detail. + +## `tabindex` + +Prefer **no** `tabindex` to using `tabindex`, since: + +- Using semantic HTML such as `button` (`GlButton`) implicitly provides `tabindex="0"`. +- Tabbing order should match the visual reading order and positive `tabindex`s interfere with this. + +### Avoid using `tabindex="0"` to make an element interactive + +Use interactive elements instead of `div` and `span` tags. +For example: + +- If the element should be clickable, use a `button` (`GlButton`). +- If the element should be text editable, use an [`input` or `textarea`](#text-inputs-with-accessible-names). + +Once the markup is semantically complete, use CSS to update it to its desired visual state. + +```html +<!-- bad --> +<div role="button" tabindex="0" @click="expand">Expand</div> + +<!-- good --> +<gl-button class="gl-p-0!" category="tertiary" @click="expand">Expand</gl-button> +``` + +### Do not use `tabindex="0"` on interactive elements + +Interactive elements are already tab accessible so adding `tabindex` is redundant. + +```html +<!-- bad --> +<gl-link href="help" tabindex="0">Help</gl-link> +<gl-button tabindex="0">Submit</gl-button> + +<!-- good --> +<gl-link href="help">Help</gl-link> +<gl-button>Submit</gl-button> +``` + +### Do not use `tabindex="0"` on elements for screen readers to read + +Screen readers can read text that is not tab accessible. +The use of `tabindex="0"` is unnecessary and can cause problems, +as screen reader users then expect to be able to interact with it. + +```html +<!-- bad --> +<p tabindex="0" :aria-label="message">{{ message }}</p> + +<!-- good --> +<p>{{ message }}</p> +``` + +### Do not use a positive `tabindex` + +[Always avoid using `tabindex="1"`](https://webaim.org/techniques/keyboard/tabindex#overview) +or greater. + +## Icons + +Icons can be split into three different types: + +- Icons that are decorative +- Icons that convey meaning +- Icons that are clickable + +### Icons that are decorative + +Icons are decorative when there's no loss of information to the user when they are removed from the UI. + +As the majority of icons within GitLab are decorative, `GlIcon` automatically hides its rendered icons from screen readers. +Therefore, you do not need to add `aria-hidden="true"` to `GlIcon`, as this is redundant. + +```html +<!-- unnecessary — gl-icon hides icons from screen readers by default --> +<gl-icon name="rocket" aria-hidden="true" />` + +<!-- good --> +<gl-icon name="rocket" />` +``` + +### Icons that convey information + +Icons convey information if there is loss of information to the user when they are removed from the UI. + +An example is a confidential icon that conveys the issue is confidential, and does not have the text "Confidential" next to it. + +Icons that convey information must have an accessible name so that the information is conveyed to screen reader users too. + +```html +<!-- bad --> +<gl-icon name="eye-slash" />` + +<!-- good --> +<gl-icon name="eye-slash" :aria-label="__('Confidential issue')" />` +``` + +### Icons that are clickable + +Icons that are clickable are semantically buttons, so they should be rendered as buttons, with an accessible name. + +```html +<!-- bad --> +<gl-icon name="close" :aria-label="__('Close')" @click="handleClick" /> + +<!-- good --> +<gl-button icon="close" category="tertiary" :aria-label="__('Close')" @click="handleClick" /> +``` + +## Tooltips + +When adding tooltips, we must ensure that the element with the tooltip can receive focus so keyboard users can see the tooltip. +If the element is a static one, such as an icon, we can enclose it in a button, which already is +focusable, so we don't have to add `tabindex=0` to the icon. + +The following code snippet is a good example of an icon with a tooltip. + +- It is automatically focusable, as it is a button. +- It is given an accessible name with `aria-label`, as it is a button with no text. +- We can use the `gl-hover-bg-transparent!` class if we don't want the button's background to become gray on hover. +- We can use the `gl-p-0!` class to remove the button padding, if needed. + +```html +<gl-button + v-gl-tooltip + class="gl-hover-bg-transparent! gl-p-0!" + icon="warning" + category="tertiary" + :title="tooltipText" + :aria-label="__('Warning')" +/> +``` + +## Hiding elements + +Use the following table to hide elements from users, when appropriate. + +| Hide from sighted users | Hide from screen readers | Hide from both sighted and screen reader users | +| --- | --- | --- | +| `.gl-sr-only` | `aria-hidden="true"` | `display: none`, `visibility: hidden`, or `hidden` attribute | + +### Hide decorative images from screen readers + +To reduce noise for screen reader users, hide decorative images using `alt=""`. +If the image is not an `img` element, such as an inline SVG, you can hide it by adding both `role="img"` and `alt=""`. + +`gl-icon` components automatically hide their icons from screen readers so `aria-hidden="true"` is +unnecessary when using `gl-icon`. + +```html +<!-- good - decorative images hidden from screen readers --> + +<img src="decorative.jpg" alt=""> + +<svg role="img" alt="" /> + +<gl-icon name="epic" /> +``` + +## When to use ARIA + +No ARIA is required when using semantic HTML, because it already incorporates accessibility. + +However, there are some UI patterns that do not have semantic HTML equivalents. +General examples of these are dialogs (modals) and tabs. +GitLab-specific examples are assignee and label dropdowns. +Building such widgets require ARIA to make them understandable to screen readers. +Proper research and testing should be done to ensure compliance with [WCAG](https://www.w3.org/WAI/standards-guidelines/wcag/). diff --git a/doc/development/fe_guide/accessibility/index.md b/doc/development/fe_guide/accessibility/index.md new file mode 100644 index 00000000000..5274fa644e1 --- /dev/null +++ b/doc/development/fe_guide/accessibility/index.md @@ -0,0 +1,50 @@ +--- +stage: none +group: unassigned +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments +--- + +# Accessibility + +Accessibility is important for users who use screen readers or rely on keyboard-only functionality +to ensure they have an equivalent experience to sighted mouse users. + +## Accessibility best practices + +Follow these [best practices](best_practices.md) to implement accessible web applications. These are +some of the topics covered in that guide: + +- [Quick checklist](best_practices.md#quick-checklist) +- [Accessible names for screen readers](best_practices.md#provide-accessible-names-for-screen-readers) +- [Icons](best_practices.md#icons) +- [When to use ARIA](best_practices.md#when-to-use-aria) + +## Automated accessibility testing + +Uncover accessibility problems and ensure that your features stay accessible over time by +[implementing automated A11Y tests](automated_testing.md). + +- [When to add accessibility tests](automated_testing.md#when-to-add-accessibility-tests) +- [How to add accessibility tests](automated_testing.md#how-to-add-accessibility-tests) + +## Other resources + +Use these tools and learning resources to improve your web accessibility workflow and skills. + +### Viewing the browser accessibility tree + +- [Firefox DevTools guide](https://developer.mozilla.org/en-US/docs/Tools/Accessibility_inspector#accessing_the_accessibility_inspector) +- [Chrome DevTools guide](https://developer.chrome.com/docs/devtools/accessibility/reference/#pane) + +### Browser extensions + +We have two options for Web accessibility testing: + +- [axe](https://www.deque.com/axe/) for [Firefox](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/) +- [axe](https://www.deque.com/axe/) for [Chrome](https://chrome.google.com/webstore/detail/axe-devtools-web-accessib/lhdoppojpmngadmnindnejefpokejbdd) + +### Other links + +- [The A11Y Project](https://www.a11yproject.com/) is a good resource for accessibility +- [Awesome Accessibility](https://github.com/brunopulis/awesome-a11y) + is a compilation of accessibility-related material diff --git a/doc/development/fe_guide/style/html.md b/doc/development/fe_guide/style/html.md index c92f77e9033..9d8809f19c7 100644 --- a/doc/development/fe_guide/style/html.md +++ b/doc/development/fe_guide/style/html.md @@ -6,7 +6,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w # HTML style guide -See also our [accessibility page](../accessibility.md). +See also our [accessibility best practices](../accessibility/best_practices.md). ## Semantic elements diff --git a/doc/development/merge_request_concepts/diffs/development.md b/doc/development/merge_request_concepts/diffs/development.md index 4d0be680d9b..42f6c2dc16e 100644 --- a/doc/development/merge_request_concepts/diffs/development.md +++ b/doc/development/merge_request_concepts/diffs/development.md @@ -176,6 +176,174 @@ These flowcharts should help explain the flow from the controllers down to the models for different features. This page is not intended to document the entirety of options for access and working with diffs, focusing solely on the most common. +### Generation of `MergeRequestDiff*` records + +As explained above, we use database tables to cache information from Gitaly when displaying +diffs on merge requests. When enabled, we also use object storage when storing diffs. + +We have 2 types of merge request diffs: base diff and `HEAD` diff. Each type +is generated differently. + +#### Base diff + +On every push to a merge request branch, we create a new merge request diff version. + +This flowchart shows a basic explanation of how each component is used in this case. + +```mermaid +flowchart TD + A[PostReceive worker] --> B[MergeRequests::RefreshService] + B --> C[Reload diff of merge requests] + C --> D[Create merge request diff] + D --> K[(Database)] + D --> E[Ensure commit SHAs] + E --> L[Gitaly] + E --> F[Set patch-id] + F --> L[Gitaly] + F --> G[Save commits] + G --> L[Gitaly] + G --> K[(Database)] + G --> H[Save diffs] + H --> L[Gitaly] + H --> K[(Database)] + H --> M[(Object Storage)] + H --> I[Keep around commits] + I --> L[Gitaly] + I --> J[Clear highlight and stats cache] + J --> N[(Redis)] +``` + +This sequence diagram shows a more detailed explanation of this flow. + +```mermaid +sequenceDiagram + PostReceive-->>+MergeRequests_RefreshService: execute() + Note over MergeRequests_RefreshService: Reload diff of merge requests + MergeRequests_RefreshService-->>+MergeRequest: reload_diff() + Note over MergeRequests_ReloadDiffsService: Create merge request diff + MergeRequest-->>+MergeRequests_ReloadDiffsService: execute() + MergeRequests_ReloadDiffsService-->>+MergeRequest: create_merge_request_diff() + MergeRequest-->>+MergeRequestDiff: create() + Note over MergeRequestDiff: Ensure commit SHAs + MergeRequestDiff-->>+MergeRequest: source_branch_sha() + MergeRequest-->>+Repository: commit() + Repository-->>+Gitaly: FindCommit RPC + Gitaly-->>-Repository: Gitlab::Git::Commit + Repository-->>+Commit: new() + Commit-->>-Repository: Commit + Repository-->>-MergeRequest: Commit + MergeRequest-->>-MergeRequestDiff: Commit SHA + Note over MergeRequestDiff: Set patch-id + MergeRequestDiff-->>+Repository: get_patch_id() + Repository-->>+Gitaly: GetPatchID RPC + Gitaly-->>-Repository: Patch ID + Repository-->>-MergeRequestDiff: Patch ID + Note over MergeRequestDiff: Save commits + MergeRequestDiff-->>+Gitaly: ListCommits RPC + Gitaly-->>-MergeRequestDiff: Commits + MergeRequestDiff-->>+MergeRequestDiffCommit: create_bulk() + Note over MergeRequestDiff: Save diffs + MergeRequestDiff-->>+Gitaly: ListCommits RPC + Gitaly-->>-MergeRequestDiff: Commits + opt When external diffs is enabled + MergeRequestDiff-->>+ObjectStorage: upload diffs + end + MergeRequestDiff-->>+MergeRequestDiffFile: legacy_bulk_insert() + Note over MergeRequestDiff: Keep around commits + MergeRequestDiff-->>+Repository: keep_around() + Repository-->>+Gitaly: WriteRef RPC + Note over MergeRequests_ReloadDiffsService: Clear highlight and stats cache + MergeRequests_ReloadDiffsService->>+Gitlab_Diff_HighlightCache: clear() + MergeRequests_ReloadDiffsService->>+Gitlab_Diff_StatsCache: clear() + Gitlab_Diff_HighlightCache-->>+Redis: cache + Gitlab_Diff_StatsCache-->>+Redis: cache +``` + +#### `HEAD` diff + +Whenever mergeability of a merge request is checked and the merge request `merge_status` +is either `:unchecked`, `:cannot_be_merged_recheck`, `:checking`, or `:cannot_be_merged_rechecking`, +we attempt to merge the changes from source branch to target branch and write to a ref. +If it's successful (meaning, no conflict), we generate a diff based on the +generated commit and show it as the `HEAD` diff. + +The flow differs from the base diff generation as it has a different entry point. + +This flowchart shows a basic explanation of how each component is used when generating +a `HEAD` diff. + +```mermaid +flowchart TD + A[MergeRequestMergeabilityCheckWorker] --> B[MergeRequests::MergeabilityCheckService] + B --> C[Merge changes to ref] + C --> L[Gitaly] + C --> D[Recreate merge request HEAD diff] + D --> K[(Database)] + D --> E[Ensure commit SHAs] + E --> L[Gitaly] + E --> F[Set patch-id] + F --> L[Gitaly] + F --> G[Save commits] + G --> L[Gitaly] + G --> K[(Database)] + G --> H[Save diffs] + H --> L[Gitaly] + H --> K[(Database)] + H --> M[(Object Storage)] + H --> I[Keep around commits] + I --> L[Gitaly] +``` + +This sequence diagram shows a more detailed explanation of this flow. + +```mermaid +sequenceDiagram + MergeRequestMergeabilityCheckWorker-->>+MergeRequests_MergeabilityCheckService: execute() + Note over MergeRequests_MergeabilityCheckService: Merge changes to ref + MergeRequests_MergeabilityCheckService-->>+MergeRequests_MergeToRefService: execute() + MergeRequests_MergeToRefService-->>+Repository: merge_to_ref() + Repository-->>+Gitaly: UserMergeBranch RPC + Gitaly-->>-Repository: Commit SHA + MergeRequests_MergeToRefService-->>+Repository: commit() + Repository-->>+Gitaly: FindCommit RPC + Gitaly-->>-Repository: Gitlab::Git::Commit + Repository-->>+Commit: new() + Commit-->>-Repository: Commit + Repository-->>-MergeRequests_MergeToRefService: Commit + Note over MergeRequests_MergeabilityCheckService: Recreate merge request HEAD diff + MergeRequests_MergeabilityCheckService-->>+MergeRequests_ReloadMergeHeadDiffService: execute() + MergeRequests_ReloadMergeHeadDiffService-->>+MergeRequest: create_merge_request_diff() + MergeRequest-->>+MergeRequestDiff: create() + Note over MergeRequestDiff: Ensure commit SHAs + MergeRequestDiff-->>+MergeRequest: merge_ref_head() + MergeRequest-->>+Repository: commit() + Repository-->>+Gitaly: FindCommit RPC + Gitaly-->>-Repository: Gitlab::Git::Commit + Repository-->>+Commit: new() + Commit-->>-Repository: Commit + Repository-->>-MergeRequest: Commit + MergeRequest-->>-MergeRequestDiff: Commit SHA + Note over MergeRequestDiff: Set patch-id + MergeRequestDiff-->>+Repository: get_patch_id() + Repository-->>+Gitaly: GetPatchID RPC + Gitaly-->>-Repository: Patch ID + Repository-->>-MergeRequestDiff: Patch ID + Note over MergeRequestDiff: Save commits + MergeRequestDiff-->>+Gitaly: ListCommits RPC + Gitaly-->>-MergeRequestDiff: Commits + MergeRequestDiff-->>+MergeRequestDiffCommit: create_bulk() + Note over MergeRequestDiff: Save diffs + MergeRequestDiff-->>+Gitaly: ListCommits RPC + Gitaly-->>-MergeRequestDiff: Commits + opt When external diffs is enabled + MergeRequestDiff-->>+ObjectStorage: upload diffs + end + MergeRequestDiff-->>+MergeRequestDiffFile: legacy_bulk_insert() + Note over MergeRequestDiff: Keep around commits + MergeRequestDiff-->>+Repository: keep_around() + Repository-->>+Gitaly: WriteRef RPC +``` + ### `diffs_batch.json` The most common avenue for viewing diffs is the **Changes** diff --git a/doc/development/testing_guide/best_practices.md b/doc/development/testing_guide/best_practices.md index 04699becd33..89e2442e33c 100644 --- a/doc/development/testing_guide/best_practices.md +++ b/doc/development/testing_guide/best_practices.md @@ -607,7 +607,7 @@ This means preferring Capybara's semantic methods and avoiding querying by IDs, The benefits of testing in this way are that: -- It ensures all interactive elements have an [accessible name](../fe_guide/accessibility.md#provide-accessible-names-for-screen-readers). +- It ensures all interactive elements have an [accessible name](../fe_guide/accessibility/best_practices.md#provide-accessible-names-for-screen-readers). - It is more readable, as it uses more natural language. - It is less brittle, as it avoids querying by IDs, classes, and attributes, which are not visible to the user. @@ -617,7 +617,7 @@ If needed, you can scope interactions within a specific area of the page by usin As you will likely be scoping to an element such as a `div`, which typically does not have a label, you may use a `data-testid` selector in this case. -You can use the `be_axe_clean` matcher to run [axe automated accessibility testing](../fe_guide/accessibility.md#automated-accessibility-testing-with-axe) in feature tests. +You can use the `be_axe_clean` matcher to run [axe automated accessibility testing](../fe_guide/accessibility/automated_testing.md) in feature tests. ##### Externalized contents diff --git a/doc/integration/facebook.md b/doc/integration/facebook.md index 299b1e53ff2..2366005ca55 100644 --- a/doc/integration/facebook.md +++ b/doc/integration/facebook.md @@ -6,65 +6,69 @@ info: To determine the technical writer assigned to the Stage/Group associated w # Use Facebook as an OAuth 2.0 authentication provider **(FREE ALL)** -To enable the Facebook OmniAuth provider you must register your application with -Facebook. Facebook generates an app ID and secret key for you to use. +You can use the Facebook OmniAuth provider to authenticate users with their Facebook account. -1. Sign in to the [Facebook Developer Platform](https://developers.facebook.com/). +To enable the Facebook OmniAuth provider, you must: -1. Choose "My Apps" > "Add a New App" +- Register your application with Facebook. Facebook generates an app ID and a secret key for you to use. +- Configure the GitLab server. -1. Select the type "Website" +## Register your application with Facebook -1. Enter a name for your app. This can be anything. Consider something like - "<Organization>'s GitLab" or "<Your Name>'s GitLab" or something - else descriptive. +1. Sign in to your [Facebook developer account](https://developers.facebook.com/). -1. Choose "Create New Facebook App ID" +1. Go to **My Apps** > **Create App**, then complete the following steps: -1. Select a Category, for example "Productivity" + 1. Enter a descriptive name for your app. For example: **`<your_organization's>` GitLab** or **`<your_name's>` GitLab**. -1. Choose "Create App ID" + 1. Select **Create New Facebook App ID**. -1. Enter the address of your GitLab installation at the bottom of the package + 1. Select a **Category**. For example **Productivity**. - ![Facebook Website URL](img/facebook_website_url.png) + 1. Select **Create App ID**. -1. Choose "Next" + 1. At the bottom of the page, enter the address of your GitLab installation. + + ![Facebook Website URL](img/facebook_website_url.png) + + 1. Select **Next**. 1. In the upper-right corner, select **Skip Quick Start**. -1. Choose "Settings" in the menu on the left +1. From the menu on the left, select **Settings**, then complete the following: -1. Fill in a contact email for your app + 1. Enter a contact email for your app. - ![Facebook App Settings](img/facebook_app_settings.png) + ![Facebook App Settings](img/facebook_app_settings.png) -1. Choose "Save Changes" + 1. Select **Save Changes**. -1. Choose "Status & Review" in the menu on the left +1. From the menu on the left, select **Status & Review**, then complete the following: -1. Change the switch on the right from No to Yes + 1. Change the switch on the right from **No** to **Yes**. -1. Choose "Confirm" when prompted to make the app public + 1. When prompted to make the app public, select **Confirm**. -1. Choose "Dashboard" in the menu on the left +1. From the menu on the left, select **Dashboard**, then complete the following: -1. Choose "Show" next to the hidden "App Secret" + 1. Next to the hidden **App Secret**, select **Show**. -1. You should now see an app key and app secret (see screenshot). Keep this page - open as you continue configuration. + 1. Copy the **App ID** and **App Secret**. Keep this page + open as you continue configuration. - ![Facebook API Keys](img/facebook_api_keys.png) + ![Facebook API Keys](img/facebook_api_keys.png) -1. On your GitLab server, open the configuration file. +## Configure the GitLab server - For Linux package installations: +1. On your GitLab server, open the configuration file: + + - For Linux package installations: ```shell sudo editor /etc/gitlab/gitlab.rb ``` - For self-compiled installations: + - For self-compiled installations: ```shell cd /home/git/gitlab @@ -76,9 +80,9 @@ Facebook. Facebook generates an app ID and secret key for you to use. to add `facebook` as a single sign-on provider. This enables Just-In-Time account provisioning for users who do not have an existing GitLab account. -1. Add the provider configuration. +1. Add the provider configuration: - For Linux package installations: + - For Linux package installations: ```ruby gitlab_rails['omniauth_providers'] = [ @@ -91,7 +95,7 @@ Facebook. Facebook generates an app ID and secret key for you to use. ] ``` - For self-compiled installations: + - For self-compiled installations: ```yaml - { name: 'facebook', @@ -100,9 +104,11 @@ Facebook. Facebook generates an app ID and secret key for you to use. app_secret: 'YOUR_APP_SECRET' } ``` -1. Change 'YOUR_APP_ID' to the API key from Facebook page in step 10. +1. In the provide configuration, paste the following values: + + 1. `YOUR_APP_ID`: The **App ID** you copied in the previous step. -1. Change 'YOUR_APP_SECRET' to the API secret from the Facebook page in step 10. + 1. `YOUR_APP_SECRET`: The **App Secret** you copied in the previous step. 1. Save the configuration file. @@ -110,7 +116,5 @@ Facebook. Facebook generates an app ID and secret key for you to use. - If you installed using the Linux package, [reconfigure GitLab](../administration/restart_gitlab.md#reconfigure-a-linux-package-installation). - If you self-compiled your installation, [restart GitLab](../administration/restart_gitlab.md#self-compiled-installations). -On the sign in page there should now be a Facebook icon below the regular sign -in form. Select the icon to begin the authentication process. Facebook asks the -user to sign in and authorize the GitLab application. If everything goes well -the user is returned to GitLab and signed in. +On the sign in page, a Facebook icon should now appear below the sign-in fields. +The user can select the icon to sign in. diff --git a/doc/integration/img/facebook_api_keys.png b/doc/integration/img/facebook_api_keys.png Binary files differindex 7480b144091..fa16ff2c803 100644 --- a/doc/integration/img/facebook_api_keys.png +++ b/doc/integration/img/facebook_api_keys.png diff --git a/doc/integration/img/facebook_app_settings.png b/doc/integration/img/facebook_app_settings.png Binary files differindex 81f38cab16e..b2ff3066051 100644 --- a/doc/integration/img/facebook_app_settings.png +++ b/doc/integration/img/facebook_app_settings.png diff --git a/doc/integration/img/facebook_website_url.png b/doc/integration/img/facebook_website_url.png Binary files differindex 7873c9905f1..56023a9a003 100644 --- a/doc/integration/img/facebook_website_url.png +++ b/doc/integration/img/facebook_website_url.png diff --git a/doc/operations/tracing.md b/doc/operations/tracing.md index 8c0c0167352..1df644f4de4 100644 --- a/doc/operations/tracing.md +++ b/doc/operations/tracing.md @@ -10,8 +10,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w > - Feature flag `observability_group_tab` [removed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/133264) in GitLab 16.5. FLAG: -On self-managed GitLab, this feature is not available. -On GitLab.com, this feature is available but can be configured by GitLab.com administrators only. +On GitLab.com, by default this feature is not available. To make it available, an administrator can [enable the feature flag](../administration/feature_flags.md) named `observability_tracing`. The feature is not ready for production use. @@ -34,11 +33,10 @@ Prerequisites: To enable tracing in a project: -1. On the left sidebar, select **Search or go to** and find your group. +1. On the left sidebar, select **Search or go to** and find your project. 1. Select **Settings > Access Tokens**. 1. Create an access token with the following scopes: `read_api`, `read_observability`, `write_observability`. 1. Copy the value of the access token. -1. Navigate to your project. 1. Select **Monitor > Tracing**. 1. Select **Enable**. diff --git a/doc/user/clusters/management_project.md b/doc/user/clusters/management_project.md index 3c5c90abdae..f2f9aceda69 100644 --- a/doc/user/clusters/management_project.md +++ b/doc/user/clusters/management_project.md @@ -99,9 +99,8 @@ to a management project: | Staging | `staging` | | Production | `production` | -The following environments set in -[`.gitlab-ci.yml`](../../ci/yaml/index.md) deploy to the -Development, Staging, and Production cluster respectively. +The environments set in the +[`.gitlab-ci.yml`](../../ci/yaml/index.md) file deploy to the Development, Staging, and Production cluster. ```yaml stages: diff --git a/spec/features/users/google_analytics_csp_spec.rb b/spec/features/users/google_analytics_csp_spec.rb deleted file mode 100644 index 0837296922c..00000000000 --- a/spec/features/users/google_analytics_csp_spec.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'Google Analytics 4 content security policy', feature_category: :purchase do - include ContentSecurityPolicyHelpers - - subject(:csp_header) { response_headers['Content-Security-Policy'] } - - it 'includes the GA4 content security policy headers' do - visit root_path - - expect(find_csp_directive('script-src', header: csp_header)).to include( - '*.googletagmanager.com' - ) - - expect(find_csp_directive('connect-src', header: csp_header)).to include( - '*.googletagmanager.com', - '*.google-analytics.com', - '*.analytics.google.com', - '*.g.doubleclick.net' - ) - - expect(find_csp_directive('img-src', header: csp_header)).to include( - '*.googletagmanager.com', - '*.google-analytics.com', - '*.analytics.google.com', - '*.g.doubleclick.net' - ) - end -end diff --git a/spec/features/users/google_syndication_csp_spec.rb b/spec/features/users/google_syndication_csp_spec.rb deleted file mode 100644 index 801716449c8..00000000000 --- a/spec/features/users/google_syndication_csp_spec.rb +++ /dev/null @@ -1,54 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'Google Syndication content security policy', feature_category: :purchase do - include ContentSecurityPolicyHelpers - - let_it_be(:connect_src) { 'https://other-cdn.test' } - - let_it_be(:google_analytics_src) do - 'localhost https://cdn.cookielaw.org https://*.onetrust.com *.google-analytics.com ' \ - '*.analytics.google.com *.googletagmanager.com *.g.doubleclick.net' - end - - let_it_be(:allowed_src) do - '*.google.com/pagead/landing pagead2.googlesyndication.com/pagead/landing' - end - - let(:extra) { { google_tag_manager_nonce_id: 'google_tag_manager_nonce_id' } } - - let(:csp) do - ActionDispatch::ContentSecurityPolicy.new do |p| - p.connect_src(*connect_src.split) - end - end - - subject { response_headers['Content-Security-Policy'] } - - before do - setup_csp_for_controller(SessionsController, csp, any_time: true) - stub_config(extra: extra) - visit new_user_session_path - end - - context 'when self-hosted' do - context 'when there is no CSP config' do - let(:extra) { {} } - let(:csp) { ActionDispatch::ContentSecurityPolicy.new } - - it { is_expected.to be_blank } - end - - context 'when connect-src CSP config exists' do - it { is_expected.to include("connect-src #{connect_src} #{google_analytics_src}") } - it { is_expected.not_to include(allowed_src) } - end - end - - context 'when SaaS', :saas do - context 'when connect-src CSP config exists' do - it { is_expected.to include("connect-src #{connect_src} #{google_analytics_src} #{allowed_src}") } - end - end -end diff --git a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js index eb76b0bfbb4..d1844d609f2 100644 --- a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js @@ -1,4 +1,4 @@ -import { GlAlert, GlEmptyState, GlLink, GlLoadingIcon, GlTabs } from '@gitlab/ui'; +import { GlAlert, GlEmptyState, GlLink, GlLoadingIcon, GlPagination, GlTabs } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { trimText } from 'helpers/text_helper'; @@ -14,6 +14,7 @@ import deletePipelineScheduleMutation from '~/ci/pipeline_schedules/graphql/muta import playPipelineScheduleMutation from '~/ci/pipeline_schedules/graphql/mutations/play_pipeline_schedule.mutation.graphql'; import takeOwnershipMutation from '~/ci/pipeline_schedules/graphql/mutations/take_ownership.mutation.graphql'; import getPipelineSchedulesQuery from '~/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql'; +import { SCHEDULES_PER_PAGE } from '~/ci/pipeline_schedules/constants'; import { mockGetPipelineSchedulesGraphQLResponse, mockPipelineScheduleNodes, @@ -22,6 +23,7 @@ import { playMutationResponse, takeOwnershipMutationResponse, emptyPipelineSchedulesResponse, + mockPipelineSchedulesResponseWithPagination, } from '../mock_data'; Vue.use(VueApollo); @@ -34,6 +36,9 @@ describe('Pipeline schedules app', () => { let wrapper; const successHandler = jest.fn().mockResolvedValue(mockGetPipelineSchedulesGraphQLResponse); + const successHandlerWithPagination = jest + .fn() + .mockResolvedValue(mockPipelineSchedulesResponseWithPagination); const successEmptyHandler = jest.fn().mockResolvedValue(emptyPipelineSchedulesResponse); const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); @@ -81,6 +86,11 @@ describe('Pipeline schedules app', () => { const findInactiveTab = () => wrapper.findByTestId('pipeline-schedules-inactive-tab'); const findSchedulesCharacteristics = () => wrapper.findByTestId('pipeline-schedules-characteristics'); + const findPagination = () => wrapper.findComponent(GlPagination); + const setPage = async (page) => { + findPagination().vm.$emit('input', page); + await waitForPromises(); + }; describe('default', () => { beforeEach(() => { @@ -107,6 +117,10 @@ describe('Pipeline schedules app', () => { it('new schedule button links to new schedule path', () => { expect(findNewButton().attributes('href')).toBe('/root/ci-project/-/pipeline_schedules/new'); }); + + it('does not display pagination when no next page exists', () => { + expect(findPagination().exists()).toBe(false); + }); }); describe('fetching pipeline schedules', () => { @@ -333,6 +347,10 @@ describe('Pipeline schedules app', () => { ids: null, projectPath: 'gitlab-org/gitlab', status: null, + first: SCHEDULES_PER_PAGE, + last: null, + nextPageCursor: '', + prevPageCursor: '', }); }); }); @@ -370,4 +388,57 @@ describe('Pipeline schedules app', () => { }); }); }); + + describe('pagination', () => { + const { pageInfo } = mockPipelineSchedulesResponseWithPagination.data.project.pipelineSchedules; + + beforeEach(async () => { + createComponent([[getPipelineSchedulesQuery, successHandlerWithPagination]]); + + await waitForPromises(); + }); + + it('displays pagination', () => { + expect(findPagination().exists()).toBe(true); + expect(findPagination().props()).toMatchObject({ + value: 1, + prevPage: Number(pageInfo.hasPreviousPage), + nextPage: Number(pageInfo.hasNextPage), + }); + expect(successHandlerWithPagination).toHaveBeenCalledWith({ + projectPath: 'gitlab-org/gitlab', + ids: null, + first: SCHEDULES_PER_PAGE, + last: null, + nextPageCursor: '', + prevPageCursor: '', + }); + }); + + it('updates query variables when going to next page', async () => { + await setPage(2); + + expect(successHandlerWithPagination).toHaveBeenCalledWith({ + projectPath: 'gitlab-org/gitlab', + ids: null, + first: SCHEDULES_PER_PAGE, + last: null, + prevPageCursor: '', + nextPageCursor: pageInfo.endCursor, + }); + expect(findPagination().props('value')).toEqual(2); + }); + + it('when switching tabs pagination should reset', async () => { + await setPage(2); + + expect(findPagination().props('value')).toEqual(2); + + await findInactiveTab().trigger('click'); + + await waitForPromises(); + + expect(findPagination().props('value')).toEqual(1); + }); + }); }); diff --git a/spec/frontend/ci/pipeline_schedules/mock_data.js b/spec/frontend/ci/pipeline_schedules/mock_data.js index 711b120c61e..1bff296305d 100644 --- a/spec/frontend/ci/pipeline_schedules/mock_data.js +++ b/spec/frontend/ci/pipeline_schedules/mock_data.js @@ -48,6 +48,26 @@ export const mockSinglePipelineScheduleNodeNoVars = { }, }; +export const mockPipelineSchedulesResponseWithPagination = { + data: { + currentUser: mockGetPipelineSchedulesGraphQLResponse.data.currentUser, + project: { + id: mockGetPipelineSchedulesGraphQLResponse.data.project.id, + pipelineSchedules: { + count: 3, + nodes: mockGetPipelineSchedulesGraphQLResponse.data.project.pipelineSchedules.nodes, + pageInfo: { + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'eyJpZCI6IjQ0In0', + endCursor: 'eyJpZCI6IjI4In0', + __typename: 'PageInfo', + }, + }, + }, + }, +}; + export const emptyPipelineSchedulesResponse = { data: { currentUser: { @@ -59,6 +79,13 @@ export const emptyPipelineSchedulesResponse = { pipelineSchedules: { count: 0, nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: '', + endCursor: '', + __typename: 'PageInfo', + }, }, }, }, diff --git a/spec/frontend/google_tag_manager/index_spec.js b/spec/frontend/google_tag_manager/index_spec.js index dd8e886e6bc..c32c86d5f5a 100644 --- a/spec/frontend/google_tag_manager/index_spec.js +++ b/spec/frontend/google_tag_manager/index_spec.js @@ -1,537 +1,9 @@ -import { merge } from 'lodash'; -import { v4 as uuidv4 } from 'uuid'; -import { - trackCombinedGroupProjectForm, - trackFreeTrialAccountSubmissions, - trackProjectImport, - trackNewRegistrations, - trackSaasTrialSubmit, - trackSaasTrialGroup, - trackSaasTrialGetStarted, - trackTrialAcceptTerms, - trackCheckout, - trackTransaction, - trackAddToCartUsageTab, - getNamespaceId, - trackCompanyForm, -} from '~/google_tag_manager'; -import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import { logError } from '~/lib/logger'; - -jest.mock('~/lib/logger'); -jest.mock('uuid'); +import { trackTrialAcceptTerms } from 'ee_else_ce/google_tag_manager'; describe('~/google_tag_manager/index', () => { - let spy; - - beforeEach(() => { - spy = jest.fn(); - - window.dataLayer = { - push: spy, - }; - window.gon.features = { - gitlabGtmDatalayer: true, - }; - }); - - const createHTML = ({ links = [], forms = [] } = {}) => { - // .foo elements are used to test elements which shouldn't do anything - const allLinks = links.concat({ cls: 'foo' }); - const allForms = forms.concat({ cls: 'foo' }); - - const el = document.createElement('div'); - - allLinks.forEach(({ cls = '', id = '', href = '#', text = 'Hello', attributes = {} }) => { - const a = document.createElement('a'); - a.id = id; - a.href = href || '#'; - a.className = cls; - a.textContent = text; - - Object.entries(attributes).forEach(([key, value]) => { - a.setAttribute(key, value); - }); - - el.append(a); - }); - - allForms.forEach(({ cls = '', id = '' }) => { - const form = document.createElement('form'); - form.id = id; - form.className = cls; - - el.append(form); - }); - - return el.innerHTML; - }; - - const triggerEvent = (selector, eventType) => { - const el = document.querySelector(selector); - - el.dispatchEvent(new Event(eventType)); - }; - - const getSelector = ({ id, cls }) => (id ? `#${id}` : `.${cls}`); - - const createTestCase = (subject, { forms = [], links = [] }) => { - const expectedFormEvents = forms.map(({ expectation, ...form }) => ({ - selector: getSelector(form), - trigger: 'submit', - expectation, - })); - - const expectedLinkEvents = links.map(({ expectation, ...link }) => ({ - selector: getSelector(link), - trigger: 'click', - expectation, - })); - - return [ - subject, - { - forms, - links, - expectedEvents: [...expectedFormEvents, ...expectedLinkEvents], - }, - ]; - }; - - const createOmniAuthTestCase = (subject, accountType) => - createTestCase(subject, { - forms: [ - { - id: 'new_new_user', - expectation: { - event: 'accountSubmit', - accountMethod: 'form', - accountType, - }, - }, - ], - links: [ - { - // id is needed so that the test selects the right element to trigger - id: 'test-0', - cls: 'js-oauth-login', - attributes: { - 'data-provider': 'myspace', - }, - expectation: { - event: 'accountSubmit', - accountMethod: 'myspace', - accountType, - }, - }, - { - id: 'test-1', - cls: 'js-oauth-login', - attributes: { - 'data-provider': 'gitlab', - }, - expectation: { - event: 'accountSubmit', - accountMethod: 'gitlab', - accountType, - }, - }, - ], - }); - - describe.each([ - createOmniAuthTestCase(trackFreeTrialAccountSubmissions, 'freeThirtyDayTrial'), - createOmniAuthTestCase(trackNewRegistrations, 'standardSignUp'), - createTestCase(trackSaasTrialGroup, { - forms: [{ cls: 'js-saas-trial-group', expectation: { event: 'saasTrialGroup' } }], - }), - createTestCase(trackProjectImport, { - links: [ - { - id: 'js-test-btn-0', - cls: 'js-import-project-btn', - attributes: { 'data-platform': 'bitbucket' }, - expectation: { event: 'projectImport', platform: 'bitbucket' }, - }, - { - // id is neeeded so we trigger the right element in the test - id: 'js-test-btn-1', - cls: 'js-import-project-btn', - attributes: { 'data-platform': 'github' }, - expectation: { event: 'projectImport', platform: 'github' }, - }, - ], - }), - createTestCase(trackSaasTrialGetStarted, { - links: [ - { - cls: 'js-get-started-btn', - expectation: { event: 'saasTrialGetStarted' }, - }, - ], - }), - createTestCase(trackAddToCartUsageTab, { - links: [ - { - cls: 'js-buy-additional-minutes', - expectation: { - event: 'EECproductAddToCart', - ecommerce: { - currencyCode: 'USD', - add: { - products: [ - { - name: 'CI/CD Minutes', - id: '0003', - price: '10', - brand: 'GitLab', - category: 'DevOps', - variant: 'add-on', - quantity: 1, - }, - ], - }, - }, - }, - }, - ], - }), - createTestCase(trackCombinedGroupProjectForm, { - forms: [ - { - cls: 'js-groups-projects-form', - expectation: { event: 'combinedGroupProjectFormSubmit' }, - }, - ], - }), - ])('%p', (subject, { links = [], forms = [], expectedEvents }) => { - beforeEach(() => { - setHTMLFixture(createHTML({ links, forms })); - - subject(); - }); - - afterEach(() => { - resetHTMLFixture(); - }); - - it.each(expectedEvents)('when %p', ({ selector, trigger, expectation }) => { - expect(spy).not.toHaveBeenCalled(); - - triggerEvent(selector, trigger); - - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith(expectation); - expect(logError).not.toHaveBeenCalled(); - }); - - it('when random link is clicked, does nothing', () => { - triggerEvent('a.foo', 'click'); - - expect(spy).not.toHaveBeenCalled(); - }); - - it('when random form is submitted, does nothing', () => { - triggerEvent('form.foo', 'submit'); - - expect(spy).not.toHaveBeenCalled(); - }); - }); - describe('No listener events', () => { - it('when trackSaasTrialSubmit is invoked', () => { - expect(spy).not.toHaveBeenCalled(); - - trackSaasTrialSubmit(); - - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith({ event: 'saasTrialSubmit' }); - expect(logError).not.toHaveBeenCalled(); - }); - it('when trackTrialAcceptTerms is invoked', () => { - expect(spy).not.toHaveBeenCalled(); - - trackTrialAcceptTerms(); - - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith({ event: 'saasTrialAcceptTerms' }); - expect(logError).not.toHaveBeenCalled(); - }); - - describe('when trackCheckout is invoked', () => { - it('with selectedPlan: 2c92a00d76f0d5060176f2fb0a5029ff', () => { - expect(spy).not.toHaveBeenCalled(); - - trackCheckout('2c92a00d76f0d5060176f2fb0a5029ff', 1); - - expect(spy.mock.calls.flatMap((x) => x)).toEqual([ - { ecommerce: null }, - { - event: 'EECCheckout', - ecommerce: { - currencyCode: 'USD', - checkout: { - actionField: { step: 1 }, - products: [ - { - brand: 'GitLab', - category: 'DevOps', - id: '0002', - name: 'Premium', - price: '228', - quantity: 1, - variant: 'SaaS', - }, - ], - }, - }, - }, - ]); - }); - - it('with selectedPlan: 2c92a0ff76f0d5250176f2f8c86f305a', () => { - expect(spy).not.toHaveBeenCalled(); - - trackCheckout('2c92a0ff76f0d5250176f2f8c86f305a', 1); - - expect(spy).toHaveBeenCalledTimes(2); - expect(spy).toHaveBeenCalledWith({ ecommerce: null }); - expect(spy).toHaveBeenCalledWith({ - event: 'EECCheckout', - ecommerce: { - currencyCode: 'USD', - checkout: { - actionField: { step: 1 }, - products: [ - { - brand: 'GitLab', - category: 'DevOps', - id: '0001', - name: 'Ultimate', - price: '1188', - quantity: 1, - variant: 'SaaS', - }, - ], - }, - }, - }); - }); - - it('with selectedPlan: Something else', () => { - expect(spy).not.toHaveBeenCalled(); - - trackCheckout('Something else', 1); - - expect(spy).not.toHaveBeenCalled(); - }); - - it('with a different number of users', () => { - expect(spy).not.toHaveBeenCalled(); - - trackCheckout('2c92a0ff76f0d5250176f2f8c86f305a', 5); - - expect(spy).toHaveBeenCalledTimes(2); - expect(spy).toHaveBeenCalledWith({ ecommerce: null }); - expect(spy).toHaveBeenCalledWith({ - event: 'EECCheckout', - ecommerce: { - currencyCode: 'USD', - checkout: { - actionField: { step: 1 }, - products: [ - { - brand: 'GitLab', - category: 'DevOps', - id: '0001', - name: 'Ultimate', - price: '1188', - quantity: 5, - variant: 'SaaS', - }, - ], - }, - }, - }); - }); - }); - - describe('when trackTransactions is invoked', () => { - describe.each([ - { - selectedPlan: '2c92a00d76f0d5060176f2fb0a5029ff', - revenue: 228, - name: 'Premium', - id: '0002', - }, - { - selectedPlan: '2c92a0ff76f0d5250176f2f8c86f305a', - revenue: 1188, - name: 'Ultimate', - id: '0001', - }, - ])('with %o', (planObject) => { - it('invokes pushes a new event that references the selected plan', () => { - const { selectedPlan, revenue, name, id } = planObject; - - expect(spy).not.toHaveBeenCalled(); - uuidv4.mockImplementationOnce(() => '123'); - - const transactionDetails = { - paymentOption: 'visa', - revenue, - tax: 10, - selectedPlan, - quantity: 1, - }; - - trackTransaction(transactionDetails); - - expect(spy.mock.calls.flatMap((x) => x)).toEqual([ - { ecommerce: null }, - { - event: 'EECtransactionSuccess', - ecommerce: { - currencyCode: 'USD', - purchase: { - actionField: { - id: '123', - affiliation: 'GitLab', - option: 'visa', - revenue: revenue.toString(), - tax: '10', - }, - products: [ - { - brand: 'GitLab', - category: 'DevOps', - dimension36: 'not available', - id, - name, - price: revenue.toString(), - quantity: 1, - variant: 'SaaS', - }, - ], - }, - }, - }, - ]); - }); - }); - }); - - describe('when trackTransaction is invoked', () => { - describe('with an invalid plan object', () => { - it('does not get called', () => { - expect(spy).not.toHaveBeenCalled(); - - trackTransaction({ selectedPlan: 'notAplan' }); - - expect(spy).not.toHaveBeenCalled(); - }); - }); - }); - - describe('when trackCompanyForm is invoked', () => { - it('with an ultimate trial', () => { - expect(spy).not.toHaveBeenCalled(); - - trackCompanyForm('ultimate_trial'); - - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith({ - event: 'aboutYourCompanyFormSubmit', - aboutYourCompanyType: 'ultimate_trial', - }); - expect(logError).not.toHaveBeenCalled(); - }); - - it('with a free account', () => { - expect(spy).not.toHaveBeenCalled(); - - trackCompanyForm('free_account'); - - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith({ - event: 'aboutYourCompanyFormSubmit', - aboutYourCompanyType: 'free_account', - }); - expect(logError).not.toHaveBeenCalled(); - }); - }); - }); - - describe.each([ - { dataLayer: null }, - { gon: { features: null } }, - { gon: { features: { gitlabGtmDatalayer: false } } }, - ])('when window %o', (windowAttrs) => { - beforeEach(() => { - merge(window, windowAttrs); - }); - - it('no ops', () => { - setHTMLFixture(createHTML({ forms: [{ cls: 'js-saas-trial-group' }] })); - - trackSaasTrialGroup(); - - triggerEvent('.js-saas-trial-group', 'submit'); - - expect(spy).not.toHaveBeenCalled(); - expect(logError).not.toHaveBeenCalled(); - - resetHTMLFixture(); - }); - }); - - describe('when window.dataLayer throws error', () => { - const pushError = new Error('test'); - - beforeEach(() => { - window.dataLayer = { - push() { - throw pushError; - }, - }; - }); - - it('logs error', () => { - setHTMLFixture(createHTML({ forms: [{ cls: 'js-saas-trial-group' }] })); - - trackSaasTrialGroup(); - - triggerEvent('.js-saas-trial-group', 'submit'); - - expect(logError).toHaveBeenCalledWith( - 'Unexpected error while pushing to dataLayer', - pushError, - ); - - resetHTMLFixture(); - }); - }); - - describe('when getting the namespace_id from Snowplow standard context', () => { - describe('when window.gl.snowplowStandardContext.data.namespace_id has a value', () => { - beforeEach(() => { - window.gl = { snowplowStandardContext: { data: { namespace_id: '321' } } }; - }); - - it('returns the value', () => { - expect(getNamespaceId()).toBe('321'); - }); - }); - - describe('when window.gl.snowplowStandardContext.data.namespace_id is undefined', () => { - beforeEach(() => { - window.gl = {}; - }); - - it('returns a placeholder value', () => { - expect(getNamespaceId()).toBe('not available'); - }); + expect(trackTrialAcceptTerms()).toBeUndefined(); }); }); }); diff --git a/spec/frontend/vue_alerts_spec.js b/spec/frontend/vue_alerts_spec.js index 43a673299b3..be4a45639cf 100644 --- a/spec/frontend/vue_alerts_spec.js +++ b/spec/frontend/vue_alerts_spec.js @@ -1,4 +1,5 @@ import { nextTick } from 'vue'; +import { alertVariantOptions } from '@gitlab/ui/dist/utils/constants'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'helpers/test_constants'; import initVueAlerts from '~/vue_alerts'; @@ -55,7 +56,11 @@ describe('VueAlerts', () => { primaryButtonText: alert.querySelector('.gl-alert-action').textContent.trim(), primaryButtonLink: alert.querySelector('.gl-alert-action').href, variant: [...alert.classList] - .find((x) => x.match(/gl-alert-(?!not-dismissible|has-title)/)) + .find((cssClass) => { + return Object.values(alertVariantOptions).some( + (variant) => cssClass === `gl-alert-${variant}`, + ); + }) .replace('gl-alert-', ''), }); diff --git a/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_cli_instructions_spec.js b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_cli_instructions_spec.js index c6cd963fc33..67aa57a019b 100644 --- a/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_cli_instructions_spec.js +++ b/spec/frontend/vue_shared/components/runner_instructions/instructions/runner_cli_instructions_spec.js @@ -1,5 +1,5 @@ -import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlAlert, GlListboxItem, GlLoadingIcon } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -32,7 +32,7 @@ describe('RunnerCliInstructions component', () => { const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findAlert = () => wrapper.findComponent(GlAlert); - const findArchitectureDropdownItems = () => wrapper.findAllByTestId('architecture-dropdown-item'); + const findArchitectureDropdownItems = () => wrapper.findAllComponents(GlListboxItem); const findBinaryDownloadButton = () => wrapper.findByTestId('binary-download-button'); const findBinaryInstructions = () => wrapper.findByTestId('binary-instructions'); const findRegisterCommand = () => wrapper.findByTestId('register-command'); @@ -43,7 +43,7 @@ describe('RunnerCliInstructions component', () => { fakeApollo = createMockApollo(requestHandlers); wrapper = extendedWrapper( - shallowMount(RunnerCliInstructions, { + mount(RunnerCliInstructions, { propsData: { platform: mockPlatform, registrationToken: 'MY_TOKEN', diff --git a/spec/helpers/auth_helper_spec.rb b/spec/helpers/auth_helper_spec.rb index 5684f1cc99d..40798b4c038 100644 --- a/spec/helpers/auth_helper_spec.rb +++ b/spec/helpers/auth_helper_spec.rb @@ -277,66 +277,6 @@ RSpec.describe AuthHelper do end end - describe '#google_tag_manager_enabled?' do - let(:is_gitlab_com) { true } - let(:user) { nil } - - before do - allow(Gitlab).to receive(:com?).and_return(is_gitlab_com) - allow(helper).to receive(:current_user).and_return(user) - end - - subject(:google_tag_manager_enabled) { helper.google_tag_manager_enabled? } - - context 'when not on gitlab.com' do - let(:is_gitlab_com) { false } - - it { is_expected.to eq(false) } - end - - context 'regular and nonce versions' do - before do - stub_config(extra: { 'google_tag_manager_nonce_id' => 'key' }) - end - - context 'on gitlab.com and a key set without a current user' do - it { is_expected.to be_truthy } - end - - context 'when no key is set' do - before do - stub_config(extra: {}) - end - - it { is_expected.to eq(false) } - end - end - end - - describe '#google_tag_manager_id' do - subject(:google_tag_manager_id) { helper.google_tag_manager_id } - - before do - stub_config(extra: { 'google_tag_manager_nonce_id': 'nonce', 'google_tag_manager_id': 'gtm' }) - end - - context 'when google tag manager is disabled' do - before do - allow(helper).to receive(:google_tag_manager_enabled?).and_return(false) - end - - it { is_expected.to be_falsey } - end - - context 'when google tag manager is enabled' do - before do - allow(helper).to receive(:google_tag_manager_enabled?).and_return(true) - end - - it { is_expected.to eq('nonce') } - end - end - describe '#auth_app_owner_text' do shared_examples 'generates text with the correct info' do it 'includes the name of the application owner' do diff --git a/spec/helpers/form_helper_spec.rb b/spec/helpers/form_helper_spec.rb index 83b08e5fcec..0db48dfc28e 100644 --- a/spec/helpers/form_helper_spec.rb +++ b/spec/helpers/form_helper_spec.rb @@ -67,11 +67,10 @@ RSpec.describe FormHelper do it 'renders an appropriately styled alert div' do model = double(errors: errors_stub('Error 1')) + alert_classes = "gl-alert gl-mb-5 gl-alert-danger gl-alert-not-dismissible gl-alert-has-title" expect(helper.form_errors(model)) - .to include( - '<div class="gl-alert gl-mb-5 gl-alert-danger gl-alert-not-dismissible" id="error_explanation" role="alert">' - ) + .to include("<div class=\"#{alert_classes}\" id=\"error_explanation\" role=\"alert\">") end it 'contains a summary message' do diff --git a/spec/presenters/ci/pipeline_presenter_spec.rb b/spec/presenters/ci/pipeline_presenter_spec.rb index fc13b377014..40a4f0ee35d 100644 --- a/spec/presenters/ci/pipeline_presenter_spec.rb +++ b/spec/presenters/ci/pipeline_presenter_spec.rb @@ -154,8 +154,8 @@ RSpec.describe Ci::PipelinePresenter do let(:pipeline) { merge_request.all_pipelines.last } it 'returns a correct ref text' do - is_expected.to eq("Related merge request <a class=\"mr-iid\" href=\"#{project_merge_request_path(merge_request.project, merge_request)}\">#{merge_request.to_reference}</a> " \ - "to merge <a class=\"ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2\" href=\"#{project_commits_path(merge_request.source_project, merge_request.source_branch)}\">#{merge_request.source_branch}</a>") + is_expected.to eq("Related merge request <a class=\"mr-iid ref-container\" href=\"#{project_merge_request_path(merge_request.project, merge_request)}\">#{merge_request.to_reference}</a> " \ + "to merge <a class=\"ref-container gl-link\" href=\"#{project_commits_path(merge_request.source_project, merge_request.source_branch)}\">#{merge_request.source_branch}</a>") end end @@ -164,9 +164,9 @@ RSpec.describe Ci::PipelinePresenter do let(:pipeline) { merge_request.all_pipelines.last } it 'returns a correct ref text' do - is_expected.to eq("Related merge request <a class=\"mr-iid\" href=\"#{project_merge_request_path(merge_request.project, merge_request)}\">#{merge_request.to_reference}</a> " \ - "to merge <a class=\"ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2\" href=\"#{project_commits_path(merge_request.source_project, merge_request.source_branch)}\">#{merge_request.source_branch}</a> " \ - "into <a class=\"ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2\" href=\"#{project_commits_path(merge_request.target_project, merge_request.target_branch)}\">#{merge_request.target_branch}</a>") + is_expected.to eq("Related merge request <a class=\"mr-iid ref-container\" href=\"#{project_merge_request_path(merge_request.project, merge_request)}\">#{merge_request.to_reference}</a> " \ + "to merge <a class=\"ref-container gl-link\" href=\"#{project_commits_path(merge_request.source_project, merge_request.source_branch)}\">#{merge_request.source_branch}</a> " \ + "into <a class=\"ref-container gl-link\" href=\"#{project_commits_path(merge_request.target_project, merge_request.target_branch)}\">#{merge_request.target_branch}</a>") end end @@ -177,7 +177,7 @@ RSpec.describe Ci::PipelinePresenter do end it 'returns a correct ref text' do - is_expected.to eq("For <a class=\"ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2\" href=\"#{project_commits_path(pipeline.project, pipeline.ref)}\">#{pipeline.ref}</a>") + is_expected.to eq("For <a class=\"ref-container gl-link\" href=\"#{project_commits_path(pipeline.project, pipeline.ref)}\">#{pipeline.ref}</a>") end context 'when ref contains malicious script' do diff --git a/spec/presenters/merge_request_presenter_spec.rb b/spec/presenters/merge_request_presenter_spec.rb index b4210099e14..b642292b458 100644 --- a/spec/presenters/merge_request_presenter_spec.rb +++ b/spec/presenters/merge_request_presenter_spec.rb @@ -485,7 +485,7 @@ RSpec.describe MergeRequestPresenter do allow(resource).to receive(:source_branch_exists?) { true } is_expected - .to eq("<a class=\"ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2\" href=\"#{presenter.source_branch_commits_path}\">#{presenter.source_branch}</a>") + .to eq("<a class=\"ref-container gl-link\" href=\"#{presenter.source_branch_commits_path}\">#{presenter.source_branch}</a>") end end @@ -508,7 +508,7 @@ RSpec.describe MergeRequestPresenter do allow(resource).to receive(:target_branch_exists?) { true } is_expected - .to eq("<a class=\"ref-name gl-link gl-bg-blue-50 gl-rounded-base gl-px-2\" href=\"#{presenter.target_branch_commits_path}\">#{presenter.target_branch}</a>") + .to eq("<a class=\"ref-container gl-link\" href=\"#{presenter.target_branch_commits_path}\">#{presenter.target_branch}</a>") end end diff --git a/spec/views/devise/sessions/new.html.haml_spec.rb b/spec/views/devise/sessions/new.html.haml_spec.rb index 70ca0bb2195..5f611ae1d8f 100644 --- a/spec/views/devise/sessions/new.html.haml_spec.rb +++ b/spec/views/devise/sessions/new.html.haml_spec.rb @@ -47,8 +47,6 @@ RSpec.describe 'devise/sessions/new' do disable_captcha disable_sign_up disable_other_signin_methods - - allow(view).to receive(:experiment_enabled?).and_return(false) end it 'is shown when enabled' do @@ -69,36 +67,6 @@ RSpec.describe 'devise/sessions/new' do expect(rendered).not_to have_field(_('Username')) end end - - describe 'Google Tag Manager' do - let!(:gtm_id) { 'GTM-WWKMTWS' } - - subject { rendered } - - before do - stub_devise - disable_captcha - stub_config(extra: { google_tag_manager_id: gtm_id, google_tag_manager_nonce_id: gtm_id }) - end - - describe 'when Google Tag Manager is enabled' do - before do - enable_gtm - render - end - - it { is_expected.to match /www.googletagmanager.com/ } - end - - describe 'when Google Tag Manager is disabled' do - before do - disable_gtm - render - end - - it { is_expected.not_to match /www.googletagmanager.com/ } - end - end end end @@ -133,12 +101,4 @@ RSpec.describe 'devise/sessions/new' do allow(view).to receive(:captcha_enabled?).and_return(false) allow(view).to receive(:captcha_on_login_required?).and_return(false) end - - def disable_gtm - allow(view).to receive(:google_tag_manager_enabled?).and_return(false) - end - - def enable_gtm - allow(view).to receive(:google_tag_manager_enabled?).and_return(true) - end end |