Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-06-28 18:08:20 +00:00
parent 5bb54b8711
commit d12d801795
49 changed files with 764 additions and 500 deletions

View File

@ -4583,7 +4583,7 @@ Layout/LineLength:
- 'spec/graphql/resolvers/projects/jira_projects_resolver_spec.rb'
- 'spec/graphql/resolvers/releases_resolver_spec.rb'
- 'spec/graphql/resolvers/snippets_resolver_spec.rb'
- 'spec/graphql/resolvers/todo_resolver_spec.rb'
- 'spec/graphql/resolvers/todos_resolver_spec.rb'
- 'spec/graphql/resolvers/user_discussions_count_resolver_spec.rb'
- 'spec/graphql/resolvers/users/group_count_resolver_spec.rb'
- 'spec/graphql/resolvers/users/groups_resolver_spec.rb'

View File

@ -145,9 +145,9 @@ gem 'seed-fu', '~> 2.3.7'
gem 'elasticsearch-model', '~> 7.2'
gem 'elasticsearch-rails', '~> 7.2', require: 'elasticsearch/rails/instrumentation'
gem 'elasticsearch-api', '7.13.3'
gem 'aws-sdk-core', '~> 3'
gem 'aws-sdk-core', '~> 3.131.0'
gem 'aws-sdk-cloudformation', '~> 1'
gem 'aws-sdk-s3', '~> 1'
gem 'aws-sdk-s3', '~> 1.114.0'
gem 'faraday_middleware-aws-sigv4', '~>0.3.0'
gem 'typhoeus', '~> 1.4.0' # Used with Elasticsearch to support http keep-alive connections

View File

@ -105,24 +105,24 @@ GEM
execjs (> 0)
awesome_print (1.9.2)
awrence (1.1.1)
aws-eventstream (1.1.0)
aws-partitions (1.345.0)
aws-eventstream (1.2.0)
aws-partitions (1.600.0)
aws-sdk-cloudformation (1.41.0)
aws-sdk-core (~> 3, >= 3.99.0)
aws-sigv4 (~> 1.1)
aws-sdk-core (3.104.3)
aws-sdk-core (3.131.1)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.239.0)
aws-partitions (~> 1, >= 1.525.0)
aws-sigv4 (~> 1.1)
jmespath (~> 1.0)
aws-sdk-kms (1.36.0)
aws-sdk-core (~> 3, >= 3.99.0)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.57.0)
aws-sdk-core (~> 3, >= 3.127.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.75.0)
aws-sdk-core (~> 3, >= 3.104.1)
aws-sdk-s3 (1.114.0)
aws-sdk-core (~> 3, >= 3.127.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.1)
aws-sigv4 (1.2.1)
aws-sigv4 (~> 1.4)
aws-sigv4 (1.5.0)
aws-eventstream (~> 1, >= 1.0.2)
azure-storage-blob (2.0.3)
azure-storage-common (~> 2.0)
@ -679,7 +679,7 @@ GEM
atlassian-jwt
multipart-post
oauth (~> 0.5, >= 0.5.0)
jmespath (1.4.0)
jmespath (1.6.1)
js_regex (3.7.0)
character_set (~> 1.4)
regexp_parser (~> 2.1)
@ -1464,8 +1464,8 @@ DEPENDENCIES
autoprefixer-rails (= 10.2.5.1)
awesome_print
aws-sdk-cloudformation (~> 1)
aws-sdk-core (~> 3)
aws-sdk-s3 (~> 1)
aws-sdk-core (~> 3.131.0)
aws-sdk-s3 (~> 1.114.0)
babosa (~> 1.0.4)
base32 (~> 0.3.0)
batch-loader (~> 2.0.1)

View File

@ -68,8 +68,7 @@ export default class IssuableForm {
this.gfmAutoComplete = new GfmAutoComplete(
gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources,
).setup();
const autoAssignToMe = form.get(0).id === 'new_merge_request';
this.usersSelect = new UsersSelect(undefined, undefined, { autoAssignToMe });
this.usersSelect = new UsersSelect();
this.reviewersSelect = new UsersSelect(undefined, '.js-reviewer-search');
this.zenMode = new ZenMode();

View File

@ -136,7 +136,9 @@ export default {
<template>
<section class="settings no-animate js-self-monitoring-settings">
<div class="settings-header">
<h4 class="js-section-header">
<h4
class="js-section-header settings-title js-settings-toggle js-settings-toggle-trigger-only"
>
{{ s__('SelfMonitoring|Self monitoring') }}
</h4>
<gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button>

View File

@ -35,7 +35,7 @@ function UsersSelect(currentUser, els, options = {}) {
}
}
const { handleClick, autoAssignToMe } = options;
const { handleClick } = options;
const userSelect = this;
$els.each((i, dropdown) => {
@ -172,7 +172,10 @@ function UsersSelect(currentUser, els, options = {}) {
});
};
const onAssignToMeClick = () => {
$assignToMeLink.on('click', (e) => {
e.preventDefault();
$(e.currentTarget).hide();
if ($dropdown.data('multiSelect')) {
assignYourself();
checkMaxSelect();
@ -191,19 +194,8 @@ function UsersSelect(currentUser, els, options = {}) {
.text(gon.current_user_fullname)
.removeClass('is-default');
}
};
$assignToMeLink.on('click', (e) => {
e.preventDefault();
$(e.currentTarget).hide();
onAssignToMeClick();
});
if (autoAssignToMe) {
$assignToMeLink.hide();
onAssignToMeClick();
}
$block.on('click', '.js-assign-yourself', (e) => {
e.preventDefault();
return assignTo(userSelect.currentUser.id);

View File

@ -1,10 +1,23 @@
<script>
import { GlTokenSelector, GlIcon, GlAvatar, GlLink } from '@gitlab/ui';
import { GlTokenSelector, GlIcon, GlAvatar, GlLink, GlSkeletonLoader } from '@gitlab/ui';
import { debounce } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql';
import { n__ } from '~/locale';
import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql';
import { i18n } from '../constants';
function isClosingIcon(el) {
return el?.classList.contains('gl-token-close');
function isTokenSelectorElement(el) {
return el?.classList.contains('gl-token-close') || el?.classList.contains('dropdown-item');
}
function addClass(el) {
return {
...el,
class: 'gl-bg-transparent',
};
}
export default {
@ -13,7 +26,10 @@ export default {
GlIcon,
GlAvatar,
GlLink,
GlSkeletonLoader,
SidebarParticipant,
},
inject: ['fullPath'],
props: {
workItemId: {
type: String,
@ -27,45 +43,95 @@ export default {
data() {
return {
isEditing: false,
localAssignees: this.assignees.map((assignee) => ({
...assignee,
class: 'gl-bg-transparent!',
})),
searchStarted: false,
localAssignees: this.assignees.map(addClass),
searchKey: '',
searchUsers: [],
};
},
computed: {
assigneeIds() {
return this.localAssignees.map((assignee) => assignee.id);
apollo: {
searchUsers: {
query() {
return userSearchQuery;
},
variables() {
return {
fullPath: this.fullPath,
search: this.searchKey,
};
},
skip() {
return !this.searchStarted;
},
update(data) {
return data.workspace?.users?.nodes.map((node) => addClass({ ...node, ...node.user }));
},
error() {
this.$emit('error', i18n.fetchError);
},
},
},
computed: {
assigneeListEmpty() {
return this.assignees.length === 0;
},
containerClass() {
return !this.isEditing ? 'gl-shadow-none! gl-bg-transparent!' : '';
},
isLoading() {
return this.$apollo.queries.searchUsers.loading;
},
assigneeText() {
return n__('WorkItem|Assignee', 'WorkItem|Assignees', this.localAssignees.length);
},
},
watch: {
assignees(newVal) {
if (!this.isEditing) {
this.localAssignees = newVal.map(addClass);
}
},
},
created() {
this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
},
methods: {
getUserId(id) {
return getIdFromGraphQLId(id);
},
setAssignees(e) {
if (isClosingIcon(e.relatedTarget) || !this.isEditing) return;
if (isTokenSelectorElement(e.relatedTarget) || !this.isEditing) return;
this.isEditing = false;
this.$apollo.mutate({
mutation: localUpdateWorkItemMutation,
variables: {
input: {
id: this.workItemId,
assigneeIds: this.assigneeIds,
assignees: this.localAssignees,
},
},
});
},
async focusTokenSelector() {
handleFocus() {
this.isEditing = true;
this.searchStarted = true;
},
async focusTokenSelector() {
this.handleFocus();
await this.$nextTick();
this.$refs.tokenSelector.focusTextInput();
},
handleMouseOver() {
this.timeout = setTimeout(() => {
this.searchStarted = true;
}, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
},
handleMouseOut() {
clearTimeout(this.timeout);
},
setSearchKey(value) {
this.searchKey = value;
},
},
};
</script>
@ -73,17 +139,21 @@ export default {
<template>
<div class="gl-display-flex gl-mb-4 work-item-assignees gl-relative">
<span class="gl-font-weight-bold gl-w-15 gl-pt-2" data-testid="assignees-title">{{
__('Assignee(s)')
assigneeText
}}</span>
<gl-token-selector
ref="tokenSelector"
v-model="localAssignees"
hide-dropdown-with-no-items
:container-class="containerClass"
:dropdown-items="searchUsers"
:loading="isLoading"
class="gl-w-full gl-border gl-border-white gl-hover-border-gray-200 gl-rounded-base"
@token-remove="focusTokenSelector"
@focus="isEditing = true"
@input="focusTokenSelector"
@text-input="debouncedSearchKeyUpdate"
@focus="handleFocus"
@blur="setAssignees"
@mouseover.native="handleMouseOver"
@mouseout.native="handleMouseOut"
>
<template #empty-placeholder>
<div
@ -106,6 +176,17 @@ export default {
<span class="gl-pl-2">{{ token.name }}</span>
</gl-link>
</template>
<template #dropdown-item-content="{ dropdownItem }">
<sidebar-participant :user="dropdownItem" />
</template>
<template #loading-content>
<gl-skeleton-loader :height="170">
<rect width="380" height="20" x="10" y="15" rx="4" />
<rect width="280" height="20" x="10" y="50" rx="4" />
<rect width="380" height="20" x="10" y="95" rx="4" />
<rect width="280" height="20" x="10" y="130" rx="4" />
</gl-skeleton-loader>
</template>
</gl-token-selector>
</div>
</template>

View File

@ -70,9 +70,7 @@ export const resolvers = {
const assigneesWidget = draftData.workItem.mockWidgets.find(
(widget) => widget.type === WIDGET_TYPE_ASSIGNEE,
);
assigneesWidget.nodes = assigneesWidget.nodes.filter((assignee) =>
input.assigneeIds.includes(assignee.id),
);
assigneesWidget.nodes = [...input.assignees];
});
cache.writeQuery({

View File

@ -23,7 +23,7 @@ extend type WorkItem {
type LocalWorkItemAssigneesInput {
id: WorkItemID!
assigneeIds: [ID!]
assignees: [UserCore!]
}
type LocalWorkItemPayload {

View File

@ -32,3 +32,4 @@
@import './pages/storage_quota';
@import './pages/tree';
@import './pages/users';
@import './pages/work_items';

View File

@ -0,0 +1,4 @@
.gl-token-selector-token-container {
display: flex;
align-items: center;
}

View File

@ -253,11 +253,6 @@ $gl-line-height-42: px-to-rem(42px);
max-width: 50%;
}
// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2894
.gl-form-lg {
max-width: 320px;
}
/**
Note: ::-webkit-scrollbar is a non-standard rule only
supported by webkit browsers.

View File

@ -15,6 +15,9 @@ module Mutations
argument :title, GraphQL::Types::String,
required: false,
description: copy_field_description(Types::WorkItemType, :title)
argument :description_widget, ::Types::WorkItems::Widgets::DescriptionInputType,
required: false,
description: 'Input for description widget.'
end
end
end

View File

@ -24,11 +24,13 @@ module Mutations
end
spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
widget_params = extract_widget_params(work_item, attributes)
::WorkItems::UpdateService.new(
project: work_item.project,
current_user: current_user,
params: attributes,
widget_params: widget_params,
spam_params: spam_params
).execute(work_item)
@ -45,6 +47,16 @@ module Mutations
def find_object(id:)
GitlabSchema.find_by_gid(id)
end
def extract_widget_params(work_item, attributes)
# Get the list of widgets for the work item's type to extract only the supported attributes
widget_keys = work_item.work_item_type.widgets.map(&:api_symbol)
widget_params = attributes.extract!(*widget_keys)
# Cannot use prepare to use `.to_h` on each input due to
# https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87472#note_945199865
widget_params.transform_values { |values| values.to_h }
end
end
end
end

View File

@ -2,6 +2,7 @@
module Mutations
module WorkItems
# TODO: Deprecate in favor of using WorkItemUpdate. See https://gitlab.com/gitlab-org/gitlab/-/issues/366300
class UpdateWidgets < BaseMutation
graphql_name 'WorkItemUpdateWidgets'
description "Updates the attributes of a work item's widgets by global ID." \

View File

@ -2,68 +2,16 @@
module Resolvers
class TodoResolver < BaseResolver
type Types::TodoType.connection_type, null: true
description 'Retrieve a single to-do item'
alias_method :target, :object
type Types::TodoType, null: true
argument :action, [Types::TodoActionEnum],
required: false,
description: 'Action to be filtered.'
argument :id, Types::GlobalIDType[Todo],
required: true,
description: 'ID of the to-do item.'
argument :author_id, [GraphQL::Types::ID],
required: false,
description: 'ID of an author.'
argument :project_id, [GraphQL::Types::ID],
required: false,
description: 'ID of a project.'
argument :group_id, [GraphQL::Types::ID],
required: false,
description: 'ID of a group.'
argument :state, [Types::TodoStateEnum],
required: false,
description: 'State of the todo.'
argument :type, [Types::TodoTargetEnum],
required: false,
description: 'Type of the todo.'
before_connection_authorization do |nodes, current_user|
Preloaders::UserMaxAccessLevelInProjectsPreloader.new(
nodes.map(&:project).compact,
current_user
).execute
end
def resolve(**args)
return Todo.none unless current_user.present? && target.present?
return Todo.none if target.is_a?(User) && target != current_user
TodosFinder.new(current_user, todo_finder_params(args)).execute.with_entity_associations
end
private
def todo_finder_params(args)
{
state: args[:state],
type: args[:type],
group_id: args[:group_id],
author_id: args[:author_id],
action_id: args[:action],
project_id: args[:project_id]
}.merge(target_params)
end
def target_params
return {} unless TodosFinder::TODO_TYPES.include?(target.class.name)
{
type: target.class.name,
target_id: target.id
}
def resolve(id:)
GitlabSchema.find_by_gid(id)
end
end
end

View File

@ -0,0 +1,69 @@
# frozen_string_literal: true
module Resolvers
class TodosResolver < BaseResolver
type Types::TodoType.connection_type, null: true
alias_method :target, :object
argument :action, [Types::TodoActionEnum],
required: false,
description: 'Action to be filtered.'
argument :author_id, [GraphQL::Types::ID],
required: false,
description: 'ID of an author.'
argument :project_id, [GraphQL::Types::ID],
required: false,
description: 'ID of a project.'
argument :group_id, [GraphQL::Types::ID],
required: false,
description: 'ID of a group.'
argument :state, [Types::TodoStateEnum],
required: false,
description: 'State of the todo.'
argument :type, [Types::TodoTargetEnum],
required: false,
description: 'Type of the todo.'
before_connection_authorization do |nodes, current_user|
Preloaders::UserMaxAccessLevelInProjectsPreloader.new(
nodes.map(&:project).compact,
current_user
).execute
end
def resolve(**args)
return Todo.none unless current_user.present? && target.present?
return Todo.none if target.is_a?(User) && target != current_user
TodosFinder.new(current_user, todo_finder_params(args)).execute.with_entity_associations
end
private
def todo_finder_params(args)
{
state: args[:state],
type: args[:type],
group_id: args[:group_id],
author_id: args[:author_id],
action_id: args[:action],
project_id: args[:project_id]
}.merge(target_params)
end
def target_params
return {} unless TodosFinder::TODO_TYPES.include?(target.class.name)
{
type: target.class.name,
target_id: target.id
}
end
end
end

View File

@ -116,7 +116,7 @@ module Types
null: true,
description: 'Runbook for the alert as defined in alert details.'
field :todos, description: 'To-do items of the current user for the alert.', resolver: Resolvers::TodoResolver
field :todos, description: 'To-do items of the current user for the alert.', resolver: Resolvers::TodosResolver
field :details_url,
GraphQL::Types::String,

View File

@ -136,6 +136,10 @@ module Types
null: true,
resolver: Resolvers::BoardListResolver
field :todo,
null: true,
resolver: Resolvers::TodoResolver
field :topics, Types::Projects::TopicType.connection_type,
null: true,
resolver: Resolvers::TopicsResolver,

View File

@ -88,7 +88,7 @@ module Types
null: true,
description: 'Personal namespace of the user.'
field :todos, resolver: Resolvers::TodoResolver, description: 'To-do items of the user.'
field :todos, resolver: Resolvers::TodosResolver, description: 'To-do items of the user.'
# Merge request field: MRs can be authored, assigned, or assigned-for-review:
field :authored_merge_requests,

View File

@ -4,10 +4,6 @@ module WorkItems
module Widgets
class Description < Base
delegate :description, to: :work_item
def update(params:)
work_item.description = params[:description] if params&.key?(:description)
end
end
end
end

View File

@ -6,6 +6,7 @@ module WorkItems
super(project: project, current_user: current_user, params: params, spam_params: nil)
@widget_params = widget_params
@widget_services = {}
end
private
@ -24,8 +25,20 @@ module WorkItems
def execute_widgets(work_item:, callback:)
work_item.widgets.each do |widget|
widget.try(callback, params: @widget_params[widget.class.api_symbol])
widget_service(widget).try(callback, params: @widget_params[widget.class.api_symbol])
end
end
def widget_service(widget)
service_class = begin
"WorkItems::Widgets::#{widget.type.capitalize}Service::UpdateService".constantize
rescue NameError
nil
end
return unless service_class
@widget_services[widget] ||= service_class.new(widget: widget, current_user: current_user)
end
end
end

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
module WorkItems
module Widgets
class BaseService < ::BaseService
attr_reader :widget, :current_user
def initialize(widget:, current_user:)
@widget = widget
@current_user = current_user
end
end
end
end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
module WorkItems
module Widgets
module DescriptionService
class UpdateService < WorkItems::Widgets::BaseService
def update(params: {})
return unless params.present? && params[:description]
widget.work_item.description = params[:description]
end
end
end
end
end

View File

@ -1,5 +1,5 @@
= gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-visibility-settings'), html: { class: 'fieldset-form', id: 'visibility-settings' } do |f|
= form_errors(@application_setting)
= form_errors(@application_setting, pajamas_alert: true)
%fieldset
= render 'shared/project_creation_levels', f: f, method: :default_project_creation, legend: s_('ProjectCreationLevel|Default project creation protection')

View File

@ -6,7 +6,7 @@
%section.settings.as-prometheus.no-animate#js-prometheus-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
%h4
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Metrics - Prometheus')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@ -17,7 +17,7 @@
%section.settings.as-grafana.no-animate#js-grafana-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
%h4
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Metrics - Grafana')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@ -30,7 +30,7 @@
%section.settings.as-performance-bar.no-animate#js-performance-bar-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'performance_bar_settings_content' } }
.settings-header
%h4
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Profiling - Performance bar')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@ -44,7 +44,7 @@
%section.settings.as-usage.no-animate#js-usage-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'usage_statistics_settings_content' } }
.settings-header#usage-statistics
%h4
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Usage statistics')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')
@ -56,7 +56,7 @@
- if Feature.enabled?(:configure_sentry_in_application_settings)
%section.settings.as-sentry.no-animate#js-sentry-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'sentry_settings_content' } }
.settings-header
%h4
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Sentry')
= render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do
= expanded_by_default? ? _('Collapse') : _('Expand')

View File

@ -8,7 +8,7 @@
.form-group.row
= f.label :name, s_('ProtectedBranch|Branch:'), class: 'col-sm-12'
.col-sm-12
= render partial: "projects/protected_branches/shared/dropdown", locals: { f: f, toggle_classes: 'gl-w-full! gl-form-lg' }
= render partial: "projects/protected_branches/shared/dropdown", locals: { f: f, toggle_classes: 'gl-w-full! gl-form-input-lg' }
.form-text.text-muted
- wildcards_url = help_page_url('user/project/protected_branches', anchor: 'configure-multiple-protected-branches-by-using-a-wildcard')
- wildcards_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: wildcards_url }

View File

@ -431,6 +431,18 @@ four standard [pagination arguments](#connection-pagination-arguments):
| <a id="querytimelogsstarttime"></a>`startTime` | [`Time`](#time) | List timelogs within a time range where the logged time is equal to or after startTime. |
| <a id="querytimelogsusername"></a>`username` | [`String`](#string) | List timelogs for a user. |
### `Query.todo`
Retrieve a single to-do item.
Returns [`Todo`](#todo).
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="querytodoid"></a>`id` | [`TodoID!`](#todoid) | ID of the to-do item. |
### `Query.topics`
Find project topics.
@ -5608,6 +5620,7 @@ Input type: `WorkItemUpdateInput`
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationworkitemupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationworkitemupdatedescriptionwidget"></a>`descriptionWidget` | [`WorkItemWidgetDescriptionInput`](#workitemwidgetdescriptioninput) | Input for description widget. |
| <a id="mutationworkitemupdateid"></a>`id` | [`WorkItemID!`](#workitemid) | Global ID of the work item. |
| <a id="mutationworkitemupdatestateevent"></a>`stateEvent` | [`WorkItemStateEvent`](#workitemstateevent) | Close or reopen a work item. |
| <a id="mutationworkitemupdatetitle"></a>`title` | [`String`](#string) | Title of the work item. |
@ -11751,6 +11764,7 @@ four standard [pagination arguments](#connection-pagination-arguments):
| <a id="groupdescriptionhtml"></a>`descriptionHtml` | [`String`](#string) | The GitLab Flavored Markdown rendering of `description`. |
| <a id="groupdora"></a>`dora` | [`Dora`](#dora) | Group's DORA metrics. |
| <a id="groupemailsdisabled"></a>`emailsDisabled` | [`Boolean`](#boolean) | Indicates if a group has email notifications disabled. |
| <a id="groupenforcefreeusercap"></a>`enforceFreeUserCap` | [`Boolean`](#boolean) | Indicates whether the group has limited users for a free plan. |
| <a id="groupepicboards"></a>`epicBoards` | [`EpicBoardConnection`](#epicboardconnection) | Find epic boards. (see [Connections](#connections)) |
| <a id="groupepicsenabled"></a>`epicsEnabled` | [`Boolean`](#boolean) | Indicates if Epics are enabled for namespace. |
| <a id="groupexternalauditeventdestinations"></a>`externalAuditEventDestinations` | [`ExternalAuditEventDestinationConnection`](#externalauditeventdestinationconnection) | External locations that receive audit events belonging to the group. (see [Connections](#connections)) |
@ -21925,6 +21939,7 @@ A time-frame defined as a closed inclusive range of two dates.
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="workitemupdatedtaskinputdescriptionwidget"></a>`descriptionWidget` | [`WorkItemWidgetDescriptionInput`](#workitemwidgetdescriptioninput) | Input for description widget. |
| <a id="workitemupdatedtaskinputid"></a>`id` | [`WorkItemID!`](#workitemid) | Global ID of the work item. |
| <a id="workitemupdatedtaskinputstateevent"></a>`stateEvent` | [`WorkItemStateEvent`](#workitemstateevent) | Close or reopen a work item. |
| <a id="workitemupdatedtaskinputtitle"></a>`title` | [`String`](#string) | Title of the work item. |

View File

@ -102,3 +102,8 @@ To avoid this error, make sure you are using the latest stable version of Docker
## Lack of IPv6 support
Due to the underlying [ZAProxy engine not supporting IPv6](https://github.com/zaproxy/zaproxy/issues/3705), DAST is unable to scan or crawl IPv6-based applications.
## Additional insight into DAST scan activity
For additional insight into what a DAST scan is doing at a given time, you may find it helpful to review
the web server access logs for a DAST target endpoint during or following a scan.

View File

@ -43507,6 +43507,11 @@ msgstr ""
msgid "WorkItem|Are you sure you want to delete the work item? This action cannot be reversed."
msgstr ""
msgid "WorkItem|Assignee"
msgid_plural "WorkItem|Assignees"
msgstr[0] ""
msgstr[1] ""
msgid "WorkItem|Cancel"
msgstr ""

View File

@ -24,6 +24,7 @@ module QA
Resource::MergeRequest.fabricate_via_browser_ui! do |merge_request|
merge_request.project = project
merge_request.title = merge_request_title
merge_request.assignee = 'me'
merge_request.description = merge_request_description
end
@ -53,6 +54,7 @@ module QA
merge_request.description = merge_request_description
merge_request.project = project
merge_request.milestone = milestone
merge_request.assignee = 'me'
merge_request.labels.push(label)
end

View File

@ -25,20 +25,6 @@ RSpec.describe 'User creates branch and merge request on issue page', :js do
sign_in(user)
end
context 'when Create merge request button is clicked' do
before do
visit project_issue_path(project, issue)
wait_for_requests
click_button('Create merge request')
wait_for_requests
end
it_behaves_like 'merge request author auto assign'
end
context 'when interacting with the dropdown' do
before do
visit project_issue_path(project, issue)

View File

@ -15,39 +15,27 @@ RSpec.describe "User creates a merge request", :js do
sign_in(user)
end
context 'when completed the compare branches form' do
before do
visit(project_new_merge_request_path(project))
it "creates a merge request" do
visit(project_new_merge_request_path(project))
find(".js-source-branch").click
click_link("fix")
find(".js-source-branch").click
click_link("fix")
find(".js-target-branch").click
click_link("feature")
find(".js-target-branch").click
click_link("feature")
click_button("Compare branches")
click_button("Compare branches")
page.within('.merge-request-form') do
expect(page.find('#merge_request_description')['placeholder']).to eq 'Describe the goal of the changes and what reviewers should be aware of.'
end
it "shows merge request form" do
page.within('.merge-request-form') do
expect(page.find('#merge_request_description')['placeholder']).to eq 'Describe the goal of the changes and what reviewers should be aware of.'
end
fill_in("Title", with: title)
click_button("Create merge request")
page.within(".merge-request") do
expect(page).to have_content(title)
end
context "when completed the merge request form" do
before do
fill_in("Title", with: title)
click_button("Create merge request")
end
it "creates a merge request" do
page.within(".merge-request") do
expect(page).to have_content(title)
end
end
end
it_behaves_like 'merge request author auto assign'
end
context "XSS branch name exists" do

View File

@ -2,40 +2,68 @@
require 'spec_helper'
RSpec.describe Projects::JobsController, '(JavaScript fixtures)', type: :controller do
RSpec.describe 'Jobs (JavaScript fixtures)' do
include ApiHelpers
include JavaScriptFixturesHelpers
include GraphqlHelpers
let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
let(:project) { create(:project, :repository, namespace: namespace, path: 'builds-project') }
let(:user) { project.first_owner }
let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.id) }
let!(:build_with_artifacts) { create(:ci_build, :success, :artifacts, :trace_artifact, pipeline: pipeline, stage: 'test', artifacts_expire_at: Time.now + 18.months) }
let!(:failed_build) { create(:ci_build, :failed, pipeline: pipeline, stage: 'build') }
let!(:pending_build) { create(:ci_build, :pending, pipeline: pipeline, stage: 'deploy') }
let!(:delayed_job) do
create(:ci_build, :scheduled,
pipeline: pipeline,
name: 'delayed job',
stage: 'test')
end
render_views
before do
sign_in(user)
end
after do
remove_repository(project)
end
it 'jobs/delayed.json' do
get :show, params: {
namespace_id: project.namespace.to_param,
project_id: project,
id: delayed_job.to_param
}, format: :json
describe Projects::JobsController, type: :controller do
let!(:delayed) { create(:ci_build, :scheduled, pipeline: pipeline, name: 'delayed job') }
expect(response).to be_successful
before do
sign_in(user)
end
it 'jobs/delayed.json' do
get :show, params: {
namespace_id: project.namespace.to_param,
project_id: project,
id: delayed.to_param
}, format: :json
expect(response).to be_successful
end
end
describe GraphQL::Query, type: :request do
let!(:build) { create(:ci_build, :success, name: 'build', pipeline: pipeline) }
let!(:created_by_tag) { create(:ci_build, :success, name: 'created_by_tag', tag: true, pipeline: pipeline) }
let!(:with_coverage) { create(:ci_build, :success, name: 'with_coverage', coverage: 40.0, pipeline: pipeline) }
let!(:stuck) { create(:ci_build, :pending, name: 'stuck', pipeline: pipeline) }
fixtures_path = 'graphql/jobs/'
get_jobs_query = 'get_jobs.query.graphql'
let_it_be(:query) do
get_graphql_query_as_string("jobs/components/table/graphql/queries/#{get_jobs_query}")
end
it "#{fixtures_path}#{get_jobs_query}.json" do
post_graphql(query, current_user: user, variables: {
fullPath: 'frontend-fixtures/builds-project'
})
expect_graphql_errors_to_be_empty
end
it "#{fixtures_path}#{get_jobs_query}.as_guest.json" do
guest = create(:user)
project.add_guest(guest)
post_graphql(query, current_user: guest, variables: {
fullPath: 'frontend-fixtures/builds-project'
})
expect_graphql_errors_to_be_empty
end
end
end

View File

@ -2,12 +2,9 @@ import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import JobCell from '~/jobs/components/table/cells/job_cell.vue';
import { mockJobsInTable } from '../../../mock_data';
import { mockJobsInTable, mockJobsAsGuestInTable } from '../../../mock_data';
const mockJob = mockJobsInTable[0];
const mockJobCreatedByTag = mockJobsInTable[1];
const mockJobLimitedAccess = mockJobsInTable[2];
const mockStuckJob = mockJobsInTable[3];
const getMockJob = (name) => mockJobsInTable.find((job) => job.name === name);
describe('Job Cell', () => {
let wrapper;
@ -23,6 +20,8 @@ describe('Job Cell', () => {
const findBadgeById = (id) => wrapper.findByTestId(id);
const mockJob = getMockJob('build');
const createComponent = (jobData = mockJob) => {
wrapper = extendedWrapper(
shallowMount(JobCell, {
@ -49,9 +48,11 @@ describe('Job Cell', () => {
});
it('display the job id with no link', () => {
createComponent(mockJobLimitedAccess);
const mockJobAsGuest = mockJobsAsGuestInTable[0];
const expectedJobId = `#${getIdFromGraphQLId(mockJobLimitedAccess.id)}`;
createComponent(mockJobAsGuest);
const expectedJobId = `#${getIdFromGraphQLId(mockJobAsGuest.id)}`;
expect(findJobIdNoLink().text()).toBe(expectedJobId);
expect(findJobIdNoLink().exists()).toBe(true);
@ -75,7 +76,7 @@ describe('Job Cell', () => {
});
it('displays label icon when job is created by a tag', () => {
createComponent(mockJobCreatedByTag);
createComponent(getMockJob('created_by_tag'));
expect(findLabelIcon().exists()).toBe(true);
expect(findForkIcon().exists()).toBe(false);
@ -131,7 +132,7 @@ describe('Job Cell', () => {
});
it('stuck icon is shown if job is stuck', () => {
createComponent(mockStuckJob);
createComponent(getMockJob('stuck'));
expect(findStuckIcon().exists()).toBe(true);
expect(findStuckIcon().attributes('name')).toBe('warning');

View File

@ -1,8 +1,14 @@
import mockJobs from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.json';
import mockJobsAsGuest from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.as_guest.json';
import { TEST_HOST } from 'spec/test_constants';
const threeWeeksAgo = new Date();
threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
// Fixtures generated at spec/frontend/fixtures/jobs.rb
export const mockJobsInTable = mockJobs.data.project.jobs.nodes;
export const mockJobsAsGuestInTable = mockJobsAsGuest.data.project.jobs.nodes;
export const stages = [
{
name: 'build',
@ -1283,199 +1289,6 @@ export const mockPipelineDetached = {
},
};
export const mockJobsInTable = [
{
detailedStatus: {
icon: 'status_manual',
label: 'manual play action',
text: 'manual',
tooltip: 'manual action',
action: {
buttonTitle: 'Trigger this manual action',
icon: 'play',
method: 'post',
path: '/root/ci-project/-/jobs/2004/play',
title: 'Play',
__typename: 'StatusAction',
},
detailsPath: '/root/ci-project/-/jobs/2004',
__typename: 'DetailedStatus',
},
id: 'gid://gitlab/Ci::Build/2004',
refName: 'main',
refPath: '/root/ci-project/-/commits/main',
tags: [],
shortSha: '2d5d8323',
commitPath: '/root/ci-project/-/commit/2d5d83230bdea0e003d83ef4c16d2bf9a8808ebe',
pipeline: {
id: 'gid://gitlab/Ci::Pipeline/423',
path: '/root/ci-project/-/pipelines/423',
user: {
webPath: '/root',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
__typename: 'User',
},
__typename: 'Pipeline',
},
stage: { name: 'test', __typename: 'CiStage' },
name: 'test_manual_job',
duration: null,
finishedAt: null,
coverage: null,
createdByTag: false,
retryable: false,
playable: true,
cancelable: false,
active: false,
stuck: false,
userPermissions: { readBuild: true, __typename: 'JobPermissions' },
__typename: 'CiJob',
},
{
detailedStatus: {
icon: 'status_skipped',
label: 'skipped',
text: 'skipped',
tooltip: 'skipped',
action: null,
__typename: 'DetailedStatus',
},
id: 'gid://gitlab/Ci::Build/2021',
refName: 'main',
refPath: '/root/ci-project/-/commits/main',
tags: [],
shortSha: '2d5d8323',
commitPath: '/root/ci-project/-/commit/2d5d83230bdea0e003d83ef4c16d2bf9a8808ebe',
pipeline: {
id: 'gid://gitlab/Ci::Pipeline/425',
path: '/root/ci-project/-/pipelines/425',
user: {
webPath: '/root',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
__typename: 'User',
},
__typename: 'Pipeline',
},
stage: { name: 'test', __typename: 'CiStage' },
name: 'coverage_job',
duration: null,
finishedAt: null,
coverage: null,
createdByTag: true,
retryable: false,
playable: false,
cancelable: false,
active: false,
stuck: false,
userPermissions: { readBuild: true, __typename: 'JobPermissions' },
__typename: 'CiJob',
},
{
detailedStatus: {
icon: 'status_success',
label: 'passed',
text: 'passed',
tooltip: 'passed',
action: {
buttonTitle: 'Retry this job',
icon: 'retry',
method: 'post',
path: '/root/ci-project/-/jobs/2015/retry',
title: 'Retry',
__typename: 'StatusAction',
},
__typename: 'DetailedStatus',
},
id: 'gid://gitlab/Ci::Build/2015',
refName: 'main',
refPath: '/root/ci-project/-/commits/main',
tags: [],
shortSha: '2d5d8323',
commitPath: '/root/ci-project/-/commit/2d5d83230bdea0e003d83ef4c16d2bf9a8808ebe',
pipeline: {
id: 'gid://gitlab/Ci::Pipeline/424',
path: '/root/ci-project/-/pipelines/424',
user: {
webPath: '/root',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
__typename: 'User',
},
__typename: 'Pipeline',
},
stage: { name: 'deploy', __typename: 'CiStage' },
name: 'artifact_job',
duration: 2,
finishedAt: '2021-04-01T17:36:18Z',
coverage: 82.71,
createdByTag: false,
retryable: true,
playable: false,
cancelable: false,
active: false,
stuck: false,
userPermissions: { readBuild: false, __typename: 'JobPermissions' },
__typename: 'CiJob',
},
{
artifacts: { nodes: [], __typename: 'CiJobArtifactConnection' },
allowFailure: false,
status: 'PENDING',
scheduledAt: null,
manualJob: false,
triggered: null,
createdByTag: false,
detailedStatus: {
detailsPath: '/root/ci-project/-/jobs/2391',
group: 'pending',
icon: 'status_pending',
label: 'pending',
text: 'pending',
tooltip: 'pending',
action: {
buttonTitle: 'Cancel this job',
icon: 'cancel',
method: 'post',
path: '/root/ci-project/-/jobs/2391/cancel',
title: 'Cancel',
__typename: 'StatusAction',
},
__typename: 'DetailedStatus',
},
id: 'gid://gitlab/Ci::Build/2391',
refName: 'master',
refPath: '/root/ci-project/-/commits/master',
tags: [],
shortSha: '916330b4',
commitPath: '/root/ci-project/-/commit/916330b4fda5dae226524ceb51c756c0ed26679d',
pipeline: {
id: 'gid://gitlab/Ci::Pipeline/482',
path: '/root/ci-project/-/pipelines/482',
user: {
webPath: '/root',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
__typename: 'UserCore',
},
__typename: 'Pipeline',
},
stage: { name: 'build', __typename: 'CiStage' },
name: 'build_job',
duration: null,
finishedAt: null,
coverage: null,
retryable: false,
playable: false,
cancelable: true,
active: true,
stuck: true,
userPermissions: { readBuild: true, __typename: 'JobPermissions' },
__typename: 'CiJob',
},
];
export const mockJobsQueryResponse = {
data: {
project: {

View File

@ -8,7 +8,7 @@ exports[`self monitor component When the self monitor project has not been creat
class="settings-header"
>
<h4
class="js-section-header"
class="js-section-header settings-title js-settings-toggle js-settings-toggle-trigger-only"
>
Self monitoring

View File

@ -1,52 +1,59 @@
import { GlLink, GlTokenSelector } from '@gitlab/ui';
import { nextTick } from 'vue';
import { GlLink, GlTokenSelector, GlSkeletonLoader } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue';
import localUpdateWorkItemMutation from '~/work_items/graphql/local_update_work_item.mutation.graphql';
import { i18n } from '~/work_items/constants';
import { temporaryConfig, resolvers } from '~/work_items/graphql/provider';
import { projectMembersResponse, mockAssignees, workItemQueryResponse } from '../mock_data';
const mockAssignees = [
{
__typename: 'UserCore',
id: 'gid://gitlab/User/1',
avatarUrl: '',
webUrl: '',
name: 'John Doe',
username: 'doe_I',
},
{
__typename: 'UserCore',
id: 'gid://gitlab/User/2',
avatarUrl: '',
webUrl: '',
name: 'Marcus Rutherford',
username: 'ruthfull',
},
];
Vue.use(VueApollo);
const workItemId = 'gid://gitlab/WorkItem/1';
const mutate = jest.fn();
describe('WorkItemAssignees component', () => {
let wrapper;
const findAssigneeLinks = () => wrapper.findAllComponents(GlLink);
const findTokenSelector = () => wrapper.findComponent(GlTokenSelector);
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findEmptyState = () => wrapper.findByTestId('empty-state');
const createComponent = ({ assignees = mockAssignees } = {}) => {
const successSearchQueryHandler = jest.fn().mockResolvedValue(projectMembersResponse);
const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem');
const createComponent = ({
assignees = mockAssignees,
searchQueryHandler = successSearchQueryHandler,
} = {}) => {
const apolloProvider = createMockApollo([[userSearchQuery, searchQueryHandler]], resolvers, {
typePolicies: temporaryConfig.cacheConfig.typePolicies,
});
apolloProvider.clients.defaultClient.writeQuery({
query: workItemQuery,
variables: {
id: workItemId,
},
data: workItemQueryResponse.data,
});
wrapper = mountExtended(WorkItemAssignees, {
provide: {
fullPath: 'test-project-path',
},
propsData: {
assignees,
workItemId,
},
mocks: {
$apollo: {
mutate,
},
},
attachTo: document.body,
apolloProvider,
});
};
@ -54,40 +61,114 @@ describe('WorkItemAssignees component', () => {
wrapper.destroy();
});
it('should pass the correct data-user-id attribute', () => {
it('passes the correct data-user-id attribute', () => {
createComponent();
expect(findAssigneeLinks().at(0).attributes('data-user-id')).toBe('1');
});
describe('when there are assignees', () => {
beforeEach(() => {
createComponent();
});
it('focuses token selector on token selector input event', async () => {
createComponent();
findTokenSelector().vm.$emit('input', [mockAssignees[0]]);
await nextTick();
it('should focus token selector on token removal', async () => {
findTokenSelector().vm.$emit('token-remove', mockAssignees[0].id);
await nextTick();
expect(findEmptyState().exists()).toBe(false);
expect(findTokenSelector().element.contains(document.activeElement)).toBe(true);
});
expect(findEmptyState().exists()).toBe(false);
expect(findTokenSelector().element.contains(document.activeElement)).toBe(true);
});
it('calls a mutation on clicking outside the token selector', async () => {
createComponent();
findTokenSelector().vm.$emit('input', [mockAssignees[0]]);
findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null }));
await waitForPromises();
it('should call a mutation on clicking outside the token selector', async () => {
findTokenSelector().vm.$emit('input', [mockAssignees[0]]);
findTokenSelector().vm.$emit('token-remove');
await nextTick();
expect(mutate).not.toHaveBeenCalled();
expect(findTokenSelector().props('selectedTokens')).toEqual([mockAssignees[0]]);
});
findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null }));
await nextTick();
it('does not start user search by default', () => {
createComponent();
expect(mutate).toHaveBeenCalledWith({
mutation: localUpdateWorkItemMutation,
variables: {
input: { id: workItemId, assigneeIds: [mockAssignees[0].id] },
},
});
});
expect(findTokenSelector().props('loading')).toBe(false);
expect(findTokenSelector().props('dropdownItems')).toEqual([]);
});
it('starts user search on hovering for more than 250ms', async () => {
createComponent();
findTokenSelector().trigger('mouseover');
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
await nextTick();
expect(findTokenSelector().props('loading')).toBe(true);
});
it('starts user search on focusing token selector', async () => {
createComponent();
findTokenSelector().vm.$emit('focus');
await nextTick();
expect(findTokenSelector().props('loading')).toBe(true);
});
it('does not start searching if token-selector was hovered for less than 250ms', async () => {
createComponent();
findTokenSelector().trigger('mouseover');
jest.advanceTimersByTime(100);
await nextTick();
expect(findTokenSelector().props('loading')).toBe(false);
});
it('does not start searching if cursor was moved out from token selector before 250ms passed', async () => {
createComponent();
findTokenSelector().trigger('mouseover');
jest.advanceTimersByTime(100);
findTokenSelector().trigger('mouseout');
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
await nextTick();
expect(findTokenSelector().props('loading')).toBe(false);
});
it('shows skeleton loader on dropdown when loading users', async () => {
createComponent();
findTokenSelector().vm.$emit('focus');
await nextTick();
expect(findSkeletonLoader().exists()).toBe(true);
});
it('shows correct user list in dropdown when loaded', async () => {
createComponent();
findTokenSelector().vm.$emit('focus');
await nextTick();
expect(findSkeletonLoader().exists()).toBe(true);
await waitForPromises();
expect(findSkeletonLoader().exists()).toBe(false);
expect(findTokenSelector().props('dropdownItems')).toHaveLength(2);
});
it('emits error event if search users query fails', async () => {
createComponent({ searchQueryHandler: errorHandler });
findTokenSelector().vm.$emit('focus');
await waitForPromises();
expect(wrapper.emitted('error')).toEqual([[i18n.fetchError]]);
});
it('should search for users with correct key after text input', async () => {
const searchKey = 'Hello';
createComponent();
findTokenSelector().vm.$emit('focus');
findTokenSelector().vm.$emit('text-input', searchKey);
await waitForPromises();
expect(successSearchQueryHandler).toHaveBeenCalledWith(
expect.objectContaining({ search: searchKey }),
);
});
});

View File

@ -300,3 +300,60 @@ export const availableWorkItemsResponse = {
},
},
};
export const projectMembersResponse = {
data: {
workspace: {
id: '1',
__typename: 'Project',
users: {
nodes: [
{
id: 'user-1',
user: {
__typename: 'UserCore',
id: 'gid://gitlab/User/1',
avatarUrl:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
name: 'Administrator',
username: 'root',
webUrl: '/root',
status: null,
},
},
{
id: 'user-2',
user: {
__typename: 'UserCore',
id: 'gid://gitlab/User/5',
avatarUrl: '/avatar2',
name: 'rookie',
username: 'rookie',
webUrl: 'rookie',
status: null,
},
},
],
},
},
},
};
export const mockAssignees = [
{
__typename: 'UserCore',
id: 'gid://gitlab/User/1',
avatarUrl: '',
webUrl: '',
name: 'John Doe',
username: 'doe_I',
},
{
__typename: 'UserCore',
id: 'gid://gitlab/User/2',
avatarUrl: '',
webUrl: '',
name: 'Marcus Rutherford',
username: 'ruthfull',
},
];

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe Resolvers::TodoResolver do
RSpec.describe Resolvers::TodosResolver do
include GraphqlHelpers
include DesignManagementTestHelpers

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ::Types::WorkItems::Widgets::DescriptionInputType do
it { expect(described_class.graphql_name).to eq('WorkItemWidgetDescriptionInput') }
it { expect(described_class.arguments.keys).to match_array(%w[description]) }
end

View File

@ -11,8 +11,17 @@ RSpec.describe 'Update a work item' do
let(:work_item_event) { 'CLOSE' }
let(:input) { { 'stateEvent' => work_item_event, 'title' => 'updated title' } }
let(:fields) do
<<~FIELDS
workItem {
state
title
}
errors
FIELDS
end
let(:mutation) { graphql_mutation(:workItemUpdate, input.merge('id' => work_item.to_global_id.to_s)) }
let(:mutation) { graphql_mutation(:workItemUpdate, input.merge('id' => work_item.to_global_id.to_s), fields) }
let(:mutation_response) { graphql_mutation_response(:work_item_update) }
@ -80,5 +89,29 @@ RSpec.describe 'Update a work item' do
expect(mutation_response['errors']).to contain_exactly('`work_items` feature flag disabled for this project')
end
end
context 'with description widget input' do
let(:fields) do
<<~FIELDS
workItem {
description
widgets {
type
... on WorkItemWidgetDescription {
description
}
}
}
errors
FIELDS
end
it_behaves_like 'update work item description widget' do
let(:new_description) { 'updated description' }
let(:input) do
{ 'descriptionWidget' => { 'description' => new_description } }
end
end
end
end
end

View File

@ -9,15 +9,22 @@ RSpec.describe 'Update work item widgets' do
let_it_be(:developer) { create(:user).tap { |user| project.add_developer(user) } }
let_it_be(:work_item, refind: true) { create(:work_item, project: project) }
let(:input) do
{
'descriptionWidget' => { 'description' => 'updated description' }
}
end
let(:mutation) { graphql_mutation(:workItemUpdateWidgets, input.merge('id' => work_item.to_global_id.to_s)) }
let(:input) { { 'descriptionWidget' => { 'description' => 'updated description' } } }
let(:mutation_response) { graphql_mutation_response(:work_item_update_widgets) }
let(:mutation) do
graphql_mutation(:workItemUpdateWidgets, input.merge('id' => work_item.to_global_id.to_s), <<~FIELDS)
errors
workItem {
description
widgets {
type
... on WorkItemWidgetDescription {
description
}
}
}
FIELDS
end
context 'the user is not allowed to update a work item' do
let(:current_user) { create(:user) }
@ -28,32 +35,8 @@ RSpec.describe 'Update work item widgets' do
context 'when user has permissions to update a work item', :aggregate_failures do
let(:current_user) { developer }
context 'when the updated work item is not valid' do
it 'returns validation errors without the work item' do
errors = ActiveModel::Errors.new(work_item).tap { |e| e.add(:description, 'error message') }
allow_next_found_instance_of(::WorkItem) do |instance|
allow(instance).to receive(:valid?).and_return(false)
allow(instance).to receive(:errors).and_return(errors)
end
post_graphql_mutation(mutation, current_user: current_user)
expect(mutation_response['workItem']).to be_nil
expect(mutation_response['errors']).to match_array(['Description error message'])
end
end
it 'updates the work item widgets' do
expect do
post_graphql_mutation(mutation, current_user: current_user)
work_item.reload
end.to change(work_item, :description).from(nil).to('updated description')
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['workItem']).to include(
'title' => work_item.title
)
it_behaves_like 'update work item description widget' do
let(:new_description) { 'updated description' }
end
it_behaves_like 'has spam protection' do
@ -69,7 +52,7 @@ RSpec.describe 'Update work item widgets' do
expect do
post_graphql_mutation(mutation, current_user: current_user)
work_item.reload
end.to not_change(work_item, :title)
end.to not_change(work_item, :description)
expect(mutation_response['errors']).to contain_exactly('`work_items` feature flag disabled for this project')
end

View File

@ -0,0 +1,50 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Todo Query' do
include GraphqlHelpers
let_it_be(:current_user) { nil }
let_it_be(:project) { create(:project) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:todo_owner) { create(:user) }
let_it_be(:todo) { create(:todo, user: todo_owner, target: project) }
before do
project.add_developer(todo_owner)
end
let(:fields) do
<<~GRAPHQL
id
GRAPHQL
end
let(:query) do
graphql_query_for(:todo, { id: todo.to_global_id.to_s }, fields)
end
subject do
result = GitlabSchema.execute(query, context: { current_user: current_user }).to_h
graphql_dig_at(result, :data, :todo)
end
context 'when requesting user is todo owner' do
let(:current_user) { todo_owner }
it { is_expected.to include('id' => todo.to_global_id.to_s) }
end
context 'when requesting user is not todo owner' do
let(:current_user) { create(:user) }
it { is_expected.to be_nil }
end
context 'when unauthenticated' do
it { is_expected.to be_nil }
end
end

View File

@ -0,0 +1,35 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe WorkItems::Widgets::DescriptionService::UpdateService do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be_with_reload(:work_item) { create(:work_item, project: project, description: 'old description') }
let(:widget) { work_item.widgets.find {|widget| widget.is_a?(WorkItems::Widgets::Description) } }
describe '#update' do
subject { described_class.new(widget: widget, current_user: user).update(params: params) } # rubocop:disable Rails/SaveBang
context 'when description param is present' do
let(:params) { { description: 'updated description' } }
it 'correctly sets work item description value' do
subject
expect(work_item.description).to eq('updated description')
end
end
context 'when description param is not present' do
let(:params) { {} }
it 'does not change work item description value' do
subject
expect(work_item.description).to eq('old description')
end
end
end
end

View File

@ -4,7 +4,7 @@ RSpec.shared_examples 'multiple assignees merge request' do |action, save_button
it "#{action} a MR with multiple assignees", :js do
find('.js-assignee-search').click
page.within '.dropdown-menu-user' do
click_link user.name unless action == 'creates'
click_link user.name
click_link user2.name
end

View File

@ -4,7 +4,7 @@ RSpec.shared_examples 'multiple assignees widget merge request' do |action, save
it "#{action} a MR with multiple assignees", :js do
find('.js-assignee-search').click
page.within '.dropdown-menu-user' do
click_link user.name unless action == 'creates'
click_link user.name
click_link user2.name
end

View File

@ -0,0 +1,34 @@
# frozen_string_literal: true
RSpec.shared_examples 'update work item description widget' do
it 'updates the description widget' do
expect do
post_graphql_mutation(mutation, current_user: current_user)
work_item.reload
end.to change(work_item, :description).from(nil).to(new_description)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_response['workItem']['widgets']).to include(
{
'description' => new_description,
'type' => 'DESCRIPTION'
}
)
end
context 'when the updated work item is not valid' do
it 'returns validation errors without the work item' do
errors = ActiveModel::Errors.new(work_item).tap { |e| e.add(:description, 'error message') }
allow_next_found_instance_of(::WorkItem) do |instance|
allow(instance).to receive(:valid?).and_return(false)
allow(instance).to receive(:errors).and_return(errors)
end
post_graphql_mutation(mutation, current_user: current_user)
expect(mutation_response['workItem']).to be_nil
expect(mutation_response['errors']).to match_array(['Description error message'])
end
end
end

View File

@ -1,8 +0,0 @@
# frozen_string_literal: true
RSpec.shared_examples 'merge request author auto assign' do
it 'populates merge request author as assignee' do
expect(find('.js-assignee-search')).to have_content(user.name)
expect(page).not_to have_content 'Assign yourself'
end
end