Commit e289ce65 authored by Filipa Lacerda's avatar Filipa Lacerda

Merge branch 'master' into 3995-improve-sast

* master: (31 commits)
  Fix prop type
  Include epics from subgroups
  Port changes from gitlab-org/gitlab-ee!4064 to CE
  Replace $.get in single file diff with axios
  Add Geo attachment replication QA spec
  Update icon size and alignment Update neutral icon color
  cache jQuery selector
  Add createNewItemFromValue option and clearDropdown method
  Add createNewItemFromValue option and clearDropdown method
  Don't run scripts/lint-changelog-yaml in scripts/static-analysis but only in the 'docs lint' job
  Update CHANGELOG.md for 10.4.2
  Check MR state before submitting queries for discussion state
  Fix grape-route-helper route shadowing
  fixed issuable_spec.js
  Converted integration_settings_form to axios
  Converted issuable_bulk_update_actions to axios
  Converted issuable_index to axios
  Converted group_label_subscription to axios
  Converted graphs_show to axios
  Converted gl_dropdown to axios
  ...
parents 7f818790 cdefac3a
......@@ -12,6 +12,7 @@ export default class CreateItemDropdown {
this.fieldName = options.fieldName;
this.onSelect = options.onSelect || (() => {});
this.getDataOption = options.getData;
this.createNewItemFromValueOption = options.createNewItemFromValue;
this.$dropdown = options.$dropdown;
this.$dropdownContainer = this.$dropdown.parent();
this.$dropdownFooter = this.$dropdownContainer.find('.dropdown-footer');
......@@ -30,15 +31,15 @@ export default class CreateItemDropdown {
filterable: true,
remote: false,
search: {
fields: ['title'],
fields: ['text'],
},
selectable: true,
toggleLabel(selected) {
return (selected && 'id' in selected) ? selected.title : this.defaultToggleLabel;
return (selected && 'id' in selected) ? _.escape(selected.title) : this.defaultToggleLabel;
},
fieldName: this.fieldName,
text(item) {
return _.escape(item.title);
return _.escape(item.text);
},
id(item) {
return _.escape(item.id);
......@@ -51,6 +52,11 @@ export default class CreateItemDropdown {
});
}
clearDropdown() {
this.$dropdownContainer.find('.dropdown-content').html('');
this.$dropdownContainer.find('.dropdown-input-field').val('');
}
bindEvents() {
this.$createButton.on('click', this.onClickCreateWildcard.bind(this));
}
......@@ -58,9 +64,13 @@ export default class CreateItemDropdown {
onClickCreateWildcard(e) {
e.preventDefault();
this.refreshData();
this.$dropdown.data('glDropdown').selectRowAtIndex();
}
refreshData() {
// Refresh the dropdown's data, which ends up calling `getData`
this.$dropdown.data('glDropdown').remote.execute();
this.$dropdown.data('glDropdown').selectRowAtIndex();
}
getData(term, callback) {
......@@ -79,20 +89,28 @@ export default class CreateItemDropdown {
});
}
toggleCreateNewButton(item) {
if (item) {
this.selectedItem = {
title: item,
id: item,
text: item,
};
createNewItemFromValue(newValue) {
if (this.createNewItemFromValueOption) {
return this.createNewItemFromValueOption(newValue);
}
return {
title: newValue,
id: newValue,
text: newValue,
};
}
toggleCreateNewButton(newValue) {
if (newValue) {
this.selectedItem = this.createNewItemFromValue(newValue);
this.$dropdownContainer
.find('.js-dropdown-create-new-item code')
.text(item);
.text(newValue);
}
this.toggleFooter(!item);
this.toggleFooter(!newValue);
}
toggleFooter(toggleState) {
......
......@@ -2,6 +2,7 @@
/* global fuzzaldrinPlus */
import _ from 'underscore';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import axios from './lib/utils/axios_utils';
import { visitUrl } from './lib/utils/url_utility';
import { isObject } from './lib/utils/type_utility';
......@@ -212,25 +213,17 @@ GitLabDropdownRemote = (function() {
};
GitLabDropdownRemote.prototype.fetchData = function() {
return $.ajax({
url: this.dataEndpoint,
dataType: this.options.dataType,
beforeSend: (function(_this) {
return function() {
if (_this.options.beforeSend) {
return _this.options.beforeSend();
}
};
})(this),
success: (function(_this) {
return function(data) {
if (_this.options.success) {
return _this.options.success(data);
}
};
})(this)
});
// Fetch the data through ajax if the data is a string
if (this.options.beforeSend) {
this.options.beforeSend();
}
// Fetch the data through ajax if the data is a string
return axios.get(this.dataEndpoint)
.then(({ data }) => {
if (this.options.success) {
return this.options.success(data);
}
});
};
return GitLabDropdownRemote;
......
import flash from '../flash';
import { __ } from '../locale';
import axios from '../lib/utils/axios_utils';
import ContributorsStatGraph from './stat_graph_contributors';
document.addEventListener('DOMContentLoaded', () => {
$.ajax({
type: 'GET',
url: document.querySelector('.js-graphs-show').dataset.projectGraphPath,
dataType: 'json',
success(data) {
const url = document.querySelector('.js-graphs-show').dataset.projectGraphPath;
axios.get(url)
.then(({ data }) => {
const graph = new ContributorsStatGraph();
graph.init(data);
......@@ -16,6 +18,6 @@ document.addEventListener('DOMContentLoaded', () => {
$('.stat-graph').fadeIn();
$('.loading-graph').hide();
},
});
})
.catch(() => flash(__('Error fetching contributors data.')));
});
import axios from './lib/utils/axios_utils';
import flash from './flash';
import { __ } from './locale';
export default class GroupLabelSubscription {
constructor(container) {
const $container = $(container);
......@@ -13,14 +17,12 @@ export default class GroupLabelSubscription {
event.preventDefault();
const url = this.$unsubscribeButtons.attr('data-url');
$.ajax({
type: 'POST',
url,
}).done(() => {
this.toggleSubscriptionButtons();
this.$unsubscribeButtons.removeAttr('data-url');
});
axios.post(url)
.then(() => {
this.toggleSubscriptionButtons();
this.$unsubscribeButtons.removeAttr('data-url');
})
.catch(() => flash(__('There was an error when unsubscribing from this label.')));
}
subscribe(event) {
......@@ -31,12 +33,9 @@ export default class GroupLabelSubscription {
this.$unsubscribeButtons.attr('data-url', url);
$.ajax({
type: 'POST',
url,
}).done(() => {
this.toggleSubscriptionButtons();
});
axios.post(url)
.then(() => this.toggleSubscriptionButtons())
.catch(() => flash(__('There was an error when subscribing to this label.')));
}
toggleSubscriptionButtons() {
......
import Flash from '../flash';
import axios from '../lib/utils/axios_utils';
import flash from '../flash';
export default class IntegrationSettingsForm {
constructor(formSelector) {
......@@ -95,29 +96,26 @@ export default class IntegrationSettingsForm {
*/
testSettings(formData) {
this.toggleSubmitBtnState(true);
$.ajax({
type: 'PUT',
url: this.testEndPoint,
data: formData,
})
.done((res) => {
if (res.error) {
new Flash(`${res.message} ${res.service_response}`, 'alert', document, {
title: 'Save anyway',
clickHandler: (e) => {
e.preventDefault();
this.$form.submit();
},
});
} else {
this.$form.submit();
}
})
.fail(() => {
new Flash('Something went wrong on our end.');
})
.always(() => {
this.toggleSubmitBtnState(false);
});
return axios.put(this.testEndPoint, formData)
.then(({ data }) => {
if (data.error) {
flash(`${data.message} ${data.service_response}`, 'alert', document, {
title: 'Save anyway',
clickHandler: (e) => {
e.preventDefault();
this.$form.submit();
},
});
} else {
this.$form.submit();
}
this.toggleSubmitBtnState(false);
})
.catch(() => {
flash('Something went wrong on our end.');
this.toggleSubmitBtnState(false);
});
}
}
/* eslint-disable comma-dangle, quotes, consistent-return, func-names, array-callback-return, space-before-function-paren, prefer-arrow-callback, max-len, no-unused-expressions, no-sequences, no-underscore-dangle, no-unused-vars, no-param-reassign */
import _ from 'underscore';
import axios from './lib/utils/axios_utils';
import Flash from './flash';
export default {
......@@ -22,15 +23,9 @@ export default {
},
submit() {
const _this = this;
const xhr = $.ajax({
url: this.form.attr('action'),
method: this.form.attr('method'),
dataType: 'JSON',
data: this.getFormDataAsObject()
});
xhr.done(() => window.location.reload());
xhr.fail(() => this.onFormSubmitFailure());
axios[this.form.attr('method')](this.form.attr('action'), this.getFormDataAsObject())
.then(() => window.location.reload())
.catch(() => this.onFormSubmitFailure());
},
onFormSubmitFailure() {
......
import axios from './lib/utils/axios_utils';
import flash from './flash';
import { __ } from './locale';
import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
......@@ -20,23 +23,24 @@ export default class IssuableIndex {
}
static resetIncomingEmailToken() {
$('.incoming-email-token-reset').on('click', (e) => {
const $resetToken = $('.incoming-email-token-reset');
$resetToken.on('click', (e) => {
e.preventDefault();
$.ajax({
type: 'PUT',
url: $('.incoming-email-token-reset').attr('href'),
dataType: 'json',
success(response) {
$('#issuable_email').val(response.new_address).focus();
},
beforeSend() {
$('.incoming-email-token-reset').text('resetting...');
},
complete() {
$('.incoming-email-token-reset').text('reset it');
},
});
$resetToken.text('resetting...');
axios.put($resetToken.attr('href'))
.then(({ data }) => {
$('#issuable_email').val(data.new_address).focus();
$resetToken.text('reset it');
})
.catch(() => {
flash(__('There was an error when reseting email token.'));
$resetToken.text('reset it');
});
});
}
}
/* eslint-disable comma-dangle, class-methods-use-this, no-underscore-dangle, no-param-reassign, no-unused-vars, consistent-return, func-names, space-before-function-paren, max-len */
import Sortable from 'vendor/Sortable';
import Flash from './flash';
import flash from './flash';
import axios from './lib/utils/axios_utils';
export default class LabelManager {
constructor({ togglePriorityButton, prioritizedLabels, otherLabels } = {}) {
......@@ -50,11 +51,12 @@ export default class LabelManager {
if (persistState == null) {
persistState = true;
}
let xhr;
const _this = this;
const url = $label.find('.js-toggle-priority').data('url');
let $target = this.prioritizedLabels;
let $from = this.otherLabels;
const rollbackLabelPosition = this.rollbackLabelPosition.bind(this, $label, action);
if (action === 'remove') {
$target = this.otherLabels;
$from = this.prioritizedLabels;
......@@ -71,40 +73,34 @@ export default class LabelManager {
return;
}
if (action === 'remove') {
xhr = $.ajax({
url,
type: 'DELETE'
});
axios.delete(url)
.catch(rollbackLabelPosition);
// Restore empty message
if (!$from.find('li').length) {
$from.find('.empty-message').removeClass('hidden');
}
} else {
xhr = this.savePrioritySort($label, action);
this.savePrioritySort($label, action)
.catch(rollbackLabelPosition);
}
return xhr.fail(this.rollbackLabelPosition.bind(this, $label, action));
}
onPrioritySortUpdate() {
const xhr = this.savePrioritySort();
return xhr.fail(function() {
return new Flash(this.errorMessage, 'alert');
});
this.savePrioritySort()
.catch(() => flash(this.errorMessage));
}
savePrioritySort() {
return $.post({
url: this.prioritizedLabels.data('url'),
data: {
label_ids: this.getSortedLabelsIds()
}
return axios.post(this.prioritizedLabels.data('url'), {
label_ids: this.getSortedLabelsIds(),
});
}
rollbackLabelPosition($label, originalAction) {
const action = originalAction === 'remove' ? 'add' : 'remove';
this.toggleLabelPriority($label, action, false);
return new Flash(this.errorMessage, 'alert');
flash(this.errorMessage);
}
getSortedLabelsIds() {
......
/* eslint-disable func-names, prefer-arrow-callback, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, one-var-declaration-per-line, consistent-return, no-param-reassign, max-len */
import { __ } from './locale';
import axios from './lib/utils/axios_utils';
import createFlash from './flash';
import FilesCommentButton from './files_comment_button';
import imageDiffHelper from './image_diff/helpers/index';
import syntaxHighlight from './syntax_highlight';
......@@ -60,30 +63,31 @@ export default class SingleFileDiff {
getContentHTML(cb) {
this.collapsedContent.hide();
this.loadingContent.show();
$.get(this.diffForPath, (function(_this) {
return function(data) {
_this.loadingContent.hide();
axios.get(this.diffForPath)
.then(({ data }) => {
this.loadingContent.hide();
if (data.html) {
_this.content = $(data.html);
syntaxHighlight(_this.content);
this.content = $(data.html);
syntaxHighlight(this.content);
} else {
_this.hasError = true;
_this.content = $(ERROR_HTML);
this.hasError = true;
this.content = $(ERROR_HTML);
}
_this.collapsedContent.after(_this.content);
this.collapsedContent.after(this.content);
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
gl.diffNotesCompileComponents();
}
const $file = $(_this.file);
const $file = $(this.file);
FilesCommentButton.init($file);
const canCreateNote = $file.closest('.files').is('[data-can-create-note]');
imageDiffHelper.initImageDiff($file[0], canCreateNote);
if (cb) cb();
};
})(this));
})
.catch(createFlash(__('An error occurred while retrieving diff')));
}
}
......@@ -785,10 +785,6 @@
}
.mr-widget-code-quality {
.ci-status-icon-warning svg {
fill: $theme-gray-600;
}
.code-quality-container {
border-top: 1px solid $gray-darker;
padding: $gl-padding-top;
......@@ -796,7 +792,7 @@
margin: $gl-padding -16px -16px;
.mr-widget-code-quality-info {
padding-left: 12px;
padding-left: 10px;
}
.mr-widget-dast-code {
......@@ -805,34 +801,29 @@
.mr-widget-code-quality-list {
list-style: none;
padding: 0 12px;
padding: 0 2px 0 0;
margin: 0;
line-height: $code_line_height;
.mr-widget-code-quality-icon {
margin-right: 12px;
fill: currentColor;
svg {
width: 10px;
height: 10px;
}
.mr-widget-code-quality-list-item {
display: flex;
}
.success {
color: $green-500;
.failed .mr-widget-code-quality-icon {
color: $red-500;
}
.failed {
color: $red-500;
.success .mr-widget-code-quality-icon {
color: $green-500;
}
.neutral {
color: $gl-gray-light;
.neutral .mr-widget-code-quality-icon {
color: $theme-gray-700;
}
.modal-body {
color: $gl-text-color;
.mr-widget-code-quality-icon {
margin: -5px 4px 0 0;
fill: currentColor;
}
}
}
......
......@@ -645,12 +645,12 @@ class MergeRequest < ActiveRecord::Base
can_be_merged? && !should_be_rebased?
end
def mergeable_state?(skip_ci_check: false)
def mergeable_state?(skip_ci_check: false, skip_discussions_check: false)
return false unless open?
return false if work_in_progress?
return false if broken?
return false unless skip_ci_check || mergeable_ci_state?
return false unless mergeable_discussions_state?
return false unless skip_discussions_check || mergeable_discussions_state?
true
end
......
......@@ -67,7 +67,18 @@ class MergeRequestWidgetEntity < IssuableEntity
expose :merge_ongoing?, as: :merge_ongoing
expose :work_in_progress?, as: :work_in_progress
expose :source_branch_exists?, as: :source_branch_exists
expose :mergeable_discussions_state?, as: :mergeable_discussions_state
expose :mergeable_discussions_state?, as: :mergeable_discussions_state do |merge_request|
# This avoids calling MergeRequest#mergeable_discussions_state without
# considering the state of the MR first. If a MR isn't mergeable, we can
# safely short-circuit it.
if merge_request.mergeable_state?(skip_ci_check: true, skip_discussions_check: true)
merge_request.mergeable_discussions_state?
else
false
end
end
expose :branch_missing?, as: :branch_missing
expose :commits_count
expose :cannot_be_merged?, as: :has_conflicts
......
%li.header-new.dropdown
= link_to new_project_path, class: "header-new-dropdown-toggle has-tooltip", title: "New...", ref: 'tooltip', aria: { label: "New..." }, data: { toggle: 'dropdown', placement: 'bottom', container: 'body' } do
= link_to new_project_path, class: "header-new-dropdown-toggle has-tooltip qa-new-menu-toggle", title: "New...", ref: 'tooltip', aria: { label: "New..." }, data: { toggle: 'dropdown', placement: 'bottom', container: 'body' } do
= sprite_icon('plus-square', size: 16)
= sprite_icon('angle-down', css_class: 'caret-down')
.dropdown-menu-nav.dropdown-menu-align-right
......
......@@ -32,5 +32,5 @@
= icon("pencil")
- if can?(current_user, :admin_project, @project)
= link_to project_tag_path(@project, tag.name), class: "btn btn-remove remove-row has-tooltip #{protected_tag?(@project, tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), method: :delete, data: { confirm: s_('TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?') % { tag_name: tag.name }, container: 'body' }, remote: true do
= link_to project_tag_path(@project, tag.name), class: "btn btn-remove remove-row has-tooltip prepend-left-10 #{protected_tag?(@project, tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), method: :delete, data: { confirm: s_('TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?') % { tag_name: tag.name }, container: 'body' }, remote: true do
= icon("trash-o")
---
title: Update UI for merge widget reports
merge_request:
author:
type: changed
---
title: Include epics from subgroups on Epic index page
merge_request:
author:
type: fixed
---
title: Adds spacing between edit and delete tag btn in tag list
merge_request: 16757
author: Jacopo Beschi @jacopo-beschi
type: fixed
---
title: Stop checking if discussions are in a mergeable state if the MR isn't
merge_request:
author:
type: performance
if defined?(GrapeRouteHelpers)
module GrapeRouteHelpers
module AllRoutes
# Bringing in PR https://github.com/reprah/grape-route-helpers/pull/21 due to abandonment.
#
# Without the following fix, when two helper methods are the same, but have different arguments
# (for example: api_v1_cats_owners_path(id: 1) vs api_v1_cats_owners_path(id: 1, owner_id: 2))
# if the helper method with the least number of arguments is defined first (because the route was defined first)
# then it will shadow the longer route.
#
# The fix is to sort descending by amount of arguments
def decorated_routes
@decorated_routes ||= all_routes
.map { |r| DecoratedRoute.new(r) }
.sort_by { |r| -r.dynamic_path_segments.count }
end
end
class DecoratedRoute
# GrapeRouteHelpers gem tries to parse the versions
# from a string, not supporting Grape `version` array definition.
......
......@@ -12,7 +12,7 @@ The [epic issues API](epic_issues.md) allows you to interact with issues associa
## List epics for a group
Gets all epics of the requested group.
Gets all epics of the requested group and its subgroups.
```
GET /groups/:id/-/epics
......
......@@ -68,7 +68,7 @@ Example response:
```json
{
"file_name": "app/project.rb",
"file_path": "app/project.rb",
"branch": "master"
}
```
......@@ -98,7 +98,7 @@ Example response:
```json
{
"file_name": "app/project.rb",
"file_path": "app/project.rb",
"branch": "master"
}
```
......@@ -134,15 +134,6 @@ DELETE /projects/:id/repository/files/:file_path
curl --request DELETE --header 'PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK' 'https://gitlab.example.com/api/v4/projects/13083/repository/files/app%2Fproject%2Erb?branch=master&author_email=author%40example.com&author_name=Firstname%20Lastname&commit_message=delete%20file'
```
Example response:
```json
{
"file_name": "app/project.rb",
"branch": "master"
}
```
Parameters:
- `file_path` (required) - Url encoded full path to new file. Ex. lib%2Fclass%2Erb
......
......@@ -9,7 +9,8 @@ milestones.
## Creating an epic
A paginated list of epics is available in each group from where you can create
a new epic. From your group page:
a new epic. The list of epics includes also epics from all subgroups of the
selected group. From your group page:
1. Go to **Epics**
1. Click the **New epic** button at the top right
......
......@@ -18,7 +18,7 @@ When you create a new [project](../../index.md), GitLab sets `master` as the def
branch for your project. You can choose another branch to be your project's
default under your project's **Settings > General**.
The default branch is the branched affected by the
The default branch is the branch affected by the
[issue closing pattern](../../issues/automatic_issue_closing.md),
which means that _an issue will be closed when a merge request is merged to
the **default branch**_.
......
<script>
<<<<<<< HEAD
=======
import { __ } from '~/locale';
>>>>>>> master
import statusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue';
import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import issuesBlock from './mr_widget_report_issues.vue';
......@@ -67,7 +71,7 @@
data() {
return {
collapseText: 'Expand',
collapseText: __('Expand'),
isCollapsed: true,
isFullReportVisible: false,
};
......@@ -100,7 +104,7 @@
toggleCollapsed() {
this.isCollapsed = !this.isCollapsed;
const text = this.isCollapsed ? 'Expand' : 'Collapse';
const text = this.isCollapsed ? __('Expand') : __('Collapse');
this.collapseText = text;
},
openFullReport() {
......@@ -114,28 +118,40 @@
<div
v-if="isLoading"
class="media">
<div class="mr-widget-icon">
class="media"
>
<div
class="mr-widget-icon"
>
<loading-icon />
</div>
<div class="media-body">
<div
class="media-body"
>
{{ loadingText }}
</div>
</div>
<div
v-else-if="isSuccess"
class="media">
<status-icon :status="statusIconName" />
class="media"
>
<status-icon
:status="statusIconName"
/>
<div class="media-body space-children">
<span class="js-code-text">
<div
class="media-body space-children"
>
<span
class="js-code-text"
>
{{ successText }}
</span>
<button
type="button"
class="btn-link btn-blank"
class="btn pull-right btn-sm"
v-if="hasIssues"
@click="toggleCollapsed"
>
......
<script>
import { s__ } from '~/locale';
import { spriteIcon } from '~/lib/utils/common_utils';
import icon from '~/vue_shared/components/icon.vue';
import modal from './mr_widget_dast_modal.vue';
const modalDefaultData = {
......@@ -15,6 +15,7 @@
name: 'MrWidgetReportIssues',
components: {
modal,
icon,
},
props: {
issues: {
......@@ -41,15 +42,18 @@
return modalDefaultData;
},
computed: {
icon() {
return this.isStatusSuccess ? spriteIcon('plus') : this.cutIcon;
},
cutIcon() {
return spriteIcon('cut');
},
fixedLabel() {
return s__('ciReport|Fixed:');
},
iconName() {
if (this.isStatusFailed) {
return 'status_failed_borderless';
} else if (this.isStatusSuccess) {
return 'status_success_borderless';
}
return 'status_created_borderless';
},
isStatusFailed() {
return this.status === 'failed';
},
......@@ -114,15 +118,15 @@
success: isStatusSuccess,
neutral: isStatusNeutral
}"
class="mr-widget-code-quality-list-item"
v-for="(issue, index) in issues"
:key="index"
>
<span
<icon
class="mr-widget-code-quality-icon"
v-html="icon"
>
</span>
:name="iconName"
:size="32"
/>
<template v-if="isStatusSuccess && isTypeQuality">{{ fixedLabel }}</template>
<template v-if="shouldRenderPriority(issue)">{{ issue.priority }}:</template>
......
......@@ -10,7 +10,6 @@ class EpicsFinder < IssuableFinder
items = by_created_at(items)
items = by_search(items)
items = by_author(items)
items = by_iids(items)
sort(items)
end
......@@ -37,6 +36,16 @@ class EpicsFinder < IssuableFinder
end
def init_collection
group.epics
groups = groups_user_can_read_epics(group.self_and_descendants)
Epic.where(group: groups)
end
private
def groups_user_can_read_epics(groups)
DeclarativePolicy.user_scope do
groups.select { |g| Ability.allowed?(current_user, :read_epic, g) }
end
end
end
......@@ -7,8 +7,7 @@
= link_to epic.title, epic_path(epic)
.issuable-info
%span.issuable-reference
-# TODO: Use to_reference
= "&#{epic.iid}"
= epic.to_reference(@group)
%span.issuable-authored.hidden-xs
&middot;
opened #{time_ago_with_tooltip(epic.created_at, placement: 'bottom')}
......
......@@ -40,7 +40,7 @@ module API
success Entities::Epic
end
get ':id/-/epics' do
present user_group.epics, with: Entities::Epic
present EpicsFinder.new(current_user, group_id: user_group.id).execute, with: Entities::Epic
end
desc 'Get details of an epic' do
......
......@@ -27,6 +27,7 @@ module QA
module Resource
autoload :Sandbox, 'qa/factory/resource/sandbox'
autoload :Group, 'qa/factory/resource/group'
autoload :Issue, 'qa/factory/resource/issue'
autoload :Project, 'qa/factory/resource/project'
autoload :MergeRequest, 'qa/factory/resource/merge_request'
autoload :DeployKey, 'qa/factory/resource/deploy_key'
......@@ -130,6 +131,12 @@ module QA
autoload :SecretVariables, 'qa/page/project/settings/secret_variables'
autoload :Runners, 'qa/page/project/settings/runners'
end
module Issue
autoload :New, 'qa/page/project/issue/new'
autoload :Show, 'qa/page/project/issue/show'
autoload :Index, 'qa/page/project/issue/index'
end
end
module Profile
......
require 'securerandom'
module QA
module Factory
module Resource
class Issue < Factory::Base
attr_writer :title, :description, :project
dependency Factory::Resource::Project, as: :project do |project|
project.name = 'project-for-issues'
project.description = 'project for adding issues'
end
product :title do
Page::Project::Issue::Show.act { issue_title }
end
def fabricate!
project.visit!
Page::Project::Show.act do
go_to_new_issue
end
Page::Project::Issue::New.perform do |page|
page.add_title(@title)
page.add_description(@description)
page.create_new_issue
end
end
end
end
end
end
......@@ -42,6 +42,23 @@ module QA
page.within(selector) { yield } if block_given?
end
# Returns true if successfully GETs the given URL
# Useful because `page.status_code` is unsupported by our driver, and
# we don't have access to the `response` to use `have_http_status`.
def asset_exists?(url)
page.execute_script <<~JS
xhr = new XMLHttpRequest();
xhr.open('GET', '#{url}', true);
xhr.send();
JS
return false unless wait(time: 0.5, max: 60, reload: false) do
page.evaluate_script('xhr.readyState == XMLHttpRequest.DONE')
end
page.evaluate_script('xhr.status') == 200
end
def find_element(name)
find(element_selector_css(name))
end
......@@ -80,6 +97,21 @@ module QA
views.map(&:errors).flatten
end
# Not tested and not expected to work with multiple dropzones
# instantiated on one page because there is no distinguishing
# attribute per dropzone file field.
def attach_file_to_dropzone(attachment, dropzone_form_container)
filename = File.basename(attachment)
field_style = { visibility: 'visible', height: '', width: '' }
attach_file(attachment, class: 'dz-hidden-input', make_visible: field_style)
# Wait for link to be appended to dropzone text
wait(reload: false) do
find("#{dropzone_form_container} textarea").value.match(filename)
end
end
class DSL
attr_reader :views
......
......@@ -2,12 +2,16 @@ module QA
module Page
module Group
class Show < Page::Base
##
# TODO, define all selectors required by this page object
#
# See gitlab-org/gitlab-qa#154
#
view 'app/views/groups/show.html.haml'
view 'app/views/groups/show.html.haml' do
element :new_project_or_subgroup_dropdown, '.new-project-subgroup'
element :new_project_or_subgroup_dropdown_toggle, '.dropdown-toggle'
element :new_project_option, /%li.*data:.*value: "new-project"/
element :new_project_button, /%input.*data:.*action: "new-project"/
element :new_subgroup_option, /%li.*data:.*value: "new-subgroup"/
# data-value and data-action get modified by JS for subgroup
element :new_subgroup_button, /%input.*\.js-new-group-child/
end
def go_to_subgroup(name)
click_link name
......
......@@ -7,6 +7,8 @@ module QA
element :settings_link, 'link_to edit_project_path'
element :repository_link, "title: 'Repository'"
element :pipelines_settings_link, "title: 'CI / CD'"
element :issues_link, %r{link_to.*shortcuts-issues}
element :issues_link_text, "Issues"
element :top_level_items, '.sidebar-top-level-items'
element :activity_link, "title: 'Activity'"
end
......@@ -43,6 +45,12 @@ module QA
end
end
def click_issues
within_sidebar do
click_link('Issues')
end
end
private
def hover_settings
......
module QA
module Page
module Project
module Issue
class Index < Page::Base
view 'app/views/projects/issues/_issue.html.haml' do
element :issue_link, 'link_to issue.title'
end
def go_to_issue(title)
click_link(title)
end
end
end
end
end
end
module QA
module Page
module Project
module Issue
class New < Page::Base
view 'app/views/shared/issuable/_form.html.haml' do
element :submit_issue_button, 'form.submit "Submit'
end
view 'app/views/shared/issuable/form/_title.html.haml' do
element :issue_title_textbox, 'form.text_field :title'
end
view 'app/views/shared/form_elements/_description.html.haml' do
element :issue_description_textarea, "render 'projects/zen', f: form, attr: :description"
end
def add_title(title)
fill_in 'issue_title', with: title
end
def add_description(description)
fill_in 'issue_description', with: description
end
def create_new_issue
click_on 'Submit issue'
end
end
end
end
end
end
module QA
module Page
module Project
module Issue
class Show < Page::Base
view 'app/views/projects/issues/show.html.haml' do
element :issue_details, '.issue-details'
element :title, '.title'
end
view 'app/views/shared/notes/_form.html.haml' do
element :new_note_form, 'new-note'
element :new_note_form, 'attr: :note'
end
view 'app/views/shared/notes/_comment_button.html.haml' do
element :comment_button, '%strong Comment'
end
def issue_title
find('.issue-details .title').text
end
# Adds a comment to an issue
# attachment option should be an absolute path
def comment(text, attachment:)
fill_in(with: text, name: 'note[note]')
attach_file_to_dropzone(attachment, '.new-note') if attachment
click_on 'Comment'
end
end
end
end
end
end
......@@ -17,6 +17,11 @@ module QA
element :project_name
end
view 'app/views/layouts/header/_new_dropdown.haml' do
element :new_menu_toggle
element :new_issue_link, "link_to 'New issue', new_project_issue_path(@project)"
end
def choose_repository_clone_http
wait(reload: false) do
click_element :clone_dropdown
......@@ -46,6 +51,12 @@ module QA
sleep 5
refresh
end
def go_to_new_issue
click_element :new_menu_toggle
click_link 'New issue'
end
end
end
end
......
module QA
feature 'GitLab Geo attachment replication', :geo do
let(:file_to_attach) { File.absolute_path(File.join('spec', 'fixtures', 'banana_sample.gif')) }
scenario 'user uploads attachment to the primary node' do
Runtime::Browser.visit(:geo_primary, QA::Page::Main::Login) do
Page::Main::Login.act { sign_in_using_credentials }
project = Factory::Resource::Project.fabricate! do |project|
project.name = 'project-for-issues'
project.description = 'project for adding issues'
end
issue = Factory::Resource::Issue.fabricate! do |issue|
issue.title = 'My geo issue'
issue.project = project
end
Page::Project::Issue::Show.perform do |show|
show.comment('See attached banana for scale', attachment: file_to_attach)
end
Runtime::Browser.visit(:geo_secondary, QA::Page::Main::Login) do |session|
Page::Main::OAuth.act do
authorize! if needs_authorization?
end
expect(page).to have_content 'You are on a secondary (read-only) Geo node'
Page::Menu::Main.perform do |menu|
menu.go_to_projects
end
Page::Dashboard::Projects.perform do |dashboard|
dashboard.go_to_project(project.name)
end
Page::Menu::Side.act { click_issues }
Page::Project::Issue::Index.perform do |index|
index.go_to_issue(issue.title)
end
image_url = find('a[href$="banana_sample.gif"]')[:href]
Page::Project::Issue::Show.perform do |show|
# Wait for attachment replication
found = show.wait(reload: false) do
show.asset_exists?(image_url)
end
expect(found).to be_truthy
end
end
end
end
end
end
module QA
feature 'GitLab Geo replication', :geo do
feature 'GitLab Geo repository replication', :geo do
scenario 'users pushes code to the primary node' do
Runtime::Browser.visit(:geo_primary, QA::Page::Main::Login) do
Page::Main::Login.act { sign_in_using_credentials }
......
......@@ -35,7 +35,6 @@ tasks = [
%w[bundle exec rubocop --parallel],
%w[bundle exec rake gettext:lint],
%w[bundle exec rake lint:static_verification],
%w[scripts/lint-changelog-yaml],
%w[scripts/lint-conflicts.sh],
%w[scripts/lint-rugged]
]
......
......@@ -44,7 +44,7 @@ describe EpicsFinder do
end
end
context 'wtih correct params' do
context 'with correct params' do
before do
group.add_developer(search_user)
end
......@@ -79,9 +79,14 @@ describe EpicsFinder do
end
end
context 'by iids' do
it 'returns all epics by the given iids' do
expect(epics(iids: [epic1.iid, epic3.iid])).to contain_exactly(epic1, epic3)
context 'when subgroups are supported', :nested_groups do
let(:subgroup) { create(:group, :private, parent: group) }
let(:subgroup2) { create(:group, :private, parent: subgroup) }
let!(:subepic1) { create(:epic, group: subgroup) }
let!(:subepic2) { create(:epic, group: subgroup2) }
it 'returns all epics that belong to the given group and its subgroups' do
expect(epics).to contain_exactly(epic1, epic2, epic3, subepic1, subepic2)
end
end
end
......
......@@ -112,13 +112,6 @@ feature 'Expand and collapse diffs', :js do
wait_for_requests
end
it 'makes a request to get the content' do
ajax_uris = evaluate_script('ajaxUris')
expect(ajax_uris).not_to be_empty
expect(ajax_uris.first).to include('large_diff.md')
end
it 'shows the diff content' do
expect(large_diff).to have_selector('.code')
expect(large_diff).not_to have_selector('.nothing-here-block')
......
require 'spec_helper'
require_relative '../../config/initializers/grape_route_helpers_fix'
describe 'route shadowing' do
include GrapeRouteHelpers::NamedRouteMatcher
it 'does not occur' do
path = api_v4_projects_merge_requests_path(id: 1)
expect(path).to eq('/api/v4/projects/1/merge_requests')
path = api_v4_projects_merge_requests_path(id: 1, merge_request_iid: 3)
expect(path).to eq('/api/v4/projects/1/merge_requests/3')
end
end
......@@ -18,54 +18,67 @@ describe('CreateItemDropdown', () => {
preloadFixtures('static/create_item_dropdown.html.raw');
let $wrapperEl;
let createItemDropdown;
function createItemAndClearInput(text) {
// Filter for the new item
$wrapperEl.find('.dropdown-input-field')
.val(text)
.trigger('input');
// Create the new item
const $createButton = $wrapperEl.find('.js-dropdown-create-new-item');
$createButton.click();
// Clear out the filter
$wrapperEl.find('.dropdown-input-field')
.val('')
.trigger('input');
}
beforeEach(() => {
loadFixtures('static/create_item_dropdown.html.raw');
$wrapperEl = $('.js-create-item-dropdown-fixture-root');
// eslint-disable-next-line no-new
new CreateItemDropdown({
$dropdown: $wrapperEl.find('.js-dropdown-menu-toggle'),
defaultToggleLabel: 'All variables',
fieldName: 'variable[environment]',
getData: (term, callback) => {
callback(DROPDOWN_ITEM_DATA);
},
});
});
afterEach(() => {
$wrapperEl.remove();
});
it('should have a dropdown item for each piece of data', () => {
// Get the data in the dropdown
$('.js-dropdown-menu-toggle').click();
describe('items', () => {
beforeEach(() => {
createItemDropdown = new CreateItemDropdown({
$dropdown: $wrapperEl.find('.js-dropdown-menu-toggle'),
defaultToggleLabel: 'All variables',
fieldName: 'variable[environment]',
getData: (term, callback) => {
callback(DROPDOWN_ITEM_DATA);
},
});
});
it('should have a dropdown item for each piece of data', () => {
// Get the data in the dropdown
$('.js-dropdown-menu-toggle').click();
const $itemEls = $wrapperEl.find('.js-dropdown-content a');
expect($itemEls.length).toEqual(DROPDOWN_ITEM_DATA.length);
const $itemEls = $wrapperEl.find('.js-dropdown-content a');
expect($itemEls.length).toEqual(DROPDOWN_ITEM_DATA.length);
});
});
describe('created items', () => {
const NEW_ITEM_TEXT = 'foobarbaz';
function createItemAndClearInput(text) {
// Filter for the new item
$wrapperEl.find('.dropdown-input-field')
.val(text)
.trigger('input');
// Create the new item
const $createButton = $wrapperEl.find('.js-dropdown-create-new-item');
$createButton.click();
// Clear out the filter
$wrapperEl.find('.dropdown-input-field')
.val('')
.trigger('input');
}
beforeEach(() => {
createItemDropdown = new CreateItemDropdown({
$dropdown: $wrapperEl.find('.js-dropdown-menu-toggle'),
defaultToggleLabel: 'All variables',
fieldName: 'variable[environment]',
getData: (term, callback) => {
callback(DROPDOWN_ITEM_DATA);
},
});
// Open the dropdown
$('.js-dropdown-menu-toggle').click();
......@@ -103,4 +116,68 @@ describe('CreateItemDropdown', () => {
expect($itemEls.length).toEqual(DROPDOWN_ITEM_DATA.length);
});
});
describe('clearDropdown()', () => {
beforeEach(() => {
createItemDropdown = new CreateItemDropdown({
$dropdown: $wrapperEl.find('.js-dropdown-menu-toggle'),
defaultToggleLabel: 'All variables',
fieldName: 'variable[environment]',
getData: (term, callback) => {
callback(DROPDOWN_ITEM_DATA);
},
});
});
it('should clear all data and filter input', () => {
const filterInput = $wrapperEl.find('.dropdown-input-field');
// Get the data in the dropdown
$('.js-dropdown-menu-toggle').click();
// Filter for an item
filterInput
.val('one')
.trigger('input');
const $itemElsAfterFilter = $wrapperEl.find('.js-dropdown-content a');
expect($itemElsAfterFilter.length).toEqual(1);
createItemDropdown.clearDropdown();
const $itemElsAfterClear = $wrapperEl.find('.js-dropdown-content a');
expect($itemElsAfterClear.length).toEqual(0);
expect(filterInput.val()).toEqual('');
});
});
describe('createNewItemFromValue option', () => {
beforeEach(() => {
createItemDropdown = new CreateItemDropdown({
$dropdown: $wrapperEl.find('.js-dropdown-menu-toggle'),
defaultToggleLabel: 'All variables',
fieldName: 'variable[environment]',
getData: (term, callback) => {
callback(DROPDOWN_ITEM_DATA);
},
createNewItemFromValue: newValue => ({
title: `${newValue}-title`,
id: `${newValue}-id`,
text: `${newValue}-text`,
}),
});
});
it('all items go through createNewItemFromValue', () => {
// Get the data in the dropdown
$('.js-dropdown-menu-toggle').click();
createItemAndClearInput('new-item');
const $itemEls = $wrapperEl.find('.js-dropdown-content a');
expect($itemEls.length).toEqual(1 + DROPDOWN_ITEM_DATA.length);
expect($($itemEls[3]).text()).toEqual('new-item-text');
expect($wrapperEl.find('.dropdown-toggle-text').text()).toEqual('new-item-title');
});
});
});
import MockAdaptor from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import IntegrationSettingsForm from '~/integrations/integration_settings_form';
describe('IntegrationSettingsForm', () => {
......@@ -109,91 +111,117 @@ describe('IntegrationSettingsForm', () => {
describe('testSettings', () => {
let integrationSettingsForm;
let formData;
let mock;
beforeEach(() => {
mock = new MockAdaptor(axios);
spyOn(axios, 'put').and.callThrough();
integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
formData = integrationSettingsForm.$form.serialize();
});
it('should make an ajax request with provided `formData`', () => {
const deferred = $.Deferred();
spyOn($, 'ajax').and.returnValue(deferred.promise());
afterEach(() => {
mock.restore();
});
integrationSettingsForm.testSettings(formData);
it('should make an ajax request with provided `formData`', (done) => {
integrationSettingsForm.testSettings(formData)
.then(() => {
expect(axios.put).toHaveBeenCalledWith(integrationSettingsForm.testEndPoint, formData);
expect($.ajax).toHaveBeenCalledWith({
type: 'PUT',
url: integrationSettingsForm.testEndPoint,
data: formData,
});
done();
})
.catch(done.fail);
});
it('should show error Flash with `Save anyway` action if ajax request responds with error in test', () => {
it('should show error Flash with `Save anyway` action if ajax request responds with error in test', (done) => {
const errorMessage = 'Test failed.';
const deferred = $.Deferred();
spyOn($, 'ajax').and.returnValue(deferred.promise());
integrationSettingsForm.testSettings(formData);
mock.onPut(integrationSettingsForm.testEndPoint).reply(200, {
error: true,
message: errorMessage,
service_response: 'some error',
});
deferred.resolve({ error: true, message: errorMessage, service_response: 'some error' });
integrationSettingsForm.testSettings(formData)
.then(() => {
const $flashContainer = $('.flash-container');
expect($flashContainer.find('.flash-text').text().trim()).toEqual('Test failed. some error');
expect($flashContainer.find('.flash-action')).toBeDefined();
expect($flashContainer.find('.flash-action').text().trim()).toEqual('Save anyway');
const $flashContainer = $('.flash-container');
expect($flashContainer.find('.flash-text').text().trim()).toEqual('Test failed. some error');
expect($flashContainer.find('.flash-action')).toBeDefined();
expect($flashContainer.find('.flash-action').text().trim()).toEqual('Save anyway');
done();
})
.catch(done.fail);
});
it('should submit form if ajax request responds without any error in test', () => {
const deferred = $.Deferred();
spyOn($, 'ajax').and.returnValue(deferred.promise());
it('should submit form if ajax request responds without any error in test', (done) => {
spyOn(integrationSettingsForm.$form, 'submit');
integrationSettingsForm.testSettings(formData);
mock.onPut(integrationSettingsForm.testEndPoint).reply(200, {
error: false,
});
spyOn(integrationSettingsForm.$form, 'submit');
deferred.resolve({ error: false });
integrationSettingsForm.testSettings(formData)
.then(() => {
expect(integrationSettingsForm.$form.submit).toHaveBeenCalled();
expect(integrationSettingsForm.$form.submit).toHaveBeenCalled();
done();
})
.catch(done.fail);
});
it('should submit form when clicked on `Save anyway` action of error Flash', () => {
const errorMessage = 'Test failed.';
const deferred = $.Deferred();
spyOn($, 'ajax').and.returnValue(deferred.promise());
it('should submit form when clicked on `Save anyway` action of error Flash', (done) => {
spyOn(integrationSettingsForm.$form, 'submit');
integrationSettingsForm.testSettings(formData);
const errorMessage = 'Test failed.';
mock.onPut(integrationSettingsForm.testEndPoint).reply(200, {
error: true,
message: errorMessage,
});
deferred.resolve({ error: true, message: errorMessage });
integrationSettingsForm.testSettings(formData)
.then(() => {
const $flashAction = $('.flash-container .flash-action');
expect($flashAction).toBeDefined();
const $flashAction = $('.flash-container .flash-action');
expect($flashAction).toBeDefined();
$flashAction.get(0).click();
})
.then(() => {
expect(integrationSettingsForm.$form.submit).toHaveBeenCalled();
spyOn(integrationSettingsForm.$form, 'submit');
$flashAction.get(0).click();
expect(integrationSettingsForm.$form.submit).toHaveBeenCalled();
done();
})
.catch(done.fail);
});
it('should show error Flash if ajax request failed', () => {
it('should show error Flash if ajax request failed', (done) => {
const errorMessage = 'Something went wrong on our end.';
const deferred = $.Deferred();
spyOn($, 'ajax').and.returnValue(deferred.promise());
integrationSettingsForm.testSettings(formData);
mock.onPut(integrationSettingsForm.testEndPoint).networkError();
deferred.reject();
integrationSettingsForm.testSettings(formData)
.then(() => {
expect($('.flash-container .flash-text').text().trim()).toEqual(errorMessage);
expect($('.flash-container .flash-text').text().trim()).toEqual(errorMessage);
done();
})
.catch(done.fail);
});
it('should always call `toggleSubmitBtnState` with `false` once request is completed', () => {
const deferred = $.Deferred();
spyOn($, 'ajax').and.returnValue(deferred.promise());
integrationSettingsForm.testSettings(formData);
it('should always call `toggleSubmitBtnState` with `false` once request is completed', (done) => {
mock.onPut(integrationSettingsForm.testEndPoint).networkError();
spyOn(integrationSettingsForm, 'toggleSubmitBtnState');
deferred.reject();
expect(integrationSettingsForm.toggleSubmitBtnState).toHaveBeenCalledWith(false);
integrationSettingsForm.testSettings(formData)
.then(() => {
expect(integrationSettingsForm.toggleSubmitBtnState).toHaveBeenCalledWith(false);
done();
})
.catch(done.fail);
});
});
});
import MockAdaptor from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import IssuableIndex from '~/issuable_index';
describe('Issuable', () => {
......@@ -19,6 +21,8 @@ describe('Issuable', () => {
});
describe('resetIncomingEmailToken', () => {
let mock;
beforeEach(() => {
const element = document.createElement('a');
element.classList.add('incoming-email-token-reset');
......@@ -30,14 +34,28 @@ describe('Issuable', () => {
document.body.appendChild(input);
Issuable = new IssuableIndex('issue_');
mock = new MockAdaptor(axios);
mock.onPut('foo').reply(200, {
new_address: 'testing123',
});
});
it('should send request to reset email token', () => {
spyOn(jQuery, 'ajax').and.callThrough();
afterEach(() => {
mock.restore();
});
it('should send request to reset email token', (done) => {
spyOn(axios, 'put').and.callThrough();
document.querySelector('.incoming-email-token-reset').click();
expect(jQuery.ajax).toHaveBeenCalled();
expect(jQuery.ajax.calls.argsFor(0)[0].url).toEqual('foo');
setTimeout(() => {
expect(axios.put).toHaveBeenCalledWith('foo');
expect($('#issuable_email').val()).toBe('testing123');
done();
});
});
});
});
......
......@@ -1568,6 +1568,10 @@ describe MergeRequest do
it 'returns false' do
expect(subject.mergeable_state?).to be_falsey
end
it 'returns true when skipping discussions check' do
expect(subject.mergeable_state?(skip_discussions_check: true)).to be(true)
end
end
end
end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment