Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
e40061efd4
commit
81f062b841
|
@ -1913,7 +1913,7 @@ Layout/LineLength:
|
|||
- 'ee/spec/frontend/fixtures/projects.rb'
|
||||
- 'ee/spec/graphql/ee/mutations/boards/lists/create_spec.rb'
|
||||
- 'ee/spec/graphql/ee/resolvers/board_list_issues_resolver_spec.rb'
|
||||
- 'ee/spec/graphql/ee/resolvers/issues_resolver_spec.rb'
|
||||
- 'ee/spec/graphql/ee/resolvers/project_issues_resolver_spec.rb'
|
||||
- 'ee/spec/graphql/ee/types/board_type_spec.rb'
|
||||
- 'ee/spec/graphql/ee/types/issue_sort_enum_spec.rb'
|
||||
- 'ee/spec/graphql/ee/types/merge_request_type_spec.rb'
|
||||
|
@ -4180,11 +4180,11 @@ Layout/LineLength:
|
|||
- 'spec/graphql/resolvers/group_issues_resolver_spec.rb'
|
||||
- 'spec/graphql/resolvers/group_labels_resolver_spec.rb'
|
||||
- 'spec/graphql/resolvers/issue_status_counts_resolver_spec.rb'
|
||||
- 'spec/graphql/resolvers/issues_resolver_spec.rb'
|
||||
- 'spec/graphql/resolvers/merge_requests_resolver_spec.rb'
|
||||
- 'spec/graphql/resolvers/metrics/dashboard_resolver_spec.rb'
|
||||
- 'spec/graphql/resolvers/metrics/dashboards/annotation_resolver_spec.rb'
|
||||
- 'spec/graphql/resolvers/namespace_projects_resolver_spec.rb'
|
||||
- 'spec/graphql/resolvers/project_issues_resolver_spec.rb'
|
||||
- 'spec/graphql/resolvers/project_jobs_resolver_spec.rb'
|
||||
- 'spec/graphql/resolvers/project_resolver_spec.rb'
|
||||
- 'spec/graphql/resolvers/projects/jira_projects_resolver_spec.rb'
|
||||
|
|
|
@ -3,8 +3,6 @@ Lint/MissingCopEnableDirective:
|
|||
Exclude:
|
||||
- 'app/controllers/admin/users_controller.rb'
|
||||
- 'app/controllers/projects/forks_controller.rb'
|
||||
- 'app/graphql/resolvers/group_issues_resolver.rb'
|
||||
- 'app/graphql/resolvers/issues_resolver.rb'
|
||||
- 'app/graphql/resolvers/project_members_resolver.rb'
|
||||
- 'app/graphql/resolvers/project_milestones_resolver.rb'
|
||||
- 'app/graphql/resolvers/projects/snippets_resolver.rb'
|
||||
|
|
|
@ -1568,7 +1568,6 @@ RSpec/ContextWording:
|
|||
- 'spec/graphql/resolvers/group_milestones_resolver_spec.rb'
|
||||
- 'spec/graphql/resolvers/group_packages_resolver_spec.rb'
|
||||
- 'spec/graphql/resolvers/issue_status_counts_resolver_spec.rb'
|
||||
- 'spec/graphql/resolvers/issues_resolver_spec.rb'
|
||||
- 'spec/graphql/resolvers/kas/agent_configurations_resolver_spec.rb'
|
||||
- 'spec/graphql/resolvers/kas/agent_connections_resolver_spec.rb'
|
||||
- 'spec/graphql/resolvers/last_commit_resolver_spec.rb'
|
||||
|
|
|
@ -1 +1 @@
|
|||
d4bc56074d6151875943c1b128b89b4f554af68a
|
||||
7a8f7c377bd013483aba14ced8eafd073c631d4a
|
||||
|
|
|
@ -1 +1 @@
|
|||
af0cd47633f6e0a5b8ac349a2584c01164af701a
|
||||
2a92165653c54fd23ead433e2cb477d6663c607d
|
||||
|
|
|
@ -20,26 +20,28 @@ export default {
|
|||
},
|
||||
},
|
||||
[
|
||||
h(
|
||||
'ul',
|
||||
{
|
||||
class: 'gl-p-0 gl-m-0 gl-list-style-none',
|
||||
},
|
||||
[
|
||||
...extensions.map((extension, index) =>
|
||||
h('li', { attrs: { class: index > 0 && 'mr-widget-border-top' } }, [
|
||||
h(
|
||||
{ ...extension },
|
||||
{
|
||||
props: {
|
||||
mr: this.mr,
|
||||
h('div', { attrs: { class: 'mr-widget-section' } }, [
|
||||
h(
|
||||
'ul',
|
||||
{
|
||||
class: 'gl-p-0 gl-m-0 gl-list-style-none',
|
||||
},
|
||||
[
|
||||
...extensions.map((extension, index) =>
|
||||
h('li', { attrs: { class: index > 0 && 'mr-widget-border-top' } }, [
|
||||
h(
|
||||
{ ...extension },
|
||||
{
|
||||
props: {
|
||||
mr: this.mr,
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
]),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
<template>
|
||||
<div class="mr-widget-heading">
|
||||
<div class="mr-widget-content"><slot name="default"></slot></div>
|
||||
<slot name="footer"></slot>
|
||||
<div class="mr-section-container mr-widget-workflow">
|
||||
<div class="mr-widget-section">
|
||||
<div class="mr-widget-content"><slot name="default"></slot></div>
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -521,7 +521,7 @@ export default {
|
|||
<template>
|
||||
<div
|
||||
data-testid="ready_to_merge_state"
|
||||
class="gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-pl-7 gl-rounded-bottom-left-base gl-rounded-bottom-right-base"
|
||||
class="gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-pl-7"
|
||||
>
|
||||
<div v-if="loading" class="mr-widget-body">
|
||||
<div class="gl-w-full mr-ready-to-merge-loader">
|
||||
|
|
|
@ -550,17 +550,8 @@ export default {
|
|||
:user-callout-feature-id="mr.suggestPipelineFeatureId"
|
||||
@dismiss="dismissSuggestPipelines"
|
||||
/>
|
||||
<mr-widget-pipeline-container
|
||||
v-if="shouldRenderPipelines"
|
||||
class="mr-widget-workflow"
|
||||
:mr="mr"
|
||||
/>
|
||||
<mr-widget-approvals
|
||||
v-if="shouldRenderApprovals"
|
||||
class="mr-widget-workflow"
|
||||
:mr="mr"
|
||||
:service="service"
|
||||
/>
|
||||
<mr-widget-pipeline-container v-if="shouldRenderPipelines" :mr="mr" />
|
||||
<mr-widget-approvals v-if="shouldRenderApprovals" :mr="mr" :service="service" />
|
||||
<report-widget-container>
|
||||
<extensions-container v-if="hasExtensions" :mr="mr" />
|
||||
<security-reports-app
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
import { cloneDeep, isEmpty } from 'lodash';
|
||||
import { GlFormGroup, GlFormInput, GlFormRadio, GlFormRadioGroup, GlLink } from '@gitlab/ui';
|
||||
import { __, s__ } from '~/locale';
|
||||
import { scrollToElement } from '~/lib/utils/common_utils';
|
||||
|
||||
import FormUrlMaskItem from './form_url_mask_item.vue';
|
||||
|
||||
|
@ -31,9 +32,14 @@ export default {
|
|||
maskEnabled: !isEmpty(this.initialUrlVariables),
|
||||
url: this.initialUrl,
|
||||
items: this.getInitialItems(),
|
||||
isValidated: false,
|
||||
formEl: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
urlState() {
|
||||
return !this.isValidated || !isEmpty(this.url);
|
||||
},
|
||||
maskedUrl() {
|
||||
if (!this.url) {
|
||||
return null;
|
||||
|
@ -46,13 +52,20 @@ export default {
|
|||
return;
|
||||
}
|
||||
|
||||
const replacementExpression = new RegExp(value, 'g');
|
||||
maskedUrl = maskedUrl.replace(replacementExpression, `{${key}}`);
|
||||
maskedUrl = this.maskUrl(maskedUrl, key, value);
|
||||
});
|
||||
|
||||
return maskedUrl;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.formEl = document.querySelector('.js-webhook-form');
|
||||
|
||||
this.formEl?.addEventListener('submit', this.handleSubmit);
|
||||
},
|
||||
destroy() {
|
||||
this.formEl?.removeEventListener('submit', this.handleSubmit);
|
||||
},
|
||||
methods: {
|
||||
getInitialItems() {
|
||||
return isEmpty(this.initialUrlVariables) ? [{}] : cloneDeep(this.initialUrlVariables);
|
||||
|
@ -64,6 +77,52 @@ export default {
|
|||
|
||||
return this.initialUrlVariables.some((item) => item.key === key);
|
||||
},
|
||||
keyInvalidFeedback(key) {
|
||||
if (this.isValidated && isEmpty(key)) {
|
||||
return this.$options.i18n.inputRequired;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
valueInvalidFeedback(key, value) {
|
||||
if (this.isEditingItem(key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.isValidated && isEmpty(value)) {
|
||||
return this.$options.i18n.inputRequired;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
isValid() {
|
||||
this.isValidated = true;
|
||||
|
||||
if (!this.urlState) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
this.maskEnabled &&
|
||||
this.items.some(
|
||||
({ key, value }) => this.keyInvalidFeedback(key) || this.valueInvalidFeedback(key, value),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
handleSubmit(e) {
|
||||
if (!this.isValid()) {
|
||||
scrollToElement(this.$refs.formUrl.$el);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
},
|
||||
maskUrl(url, key, value) {
|
||||
return url.split(value).join(`{${key}}`);
|
||||
},
|
||||
onItemInput({ index, key, value }) {
|
||||
this.$set(this.items, index, { key, value });
|
||||
},
|
||||
|
@ -76,6 +135,7 @@ export default {
|
|||
},
|
||||
i18n: {
|
||||
addItem: s__('Webhooks|+ Mask another portion of URL'),
|
||||
inputRequired: __('This field is required.'),
|
||||
radioFullUrlText: s__('Webhooks|Show full URL'),
|
||||
radioMaskUrlText: s__('Webhooks|Mask portions of URL'),
|
||||
radioMaskUrlHelp: s__('Webhooks|Do not show sensitive data such as tokens in the UI.'),
|
||||
|
@ -92,14 +152,18 @@ export default {
|
|||
<template>
|
||||
<div>
|
||||
<gl-form-group
|
||||
ref="formUrl"
|
||||
:label="$options.i18n.urlLabel"
|
||||
label-for="webhook-url"
|
||||
:description="$options.i18n.urlDescription"
|
||||
:invalid-feedback="$options.i18n.inputRequired"
|
||||
:state="urlState"
|
||||
>
|
||||
<gl-form-input
|
||||
id="webhook-url"
|
||||
v-model="url"
|
||||
name="hook[url]"
|
||||
:state="urlState"
|
||||
:placeholder="$options.i18n.urlPlaceholder"
|
||||
data-testid="form-url"
|
||||
/>
|
||||
|
@ -123,6 +187,8 @@ export default {
|
|||
:item-key="key"
|
||||
:item-value="value"
|
||||
:is-editing="isEditingItem(key)"
|
||||
:key-invalid-feedback="keyInvalidFeedback(key)"
|
||||
:value-invalid-feedback="valueInvalidFeedback(key, value)"
|
||||
@input="onItemInput"
|
||||
@remove="removeItem"
|
||||
/>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
<script>
|
||||
import { isEmpty } from 'lodash';
|
||||
import { GlButton, GlFormGroup, GlFormInput } from '@gitlab/ui';
|
||||
import { s__ } from '~/locale';
|
||||
import { MASK_ITEM_VALUE_HIDDEN } from '../constants';
|
||||
|
@ -30,6 +31,16 @@ export default {
|
|||
required: false,
|
||||
default: false,
|
||||
},
|
||||
keyInvalidFeedback: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
valueInvalidFeedback: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
keyInputId() {
|
||||
|
@ -38,6 +49,12 @@ export default {
|
|||
valueInputId() {
|
||||
return this.inputId('value');
|
||||
},
|
||||
keyState() {
|
||||
return isEmpty(this.keyInvalidFeedback);
|
||||
},
|
||||
valueState() {
|
||||
return isEmpty(this.valueInvalidFeedback);
|
||||
},
|
||||
displayValue() {
|
||||
return this.isEditing ? MASK_ITEM_VALUE_HIDDEN : this.itemValue;
|
||||
},
|
||||
|
@ -67,10 +84,12 @@ export default {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gl-display-flex gl-align-items-flex-end gl-gap-3 gl-mb-3">
|
||||
<div class="gl-display-flex gl-align-items-flex-start gl-gap-3 gl-mb-3">
|
||||
<gl-form-group
|
||||
:label="$options.i18n.valueLabel"
|
||||
:label-for="valueInputId"
|
||||
:invalid-feedback="valueInvalidFeedback"
|
||||
:state="valueState"
|
||||
class="gl-flex-grow-1 gl-mb-0"
|
||||
data-testid="mask-item-value"
|
||||
>
|
||||
|
@ -79,12 +98,15 @@ export default {
|
|||
:name="inputName('value')"
|
||||
:value="displayValue"
|
||||
:disabled="isEditing"
|
||||
:state="valueState"
|
||||
@input="onValueInput"
|
||||
/>
|
||||
</gl-form-group>
|
||||
<gl-form-group
|
||||
:label="$options.i18n.keyLabel"
|
||||
:label-for="keyInputId"
|
||||
:invalid-feedback="keyInvalidFeedback"
|
||||
:state="keyState"
|
||||
class="gl-flex-grow-1 gl-mb-0"
|
||||
data-testid="mask-item-key"
|
||||
>
|
||||
|
@ -93,6 +115,7 @@ export default {
|
|||
:name="inputName('key')"
|
||||
:value="itemKey"
|
||||
:disabled="isEditing"
|
||||
:state="keyState"
|
||||
@input="onKeyInput"
|
||||
/>
|
||||
</gl-form-group>
|
||||
|
@ -100,6 +123,7 @@ export default {
|
|||
icon="remove"
|
||||
:aria-label="__('Remove')"
|
||||
:disabled="isEditing"
|
||||
class="gl-mt-6"
|
||||
@click="onRemoveClick"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
import { GlAlert, GlFormGroup, GlForm, GlFormCombobox, GlButton, GlFormInput } from '@gitlab/ui';
|
||||
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
|
||||
import { __, s__ } from '~/locale';
|
||||
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
||||
import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
|
||||
import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
|
||||
import createWorkItemMutation from '../../graphql/create_work_item.mutation.graphql';
|
||||
|
@ -16,6 +17,7 @@ export default {
|
|||
GlFormGroup,
|
||||
GlFormInput,
|
||||
},
|
||||
mixins: [glFeatureFlagMixin()],
|
||||
inject: ['projectPath', 'hasIterationsFeature'],
|
||||
props: {
|
||||
issuableGid: {
|
||||
|
@ -61,6 +63,9 @@ export default {
|
|||
};
|
||||
},
|
||||
computed: {
|
||||
workItemsMvc2Enabled() {
|
||||
return this.glFeatures.workItemsMvc2;
|
||||
},
|
||||
actionsList() {
|
||||
return [
|
||||
{
|
||||
|
@ -85,6 +90,9 @@ export default {
|
|||
parentIterationId() {
|
||||
return this.parentIteration?.id;
|
||||
},
|
||||
associateIteration() {
|
||||
return this.parentIterationId && this.hasIterationsFeature && this.workItemsMvc2Enabled;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getIdFromGraphQLId,
|
||||
|
@ -145,7 +153,7 @@ export default {
|
|||
* call update mutation only when there is an iteration associated with the issue
|
||||
*/
|
||||
// TODO: setting the iteration should be moved to the creation mutation once the backend is done
|
||||
if (this.parentIterationId && this.hasIterationsFeature) {
|
||||
if (this.associateIteration) {
|
||||
this.addIterationToWorkItem(data.workItemCreate.workItem.id);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -481,6 +481,18 @@ $tabs-holder-z-index: 250;
|
|||
border-radius: $border-radius-default;
|
||||
background: var(--white, $white);
|
||||
|
||||
> .mr-widget-section {
|
||||
> :first-child {
|
||||
border-top-left-radius: $border-radius-default - 1px;
|
||||
border-top-right-radius: $border-radius-default - 1px;
|
||||
}
|
||||
|
||||
> :last-child {
|
||||
border-bottom-left-radius: $border-radius-default - 1px;
|
||||
border-bottom-right-radius: $border-radius-default - 1px;
|
||||
}
|
||||
}
|
||||
|
||||
> .mr-widget-border-top:first-of-type {
|
||||
border-top: 0;
|
||||
}
|
||||
|
@ -812,6 +824,13 @@ $tabs-holder-z-index: 250;
|
|||
|
||||
.mr-widget-border-top {
|
||||
border-top: 1px solid var(--border-color, $border-color);
|
||||
|
||||
&:last-child {
|
||||
.report-block-container {
|
||||
border-bottom-left-radius: $border-radius-default - 1px;
|
||||
border-bottom-right-radius: $border-radius-default - 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mr-widget-extension {
|
||||
|
|
|
@ -46,7 +46,8 @@ class IssuableFinder
|
|||
|
||||
requires_cross_project_access unless: -> { params.project? }
|
||||
|
||||
FULL_TEXT_SEARCH_TERM_REGEX = /\A[\p{ASCII}|\p{Latin}]+\z/.freeze
|
||||
FULL_TEXT_SEARCH_TERM_PATTERN = '[\u0000-\u02FF\u1E00-\u1EFF\u2070-\u218F]*'
|
||||
FULL_TEXT_SEARCH_TERM_REGEX = /\A#{FULL_TEXT_SEARCH_TERM_PATTERN}\z/.freeze
|
||||
NEGATABLE_PARAMS_HELPER_KEYS = %i[project_id scope status include_subgroups].freeze
|
||||
|
||||
attr_accessor :current_user, :params
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
# rubocop:disable Graphql/ResolverType (inherited from BaseIssuesResolver)
|
||||
|
||||
# rubocop:disable Graphql/ResolverType (inherited from Issues::BaseParentResolver)
|
||||
module Resolvers
|
||||
class GroupIssuesResolver < Issues::BaseParentResolver
|
||||
def self.issuable_collection_name
|
||||
|
@ -18,3 +18,4 @@ module Resolvers
|
|||
end
|
||||
end
|
||||
end
|
||||
# rubocop:enable Graphql/ResolverType
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
# rubocop:disable Graphql/ResolverType (inherited from BaseIssuesResolver)
|
||||
|
||||
module Resolvers
|
||||
class IssuesResolver < Issues::BaseParentResolver
|
||||
accept_release_tag
|
||||
end
|
||||
end
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# rubocop:disable Graphql/ResolverType (inherited from Issues::BaseParentResolver)
|
||||
module Resolvers
|
||||
class ProjectIssuesResolver < Issues::BaseParentResolver
|
||||
accept_release_tag
|
||||
end
|
||||
end
|
||||
# rubocop:enable Graphql/ResolverType
|
|
@ -227,7 +227,7 @@ module Types
|
|||
null: true,
|
||||
description: 'Issues of the project.',
|
||||
extras: [:lookahead],
|
||||
resolver: Resolvers::IssuesResolver
|
||||
resolver: Resolvers::ProjectIssuesResolver
|
||||
|
||||
field :work_items,
|
||||
Types::WorkItemType.connection_type,
|
||||
|
@ -274,7 +274,7 @@ module Types
|
|||
Types::IssueType,
|
||||
null: true,
|
||||
description: 'A single issue of the project.',
|
||||
resolver: Resolvers::IssuesResolver.single
|
||||
resolver: Resolvers::ProjectIssuesResolver.single
|
||||
|
||||
field :packages,
|
||||
description: 'Packages of the project.',
|
||||
|
|
|
@ -272,6 +272,16 @@ class Issue < ApplicationRecord
|
|||
def order_upvotes_asc
|
||||
reorder(upvotes_count: :asc)
|
||||
end
|
||||
|
||||
override :full_search
|
||||
def full_search(query, matched_columns: nil, use_minimum_char_limit: true)
|
||||
return super if query.match?(IssuableFinder::FULL_TEXT_SEARCH_TERM_REGEX)
|
||||
|
||||
super.where(
|
||||
'issues.title NOT SIMILAR TO :pattern OR issues.description NOT SIMILAR TO :pattern',
|
||||
pattern: IssuableFinder::FULL_TEXT_SEARCH_TERM_PATTERN
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def self.participant_includes
|
||||
|
|
|
@ -28,7 +28,7 @@ module Terraform
|
|||
validates :uuid, presence: true, uniqueness: true, length: { is: UUID_LENGTH },
|
||||
format: { with: HEX_REGEXP, message: 'only allows hex characters' }
|
||||
|
||||
default_value_for(:uuid, allows_nil: false) { SecureRandom.hex(UUID_LENGTH / 2) }
|
||||
attribute :uuid, default: -> { SecureRandom.hex(UUID_LENGTH / 2) }
|
||||
|
||||
def latest_file
|
||||
latest_version&.file
|
||||
|
|
|
@ -13,7 +13,7 @@ module Terraform
|
|||
scope :with_files_stored_locally, -> { where(file_store: Terraform::StateUploader::Store::LOCAL) }
|
||||
scope :preload_state, -> { includes(:terraform_state) }
|
||||
|
||||
default_value_for(:file_store) { StateUploader.default_store }
|
||||
attribute :file_store, default: -> { StateUploader.default_store }
|
||||
|
||||
mount_file_store_uploader StateUploader
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
= render 'shared/web_hooks/title_and_docs', hook: @hook
|
||||
|
||||
.col-lg-9.gl-mb-3
|
||||
= gitlab_ui_form_for [@project, @hook], as: :hook, url: project_hook_path(@project, @hook) do |f|
|
||||
= gitlab_ui_form_for [@project, @hook], as: :hook, url: project_hook_path(@project, @hook), html: { class: 'js-webhook-form' } do |f|
|
||||
= render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook }
|
||||
|
||||
= f.submit _('Save changes'), pajamas_button: true
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
= render 'shared/web_hooks/title_and_docs', hook: @hook
|
||||
|
||||
.col-lg-8.gl-mb-3
|
||||
= gitlab_ui_form_for @hook, as: :hook, url: polymorphic_path([@project, :hooks]) do |f|
|
||||
= gitlab_ui_form_for @hook, as: :hook, url: polymorphic_path([@project, :hooks]), html: { class: 'js-webhook-form' } do |f|
|
||||
= render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook }
|
||||
= f.submit _('Add webhook'), pajamas_button: true, data: { qa_selector: "create_webhook_button" }
|
||||
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddPartialTrigramIndexForIssueTitleAttempt2 < Gitlab::Database::Migration[2.0]
|
||||
disable_ddl_transaction!
|
||||
|
||||
INDEX_NAME = 'index_issues_on_title_trigram_non_latin'
|
||||
|
||||
def up
|
||||
add_concurrent_index :issues, :title,
|
||||
name: INDEX_NAME,
|
||||
using: :gin, opclass: { description: :gin_trgm_ops },
|
||||
where: "title NOT SIMILAR TO '[\\u0000-\\u02FF\\u1E00-\\u1EFF\\u2070-\\u218F]*' " \
|
||||
"OR description NOT SIMILAR TO '[\\u0000-\\u02FF\\u1E00-\\u1EFF\\u2070-\\u218F]*'"
|
||||
end
|
||||
|
||||
def down
|
||||
remove_concurrent_index_by_name :issues, INDEX_NAME
|
||||
end
|
||||
end
|
|
@ -0,0 +1,19 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddPartialTrigramIndexForIssueDescriptionAttempt2 < Gitlab::Database::Migration[2.0]
|
||||
disable_ddl_transaction!
|
||||
|
||||
INDEX_NAME = 'index_issues_on_description_trigram_non_latin'
|
||||
|
||||
def up
|
||||
add_concurrent_index :issues, :description,
|
||||
name: INDEX_NAME,
|
||||
using: :gin, opclass: { description: :gin_trgm_ops },
|
||||
where: "title NOT SIMILAR TO '[\\u0000-\\u02FF\\u1E00-\\u1EFF\\u2070-\\u218F]*' " \
|
||||
"OR description NOT SIMILAR TO '[\\u0000-\\u02FF\\u1E00-\\u1EFF\\u2070-\\u218F]*'"
|
||||
end
|
||||
|
||||
def down
|
||||
remove_concurrent_index_by_name :issues, INDEX_NAME
|
||||
end
|
||||
end
|
|
@ -0,0 +1 @@
|
|||
3192407f3034683ba226d651e247385de200a06e26142e87978fa080eecda110
|
|
@ -0,0 +1 @@
|
|||
462fd09ac4c59b9fc3f865e984da4c83c4a75d60e557d634631d5eafd67741cc
|
|
@ -29345,6 +29345,8 @@ CREATE INDEX index_issues_on_confidential ON issues USING btree (confidential);
|
|||
|
||||
CREATE INDEX index_issues_on_description_trigram ON issues USING gin (description gin_trgm_ops) WITH (fastupdate='false');
|
||||
|
||||
CREATE INDEX index_issues_on_description_trigram_non_latin ON issues USING gin (description gin_trgm_ops) WHERE (((title)::text !~ similar_escape('[\u0000-\u02FF\u1E00-\u1EFF\u2070-\u218F]*'::text, NULL::text)) OR (description !~ similar_escape('[\u0000-\u02FF\u1E00-\u1EFF\u2070-\u218F]*'::text, NULL::text)));
|
||||
|
||||
CREATE INDEX index_issues_on_duplicated_to_id ON issues USING btree (duplicated_to_id) WHERE (duplicated_to_id IS NOT NULL);
|
||||
|
||||
CREATE INDEX index_issues_on_id_and_weight ON issues USING btree (id, weight);
|
||||
|
@ -29381,6 +29383,8 @@ CREATE INDEX index_issues_on_sprint_id ON issues USING btree (sprint_id);
|
|||
|
||||
CREATE INDEX index_issues_on_title_trigram ON issues USING gin (title gin_trgm_ops) WITH (fastupdate='false');
|
||||
|
||||
CREATE INDEX index_issues_on_title_trigram_non_latin ON issues USING gin (title gin_trgm_ops) WHERE (((title)::text !~ similar_escape('[\u0000-\u02FF\u1E00-\u1EFF\u2070-\u218F]*'::text, NULL::text)) OR (description !~ similar_escape('[\u0000-\u02FF\u1E00-\u1EFF\u2070-\u218F]*'::text, NULL::text)));
|
||||
|
||||
CREATE INDEX index_issues_on_updated_at ON issues USING btree (updated_at);
|
||||
|
||||
CREATE INDEX index_issues_on_updated_by_id ON issues USING btree (updated_by_id) WHERE (updated_by_id IS NOT NULL);
|
||||
|
|
|
@ -248,7 +248,7 @@ incorrect sort order.
|
|||
There are times when the [complexity of sorting](#limitations-of-query-complexity)
|
||||
is more than our keyset pagination can handle.
|
||||
|
||||
For example, in [`IssuesResolver`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/graphql/resolvers/issues_resolver.rb),
|
||||
For example, in [`ProjectIssuesResolver`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/graphql/resolvers/project_issues_resolver.rb),
|
||||
when sorting by `priority_asc`, we can't use keyset pagination as the ordering is much
|
||||
too complex. For more information, read [`issuable.rb`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/models/concerns/issuable.rb).
|
||||
|
||||
|
|
|
@ -17,8 +17,8 @@ In case custom inflection logic is needed, custom inflectors are added in the [q
|
|||
## Link a test to its test case
|
||||
|
||||
Every test should have a corresponding test case in the [GitLab project Test Cases](https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases) as well as a results issue in the [Quality Test Cases project](https://gitlab.com/gitlab-org/quality/testcases/-/issues).
|
||||
If a test case issue does not yet exist you can create one yourself. To do so, create a new
|
||||
issue in the [Test Cases](https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases) GitLab project
|
||||
If a test case issue does not yet exist, any GitLab team member can create a new test case in
|
||||
the [Test Cases](https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases) GitLab project
|
||||
with a placeholder title. After the test case URL is linked to a test in the code, when the test is
|
||||
run in a pipeline that has reporting enabled, the `report-results` script automatically updates the
|
||||
test case and the results issue.
|
||||
|
|
|
@ -153,11 +153,15 @@ To change the namespace linked to a subscription:
|
|||
[linked](#change-the-linked-account) GitLab SaaS account.
|
||||
1. Navigate to the **Manage Purchases** page.
|
||||
1. Select **Change linked namespace**.
|
||||
1. Select the desired group from the **This subscription is for** dropdown list. For a group to appear
|
||||
here, you must have the Owner role
|
||||
for that group.
|
||||
1. Select the desired group from the **Select user or group** dropdown list. For a group to appear
|
||||
here, you must have the Owner role for that group.
|
||||
1. Select **Proceed to checkout**.
|
||||
|
||||
If the group you want to link does not appear in the dropdown list, check:
|
||||
|
||||
- You have [linked your Customers Portal account with your GitLab.com account](#change-the-linked-account).
|
||||
- That the linked account is a member of the group you want to select, and you are assigned the Owner role.
|
||||
|
||||
Subscription charges are calculated based on the total number of users in a group, including its subgroups and nested projects. If the [total number of users](gitlab_com/index.md#view-seat-usage) exceeds the number of seats in your subscription, your account is charged for the additional users and you need to pay for the overage before you can change the linked namespace.
|
||||
|
||||
Only one namespace can be linked to a subscription.
|
||||
|
|
|
@ -173,7 +173,7 @@ container_scanning:
|
|||
before_script:
|
||||
- ruby -r open-uri -e "IO.copy_stream(URI.open('https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip'), 'awscliv2.zip')"
|
||||
- unzip awscliv2.zip
|
||||
- ./aws/install
|
||||
- sudo ./aws/install
|
||||
- aws --version
|
||||
- export AWS_ECR_PASSWORD=$(aws ecr get-login-password --region region)
|
||||
|
||||
|
|
|
@ -62,6 +62,10 @@ Contributions per group member are also presented in tabular format. Select a co
|
|||
|
||||
![Contribution analytics contributions table](img/group_stats_table.png)
|
||||
|
||||
## Contribution analytics GraphQL API
|
||||
|
||||
To retrieve metrics for user contributions, use the [GraphQL](../../../api/graphql/reference/index.md#groupcontributions) API.
|
||||
|
||||
<!-- ## Troubleshooting
|
||||
|
||||
Include any troubleshooting steps that you can foresee. If you know beforehand what issues
|
||||
|
|
|
@ -196,6 +196,7 @@ module API
|
|||
mount ::API::MergeRequestDiffs
|
||||
mount ::API::Metadata
|
||||
mount ::API::PersonalAccessTokens::SelfInformation
|
||||
mount ::API::PersonalAccessTokens
|
||||
mount ::API::ProjectExport
|
||||
mount ::API::ProjectHooks
|
||||
mount ::API::ProjectRepositoryStorageMoves
|
||||
|
@ -293,7 +294,6 @@ module API
|
|||
mount ::API::PackageFiles
|
||||
mount ::API::Pages
|
||||
mount ::API::PagesDomains
|
||||
mount ::API::PersonalAccessTokens
|
||||
mount ::API::ProjectClusters
|
||||
mount ::API::ProjectContainerRepositories
|
||||
mount ::API::ProjectDebianDistributions
|
||||
|
|
|
@ -7,6 +7,9 @@ module API
|
|||
|
||||
ci_resource_groups_tags = %w[ci_resource_groups]
|
||||
|
||||
RESOURCE_GROUP_ENDPOINT_REQUIREMENTS = API::NAMESPACE_OR_PROJECT_REQUIREMENTS
|
||||
.merge(key: API::NO_SLASH_URL_PART_REGEX)
|
||||
|
||||
before { authenticate! }
|
||||
|
||||
feature_category :continuous_delivery
|
||||
|
@ -47,7 +50,7 @@ module API
|
|||
params do
|
||||
requires :key, type: String, desc: 'The key of the resource group'
|
||||
end
|
||||
get ':id/resource_groups/:key' do
|
||||
get ':id/resource_groups/:key', requirements: RESOURCE_GROUP_ENDPOINT_REQUIREMENTS do
|
||||
authorize! :read_resource_group, resource_group
|
||||
|
||||
present resource_group, with: Entities::Ci::ResourceGroup
|
||||
|
@ -67,7 +70,7 @@ module API
|
|||
|
||||
use :pagination
|
||||
end
|
||||
get ':id/resource_groups/:key/upcoming_jobs' do
|
||||
get ':id/resource_groups/:key/upcoming_jobs', requirements: RESOURCE_GROUP_ENDPOINT_REQUIREMENTS do
|
||||
authorize! :read_resource_group, resource_group
|
||||
authorize! :read_build, user_project
|
||||
|
||||
|
@ -96,7 +99,7 @@ module API
|
|||
desc: 'The process mode of the resource group',
|
||||
values: ::Ci::ResourceGroup.process_modes.keys
|
||||
end
|
||||
put ':id/resource_groups/:key' do
|
||||
put ':id/resource_groups/:key', requirements: RESOURCE_GROUP_ENDPOINT_REQUIREMENTS do
|
||||
authorize! :update_resource_group, resource_group
|
||||
|
||||
if resource_group.update(declared_params(include_missing: false))
|
||||
|
|
|
@ -6,24 +6,6 @@ module API
|
|||
|
||||
feature_category :authentication_and_authorization
|
||||
|
||||
desc 'Get all Personal Access Tokens' do
|
||||
detail 'This feature was added in GitLab 13.3'
|
||||
success Entities::PersonalAccessToken
|
||||
end
|
||||
params do
|
||||
optional :user_id, type: Integer, desc: 'Filter PATs by User ID'
|
||||
optional :revoked, type: Boolean, desc: 'Filter PATs where revoked state matches parameter'
|
||||
optional :state, type: String, desc: 'Filter PATs which are either active or not',
|
||||
values: %w[active inactive]
|
||||
optional :created_before, type: DateTime, desc: 'Filter PATs which were created before given datetime'
|
||||
optional :created_after, type: DateTime, desc: 'Filter PATs which were created after given datetime'
|
||||
optional :last_used_before, type: DateTime, desc: 'Filter PATs which were used before given datetime'
|
||||
optional :last_used_after, type: DateTime, desc: 'Filter PATs which were used after given datetime'
|
||||
optional :search, type: String, desc: 'Filters PATs by its name'
|
||||
|
||||
use :pagination
|
||||
end
|
||||
|
||||
before do
|
||||
authenticate!
|
||||
restrict_non_admins! unless current_user.can_admin_all_resources?
|
||||
|
@ -32,12 +14,47 @@ module API
|
|||
helpers ::API::Helpers::PersonalAccessTokensHelpers
|
||||
|
||||
resources :personal_access_tokens do
|
||||
desc 'List personal access tokens' do
|
||||
detail 'Get all personal access tokens the authenticated user has access to.'
|
||||
is_array true
|
||||
success Entities::PersonalAccessToken
|
||||
tags %w[personal_access_tokens]
|
||||
failure [
|
||||
{ code: 401, message: 'Unauthorized' }
|
||||
]
|
||||
end
|
||||
params do
|
||||
optional :user_id, type: Integer, desc: 'Filter PATs by User ID', documentation: { example: 2 }
|
||||
optional :revoked, type: Boolean, desc: 'Filter PATs where revoked state matches parameter',
|
||||
documentation: { example: false }
|
||||
optional :state, type: String, desc: 'Filter PATs which are either active or not',
|
||||
values: %w[active inactive], documentation: { example: 'active' }
|
||||
optional :created_before, type: DateTime, desc: 'Filter PATs which were created before given datetime',
|
||||
documentation: { example: '2022-01-01' }
|
||||
optional :created_after, type: DateTime, desc: 'Filter PATs which were created after given datetime',
|
||||
documentation: { example: '2021-01-01' }
|
||||
optional :last_used_before, type: DateTime, desc: 'Filter PATs which were used before given datetime',
|
||||
documentation: { example: '2021-01-01' }
|
||||
optional :last_used_after, type: DateTime, desc: 'Filter PATs which were used after given datetime',
|
||||
documentation: { example: '2022-01-01' }
|
||||
optional :search, type: String, desc: 'Filters PATs by its name', documentation: { example: 'token' }
|
||||
|
||||
use :pagination
|
||||
end
|
||||
get do
|
||||
tokens = PersonalAccessTokensFinder.new(finder_params(current_user), current_user).execute
|
||||
|
||||
present paginate(tokens), with: Entities::PersonalAccessToken
|
||||
end
|
||||
|
||||
desc 'Get single personal access token' do
|
||||
detail 'Get a personal access token by using the ID of the personal access token.'
|
||||
success Entities::PersonalAccessToken
|
||||
failure [
|
||||
{ code: 401, message: 'Unauthorized' },
|
||||
{ code: 404, message: 'Not found' }
|
||||
]
|
||||
end
|
||||
get ':id' do
|
||||
token = PersonalAccessToken.find_by_id(params[:id])
|
||||
|
||||
|
@ -51,6 +68,13 @@ module API
|
|||
end
|
||||
end
|
||||
|
||||
desc 'Revoke a personal access token' do
|
||||
detail 'Revoke a personal access token by using the ID of the personal access token.'
|
||||
success code: 204
|
||||
failure [
|
||||
{ code: 400, message: 'Bad Request' }
|
||||
]
|
||||
end
|
||||
delete ':id' do
|
||||
token = find_token(params[:id])
|
||||
|
||||
|
|
|
@ -48,18 +48,16 @@ module QA
|
|||
end
|
||||
|
||||
def verify_protected_branches_import
|
||||
branches = imported_project.protected_branches.map do |branch|
|
||||
branch.slice(:name, :allow_force_push, :code_owner_approval_required)
|
||||
# TODO: Add validation once https://gitlab.com/groups/gitlab-org/-/epics/8585 is closed
|
||||
# At the moment both options are always set to false regardless of state in github
|
||||
# allow_force_push: true,
|
||||
# code_owner_approval_required: true
|
||||
imported_branches = imported_project.protected_branches.map do |branch|
|
||||
branch.slice(:name)
|
||||
end
|
||||
expect(branches.first).to include(
|
||||
{
|
||||
name: 'main'
|
||||
# TODO: Add validation once https://gitlab.com/groups/gitlab-org/-/epics/8585 is closed
|
||||
# At the moment both options are always set to false regardless of state in github
|
||||
# allow_force_push: true,
|
||||
# code_owner_approval_required: true
|
||||
}
|
||||
)
|
||||
actual_branches = [{ name: 'main' }, { name: 'release' }]
|
||||
|
||||
expect(imported_branches).to match_array(actual_branches)
|
||||
end
|
||||
|
||||
def verify_commits_import
|
||||
|
|
|
@ -74,6 +74,7 @@ else
|
|||
puts missing_message % missing_testcases.join("\n") unless missing_testcases.empty?
|
||||
puts format_message % testcase_format_errors.join("\n") unless testcase_format_errors.empty?
|
||||
puts "\n*** Please link a unique test case from the GitLab project for the errors listed above.\n"
|
||||
puts " See: https://docs.gitlab.com/ee/development/testing_guide/end_to_end/best_practices.html#link-a-test-to-its-test-case."
|
||||
puts " See: https://docs.gitlab.com/ee/development/testing_guide/end_to_end/best_practices.html#link-a-test-to-its-test-case"\
|
||||
" for further details on how to create test cases"
|
||||
exit 1
|
||||
end
|
||||
|
|
|
@ -30,7 +30,7 @@ RSpec.describe 'Subscriptions Content Security Policy' do
|
|||
p.style_src :self, 'https://some-cdn.test'
|
||||
end
|
||||
|
||||
setup_existing_csp_for_controller(JiraConnect::SubscriptionsController, csp)
|
||||
setup_csp_for_controller(JiraConnect::SubscriptionsController, csp)
|
||||
end
|
||||
|
||||
it 'appends to CSP directives' do
|
||||
|
|
|
@ -151,7 +151,7 @@ RSpec.describe 'Merge request > User merges when pipeline succeeds', :js do
|
|||
# Wait for the `ci_status` and `merge_check` requests
|
||||
wait_for_requests
|
||||
|
||||
page.within('.mr-section-container') do
|
||||
page.within('.mr-state-widget') do
|
||||
expect(page).to have_content('Something went wrong. Try again.')
|
||||
end
|
||||
end
|
||||
|
@ -170,7 +170,7 @@ RSpec.describe 'Merge request > User merges when pipeline succeeds', :js do
|
|||
# Wait for the `ci_status` and `merge_check` requests
|
||||
wait_for_requests
|
||||
|
||||
page.within('.mr-section-container') do
|
||||
page.within('.mr-state-widget') do
|
||||
expect(page).to have_content('Something went wrong. Try again.')
|
||||
end
|
||||
end
|
||||
|
|
|
@ -342,7 +342,7 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
|
|||
# Wait for the `ci_status` and `merge_check` requests
|
||||
wait_for_requests
|
||||
|
||||
page.within('.mr-section-container') do
|
||||
page.within('.mr-state-widget') do
|
||||
expect(page).to have_content('Something went wrong.')
|
||||
end
|
||||
end
|
||||
|
@ -363,7 +363,7 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
|
|||
# Wait for the `ci_status` and `merge_check` requests
|
||||
wait_for_requests
|
||||
|
||||
page.within('.mr-section-container') do
|
||||
page.within('.mr-state-widget') do
|
||||
expect(page).to have_content('Something went wrong.')
|
||||
end
|
||||
end
|
||||
|
|
|
@ -20,7 +20,7 @@ describe('MrWidgetContainer', () => {
|
|||
it('has layout', () => {
|
||||
factory();
|
||||
|
||||
expect(wrapper.classes()).toContain('mr-widget-heading');
|
||||
expect(wrapper.classes()).toEqual(['mr-section-container', 'mr-widget-workflow']);
|
||||
expect(wrapper.find('.mr-widget-content').exists()).toBe(true);
|
||||
});
|
||||
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
import { nextTick } from 'vue';
|
||||
import { GlFormRadio, GlFormRadioGroup, GlLink } from '@gitlab/ui';
|
||||
import { GlFormGroup, GlFormRadio, GlFormRadioGroup, GlLink } from '@gitlab/ui';
|
||||
import { scrollToElement } from '~/lib/utils/common_utils';
|
||||
|
||||
import FormUrlApp from '~/webhooks/components/form_url_app.vue';
|
||||
import FormUrlMaskItem from '~/webhooks/components/form_url_mask_item.vue';
|
||||
|
||||
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
|
||||
|
||||
jest.mock('~/lib/utils/common_utils');
|
||||
|
||||
describe('FormUrlApp', () => {
|
||||
let wrapper;
|
||||
|
@ -26,8 +30,11 @@ describe('FormUrlApp', () => {
|
|||
const findAllUrlMaskItems = () => wrapper.findAllComponents(FormUrlMaskItem);
|
||||
const findAddItem = () => wrapper.findComponent(GlLink);
|
||||
const findFormUrl = () => wrapper.findByTestId('form-url');
|
||||
const findFormUrlGroup = () => wrapper.findAllComponents(GlFormGroup).at(0);
|
||||
const findFormUrlPreview = () => wrapper.findByTestId('form-url-preview');
|
||||
const findUrlMaskSection = () => wrapper.findByTestId('url-mask-section');
|
||||
const findFormEl = () => document.querySelector('.js-webhook-form');
|
||||
const submitForm = () => findFormEl().dispatchEvent(new Event('submit'));
|
||||
|
||||
describe('template', () => {
|
||||
it('renders radio buttons for URL masking', () => {
|
||||
|
@ -152,5 +159,82 @@ describe('FormUrlApp', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validations', () => {
|
||||
const inputRequiredText = FormUrlApp.i18n.inputRequired;
|
||||
|
||||
beforeEach(() => {
|
||||
setHTMLFixture('<form class="js-webhook-form"></form>');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetHTMLFixture();
|
||||
});
|
||||
|
||||
it.each`
|
||||
url | state | scrollToElementCalls
|
||||
${null} | ${undefined} | ${1}
|
||||
${''} | ${undefined} | ${1}
|
||||
${'https://example.com/'} | ${'true'} | ${0}
|
||||
`('when URL is `$url`, state is `$state`', async ({ url, state, scrollToElementCalls }) => {
|
||||
createComponent({
|
||||
props: { initialUrl: url },
|
||||
});
|
||||
|
||||
submitForm();
|
||||
await nextTick();
|
||||
|
||||
expect(findFormUrlGroup().attributes('state')).toBe(state);
|
||||
expect(scrollToElement).toHaveBeenCalledTimes(scrollToElementCalls);
|
||||
expect(findFormUrlGroup().attributes('invalid-feedback')).toBe(inputRequiredText);
|
||||
});
|
||||
|
||||
it.each`
|
||||
key | value | keyInvalidFeedback | valueInvalidFeedback | scrollToElementCalls
|
||||
${null} | ${null} | ${inputRequiredText} | ${inputRequiredText} | ${1}
|
||||
${null} | ${'value'} | ${inputRequiredText} | ${null} | ${1}
|
||||
${'key'} | ${null} | ${null} | ${inputRequiredText} | ${1}
|
||||
${'key'} | ${'value'} | ${null} | ${null} | ${0}
|
||||
`(
|
||||
'when key is `$key` and value is `$value`',
|
||||
async ({ key, value, keyInvalidFeedback, valueInvalidFeedback, scrollToElementCalls }) => {
|
||||
createComponent({
|
||||
props: { initialUrl: 'url' },
|
||||
});
|
||||
findRadioGroup().vm.$emit('input', true);
|
||||
await nextTick();
|
||||
|
||||
const maskItem = findAllUrlMaskItems().at(0);
|
||||
const mockInput = { index: 0, key, value };
|
||||
maskItem.vm.$emit('input', mockInput);
|
||||
|
||||
submitForm();
|
||||
await nextTick();
|
||||
|
||||
expect(maskItem.props('keyInvalidFeedback')).toBe(keyInvalidFeedback);
|
||||
expect(maskItem.props('valueInvalidFeedback')).toBe(valueInvalidFeedback);
|
||||
expect(scrollToElement).toHaveBeenCalledTimes(scrollToElementCalls);
|
||||
},
|
||||
);
|
||||
|
||||
describe('when initialUrlVariables is passed', () => {
|
||||
it('does not validate empty values', async () => {
|
||||
const initialUrlVariables = [{ key: 'key' }];
|
||||
|
||||
createComponent({
|
||||
props: { initialUrl: 'url', initialUrlVariables },
|
||||
});
|
||||
|
||||
submitForm();
|
||||
await nextTick();
|
||||
|
||||
const maskItem = findAllUrlMaskItems().at(0);
|
||||
|
||||
expect(maskItem.props('keyInvalidFeedback')).toBeNull();
|
||||
expect(maskItem.props('valueInvalidFeedback')).toBeNull();
|
||||
expect(scrollToElement).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -14,6 +14,7 @@ describe('FormUrlMaskItem', () => {
|
|||
const mockKey = 'key';
|
||||
const mockValue = 'value';
|
||||
const mockInput = 'input';
|
||||
const mockFeedback = 'feedback';
|
||||
|
||||
const createComponent = ({ props } = {}) => {
|
||||
wrapper = shallowMountExtended(FormUrlMaskItem, {
|
||||
|
@ -21,10 +22,6 @@ describe('FormUrlMaskItem', () => {
|
|||
});
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
const findMaskItemKey = () => wrapper.findByTestId('mask-item-key');
|
||||
const findMaskItemValue = () => wrapper.findByTestId('mask-item-value');
|
||||
const findRemoveButton = () => wrapper.findComponent(GlButton);
|
||||
|
@ -34,14 +31,20 @@ describe('FormUrlMaskItem', () => {
|
|||
createComponent({ props: { itemKey: mockKey, itemValue: mockValue } });
|
||||
|
||||
const keyInput = findMaskItemKey();
|
||||
expect(keyInput.attributes('label')).toBe(FormUrlMaskItem.i18n.keyLabel);
|
||||
expect(keyInput.attributes()).toMatchObject({
|
||||
label: FormUrlMaskItem.i18n.keyLabel,
|
||||
state: 'true',
|
||||
});
|
||||
expect(keyInput.findComponent(GlFormInput).attributes()).toMatchObject({
|
||||
name: 'hook[url_variables][][key]',
|
||||
value: mockKey,
|
||||
});
|
||||
|
||||
const valueInput = findMaskItemValue();
|
||||
expect(valueInput.attributes('label')).toBe(FormUrlMaskItem.i18n.valueLabel);
|
||||
expect(valueInput.attributes()).toMatchObject({
|
||||
label: FormUrlMaskItem.i18n.valueLabel,
|
||||
state: 'true',
|
||||
});
|
||||
expect(valueInput.findComponent(GlFormInput).attributes()).toMatchObject({
|
||||
name: 'hook[url_variables][][value]',
|
||||
value: mockValue,
|
||||
|
@ -69,6 +72,32 @@ describe('FormUrlMaskItem', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('when keyInvalidFeedback is passed', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({
|
||||
props: { keyInvalidFeedback: mockFeedback },
|
||||
});
|
||||
});
|
||||
|
||||
it('sets validation message on key', () => {
|
||||
expect(findMaskItemKey().attributes('invalid-feedback')).toBe(mockFeedback);
|
||||
expect(findMaskItemKey().attributes('state')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when valueInvalidFeedback is passed', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({
|
||||
props: { valueInvalidFeedback: mockFeedback },
|
||||
});
|
||||
});
|
||||
|
||||
it('sets validation message on value', () => {
|
||||
expect(findMaskItemValue().attributes('invalid-feedback')).toBe(mockFeedback);
|
||||
expect(findMaskItemValue().attributes('state')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('on key input', () => {
|
||||
beforeEach(async () => {
|
||||
createComponent({ props: { itemKey: mockKey, itemValue: mockValue } });
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
projectWorkItemTypesQueryResponse,
|
||||
createWorkItemMutationResponse,
|
||||
updateWorkItemMutationResponse,
|
||||
mockIterationWidgetResponse,
|
||||
} from '../../mock_data';
|
||||
|
||||
Vue.use(VueApollo);
|
||||
|
@ -24,11 +25,15 @@ describe('WorkItemLinksForm', () => {
|
|||
const updateMutationResolver = jest.fn().mockResolvedValue(updateWorkItemMutationResponse);
|
||||
const createMutationResolver = jest.fn().mockResolvedValue(createWorkItemMutationResponse);
|
||||
|
||||
const mockParentIteration = mockIterationWidgetResponse;
|
||||
|
||||
const createComponent = async ({
|
||||
listResponse = availableWorkItemsResponse,
|
||||
typesResponse = projectWorkItemTypesQueryResponse,
|
||||
parentConfidential = false,
|
||||
hasIterationsFeature = false,
|
||||
workItemsMvc2Enabled = false,
|
||||
parentIteration = null,
|
||||
} = {}) => {
|
||||
wrapper = shallowMountExtended(WorkItemLinksForm, {
|
||||
apolloProvider: createMockApollo([
|
||||
|
@ -37,8 +42,11 @@ describe('WorkItemLinksForm', () => {
|
|||
[updateWorkItemMutation, updateMutationResolver],
|
||||
[createWorkItemMutation, createMutationResolver],
|
||||
]),
|
||||
propsData: { issuableGid: 'gid://gitlab/WorkItem/1', parentConfidential },
|
||||
propsData: { issuableGid: 'gid://gitlab/WorkItem/1', parentConfidential, parentIteration },
|
||||
provide: {
|
||||
glFeatures: {
|
||||
workItemsMvc2: workItemsMvc2Enabled,
|
||||
},
|
||||
projectPath: 'project/path',
|
||||
hasIterationsFeature,
|
||||
},
|
||||
|
@ -133,4 +141,55 @@ describe('WorkItemLinksForm', () => {
|
|||
expect(findCombobox().props('actionList').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('associate iteration with task', () => {
|
||||
it('does not update iteration when mvc2 feature flag is not enabled', async () => {
|
||||
await createComponent({
|
||||
hasIterationsFeature: true,
|
||||
parentIteration: mockParentIteration,
|
||||
});
|
||||
|
||||
findInput().vm.$emit('input', 'Create task test');
|
||||
|
||||
findForm().vm.$emit('submit', {
|
||||
preventDefault: jest.fn(),
|
||||
});
|
||||
await waitForPromises();
|
||||
expect(updateMutationResolver).not.toHaveBeenCalled();
|
||||
});
|
||||
it('updates when parent has an iteration associated', async () => {
|
||||
await createComponent({
|
||||
workItemsMvc2Enabled: true,
|
||||
hasIterationsFeature: true,
|
||||
parentIteration: mockParentIteration,
|
||||
});
|
||||
findInput().vm.$emit('input', 'Create task test');
|
||||
|
||||
findForm().vm.$emit('submit', {
|
||||
preventDefault: jest.fn(),
|
||||
});
|
||||
await waitForPromises();
|
||||
expect(updateMutationResolver).toHaveBeenCalledWith({
|
||||
input: {
|
||||
id: 'gid://gitlab/WorkItem/1',
|
||||
iterationWidget: {
|
||||
iterationId: mockParentIteration.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
it('does not update when parent has no iteration associated', async () => {
|
||||
await createComponent({
|
||||
workItemsMvc2Enabled: true,
|
||||
hasIterationsFeature: true,
|
||||
});
|
||||
findInput().vm.$emit('input', 'Create task test');
|
||||
|
||||
findForm().vm.$emit('submit', {
|
||||
preventDefault: jest.fn(),
|
||||
});
|
||||
await waitForPromises();
|
||||
expect(updateMutationResolver).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Resolvers::IssuesResolver do
|
||||
RSpec.describe Resolvers::ProjectIssuesResolver do
|
||||
include GraphqlHelpers
|
||||
|
||||
let_it_be(:current_user) { create(:user) }
|
||||
|
@ -87,7 +87,7 @@ RSpec.describe Resolvers::IssuesResolver do
|
|||
end
|
||||
end
|
||||
|
||||
context 'negated filtering' do
|
||||
context 'when using negated filters' do
|
||||
it 'returns issues matching the searched title after applying a negated filter' do
|
||||
expect(resolve_issues(milestone_title: ['past milestone'], not: { milestone_wildcard_id: wildcard_upcoming })).to contain_exactly(issue6)
|
||||
end
|
||||
|
@ -252,7 +252,7 @@ RSpec.describe Resolvers::IssuesResolver do
|
|||
end
|
||||
end
|
||||
|
||||
context 'filtering by reaction emoji' do
|
||||
context 'when filtering by reaction emoji' do
|
||||
let_it_be(:downvoted_issue) { create(:issue, project: project) }
|
||||
let_it_be(:downvote_award) { create(:award_emoji, :downvote, user: current_user, awardable: downvoted_issue) }
|
||||
|
||||
|
@ -273,7 +273,7 @@ RSpec.describe Resolvers::IssuesResolver do
|
|||
end
|
||||
end
|
||||
|
||||
context 'confidential issues' do
|
||||
context 'when listing confidential issues' do
|
||||
let_it_be(:confidential_issue1) { create(:issue, project: project, confidential: true) }
|
||||
let_it_be(:confidential_issue2) { create(:issue, project: other_project, confidential: true) }
|
||||
|
||||
|
@ -375,13 +375,13 @@ RSpec.describe Resolvers::IssuesResolver do
|
|||
create(:issue_customer_relations_contact, issue: crm_issue3, contact: contact3)
|
||||
end
|
||||
|
||||
context 'contact' do
|
||||
context 'when filtering by contact' do
|
||||
it 'returns only the issues for the contact' do
|
||||
expect(resolve_issues({ crm_contact_id: contact1.id })).to contain_exactly(crm_issue1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'organization' do
|
||||
context 'when filtering by organization' do
|
||||
it 'returns only the issues for the contact' do
|
||||
expect(resolve_issues({ crm_organization_id: organization.id })).to contain_exactly(crm_issue1, crm_issue2)
|
||||
end
|
|
@ -290,14 +290,14 @@ RSpec.describe GitlabSchema.types['Project'] do
|
|||
subject { described_class.fields['issue'] }
|
||||
|
||||
it { is_expected.to have_graphql_type(Types::IssueType) }
|
||||
it { is_expected.to have_graphql_resolver(Resolvers::IssuesResolver.single) }
|
||||
it { is_expected.to have_graphql_resolver(Resolvers::ProjectIssuesResolver.single) }
|
||||
end
|
||||
|
||||
describe 'issues field' do
|
||||
subject { described_class.fields['issues'] }
|
||||
|
||||
it { is_expected.to have_graphql_type(Types::IssueType.connection_type) }
|
||||
it { is_expected.to have_graphql_resolver(Resolvers::IssuesResolver) }
|
||||
it { is_expected.to have_graphql_resolver(Resolvers::ProjectIssuesResolver) }
|
||||
end
|
||||
|
||||
describe 'merge_request field' do
|
||||
|
|
|
@ -1793,4 +1793,22 @@ RSpec.describe Issue do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#full_search' do
|
||||
context 'when searching non-english terms' do
|
||||
[
|
||||
'abc 中文語',
|
||||
'中文語cn',
|
||||
'中文語',
|
||||
'Привет'
|
||||
].each do |term|
|
||||
it 'adds extra where clause to match partial index' do
|
||||
expect(described_class.full_search(term).to_sql).to include(
|
||||
"AND (issues.title NOT SIMILAR TO '[\\u0000-\\u02FF\\u1E00-\\u1EFF\\u2070-\\u218F]*' " \
|
||||
"OR issues.description NOT SIMILAR TO '[\\u0000-\\u02FF\\u1E00-\\u1EFF\\u2070-\\u218F]*')"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -10,6 +10,12 @@ RSpec.describe Terraform::State do
|
|||
|
||||
it { is_expected.to validate_presence_of(:name) }
|
||||
it { is_expected.to validate_presence_of(:project_id) }
|
||||
it { is_expected.to validate_presence_of(:uuid) }
|
||||
|
||||
describe 'default values' do
|
||||
it { expect(described_class.new.uuid).to be_present }
|
||||
it { expect(described_class.new(uuid: 'test').uuid).to eq('test') }
|
||||
end
|
||||
|
||||
describe 'scopes' do
|
||||
describe '.ordered_by_name' do
|
||||
|
|
|
@ -10,6 +10,15 @@ RSpec.describe Terraform::StateVersion do
|
|||
it { is_expected.to belong_to(:created_by_user).class_name('User').optional }
|
||||
it { is_expected.to belong_to(:build).class_name('Ci::Build').optional }
|
||||
|
||||
describe 'default attributes' do
|
||||
before do
|
||||
allow(Terraform::StateUploader).to receive(:default_store).and_return(5)
|
||||
end
|
||||
|
||||
it { expect(described_class.new.file_store).to eq(5) }
|
||||
it { expect(described_class.new(file_store: 3).file_store).to eq(3) }
|
||||
end
|
||||
|
||||
describe 'scopes' do
|
||||
describe '.ordered_by_version_desc' do
|
||||
let(:terraform_state) { create(:terraform_state) }
|
||||
|
|
|
@ -56,6 +56,31 @@ RSpec.describe API::Ci::ResourceGroups do
|
|||
expect(Time.parse(json_response['updated_at'])).to be_like_time(resource_group.updated_at)
|
||||
end
|
||||
|
||||
context 'when resource group key contains multiple dots' do
|
||||
let!(:resource_group) { create(:ci_resource_group, project: project, key: 'test..test') }
|
||||
|
||||
it 'returns the resource group', :aggregate_failures do
|
||||
subject
|
||||
|
||||
expect(response).to have_gitlab_http_status(:ok)
|
||||
expect(json_response['id']).to eq(resource_group.id)
|
||||
expect(json_response['key']).to eq(resource_group.key)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when resource group key contains a slash' do
|
||||
let!(:resource_group) { create(:ci_resource_group, project: project, key: 'test/test') }
|
||||
let(:key) { 'test%2Ftest' }
|
||||
|
||||
it 'returns the resource group', :aggregate_failures do
|
||||
subject
|
||||
|
||||
expect(response).to have_gitlab_http_status(:ok)
|
||||
expect(json_response['id']).to eq(resource_group.id)
|
||||
expect(json_response['key']).to eq(resource_group.key)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is reporter' do
|
||||
let(:user) { reporter }
|
||||
|
||||
|
@ -98,6 +123,25 @@ RSpec.describe API::Ci::ResourceGroups do
|
|||
expect(json_response[0]['status']).to eq(upcoming_processable.status)
|
||||
end
|
||||
|
||||
context 'when resource group key contains a slash' do
|
||||
let_it_be(:resource_group) { create(:ci_resource_group, project: project, key: 'test/test') }
|
||||
let_it_be(:upcoming_processable) do
|
||||
create(:ci_processable,
|
||||
:waiting_for_resource,
|
||||
resource_group: resource_group)
|
||||
end
|
||||
|
||||
let(:key) { 'test%2Ftest' }
|
||||
|
||||
it 'returns the resource group', :aggregate_failures do
|
||||
subject
|
||||
|
||||
expect(response).to have_gitlab_http_status(:ok)
|
||||
expect(json_response[0]['id']).to eq(upcoming_processable.id)
|
||||
expect(json_response[0]['name']).to eq(upcoming_processable.name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is reporter' do
|
||||
let(:user) { reporter }
|
||||
|
||||
|
|
|
@ -81,7 +81,7 @@ RSpec.describe Groups::ObservabilityController do
|
|||
|
||||
describe 'CSP' do
|
||||
before do
|
||||
setup_existing_csp_for_controller(described_class, csp)
|
||||
setup_csp_for_controller(described_class, csp)
|
||||
end
|
||||
|
||||
subject do
|
||||
|
|
|
@ -1,20 +1,14 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module ContentSecurityPolicyHelpers
|
||||
# Expecting 2 calls to current_content_security_policy by default, once for
|
||||
# the call that's being tested and once for the call in ApplicationController
|
||||
def setup_csp_for_controller(controller_class, times = 2)
|
||||
# Expecting 2 calls to current_content_security_policy by default:
|
||||
# 1. call that's being tested
|
||||
# 2. call in ApplicationController
|
||||
def setup_csp_for_controller(controller_class, csp = ActionDispatch::ContentSecurityPolicy.new, times: 2)
|
||||
expect_next_instance_of(controller_class) do |controller|
|
||||
expect(controller).to receive(:current_content_security_policy)
|
||||
.and_return(ActionDispatch::ContentSecurityPolicy.new).exactly(times).times
|
||||
end
|
||||
end
|
||||
|
||||
# Expecting 2 calls to current_content_security_policy by default, once for
|
||||
# the call that's being tested and once for the call in ApplicationController
|
||||
def setup_existing_csp_for_controller(controller_class, csp, times = 2)
|
||||
expect_next_instance_of(controller_class) do |controller|
|
||||
expect(controller).to receive(:current_content_security_policy).and_return(csp).exactly(times).times
|
||||
expect(controller)
|
||||
.to receive(:current_content_security_policy).exactly(times).times
|
||||
.and_return(csp)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in New Issue