Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
		
							parent
							
								
									2d40635435
								
							
						
					
					
						commit
						26bba9525d
					
				|  | @ -1,20 +1,10 @@ | |||
| import Vue from 'vue'; | ||||
| import createFlash from '~/flash'; | ||||
| import { parseRailsFormFields } from '~/lib/utils/forms'; | ||||
| import { __ } from '~/locale'; | ||||
| 
 | ||||
| import ExpiresAtField from './components/expires_at_field.vue'; | ||||
| 
 | ||||
| const getInputAttrs = (el) => { | ||||
|   const input = el.querySelector('input'); | ||||
| 
 | ||||
|   return { | ||||
|     id: input.id, | ||||
|     name: input.name, | ||||
|     value: input.value, | ||||
|     placeholder: input.placeholder, | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export const initExpiresAtField = () => { | ||||
|   const el = document.querySelector('.js-access-tokens-expires-at'); | ||||
| 
 | ||||
|  | @ -22,7 +12,7 @@ export const initExpiresAtField = () => { | |||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   const inputAttrs = getInputAttrs(el); | ||||
|   const { expiresAt: inputAttrs } = parseRailsFormFields(el); | ||||
| 
 | ||||
|   return new Vue({ | ||||
|     el, | ||||
|  | @ -43,7 +33,7 @@ export const initProjectsField = () => { | |||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   const inputAttrs = getInputAttrs(el); | ||||
|   const { projects: inputAttrs } = parseRailsFormFields(el); | ||||
| 
 | ||||
|   if (window.gon.features.personalAccessTokensScopedToProjects) { | ||||
|     return new Promise((resolve) => { | ||||
|  |  | |||
|  | @ -328,6 +328,7 @@ export default { | |||
| 
 | ||||
|       <div | ||||
|         class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag gl-text-gray-500" | ||||
|         data-testid="issue-count-badge" | ||||
|         :class="{ | ||||
|           'gl-display-none!': list.collapsed && isSwimlanesHeader, | ||||
|           'gl-p-0': list.collapsed, | ||||
|  |  | |||
|  | @ -2,8 +2,8 @@ | |||
| import { GlButton } from '@gitlab/ui'; | ||||
| import { mapActions, mapGetters, mapState } from 'vuex'; | ||||
| import { getMilestone } from 'ee_else_ce/boards/boards_util'; | ||||
| import BoardNewIssueMixin from 'ee_else_ce/boards/mixins/board_new_issue'; | ||||
| import { __ } from '~/locale'; | ||||
| import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; | ||||
| import eventHub from '../eventhub'; | ||||
| import ProjectSelect from './project_select.vue'; | ||||
| 
 | ||||
|  | @ -17,8 +17,8 @@ export default { | |||
|     ProjectSelect, | ||||
|     GlButton, | ||||
|   }, | ||||
|   mixins: [glFeatureFlagMixin()], | ||||
|   inject: ['groupId', 'weightFeatureAvailable', 'boardWeight'], | ||||
|   mixins: [BoardNewIssueMixin], | ||||
|   inject: ['groupId'], | ||||
|   props: { | ||||
|     list: { | ||||
|       type: Object, | ||||
|  | @ -53,14 +53,11 @@ export default { | |||
|     submit(e) { | ||||
|       e.preventDefault(); | ||||
| 
 | ||||
|       const { title } = this; | ||||
|       const labels = this.list.label ? [this.list.label] : []; | ||||
|       const assignees = this.list.assignee ? [this.list.assignee] : []; | ||||
|       const milestone = getMilestone(this.list); | ||||
| 
 | ||||
|       const weight = this.weightFeatureAvailable ? this.boardWeight : undefined; | ||||
| 
 | ||||
|       const { title } = this; | ||||
| 
 | ||||
|       eventHub.$emit(`scroll-board-list-${this.list.id}`); | ||||
| 
 | ||||
|       return this.addListNewIssue({ | ||||
|  | @ -70,7 +67,7 @@ export default { | |||
|           assigneeIds: assignees?.map((a) => a?.id), | ||||
|           milestoneId: milestone?.id, | ||||
|           projectPath: this.selectedProject.fullPath, | ||||
|           weight: weight >= 0 ? weight : null, | ||||
|           ...this.extraIssueInput(), | ||||
|         }, | ||||
|         list: this.list, | ||||
|       }).then(() => { | ||||
|  |  | |||
|  | @ -0,0 +1,6 @@ | |||
| export default { | ||||
|   // EE-only
 | ||||
|   methods: { | ||||
|     extraIssueInput: () => {}, | ||||
|   }, | ||||
| }; | ||||
|  | @ -327,8 +327,8 @@ export default { | |||
|     commit(types.RESET_ISSUES); | ||||
|   }, | ||||
| 
 | ||||
|   moveItem: ({ dispatch }) => { | ||||
|     dispatch('moveIssue'); | ||||
|   moveItem: ({ dispatch }, payload) => { | ||||
|     dispatch('moveIssue', payload); | ||||
|   }, | ||||
| 
 | ||||
|   moveIssue: ( | ||||
|  |  | |||
|  | @ -0,0 +1,56 @@ | |||
| import emptySvg from '@gitlab/svgs/dist/illustrations/security-dashboard-empty-state.svg'; | ||||
| import { GlEmptyState } from '@gitlab/ui'; | ||||
| import * as Sentry from '@sentry/browser'; | ||||
| import { __ } from '~/locale'; | ||||
| 
 | ||||
| const ERROR_FETCHING_DATA_HEADER = __('Could not get the data properly'); | ||||
| const ERROR_FETCHING_DATA_DESCRIPTION = __( | ||||
|   'Please try and refresh the page. If the problem persists please contact support.', | ||||
| ); | ||||
| 
 | ||||
| /** | ||||
|  * This function takes a Component and extends it with data from the `parseData` function. | ||||
|  * The data will be made available through `props` and `proivde`. | ||||
|  * If the `parseData` throws, the `GlEmptyState` will be returned. | ||||
|  * @param  {Component} Component a component to render | ||||
|  * @param  {Object} options | ||||
|  * @param  {Function} options.parseData a function to parse `data` | ||||
|  * @param  {Object} options.data an object to pass to `parseData` | ||||
|  * @param  {Boolean} options.shouldLog to tell whether to log any thrown error by `parseData` to Sentry | ||||
|  * @param  {Object} options.props to override passed `props` data | ||||
|  * @param  {Object} options.provide to override passed `provide` data | ||||
|  * @param  {*} ...options the remaining options will be passed as properties to `createElement` | ||||
|  * @return {Component} a Vue component to render, either the GlEmptyState or the extended Component | ||||
|  */ | ||||
| export default function ensureData(Component, options = {}) { | ||||
|   const { parseData, data, shouldLog = false, props, provide, ...rest } = options; | ||||
|   try { | ||||
|     const parsedData = parseData(data); | ||||
|     return { | ||||
|       provide: { ...parsedData, ...provide }, | ||||
|       render(createElement) { | ||||
|         return createElement(Component, { | ||||
|           props: { ...parsedData, ...props }, | ||||
|           ...rest, | ||||
|         }); | ||||
|       }, | ||||
|     }; | ||||
|   } catch (error) { | ||||
|     if (shouldLog) { | ||||
|       Sentry.captureException(error); | ||||
|     } | ||||
| 
 | ||||
|     return { | ||||
|       functional: true, | ||||
|       render(createElement) { | ||||
|         return createElement(GlEmptyState, { | ||||
|           props: { | ||||
|             title: ERROR_FETCHING_DATA_HEADER, | ||||
|             description: ERROR_FETCHING_DATA_DESCRIPTION, | ||||
|             svgPath: `data:image/svg+xml;utf8,${encodeURIComponent(emptySvg)}`, | ||||
|           }, | ||||
|         }); | ||||
|       }, | ||||
|     }; | ||||
|   } | ||||
| } | ||||
|  | @ -1,3 +1,5 @@ | |||
| import { convertToCamelCase } from '~/lib/utils/text_utility'; | ||||
| 
 | ||||
| export const serializeFormEntries = (entries) => | ||||
|   entries.reduce((acc, { name, value }) => Object.assign(acc, { [name]: value }), {}); | ||||
| 
 | ||||
|  | @ -51,3 +53,95 @@ export const serializeFormObject = (form) => | |||
|       return acc; | ||||
|     }, []), | ||||
|   ); | ||||
| 
 | ||||
| /** | ||||
|  * Parse inputs of HTML forms generated by Rails. | ||||
|  * | ||||
|  * This can be helpful when mounting Vue components within Rails forms. | ||||
|  * | ||||
|  * If called with an HTML element like: | ||||
|  * | ||||
|  * ```html
 | ||||
|  * <input type="text" placeholder="Email" value="foo@bar.com" name="user[contact_info][email]" id="user_contact_info_email" data-js-name="contactInfoEmail"> | ||||
|  * <input type="text" placeholder="Phone" value="(123) 456-7890" name="user[contact_info][phone]" id="user_contact_info_phone" data-js-name="contactInfoPhone"> | ||||
|  * <input type="checkbox" name="user[interests][]" id="user_interests_vue" value="Vue" checked data-js-name="interests"> | ||||
|  * <input type="checkbox" name="user[interests][]" id="user_interests_graphql" value="GraphQL" data-js-name="interests"> | ||||
|  * ``` | ||||
|  * | ||||
|  * It will return an object like: | ||||
|  * | ||||
|  * ```javascript
 | ||||
|  * { | ||||
|  *   contactInfoEmail: { | ||||
|  *     name: 'user[contact_info][email]', | ||||
|  *     id: 'user_contact_info_email', | ||||
|  *     value: 'foo@bar.com', | ||||
|  *     placeholder: 'Email', | ||||
|  *   }, | ||||
|  *   contactInfoPhone: { | ||||
|  *     name: 'user[contact_info][phone]', | ||||
|  *     id: 'user_contact_info_phone', | ||||
|  *     value: '(123) 456-7890', | ||||
|  *     placeholder: 'Phone', | ||||
|  *   }, | ||||
|  *   interests: [ | ||||
|  *     { | ||||
|  *       name: 'user[interests][]', | ||||
|  *       id: 'user_interests_vue', | ||||
|  *       value: 'Vue', | ||||
|  *       checked: true, | ||||
|  *     }, | ||||
|  *     { | ||||
|  *       name: 'user[interests][]', | ||||
|  *       id: 'user_interests_graphql', | ||||
|  *       value: 'GraphQL', | ||||
|  *       checked: false, | ||||
|  *     }, | ||||
|  *   ], | ||||
|  * } | ||||
|  * ``` | ||||
|  * | ||||
|  * @param {HTMLInputElement} mountEl | ||||
|  * @returns {Object} object with form fields data. | ||||
|  */ | ||||
| export const parseRailsFormFields = (mountEl) => { | ||||
|   if (!mountEl) { | ||||
|     throw new TypeError('`mountEl` argument is required'); | ||||
|   } | ||||
| 
 | ||||
|   const inputs = mountEl.querySelectorAll('[name]'); | ||||
| 
 | ||||
|   return [...inputs].reduce((accumulator, input) => { | ||||
|     const fieldName = input.dataset.jsName; | ||||
| 
 | ||||
|     if (!fieldName) { | ||||
|       return accumulator; | ||||
|     } | ||||
| 
 | ||||
|     const fieldNameCamelCase = convertToCamelCase(fieldName); | ||||
|     const { id, placeholder, name, value, type, checked } = input; | ||||
|     const attributes = { | ||||
|       name, | ||||
|       id, | ||||
|       value, | ||||
|       ...(placeholder && { placeholder }), | ||||
|     }; | ||||
| 
 | ||||
|     // Store radio buttons and checkboxes as an array so they can be
 | ||||
|     // looped through and rendered in Vue
 | ||||
|     if (['radio', 'checkbox'].includes(type)) { | ||||
|       return { | ||||
|         ...accumulator, | ||||
|         [fieldNameCamelCase]: [ | ||||
|           ...(accumulator[fieldNameCamelCase] || []), | ||||
|           { ...attributes, checked }, | ||||
|         ], | ||||
|       }; | ||||
|     } | ||||
| 
 | ||||
|     return { | ||||
|       ...accumulator, | ||||
|       [fieldNameCamelCase]: attributes, | ||||
|     }; | ||||
|   }, {}); | ||||
| }; | ||||
|  |  | |||
|  | @ -10,6 +10,7 @@ class Projects::BoardsController < Projects::ApplicationController | |||
|   before_action do | ||||
|     push_frontend_feature_flag(:add_issues_button) | ||||
|     push_frontend_feature_flag(:swimlanes_buffered_rendering, project, default_enabled: :yaml) | ||||
|     push_frontend_feature_flag(:graphql_board_lists, project, default_enabled: :yaml) | ||||
|   end | ||||
| 
 | ||||
|   feature_category :boards | ||||
|  |  | |||
|  | @ -23,7 +23,7 @@ | |||
|         = render_if_exists 'personal_access_tokens/callout_max_personal_access_token_lifetime' | ||||
| 
 | ||||
|         .js-access-tokens-expires-at | ||||
|           = f.text_field :expires_at, class: 'datepicker gl-datepicker-input form-control gl-form-input', placeholder: 'YYYY-MM-DD', autocomplete: 'off' | ||||
|           = f.text_field :expires_at, class: 'datepicker gl-datepicker-input form-control gl-form-input', placeholder: 'YYYY-MM-DD', autocomplete: 'off', data: { js_name: 'expiresAt' } | ||||
| 
 | ||||
|   .form-group | ||||
|     = f.label :scopes, _('Scopes'), class: 'label-bold' | ||||
|  | @ -31,7 +31,7 @@ | |||
| 
 | ||||
|   - if prefix == :personal_access_token && Feature.enabled?(:personal_access_tokens_scoped_to_projects, current_user) | ||||
|     .js-access-tokens-projects | ||||
|       %input{ type: 'hidden', name: 'temporary-name', id: 'temporary-id' } | ||||
|       %input{ type: 'hidden', name: 'personal_access_token[projects]', id: 'personal_access_token_projects', data: { js_name: 'projects' } } | ||||
| 
 | ||||
|   .gl-mt-3 | ||||
|     = f.submit _('Create %{type}') % { type: type }, class: 'gl-button btn btn-confirm', data: { qa_selector: 'create_token_button' } | ||||
|  |  | |||
|  | @ -11,6 +11,8 @@ module WorkerAttributes | |||
|   # Urgencies that workers can declare through the `urgencies` attribute | ||||
|   VALID_URGENCIES = [:high, :low, :throttled].freeze | ||||
| 
 | ||||
|   VALID_DATA_CONSISTENCIES = [:always, :sticky, :delayed].freeze | ||||
| 
 | ||||
|   NAMESPACE_WEIGHTS = { | ||||
|     auto_devops: 2, | ||||
|     auto_merge: 3, | ||||
|  | @ -69,6 +71,35 @@ module WorkerAttributes | |||
|       class_attributes[:urgency] || :low | ||||
|     end | ||||
| 
 | ||||
|     def data_consistency(data_consistency, feature_flag: nil) | ||||
|       raise ArgumentError, "Invalid data consistency: #{data_consistency}" unless VALID_DATA_CONSISTENCIES.include?(data_consistency) | ||||
|       raise ArgumentError, 'Data consistency is already set' if class_attributes[:data_consistency] | ||||
| 
 | ||||
|       class_attributes[:data_consistency_feature_flag] = feature_flag if feature_flag | ||||
|       class_attributes[:data_consistency] = data_consistency | ||||
| 
 | ||||
|       validate_worker_attributes! | ||||
|     end | ||||
| 
 | ||||
|     def validate_worker_attributes! | ||||
|       # Since the deduplication should always take into account the latest binary replication pointer into account, | ||||
|       # not the first one, the deduplication will not work with sticky or delayed. | ||||
|       # Follow up issue to improve this: https://gitlab.com/gitlab-org/gitlab/-/issues/325291 | ||||
|       if idempotent? && get_data_consistency != :always | ||||
|         raise ArgumentError, "Class can't be marked as idempotent if data_consistency is not set to :always" | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     def get_data_consistency | ||||
|       class_attributes[:data_consistency] || :always | ||||
|     end | ||||
| 
 | ||||
|     def get_data_consistency_feature_flag_enabled? | ||||
|       return true unless class_attributes[:data_consistency_feature_flag] | ||||
| 
 | ||||
|       Feature.enabled?(class_attributes[:data_consistency_feature_flag], default_enabled: :yaml) | ||||
|     end | ||||
| 
 | ||||
|     # Set this attribute on a job when it will call to services outside of the | ||||
|     # application, such as 3rd party applications, other k8s clusters etc See | ||||
|     # doc/development/sidekiq_style_guide.md#jobs-with-external-dependencies for | ||||
|  | @ -96,6 +127,8 @@ module WorkerAttributes | |||
| 
 | ||||
|     def idempotent! | ||||
|       class_attributes[:idempotent] = true | ||||
| 
 | ||||
|       validate_worker_attributes! | ||||
|     end | ||||
| 
 | ||||
|     def idempotent? | ||||
|  |  | |||
|  | @ -0,0 +1,5 @@ | |||
| --- | ||||
| title: Fix usage data count start/finish export issue | ||||
| merge_request: 57403 | ||||
| author: | ||||
| type: fixed | ||||
|  | @ -0,0 +1,5 @@ | |||
| --- | ||||
| title: Removed migrate_delayed_project_removal feature flag | ||||
| merge_request: 57541 | ||||
| author: | ||||
| type: other | ||||
|  | @ -1,8 +0,0 @@ | |||
| --- | ||||
| name: migrate_delayed_project_removal | ||||
| introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/53916 | ||||
| rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/300207 | ||||
| milestone: '13.9' | ||||
| type: development | ||||
| group: group::access | ||||
| default_enabled: true | ||||
|  | @ -9914,7 +9914,7 @@ Counts of MAU adding epic notes | |||
| 
 | ||||
| [YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/config/metrics/counts_28d/20210314215451_g_project_management_users_creating_epic_notes_monthly.yml) | ||||
| 
 | ||||
| Group: `group:product planning` | ||||
| Group: `group::product planning` | ||||
| 
 | ||||
| Status: `implemented` | ||||
| 
 | ||||
|  | @ -9926,7 +9926,7 @@ Counts of WAU adding epic notes | |||
| 
 | ||||
| [YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/config/metrics/counts_7d/20210314231518_g_project_management_users_creating_epic_notes_weekly.yml) | ||||
| 
 | ||||
| Group: `group:product planning` | ||||
| Group: `group::product planning` | ||||
| 
 | ||||
| Status: `implemented` | ||||
| 
 | ||||
|  | @ -9938,7 +9938,7 @@ Counts of MAU destroying epic notes | |||
| 
 | ||||
| [YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/config/metrics/counts_28d/20210315034808_g_project_management_users_destroying_epic_notes_monthly.yml) | ||||
| 
 | ||||
| Group: `group:product planning` | ||||
| Group: `group::product planning` | ||||
| 
 | ||||
| Status: `implemented` | ||||
| 
 | ||||
|  | @ -9950,7 +9950,7 @@ Counts of WAU destroying epic notes | |||
| 
 | ||||
| [YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/config/metrics/counts_7d/20210315034846_g_project_management_users_destroying_epic_notes_weekly.yml) | ||||
| 
 | ||||
| Group: `group:product planning` | ||||
| Group: `group::product planning` | ||||
| 
 | ||||
| Status: `implemented` | ||||
| 
 | ||||
|  | @ -9962,7 +9962,7 @@ Counts of MAU setting epic start date as fixed | |||
| 
 | ||||
| [YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/config/metrics/counts_28d/20210315055624_g_project_management_users_setting_epic_start_date_as_fixed_monthly.yml) | ||||
| 
 | ||||
| Group: `group:product planning` | ||||
| Group: `group::product planning` | ||||
| 
 | ||||
| Status: `implemented` | ||||
| 
 | ||||
|  | @ -9974,7 +9974,7 @@ Counts of WAU setting epic start date as fixed | |||
| 
 | ||||
| [YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/config/metrics/counts_7d/20210315054905_g_project_management_users_setting_epic_start_date_as_fixed_weekly.yml) | ||||
| 
 | ||||
| Group: `group:product planning` | ||||
| Group: `group::product planning` | ||||
| 
 | ||||
| Status: `implemented` | ||||
| 
 | ||||
|  | @ -9986,7 +9986,7 @@ Counts of MAU setting epic start date as inherited | |||
| 
 | ||||
| [YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/config/metrics/counts_28d/20210315055439_g_project_management_users_setting_epic_start_date_as_inherited_monthly.yml) | ||||
| 
 | ||||
| Group: `group:product planning` | ||||
| Group: `group::product planning` | ||||
| 
 | ||||
| Status: `implemented` | ||||
| 
 | ||||
|  | @ -9998,7 +9998,7 @@ Counts of WAU setting epic start date as inherited | |||
| 
 | ||||
| [YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/config/metrics/counts_7d/20210315055342_g_project_management_users_setting_epic_start_date_as_inherited_weekly.yml) | ||||
| 
 | ||||
| Group: `group:product planning` | ||||
| Group: `group::product planning` | ||||
| 
 | ||||
| Status: `implemented` | ||||
| 
 | ||||
|  | @ -10010,7 +10010,7 @@ Counts of MAU changing epic descriptions | |||
| 
 | ||||
| [YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/config/metrics/counts_28d/20210312102051_g_project_management_users_updating_epic_descriptions_monthly.yml) | ||||
| 
 | ||||
| Group: `group:product planning` | ||||
| Group: `group::product planning` | ||||
| 
 | ||||
| Status: `implemented` | ||||
| 
 | ||||
|  | @ -10022,7 +10022,7 @@ Counts of WAU changing epic descriptions | |||
| 
 | ||||
| [YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/config/metrics/counts_7d/20210312101753_g_project_management_users_updating_epic_descriptions_weekly.yml) | ||||
| 
 | ||||
| Group: `group:product planning` | ||||
| Group: `group::product planning` | ||||
| 
 | ||||
| Status: `implemented` | ||||
| 
 | ||||
|  | @ -10034,7 +10034,7 @@ Counts of MAU updating epic notes | |||
| 
 | ||||
| [YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/config/metrics/counts_28d/20210314234202_g_project_management_users_updating_epic_notes_monthly.yml) | ||||
| 
 | ||||
| Group: `group:product planning` | ||||
| Group: `group::product planning` | ||||
| 
 | ||||
| Status: `implemented` | ||||
| 
 | ||||
|  | @ -10046,7 +10046,7 @@ Counts of WAU updating epic notes | |||
| 
 | ||||
| [YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/config/metrics/counts_7d/20210314234041_g_project_management_users_updating_epic_notes_weekly.yml) | ||||
| 
 | ||||
| Group: `group:product planning` | ||||
| Group: `group::product planning` | ||||
| 
 | ||||
| Status: `implemented` | ||||
| 
 | ||||
|  | @ -10058,7 +10058,7 @@ Counts of MAU changing epic titles | |||
| 
 | ||||
| [YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/config/metrics/counts_28d/20210312101935_g_project_management_users_updating_epic_titles_monthly.yml) | ||||
| 
 | ||||
| Group: `group:product planning` | ||||
| Group: `group::product planning` | ||||
| 
 | ||||
| Status: `implemented` | ||||
| 
 | ||||
|  | @ -10070,7 +10070,7 @@ Counts of WAU changing epic titles | |||
| 
 | ||||
| [YAML definition](https://gitlab.com/gitlab-org/gitlab/-/blob/master/ee/config/metrics/counts_7d/20210312101826_g_project_management_users_updating_epic_titles_weekly.yml) | ||||
| 
 | ||||
| Group: `group:product planning` | ||||
| Group: `group::product planning` | ||||
| 
 | ||||
| Status: `implemented` | ||||
| 
 | ||||
|  |  | |||
|  | @ -16,6 +16,7 @@ module Gitlab | |||
|         :elasticsearch_calls, | ||||
|         :elasticsearch_duration_s, | ||||
|         :elasticsearch_timed_out_count, | ||||
|         :worker_data_consistency, | ||||
|         *::Gitlab::Memory::Instrumentation::KEY_MAPPING.values, | ||||
|         *::Gitlab::Instrumentation::Redis.known_payload_keys, | ||||
|         *::Gitlab::Metrics::Subscribers::ActiveRecord::DB_COUNTERS, | ||||
|  |  | |||
|  | @ -43,3 +43,5 @@ module Gitlab | |||
|     end | ||||
|   end | ||||
| end | ||||
| 
 | ||||
| Gitlab::SidekiqMiddleware.singleton_class.prepend_if_ee('EE::Gitlab::SidekiqMiddleware') | ||||
|  |  | |||
|  | @ -5,11 +5,11 @@ module Gitlab | |||
|   # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/41091 | ||||
|   class UsageDataQueries < UsageData | ||||
|     class << self | ||||
|       def count(relation, column = nil, *rest) | ||||
|       def count(relation, column = nil, *args, **kwargs) | ||||
|         raw_sql(relation, column) | ||||
|       end | ||||
| 
 | ||||
|       def distinct_count(relation, column = nil, *rest) | ||||
|       def distinct_count(relation, column = nil, *args, **kwargs) | ||||
|         raw_sql(relation, column, :distinct) | ||||
|       end | ||||
| 
 | ||||
|  | @ -21,14 +21,14 @@ module Gitlab | |||
|         end | ||||
|       end | ||||
| 
 | ||||
|       def sum(relation, column, *rest) | ||||
|       def sum(relation, column, *args, **kwargs) | ||||
|         relation.select(relation.all.table[column].sum).to_sql | ||||
|       end | ||||
| 
 | ||||
|       # For estimated distinct count use exact query instead of hll | ||||
|       # buckets query, because it can't be used to obtain estimations without | ||||
|       # supplementary ruby code present in Gitlab::Database::PostgresHll::BatchDistinctCounter | ||||
|       def estimate_batch_distinct_count(relation, column = nil, *rest) | ||||
|       def estimate_batch_distinct_count(relation, column = nil, *args, **kwargs) | ||||
|         raw_sql(relation, column, :distinct) | ||||
|       end | ||||
| 
 | ||||
|  |  | |||
|  | @ -8707,6 +8707,9 @@ msgstr "" | |||
| msgid "Could not find iteration" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Could not get the data properly" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Could not load the user chart. Please refresh the page to try again." | ||||
| msgstr "" | ||||
| 
 | ||||
|  | @ -23056,6 +23059,9 @@ msgstr "" | |||
| msgid "Please try again" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Please try and refresh the page. If the problem persists please contact support." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Please type %{phrase_code} to proceed or close this modal to cancel." | ||||
| msgstr "" | ||||
| 
 | ||||
|  |  | |||
|  | @ -68,20 +68,30 @@ module QA | |||
|             end | ||||
|           end | ||||
| 
 | ||||
|           def has_child_pipeline? | ||||
|             has_element? :child_pipeline | ||||
|           def has_child_pipeline?(title: nil) | ||||
|             title ? find_child_pipeline_by_title(title) : has_element?(:child_pipeline) | ||||
|           end | ||||
| 
 | ||||
|           def has_no_child_pipeline? | ||||
|             has_no_element? :child_pipeline | ||||
|             has_no_element?(:child_pipeline) | ||||
|           end | ||||
| 
 | ||||
|           def click_job(job_name) | ||||
|             click_element(:job_link, Project::Job::Show, text: job_name) | ||||
|           end | ||||
| 
 | ||||
|           def expand_child_pipeline | ||||
|             within_element(:child_pipeline) do | ||||
|           def child_pipelines | ||||
|             all_elements(:child_pipeline, minimum: 1) | ||||
|           end | ||||
| 
 | ||||
|           def find_child_pipeline_by_title(title) | ||||
|             child_pipelines.find { |pipeline| pipeline[:title].include?(title) } | ||||
|           end | ||||
| 
 | ||||
|           def expand_child_pipeline(title: nil) | ||||
|             child_pipeline = title ? find_child_pipeline_by_title(title) : child_pipelines.first | ||||
| 
 | ||||
|             within_element_by_index(:child_pipeline, child_pipelines.index(child_pipeline)) do | ||||
|               click_element(:expand_pipeline_button) | ||||
|             end | ||||
|           end | ||||
|  |  | |||
|  | @ -0,0 +1,112 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| require 'faker' | ||||
| 
 | ||||
| module QA | ||||
|   RSpec.describe 'Verify', :runner do | ||||
|     describe 'Trigger matrix' do | ||||
|       let(:executor) { "qa-runner-#{Faker::Alphanumeric.alphanumeric(8)}" } | ||||
| 
 | ||||
|       let(:project) do | ||||
|         Resource::Project.fabricate_via_api! do |project| | ||||
|           project.name = 'project-with-pipeline' | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       let!(:runner) do | ||||
|         Resource::Runner.fabricate! do |runner| | ||||
|           runner.project = project | ||||
|           runner.name = executor | ||||
|           runner.tags = [executor] | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       before do | ||||
|         Flow::Login.sign_in | ||||
|         add_ci_files | ||||
|         project.visit! | ||||
|         Flow::Pipeline.visit_latest_pipeline(pipeline_condition: 'succeeded') | ||||
|       end | ||||
| 
 | ||||
|       after do | ||||
|         runner.remove_via_api! | ||||
|         project.remove_via_api! | ||||
|       end | ||||
| 
 | ||||
|       it 'creates 2 trigger jobs and passes corresponding matrix variables', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1732' do | ||||
|         Page::Project::Pipeline::Show.perform do |parent_pipeline| | ||||
|           trigger_title1 = 'deploy: [ovh, monitoring]' | ||||
|           trigger_title2 = 'deploy: [ovh, app]' | ||||
| 
 | ||||
|           aggregate_failures 'Creates two child pipelines' do | ||||
|             expect(parent_pipeline).to have_child_pipeline(title: trigger_title1) | ||||
|             expect(parent_pipeline).to have_child_pipeline(title: trigger_title2) | ||||
|           end | ||||
| 
 | ||||
|           # Only check output of one of the child pipelines, should be sufficient | ||||
|           parent_pipeline.expand_child_pipeline(title: trigger_title1) | ||||
|           parent_pipeline.click_job('test_vars') | ||||
|         end | ||||
| 
 | ||||
|         Page::Project::Job::Show.perform do |show| | ||||
|           Support::Waiter.wait_until { show.successful? } | ||||
| 
 | ||||
|           aggregate_failures 'Job output has the correct variables' do | ||||
|             expect(show.output).to have_content('ovh') | ||||
|             expect(show.output).to have_content('monitoring') | ||||
|           end | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       private | ||||
| 
 | ||||
|       def add_ci_files | ||||
|         Resource::Repository::Commit.fabricate_via_api! do |commit| | ||||
|           commit.project = project | ||||
|           commit.commit_message = 'Add parent and child pipelines CI files.' | ||||
|           commit.add_files( | ||||
|             [ | ||||
|               child_ci_file, | ||||
|               parent_ci_file | ||||
|             ] | ||||
|           ) | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       def parent_ci_file | ||||
|         { | ||||
|           file_path: '.gitlab-ci.yml', | ||||
|           content: <<~YAML | ||||
|             test: | ||||
|               stage: test | ||||
|               script: echo test | ||||
|               tags: [#{executor}] | ||||
| 
 | ||||
|             deploy: | ||||
|               stage: deploy | ||||
|               trigger: | ||||
|                 include: child.yml | ||||
|               parallel: | ||||
|                 matrix: | ||||
|                   - PROVIDER: ovh | ||||
|                     STACK: [monitoring, app] | ||||
| 
 | ||||
|           YAML | ||||
|         } | ||||
|       end | ||||
| 
 | ||||
|       def child_ci_file | ||||
|         { | ||||
|           file_path: 'child.yml', | ||||
|           content: <<~YAML | ||||
|             test_vars: | ||||
|               script: | ||||
|                 - echo $PROVIDER | ||||
|                 - echo $STACK | ||||
|               tags: [#{executor}] | ||||
|           YAML | ||||
|         } | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -70,10 +70,10 @@ gitlab: | |||
|     resources: | ||||
|       requests: | ||||
|         cpu: 746m | ||||
|         memory: 1873M | ||||
|         memory: 2809M | ||||
|       limits: | ||||
|         cpu: 1119m | ||||
|         memory: 2809M | ||||
|         memory: 4214M | ||||
|     deployment: | ||||
|       readinessProbe: | ||||
|         initialDelaySeconds: 5  # Default is 0 | ||||
|  | @ -83,10 +83,10 @@ gitlab: | |||
|       resources: | ||||
|         requests: | ||||
|           cpu: 400m | ||||
|           memory: 50M | ||||
|           memory: 75M | ||||
|         limits: | ||||
|           cpu: 600m | ||||
|           memory: 75M | ||||
|           memory: 113M | ||||
|       readinessProbe: | ||||
|         initialDelaySeconds: 5  # Default is 0 | ||||
|         periodSeconds: 15  # Default is 10 | ||||
|  |  | |||
|  | @ -14,6 +14,7 @@ RSpec.describe 'Issue Boards add issue modal', :js do | |||
|   let!(:issue2) { create(:issue, project: project, title: 'hij', description: 'klm') } | ||||
| 
 | ||||
|   before do | ||||
|     stub_feature_flags(graphql_board_lists: false) | ||||
|     project.add_maintainer(user) | ||||
| 
 | ||||
|     sign_in(user) | ||||
|  |  | |||
|  | @ -2,7 +2,7 @@ | |||
| 
 | ||||
| require 'spec_helper' | ||||
| 
 | ||||
| RSpec.describe 'Issue Boards', :js do | ||||
| RSpec.describe 'Project issue boards', :js do | ||||
|   include DragTo | ||||
|   include MobileHelpers | ||||
| 
 | ||||
|  | @ -23,7 +23,7 @@ RSpec.describe 'Issue Boards', :js do | |||
| 
 | ||||
|   context 'no lists' do | ||||
|     before do | ||||
|       visit project_board_path(project, board) | ||||
|       visit_project_board_path_without_query_limit(project, board) | ||||
|     end | ||||
| 
 | ||||
|     it 'creates default lists' do | ||||
|  | @ -52,6 +52,7 @@ RSpec.describe 'Issue Boards', :js do | |||
|     let_it_be(:a_plus)      { create(:label, project: project, name: 'A+') } | ||||
|     let_it_be(:list1)       { create(:list, board: board, label: planning, position: 0) } | ||||
|     let_it_be(:list2)       { create(:list, board: board, label: development, position: 1) } | ||||
|     let_it_be(:backlog_list) { create(:backlog_list, board: board) } | ||||
| 
 | ||||
|     let_it_be(:confidential_issue) { create(:labeled_issue, :confidential, project: project, author: user, labels: [planning], relative_position: 9) } | ||||
|     let_it_be(:issue1) { create(:labeled_issue, project: project, title: 'aaa', description: '111', assignees: [user], labels: [planning], relative_position: 8) } | ||||
|  | @ -68,7 +69,7 @@ RSpec.describe 'Issue Boards', :js do | |||
|     before do | ||||
|       stub_feature_flags(board_new_list: false) | ||||
| 
 | ||||
|       visit project_board_path(project, board) | ||||
|       visit_project_board_path_without_query_limit(project, board) | ||||
| 
 | ||||
|       wait_for_requests | ||||
| 
 | ||||
|  | @ -121,7 +122,8 @@ RSpec.describe 'Issue Boards', :js do | |||
|       context 'with the NOT queries feature flag disabled' do | ||||
|         before do | ||||
|           stub_feature_flags(not_issuable_queries: false) | ||||
|           visit project_board_path(project, board) | ||||
| 
 | ||||
|           visit_project_board_path_without_query_limit(project, board) | ||||
|         end | ||||
| 
 | ||||
|         it 'does not have the != option' do | ||||
|  | @ -141,7 +143,8 @@ RSpec.describe 'Issue Boards', :js do | |||
|       context 'with the NOT queries feature flag enabled' do | ||||
|         before do | ||||
|           stub_feature_flags(not_issuable_queries: true) | ||||
|           visit project_board_path(project, board) | ||||
| 
 | ||||
|           visit_project_board_path_without_query_limit(project, board) | ||||
|         end | ||||
| 
 | ||||
|         it 'does not have the != option' do | ||||
|  | @ -171,8 +174,7 @@ RSpec.describe 'Issue Boards', :js do | |||
|     it 'infinite scrolls list' do | ||||
|       create_list(:labeled_issue, 50, project: project, labels: [planning]) | ||||
| 
 | ||||
|       visit project_board_path(project, board) | ||||
|       wait_for_requests | ||||
|       visit_project_board_path_without_query_limit(project, board) | ||||
| 
 | ||||
|       page.within(find('.board:nth-child(2)')) do | ||||
|         expect(page.find('.board-header')).to have_content('58') | ||||
|  | @ -180,15 +182,19 @@ RSpec.describe 'Issue Boards', :js do | |||
|         expect(page).to have_content('Showing 20 of 58 issues') | ||||
| 
 | ||||
|         find('.board .board-list') | ||||
|         evaluate_script("document.querySelectorAll('.board .board-list')[1].scrollTop = document.querySelectorAll('.board .board-list')[1].scrollHeight") | ||||
|         wait_for_requests | ||||
| 
 | ||||
|         inspect_requests(inject_headers: { 'X-GITLAB-DISABLE-SQL-QUERY-LIMIT' => 'https://gitlab.com/gitlab-org/gitlab/-/issues/323426' }) do | ||||
|           evaluate_script("document.querySelectorAll('.board .board-list')[1].scrollTop = document.querySelectorAll('.board .board-list')[1].scrollHeight") | ||||
|         end | ||||
| 
 | ||||
|         expect(page).to have_selector('.board-card', count: 40) | ||||
|         expect(page).to have_content('Showing 40 of 58 issues') | ||||
| 
 | ||||
|         find('.board .board-list') | ||||
|         evaluate_script("document.querySelectorAll('.board .board-list')[1].scrollTop = document.querySelectorAll('.board .board-list')[1].scrollHeight") | ||||
|         wait_for_requests | ||||
| 
 | ||||
|         inspect_requests(inject_headers: { 'X-GITLAB-DISABLE-SQL-QUERY-LIMIT' => 'https://gitlab.com/gitlab-org/gitlab/-/issues/323426' }) do | ||||
|           evaluate_script("document.querySelectorAll('.board .board-list')[1].scrollTop = document.querySelectorAll('.board .board-list')[1].scrollHeight") | ||||
|         end | ||||
| 
 | ||||
|         expect(page).to have_selector('.board-card', count: 58) | ||||
|         expect(page).to have_content('Showing all issues') | ||||
|  | @ -236,13 +242,13 @@ RSpec.describe 'Issue Boards', :js do | |||
|         wait_for_board_cards(4, 1) | ||||
| 
 | ||||
|         expect(find('.board:nth-child(2)')).to have_content(development.title) | ||||
|         expect(find('.board:nth-child(2)')).to have_content(planning.title) | ||||
|         expect(find('.board:nth-child(3)')).to have_content(planning.title) | ||||
| 
 | ||||
|         # Make sure list positions are preserved after a reload | ||||
|         visit project_board_path(project, board) | ||||
|         visit_project_board_path_without_query_limit(project, board) | ||||
| 
 | ||||
|         expect(find('.board:nth-child(2)')).to have_content(development.title) | ||||
|         expect(find('.board:nth-child(2)')).to have_content(planning.title) | ||||
|         expect(find('.board:nth-child(3)')).to have_content(planning.title) | ||||
|       end | ||||
| 
 | ||||
|       it 'dragging does not duplicate list' do | ||||
|  | @ -254,7 +260,8 @@ RSpec.describe 'Issue Boards', :js do | |||
|         expect(page).to have_selector(selector, text: development.title, count: 1) | ||||
|       end | ||||
| 
 | ||||
|       it 'issue moves between lists and does not show the "Development" label since the card is in the "Development" list label' do | ||||
|       # TODO https://gitlab.com/gitlab-org/gitlab/-/issues/323551 | ||||
|       xit 'issue moves between lists and does not show the "Development" label since the card is in the "Development" list label' do | ||||
|         drag(list_from_index: 1, from_index: 1, list_to_index: 2) | ||||
| 
 | ||||
|         wait_for_board_cards(2, 7) | ||||
|  | @ -467,14 +474,16 @@ RSpec.describe 'Issue Boards', :js do | |||
|       end | ||||
| 
 | ||||
|       it 'removes filtered labels' do | ||||
|         set_filter("label", testing.title) | ||||
|         click_filter_link(testing.title) | ||||
|         submit_filter | ||||
|         inspect_requests(inject_headers: { 'X-GITLAB-DISABLE-SQL-QUERY-LIMIT' => 'https://gitlab.com/gitlab-org/gitlab/-/issues/323426' }) do | ||||
|           set_filter("label", testing.title) | ||||
|           click_filter_link(testing.title) | ||||
|           submit_filter | ||||
| 
 | ||||
|         wait_for_board_cards(2, 1) | ||||
|           wait_for_board_cards(2, 1) | ||||
| 
 | ||||
|         find('.clear-search').click | ||||
|         submit_filter | ||||
|           find('.clear-search').click | ||||
|           submit_filter | ||||
|         end | ||||
| 
 | ||||
|         wait_for_board_cards(2, 8) | ||||
|       end | ||||
|  | @ -484,7 +493,9 @@ RSpec.describe 'Issue Boards', :js do | |||
| 
 | ||||
|         set_filter("label", testing.title) | ||||
|         click_filter_link(testing.title) | ||||
|         submit_filter | ||||
|         inspect_requests(inject_headers: { 'X-GITLAB-DISABLE-SQL-QUERY-LIMIT' => 'https://gitlab.com/gitlab-org/gitlab/-/issues/323426' }) do | ||||
|           submit_filter | ||||
|         end | ||||
| 
 | ||||
|         wait_for_requests | ||||
| 
 | ||||
|  | @ -494,13 +505,18 @@ RSpec.describe 'Issue Boards', :js do | |||
|           expect(page).to have_content('Showing 20 of 51 issues') | ||||
| 
 | ||||
|           find('.board .board-list') | ||||
|           evaluate_script("document.querySelectorAll('.board .board-list')[1].scrollTop = document.querySelectorAll('.board .board-list')[1].scrollHeight") | ||||
| 
 | ||||
|           inspect_requests(inject_headers: { 'X-GITLAB-DISABLE-SQL-QUERY-LIMIT' => 'https://gitlab.com/gitlab-org/gitlab/-/issues/323426' }) do | ||||
|             evaluate_script("document.querySelectorAll('.board .board-list')[1].scrollTop = document.querySelectorAll('.board .board-list')[1].scrollHeight") | ||||
|           end | ||||
| 
 | ||||
|           expect(page).to have_selector('.board-card', count: 40) | ||||
|           expect(page).to have_content('Showing 40 of 51 issues') | ||||
| 
 | ||||
|           find('.board .board-list') | ||||
|           evaluate_script("document.querySelectorAll('.board .board-list')[1].scrollTop = document.querySelectorAll('.board .board-list')[1].scrollHeight") | ||||
|           inspect_requests(inject_headers: { 'X-GITLAB-DISABLE-SQL-QUERY-LIMIT' => 'https://gitlab.com/gitlab-org/gitlab/-/issues/323426' }) do | ||||
|             evaluate_script("document.querySelectorAll('.board .board-list')[1].scrollTop = document.querySelectorAll('.board .board-list')[1].scrollHeight") | ||||
|           end | ||||
| 
 | ||||
|           expect(page).to have_selector('.board-card', count: 51) | ||||
|           expect(page).to have_content('Showing all issues') | ||||
|  | @ -569,7 +585,7 @@ RSpec.describe 'Issue Boards', :js do | |||
| 
 | ||||
|   context 'keyboard shortcuts' do | ||||
|     before do | ||||
|       visit project_board_path(project, board) | ||||
|       visit_project_board_path_without_query_limit(project, board) | ||||
|       wait_for_requests | ||||
|     end | ||||
| 
 | ||||
|  | @ -617,15 +633,19 @@ RSpec.describe 'Issue Boards', :js do | |||
| 
 | ||||
|   def drag(selector: '.board-list', list_from_index: 0, from_index: 0, to_index: 0, list_to_index: 0, perform_drop: true) | ||||
|     # ensure there is enough horizontal space for four boards | ||||
|     resize_window(2000, 800) | ||||
|     inspect_requests(inject_headers: { 'X-GITLAB-DISABLE-SQL-QUERY-LIMIT' => 'https://gitlab.com/gitlab-org/gitlab/-/issues/323426' }) do | ||||
|       resize_window(2000, 800) | ||||
| 
 | ||||
|     drag_to(selector: selector, | ||||
|             scrollable: '#board-app', | ||||
|             list_from_index: list_from_index, | ||||
|             from_index: from_index, | ||||
|             to_index: to_index, | ||||
|             list_to_index: list_to_index, | ||||
|             perform_drop: perform_drop) | ||||
|       drag_to(selector: selector, | ||||
|               scrollable: '#board-app', | ||||
|               list_from_index: list_from_index, | ||||
|               from_index: from_index, | ||||
|               to_index: to_index, | ||||
|               list_to_index: list_to_index, | ||||
|               perform_drop: perform_drop) | ||||
|     end | ||||
| 
 | ||||
|     wait_for_requests | ||||
|   end | ||||
| 
 | ||||
|   def wait_for_board_cards(board_number, expected_cards) | ||||
|  | @ -666,4 +686,10 @@ RSpec.describe 'Issue Boards', :js do | |||
|       accept_confirm { find('[data-testid="remove-list"]').click } | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   def visit_project_board_path_without_query_limit(project, board) | ||||
|     inspect_requests(inject_headers: { 'X-GITLAB-DISABLE-SQL-QUERY-LIMIT' => 'https://gitlab.com/gitlab-org/gitlab/-/issues/323426' }) do | ||||
|       visit project_board_path(project, board) | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  |  | |||
|  | @ -12,6 +12,8 @@ RSpec.describe 'Issue Boards add issue modal filtering', :js do | |||
|   let!(:issue1) { create(:issue, project: project) } | ||||
| 
 | ||||
|   before do | ||||
|     stub_feature_flags(graphql_board_lists: false) | ||||
|     stub_feature_flags(add_issues_button: true) | ||||
|     project.add_maintainer(user) | ||||
| 
 | ||||
|     sign_in(user) | ||||
|  |  | |||
|  | @ -41,6 +41,10 @@ RSpec.describe 'Multi Select Issue', :js do | |||
|   before do | ||||
|     project.add_maintainer(user) | ||||
| 
 | ||||
|     # multi-drag disabled with feature flag for now | ||||
|     # https://gitlab.com/gitlab-org/gitlab/-/issues/289797 | ||||
|     stub_feature_flags(graphql_board_lists: false) | ||||
| 
 | ||||
|     sign_in(user) | ||||
|   end | ||||
| 
 | ||||
|  |  | |||
|  | @ -3,10 +3,12 @@ | |||
| require 'spec_helper' | ||||
| 
 | ||||
| RSpec.describe 'Issue Boards new issue', :js do | ||||
|   let(:project) { create(:project, :public) } | ||||
|   let(:board)   { create(:board, project: project) } | ||||
|   let!(:list)   { create(:list, board: board, position: 0) } | ||||
|   let(:user)    { create(:user) } | ||||
|   let_it_be(:project)      { create(:project, :public) } | ||||
|   let_it_be(:board)        { create(:board, project: project) } | ||||
|   let_it_be(:backlog_list) { create(:backlog_list, board: board) } | ||||
|   let_it_be(:label)        { create(:label, project: project, name: 'Label 1') } | ||||
|   let_it_be(:list)         { create(:list, board: board, label: label, position: 0) } | ||||
|   let_it_be(:user)         { create(:user) } | ||||
| 
 | ||||
|   context 'authorized user' do | ||||
|     before do | ||||
|  | @ -15,6 +17,7 @@ RSpec.describe 'Issue Boards new issue', :js do | |||
|       sign_in(user) | ||||
| 
 | ||||
|       visit project_board_path(project, board) | ||||
| 
 | ||||
|       wait_for_requests | ||||
| 
 | ||||
|       expect(page).to have_selector('.board', count: 3) | ||||
|  | @ -70,11 +73,12 @@ RSpec.describe 'Issue Boards new issue', :js do | |||
|         issue = project.issues.find_by_title('bug') | ||||
| 
 | ||||
|         expect(page).to have_content(issue.to_reference) | ||||
|         expect(page).to have_link(issue.title, href: issue_path(issue)) | ||||
|         expect(page).to have_link(issue.title, href: /#{issue_path(issue)}/) | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     it 'shows sidebar when creating new issue' do | ||||
|     # TODO https://gitlab.com/gitlab-org/gitlab/-/issues/323446 | ||||
|     xit 'shows sidebar when creating new issue' do | ||||
|       page.within(first('.board')) do | ||||
|         find('.issue-count-badge-add-button').click | ||||
|       end | ||||
|  | @ -101,12 +105,16 @@ RSpec.describe 'Issue Boards new issue', :js do | |||
| 
 | ||||
|       wait_for_requests | ||||
| 
 | ||||
|       page.within(first('.board')) do | ||||
|         find('.board-card').click | ||||
|       end | ||||
| 
 | ||||
|       page.within(first('.issue-boards-sidebar')) do | ||||
|         find('.labels .edit-link').click | ||||
|         find('.labels [data-testid="edit-button"]').click | ||||
| 
 | ||||
|         wait_for_requests | ||||
| 
 | ||||
|         expect(page).to have_selector('.labels .dropdown-content li a') | ||||
|         expect(page).to have_selector('.labels-select-contents-list .dropdown-content li a') | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  |  | |||
|  | @ -43,7 +43,7 @@ RSpec.describe 'Ensure Boards do not show stale data on browser back', :js do | |||
|         issue = project.issues.find_by_title('issue should be shown') | ||||
| 
 | ||||
|         expect(page).to have_content(issue.to_reference) | ||||
|         expect(page).to have_link(issue.title, href: issue_path(issue)) | ||||
|         expect(page).to have_link(issue.title, href: /#{issue_path(issue)}/) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  |  | |||
|  | @ -17,6 +17,8 @@ RSpec.describe 'Project issue boards sidebar assignee', :js do | |||
|   let(:card)              { find('.board:nth-child(2)').first('.board-card') } | ||||
| 
 | ||||
|   before do | ||||
|     stub_feature_flags(graphql_board_lists: false) | ||||
| 
 | ||||
|     project.add_maintainer(user) | ||||
| 
 | ||||
|     sign_in(user) | ||||
|  |  | |||
|  | @ -17,6 +17,8 @@ RSpec.describe 'Project issue boards sidebar due date', :js do | |||
|   end | ||||
| 
 | ||||
|   before do | ||||
|     stub_feature_flags(graphql_board_lists: false) | ||||
| 
 | ||||
|     project.add_maintainer(user) | ||||
| 
 | ||||
|     sign_in(user) | ||||
|  |  | |||
|  | @ -18,6 +18,8 @@ RSpec.describe 'Project issue boards sidebar labels', :js do | |||
|   let(:card)              { find('.board:nth-child(2)').first('.board-card') } | ||||
| 
 | ||||
|   before do | ||||
|     stub_feature_flags(graphql_board_lists: false) | ||||
| 
 | ||||
|     project.add_maintainer(user) | ||||
| 
 | ||||
|     sign_in(user) | ||||
|  |  | |||
|  | @ -16,6 +16,8 @@ RSpec.describe 'Project issue boards sidebar milestones', :js do | |||
|   let(:card2)           { find('.board:nth-child(1) .board-card:nth-of-type(2)') } | ||||
| 
 | ||||
|   before do | ||||
|     stub_feature_flags(graphql_board_lists: false) | ||||
| 
 | ||||
|     project.add_maintainer(user) | ||||
| 
 | ||||
|     sign_in(user) | ||||
|  |  | |||
|  | @ -13,6 +13,8 @@ RSpec.describe 'Project issue boards sidebar', :js do | |||
|   let(:card)          { find('.board:nth-child(1)').first('.board-card') } | ||||
| 
 | ||||
|   before do | ||||
|     stub_feature_flags(graphql_board_lists: false) | ||||
| 
 | ||||
|     project.add_maintainer(user) | ||||
| 
 | ||||
|     sign_in(user) | ||||
|  |  | |||
|  | @ -16,6 +16,8 @@ RSpec.describe 'Project issue boards sidebar subscription', :js do | |||
|   let(:card2)              { find('.board:nth-child(1) .board-card:nth-of-type(2)') } | ||||
| 
 | ||||
|   before do | ||||
|     stub_feature_flags(graphql_board_lists: false) | ||||
| 
 | ||||
|     project.add_maintainer(user) | ||||
| 
 | ||||
|     sign_in(user) | ||||
|  |  | |||
|  | @ -15,6 +15,8 @@ RSpec.describe 'Project issue boards sidebar time tracking', :js do | |||
|   let(:application_settings) { {} } | ||||
| 
 | ||||
|   before do | ||||
|     stub_feature_flags(graphql_board_lists: false) | ||||
| 
 | ||||
|     project.add_maintainer(user) | ||||
| 
 | ||||
|     sign_in(user) | ||||
|  |  | |||
|  | @ -21,7 +21,8 @@ RSpec.describe 'Sub-group project issue boards', :js do | |||
|     wait_for_requests | ||||
|   end | ||||
| 
 | ||||
|   it 'creates new label from sidebar' do | ||||
|   # TODO https://gitlab.com/gitlab-org/gitlab/-/issues/324290 | ||||
|   xit 'creates new label from sidebar' do | ||||
|     find('.board-card').click | ||||
| 
 | ||||
|     page.within '.labels' do | ||||
|  |  | |||
|  | @ -25,18 +25,22 @@ describe('access tokens', () => { | |||
|   }); | ||||
| 
 | ||||
|   describe.each` | ||||
|     initFunction          | mountSelector                    | expectedComponent | ||||
|     ${initExpiresAtField} | ${'js-access-tokens-expires-at'} | ${ExpiresAtField} | ||||
|     ${initProjectsField}  | ${'js-access-tokens-projects'}   | ${ProjectsField} | ||||
|   `('$initFunction', ({ initFunction, mountSelector, expectedComponent }) => {
 | ||||
|     initFunction          | mountSelector                    | fieldName      | expectedComponent | ||||
|     ${initExpiresAtField} | ${'js-access-tokens-expires-at'} | ${'expiresAt'} | ${ExpiresAtField} | ||||
|     ${initProjectsField}  | ${'js-access-tokens-projects'}   | ${'projects'}  | ${ProjectsField} | ||||
|   `('$initFunction', ({ initFunction, mountSelector, fieldName, expectedComponent }) => {
 | ||||
|     describe('when mount element exists', () => { | ||||
|       const nameAttribute = `access_tokens[${fieldName}]`; | ||||
|       const idAttribute = `access_tokens_${fieldName}`; | ||||
| 
 | ||||
|       beforeEach(() => { | ||||
|         const mountEl = document.createElement('div'); | ||||
|         mountEl.classList.add(mountSelector); | ||||
| 
 | ||||
|         const input = document.createElement('input'); | ||||
|         input.setAttribute('name', 'foo-bar'); | ||||
|         input.setAttribute('id', 'foo-bar'); | ||||
|         input.setAttribute('name', nameAttribute); | ||||
|         input.setAttribute('data-js-name', fieldName); | ||||
|         input.setAttribute('id', idAttribute); | ||||
|         input.setAttribute('placeholder', 'Foo bar'); | ||||
|         input.setAttribute('value', '1,2'); | ||||
| 
 | ||||
|  | @ -57,8 +61,8 @@ describe('access tokens', () => { | |||
| 
 | ||||
|         expect(component.exists()).toBe(true); | ||||
|         expect(component.props('inputAttrs')).toEqual({ | ||||
|           name: 'foo-bar', | ||||
|           id: 'foo-bar', | ||||
|           name: nameAttribute, | ||||
|           id: idAttribute, | ||||
|           value: '1,2', | ||||
|           placeholder: 'Foo bar', | ||||
|         }); | ||||
|  |  | |||
|  | @ -639,10 +639,13 @@ describe('resetIssues', () => { | |||
| }); | ||||
| 
 | ||||
| describe('moveItem', () => { | ||||
|   it('should dispatch moveIssue action', () => { | ||||
|   it('should dispatch moveIssue action with payload', () => { | ||||
|     const payload = { mock: 'payload' }; | ||||
| 
 | ||||
|     testAction({ | ||||
|       action: actions.moveItem, | ||||
|       expectedActions: [{ type: 'moveIssue' }], | ||||
|       payload, | ||||
|       expectedActions: [{ type: 'moveIssue', payload }], | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  |  | |||
|  | @ -1,4 +1,9 @@ | |||
| import { serializeForm, serializeFormObject, isEmptyValue } from '~/lib/utils/forms'; | ||||
| import { | ||||
|   serializeForm, | ||||
|   serializeFormObject, | ||||
|   isEmptyValue, | ||||
|   parseRailsFormFields, | ||||
| } from '~/lib/utils/forms'; | ||||
| 
 | ||||
| describe('lib/utils/forms', () => { | ||||
|   const createDummyForm = (inputs) => { | ||||
|  | @ -135,4 +140,160 @@ describe('lib/utils/forms', () => { | |||
|       }); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('parseRailsFormFields', () => { | ||||
|     let mountEl; | ||||
| 
 | ||||
|     beforeEach(() => { | ||||
|       mountEl = document.createElement('div'); | ||||
|       mountEl.classList.add('js-foo-bar'); | ||||
|     }); | ||||
| 
 | ||||
|     afterEach(() => { | ||||
|       mountEl = null; | ||||
|     }); | ||||
| 
 | ||||
|     it('parses fields generated by Rails and returns object with HTML attributes', () => { | ||||
|       mountEl.innerHTML = ` | ||||
|         <input type="text" placeholder="Name" value="Administrator" name="user[name]" id="user_name" data-js-name="name"> | ||||
|         <input type="text" placeholder="Email" value="foo@bar.com" name="user[contact_info][email]" id="user_contact_info_email" data-js-name="contactInfoEmail"> | ||||
|         <input type="text" placeholder="Phone" value="(123) 456-7890" name="user[contact_info][phone]" id="user_contact_info_phone" data-js-name="contact_info_phone"> | ||||
|         <input type="hidden" placeholder="Job title" value="" name="user[job_title]" id="user_job_title" data-js-name="jobTitle"> | ||||
|         <textarea name="user[bio]" id="user_bio" data-js-name="bio">Foo bar</textarea> | ||||
|         <select name="user[timezone]" id="user_timezone" data-js-name="timezone"> | ||||
|           <option value="utc+12">[UTC - 12] International Date Line West</option> | ||||
|           <option value="utc+11" selected>[UTC - 11] American Samoa</option> | ||||
|         </select> | ||||
|         <input type="checkbox" name="user[interests][]" id="user_interests_vue" value="Vue" checked data-js-name="interests"> | ||||
|         <input type="checkbox" name="user[interests][]" id="user_interests_graphql" value="GraphQL" data-js-name="interests"> | ||||
|         <input type="radio" name="user[access_level]" value="regular" id="user_access_level_regular" data-js-name="accessLevel"> | ||||
|         <input type="radio" name="user[access_level]" value="admin" id="user_access_level_admin" checked data-js-name="access_level"> | ||||
|         <input name="user[private_profile]" type="hidden" value="0"> | ||||
|         <input type="radio" name="user[private_profile]" id="user_private_profile" value="1" checked data-js-name="privateProfile"> | ||||
|         <input name="user[email_notifications]" type="hidden" value="0"> | ||||
|         <input type="radio" name="user[email_notifications]" id="user_email_notifications" value="1" data-js-name="emailNotifications"> | ||||
|       `;
 | ||||
| 
 | ||||
|       expect(parseRailsFormFields(mountEl)).toEqual({ | ||||
|         name: { | ||||
|           name: 'user[name]', | ||||
|           id: 'user_name', | ||||
|           value: 'Administrator', | ||||
|           placeholder: 'Name', | ||||
|         }, | ||||
|         contactInfoEmail: { | ||||
|           name: 'user[contact_info][email]', | ||||
|           id: 'user_contact_info_email', | ||||
|           value: 'foo@bar.com', | ||||
|           placeholder: 'Email', | ||||
|         }, | ||||
|         contactInfoPhone: { | ||||
|           name: 'user[contact_info][phone]', | ||||
|           id: 'user_contact_info_phone', | ||||
|           value: '(123) 456-7890', | ||||
|           placeholder: 'Phone', | ||||
|         }, | ||||
|         jobTitle: { | ||||
|           name: 'user[job_title]', | ||||
|           id: 'user_job_title', | ||||
|           value: '', | ||||
|           placeholder: 'Job title', | ||||
|         }, | ||||
|         bio: { | ||||
|           name: 'user[bio]', | ||||
|           id: 'user_bio', | ||||
|           value: 'Foo bar', | ||||
|         }, | ||||
|         timezone: { | ||||
|           name: 'user[timezone]', | ||||
|           id: 'user_timezone', | ||||
|           value: 'utc+11', | ||||
|         }, | ||||
|         interests: [ | ||||
|           { | ||||
|             name: 'user[interests][]', | ||||
|             id: 'user_interests_vue', | ||||
|             value: 'Vue', | ||||
|             checked: true, | ||||
|           }, | ||||
|           { | ||||
|             name: 'user[interests][]', | ||||
|             id: 'user_interests_graphql', | ||||
|             value: 'GraphQL', | ||||
|             checked: false, | ||||
|           }, | ||||
|         ], | ||||
|         accessLevel: [ | ||||
|           { | ||||
|             name: 'user[access_level]', | ||||
|             id: 'user_access_level_regular', | ||||
|             value: 'regular', | ||||
|             checked: false, | ||||
|           }, | ||||
|           { | ||||
|             name: 'user[access_level]', | ||||
|             id: 'user_access_level_admin', | ||||
|             value: 'admin', | ||||
|             checked: true, | ||||
|           }, | ||||
|         ], | ||||
|         privateProfile: [ | ||||
|           { | ||||
|             name: 'user[private_profile]', | ||||
|             id: 'user_private_profile', | ||||
|             value: '1', | ||||
|             checked: true, | ||||
|           }, | ||||
|         ], | ||||
|         emailNotifications: [ | ||||
|           { | ||||
|             name: 'user[email_notifications]', | ||||
|             id: 'user_email_notifications', | ||||
|             value: '1', | ||||
|             checked: false, | ||||
|           }, | ||||
|         ], | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     it('returns an empty object if there are no inputs', () => { | ||||
|       expect(parseRailsFormFields(mountEl)).toEqual({}); | ||||
|     }); | ||||
| 
 | ||||
|     it('returns an empty object if inputs do not have `name` attributes', () => { | ||||
|       mountEl.innerHTML = ` | ||||
|         <input type="text" placeholder="Name" value="Administrator" id="user_name"> | ||||
|         <input type="text" placeholder="Email" value="foo@bar.com" id="user_contact_info_email"> | ||||
|         <input type="text" placeholder="Phone" value="(123) 456-7890" id="user_contact_info_phone"> | ||||
|       `;
 | ||||
| 
 | ||||
|       expect(parseRailsFormFields(mountEl)).toEqual({}); | ||||
|     }); | ||||
| 
 | ||||
|     it('does not include field if `data-js-name` attribute is missing', () => { | ||||
|       mountEl.innerHTML = ` | ||||
|         <input type="text" placeholder="Name" value="Administrator" name="user[name]" id="user_name" data-js-name="name"> | ||||
|         <input type="text" placeholder="Email" value="foo@bar.com" name="user[email]" id="email"> | ||||
|       `;
 | ||||
| 
 | ||||
|       expect(parseRailsFormFields(mountEl)).toEqual({ | ||||
|         name: { | ||||
|           name: 'user[name]', | ||||
|           id: 'user_name', | ||||
|           value: 'Administrator', | ||||
|           placeholder: 'Name', | ||||
|         }, | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     it('throws error if `mountEl` argument is not passed', () => { | ||||
|       expect(() => parseRailsFormFields()).toThrow(new TypeError('`mountEl` argument is required')); | ||||
|     }); | ||||
| 
 | ||||
|     it('throws error if `mountEl` argument is `null`', () => { | ||||
|       expect(() => parseRailsFormFields(null)).toThrow( | ||||
|         new TypeError('`mountEl` argument is required'), | ||||
|       ); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  |  | |||
|  | @ -0,0 +1,145 @@ | |||
| import { GlEmptyState } from '@gitlab/ui'; | ||||
| import * as Sentry from '@sentry/browser'; | ||||
| import { mount } from '@vue/test-utils'; | ||||
| import ensureData from '~/ensure_data'; | ||||
| 
 | ||||
| const mockData = { message: 'Hello there' }; | ||||
| const defaultOptions = { | ||||
|   parseData: () => mockData, | ||||
|   data: mockData, | ||||
| }; | ||||
| 
 | ||||
| const MockChildComponent = { | ||||
|   inject: ['message'], | ||||
|   render(createElement) { | ||||
|     return createElement('h1', this.message); | ||||
|   }, | ||||
| }; | ||||
| 
 | ||||
| const MockParentComponent = { | ||||
|   components: { | ||||
|     MockChildComponent, | ||||
|   }, | ||||
|   props: { | ||||
|     message: { | ||||
|       type: String, | ||||
|       required: true, | ||||
|     }, | ||||
|     otherProp: { | ||||
|       type: Boolean, | ||||
|       default: false, | ||||
|       required: false, | ||||
|     }, | ||||
|   }, | ||||
|   render(createElement) { | ||||
|     return createElement('div', [this.message, createElement(MockChildComponent)]); | ||||
|   }, | ||||
| }; | ||||
| 
 | ||||
| describe('EnsureData', () => { | ||||
|   let wrapper; | ||||
| 
 | ||||
|   function findEmptyState() { | ||||
|     return wrapper.findComponent(GlEmptyState); | ||||
|   } | ||||
| 
 | ||||
|   function findChild() { | ||||
|     return wrapper.findComponent(MockChildComponent); | ||||
|   } | ||||
|   function findParent() { | ||||
|     return wrapper.findComponent(MockParentComponent); | ||||
|   } | ||||
| 
 | ||||
|   function createComponent(options = defaultOptions) { | ||||
|     return mount(ensureData(MockParentComponent, options)); | ||||
|   } | ||||
| 
 | ||||
|   beforeEach(() => { | ||||
|     Sentry.captureException = jest.fn(); | ||||
|   }); | ||||
| 
 | ||||
|   afterEach(() => { | ||||
|     wrapper.destroy(); | ||||
|     Sentry.captureException.mockClear(); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when parseData throws', () => { | ||||
|     it('should render GlEmptyState', () => { | ||||
|       wrapper = createComponent({ | ||||
|         parseData: () => { | ||||
|           throw new Error(); | ||||
|         }, | ||||
|       }); | ||||
| 
 | ||||
|       expect(findParent().exists()).toBe(false); | ||||
|       expect(findChild().exists()).toBe(false); | ||||
|       expect(findEmptyState().exists()).toBe(true); | ||||
|     }); | ||||
| 
 | ||||
|     it('should not log to Sentry when shouldLog=false (default)', () => { | ||||
|       wrapper = createComponent({ | ||||
|         parseData: () => { | ||||
|           throw new Error(); | ||||
|         }, | ||||
|       }); | ||||
| 
 | ||||
|       expect(Sentry.captureException).not.toHaveBeenCalled(); | ||||
|     }); | ||||
| 
 | ||||
|     it('should log to Sentry when shouldLog=true', () => { | ||||
|       const error = new Error('Error!'); | ||||
|       wrapper = createComponent({ | ||||
|         parseData: () => { | ||||
|           throw error; | ||||
|         }, | ||||
|         shouldLog: true, | ||||
|       }); | ||||
| 
 | ||||
|       expect(Sentry.captureException).toHaveBeenCalledWith(error); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('when parseData succeeds', () => { | ||||
|     it('should render MockParentComponent and MockChildComponent', () => { | ||||
|       wrapper = createComponent(); | ||||
| 
 | ||||
|       expect(findEmptyState().exists()).toBe(false); | ||||
|       expect(findParent().exists()).toBe(true); | ||||
|       expect(findChild().exists()).toBe(true); | ||||
|     }); | ||||
| 
 | ||||
|     it('enables user to provide data to child components', () => { | ||||
|       wrapper = createComponent(); | ||||
| 
 | ||||
|       const childComponent = findChild(); | ||||
|       expect(childComponent.text()).toBe(mockData.message); | ||||
|     }); | ||||
| 
 | ||||
|     it('enables user to override provide data', () => { | ||||
|       const message = 'Another message'; | ||||
|       wrapper = createComponent({ ...defaultOptions, provide: { message } }); | ||||
| 
 | ||||
|       const childComponent = findChild(); | ||||
|       expect(childComponent.text()).toBe(message); | ||||
|     }); | ||||
| 
 | ||||
|     it('enables user to pass props to parent component', () => { | ||||
|       wrapper = createComponent(); | ||||
| 
 | ||||
|       expect(findParent().props()).toMatchObject(mockData); | ||||
|     }); | ||||
| 
 | ||||
|     it('enables user to override props data', () => { | ||||
|       const props = { message: 'Another message', otherProp: true }; | ||||
|       wrapper = createComponent({ ...defaultOptions, props }); | ||||
| 
 | ||||
|       expect(findParent().props()).toMatchObject(props); | ||||
|     }); | ||||
| 
 | ||||
|     it('should not log to Sentry when shouldLog=true', () => { | ||||
|       wrapper = createComponent({ ...defaultOptions, shouldLog: true }); | ||||
| 
 | ||||
|       expect(Sentry.captureException).not.toHaveBeenCalled(); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  | @ -17,6 +17,7 @@ RSpec.describe Gitlab::InstrumentationHelper do | |||
|         :elasticsearch_calls, | ||||
|         :elasticsearch_duration_s, | ||||
|         :elasticsearch_timed_out_count, | ||||
|         :worker_data_consistency, | ||||
|         :mem_objects, | ||||
|         :mem_bytes, | ||||
|         :mem_mallocs, | ||||
|  |  | |||
|  | @ -11,12 +11,24 @@ RSpec.describe Gitlab::UsageDataQueries do | |||
|     it 'returns the raw SQL' do | ||||
|       expect(described_class.count(User)).to start_with('SELECT COUNT("users"."id") FROM "users"') | ||||
|     end | ||||
| 
 | ||||
|     it 'does not mix a nil column with keyword arguments' do | ||||
|       expect(described_class).to receive(:raw_sql).with(User, nil) | ||||
| 
 | ||||
|       described_class.count(User, start: 1, finish: 2) | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '.distinct_count' do | ||||
|     it 'returns the raw SQL' do | ||||
|       expect(described_class.distinct_count(Issue, :author_id)).to eq('SELECT COUNT(DISTINCT "issues"."author_id") FROM "issues"') | ||||
|     end | ||||
| 
 | ||||
|     it 'does not mix a nil column with keyword arguments' do | ||||
|       expect(described_class).to receive(:raw_sql).with(Issue, nil, :distinct) | ||||
| 
 | ||||
|       described_class.distinct_count(Issue, nil, start: 1, finish: 2) | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '.redis_usage_data' do | ||||
|  |  | |||
|  | @ -2,25 +2,26 @@ | |||
| 
 | ||||
| module NextInstanceOf | ||||
|   def expect_next_instance_of(klass, *new_args, &blk) | ||||
|     stub_new(expect(klass), nil, *new_args, &blk) | ||||
|     stub_new(expect(klass), nil, false, *new_args, &blk) | ||||
|   end | ||||
| 
 | ||||
|   def expect_next_instances_of(klass, number, *new_args, &blk) | ||||
|     stub_new(expect(klass), number, *new_args, &blk) | ||||
|   def expect_next_instances_of(klass, number, ordered = false, *new_args, &blk) | ||||
|     stub_new(expect(klass), number, ordered, *new_args, &blk) | ||||
|   end | ||||
| 
 | ||||
|   def allow_next_instance_of(klass, *new_args, &blk) | ||||
|     stub_new(allow(klass), nil, *new_args, &blk) | ||||
|     stub_new(allow(klass), nil, false, *new_args, &blk) | ||||
|   end | ||||
| 
 | ||||
|   def allow_next_instances_of(klass, number, *new_args, &blk) | ||||
|     stub_new(allow(klass), number, *new_args, &blk) | ||||
|   def allow_next_instances_of(klass, number, ordered = false, *new_args, &blk) | ||||
|     stub_new(allow(klass), number, ordered, *new_args, &blk) | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
| 
 | ||||
|   def stub_new(target, number, *new_args, &blk) | ||||
|   def stub_new(target, number, ordered = false, *new_args, &blk) | ||||
|     receive_new = receive(:new) | ||||
|     receive_new.ordered if ordered | ||||
|     receive_new.exactly(number).times if number | ||||
|     receive_new.with(*new_args) if new_args.any? | ||||
| 
 | ||||
|  |  | |||
|  | @ -0,0 +1,74 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| require 'spec_helper' | ||||
| 
 | ||||
| RSpec.describe WorkerAttributes do | ||||
|   let(:worker) do | ||||
|     Class.new do | ||||
|       def self.name | ||||
|         "TestWorker" | ||||
|       end | ||||
| 
 | ||||
|       include ApplicationWorker | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '.data_consistency' do | ||||
|     context 'with valid data_consistency' do | ||||
|       it 'returns correct data_consistency' do | ||||
|         worker.data_consistency(:sticky) | ||||
| 
 | ||||
|         expect(worker.get_data_consistency).to eq(:sticky) | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context 'when data_consistency is not provided' do | ||||
|       it 'defaults to :always' do | ||||
|         expect(worker.get_data_consistency).to eq(:always) | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context 'with invalid data_consistency' do | ||||
|       it 'raise exception' do | ||||
|         expect { worker.data_consistency(:invalid) } | ||||
|           .to raise_error('Invalid data consistency: invalid') | ||||
|       end | ||||
|     end | ||||
| 
 | ||||
|     context 'when job is idempotent' do | ||||
|       context 'when data_consistency is not :always' do | ||||
|         it 'raise exception' do | ||||
|           worker.idempotent! | ||||
| 
 | ||||
|           expect { worker.data_consistency(:sticky) } | ||||
|             .to raise_error("Class can't be marked as idempotent if data_consistency is not set to :always") | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       context 'when feature_flag is provided' do | ||||
|         before do | ||||
|           stub_feature_flags(test_feature_flag: false) | ||||
|           skip_feature_flags_yaml_validation | ||||
|           skip_default_enabled_yaml_check | ||||
|         end | ||||
| 
 | ||||
|         it 'returns correct feature flag value' do | ||||
|           worker.data_consistency(:sticky, feature_flag: :test_feature_flag) | ||||
| 
 | ||||
|           expect(worker.get_data_consistency_feature_flag_enabled?).not_to be_truthy | ||||
|         end | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '.idempotent!' do | ||||
|     context 'when data consistency is not :always' do | ||||
|       it 'raise exception' do | ||||
|         worker.data_consistency(:sticky) | ||||
| 
 | ||||
|         expect { worker.idempotent! } | ||||
|           .to raise_error("Class can't be marked as idempotent if data_consistency is not set to :always") | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
		Loading…
	
		Reference in New Issue