Commit d6886506 authored by Ahmad Hassan's avatar Ahmad Hassan

Merge remote-tracking branch 'origin/master' into support-gitaly-tls

parents dfc54352 32b6129d
......@@ -33,3 +33,4 @@ rules:
vue/no-unused-components: off
vue/no-use-v-if-with-v-for: off
vue/no-v-html: off
vue/use-v-on-exact: off
......@@ -77,18 +77,6 @@ stages:
- mysql:5.7
- redis:alpine
.rails4: &rails4
allow_failure: false
except:
variables:
- $CI_COMMIT_REF_NAME =~ /(^docs[\/-].*|.*-docs$)/
- $CI_COMMIT_REF_NAME =~ /(^qa[\/-].*|.*-qa$)/
- $CI_COMMIT_REF_NAME =~ /norails4/
- $RAILS5_DISABLED
variables:
BUNDLE_GEMFILE: "Gemfile.rails4"
RAILS5: "false"
# Skip all jobs except the ones that begin with 'docs/'.
# Used for commits including ONLY documentation changes.
# https://docs.gitlab.com/ce/development/documentation/#testing
......@@ -180,18 +168,10 @@ stages:
<<: *rspec-metadata
<<: *use-pg
.rspec-metadata-pg-rails4: &rspec-metadata-pg-rails4
<<: *rspec-metadata-pg
<<: *rails4
.rspec-metadata-mysql: &rspec-metadata-mysql
<<: *rspec-metadata
<<: *use-mysql
.rspec-metadata-mysql-rails4: &rspec-metadata-mysql-rails4
<<: *rspec-metadata-mysql
<<: *rails4
.only-canonical-masters: &only-canonical-masters
only:
- master@gitlab-org/gitlab-ce
......@@ -432,7 +412,6 @@ setup-test-env:
script:
- bundle exec ruby -Ispec -e 'require "spec_helper" ; TestEnv.init'
- scripts/gitaly-test-build # Do not use 'bundle exec' here
- BUNDLE_GEMFILE=Gemfile.rails4 bundle install $BUNDLE_INSTALL_FLAGS
artifacts:
expire_in: 7d
paths:
......@@ -513,14 +492,6 @@ rspec-mysql:
<<: *rspec-metadata-mysql
parallel: 50
rspec-pg-rails4:
<<: *rspec-metadata-pg-rails4
parallel: 50
rspec-mysql-rails4:
<<: *rspec-metadata-mysql-rails4
parallel: 50
static-analysis:
<<: *dedicated-no-docs-no-db-pull-cache-job
dependencies:
......@@ -554,8 +525,7 @@ docs lint:
# Build HTML from Markdown
- bundle exec nanoc
# Check the internal links
# Disabled until https://gitlab.com/gitlab-com/gitlab-docs/issues/305 is resolved
# - bundle exec nanoc check internal_links
- bundle exec nanoc check internal_links
downtime_check:
<<: *rake-exec
......@@ -566,12 +536,6 @@ downtime_check:
- /(^docs[\/-].*|.*-docs$)/
- /(^qa[\/-].*|.*-qa$)/
rails4_gemfile_lock_check:
<<: *dedicated-no-docs-no-db-pull-cache-job
<<: *except-docs-and-qa
script:
- scripts/rails4-gemfile-lock-check
ee_compat_check:
<<: *rake-exec
dependencies: []
......@@ -637,7 +601,7 @@ gitlab:setup-mysql:
# Frontend-related jobs
gitlab:assets:compile:
<<: *dedicated-no-docs-and-no-qa-pull-cache-job
image: dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.5.3-git-2.18-chrome-69.0-node-8.x-yarn-1.2-graphicsmagick-1.3.29-docker-18.06.1
image: dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.5.3-git-2.18-chrome-69.0-node-8.x-yarn-1.12-graphicsmagick-1.3.29-docker-18.06.1
dependencies: []
services:
- docker:stable-dind
......
......@@ -174,3 +174,9 @@ GitlabSecurity/PublicSend:
- 'ee/db/**/*'
- 'ee/lib/**/*.rake'
- 'ee/spec/**/*'
Cop/InjectEnterpriseEditionModule:
Enabled: true
Exclude:
- 'spec/**/*'
- 'ee/spec/**/*'
......@@ -107,12 +107,6 @@ Lint/UriEscapeUnescape:
Metrics/LineLength:
Max: 1310
# Offense count: 2
Naming/ConstantName:
Exclude:
- 'lib/gitlab/import_sources.rb'
- 'lib/gitlab/ssh_public_key.rb'
# Offense count: 11
# Configuration parameters: EnforcedStyle.
# SupportedStyles: lowercase, uppercase
......@@ -155,17 +149,6 @@ RSpec/ExpectChange:
RSpec/ExpectInHook:
Enabled: false
# Offense count: 7
# Configuration parameters: EnforcedStyle.
# SupportedStyles: implicit, each, example
RSpec/HookArgument:
Exclude:
- 'spec/spec_helper.rb'
- 'spec/support/carrierwave.rb'
- 'spec/support/db_cleaner.rb'
- 'spec/support/gitaly.rb'
- 'spec/support/setup_builds_storage.rb'
# Offense count: 19
# Configuration parameters: EnforcedStyle.
# SupportedStyles: it_behaves_like, it_should_behave_like
......
......@@ -628,6 +628,13 @@ entry.
- Check frozen string in style builds. (gfyoung)
## 11.3.13 (2018-12-13)
### Security (1 change)
- Validate LFS hrefs before downloading them.
## 11.3.12 (2018-12-06)
### Security (1 change)
......
# --- Special code for migrating to Rails 5.0 ---
def rails5?
!%w[0 false].include?(ENV["RAILS5"])
end
gem_versions = {}
gem_versions['activerecord_sane_schema_dumper'] = rails5? ? '1.0' : '0.2'
gem_versions['rails'] = rails5? ? '5.0.7' : '4.2.11'
gem_versions['rails-i18n'] = rails5? ? '~> 5.1' : '~> 4.0.9'
# The 2.0.6 version of rack requires monkeypatch to be present in
# `config.ru`. This can be removed once a new update for Rack
# is available that contains https://github.com/rack/rack/pull/1201.
gem_versions['rack'] = rails5? ? '2.0.6' : '1.6.11'
# --- The end of special code for migrating to Rails 5.0 ---
source 'https://rubygems.org'
gem 'rails', gem_versions['rails']
gem 'rails', '5.0.7'
gem 'rails-deprecated_sanitizer', '~> 1.0.3'
# Improves copy-on-write performance for MRI
......@@ -28,11 +12,7 @@ gem 'responders', '~> 2.0'
gem 'sprockets', '~> 3.7.0'
# Default values for AR models
if rails5?
gem 'gitlab-default_value_for', '~> 3.1.1', require: 'default_value_for'
else
gem 'default_value_for', '~> 3.0.0'
end
gem 'gitlab-default_value_for', '~> 3.1.1', require: 'default_value_for'
# Supported DBs
gem 'mysql2', '~> 0.4.10', group: :mysql
......@@ -159,7 +139,10 @@ gem 'icalendar'
gem 'diffy', '~> 3.1.0'
# Application server
gem 'rack', gem_versions['rack']
# The 2.0.6 version of rack requires monkeypatch to be present in
# `config.ru`. This can be removed once a new update for Rack
# is available that contains https://github.com/rack/rack/pull/1201.
gem 'rack', '2.0.6'
group :unicorn do
gem 'unicorn', '~> 5.1.0'
......@@ -277,6 +260,7 @@ gem 'webpack-rails', '~> 0.9.10'
gem 'rack-proxy', '~> 0.6.0'
gem 'sass-rails', '~> 5.0.6'
gem 'sass', '~> 3.5'
gem 'uglifier', '~> 2.7.2'
gem 'addressable', '~> 2.5.2'
......@@ -296,7 +280,7 @@ gem 'premailer-rails', '~> 1.9.7'
# I18n
gem 'ruby_parser', '~> 3.8', require: false
gem 'rails-i18n', gem_versions['rails-i18n']
gem 'rails-i18n', '~> 5.1'
gem 'gettext_i18n_rails', '~> 1.8.0'
gem 'gettext_i18n_rails_js', '~> 1.3'
gem 'gettext', '~> 3.2.2', require: false, group: :development
......@@ -382,7 +366,7 @@ group :development, :test do
gem 'license_finder', '~> 5.4', require: false
gem 'knapsack', '~> 1.17'
gem 'activerecord_sane_schema_dumper', gem_versions['activerecord_sane_schema_dumper']
gem 'activerecord_sane_schema_dumper', '1.0'
gem 'stackprof', '~> 0.2.10', require: false
......@@ -396,8 +380,7 @@ group :test do
gem 'email_spec', '~> 2.2.0'
gem 'json-schema', '~> 2.8.0'
gem 'webmock', '~> 2.3.2'
gem 'rails-controller-testing' if rails5? # Rails5 only gem.
gem 'test_after_commit', '~> 1.1' unless rails5? # Remove this gem when migrated to rails 5.0. It's been integrated to rails 5.0.
gem 'rails-controller-testing'
gem 'sham_rack', '~> 1.3.6'
gem 'concurrent-ruby', '~> 1.1'
gem 'test-prof', '~> 0.2.5'
......
......@@ -1129,6 +1129,7 @@ DEPENDENCIES
rufus-scheduler (~> 3.4)
rugged (~> 0.27)
sanitize (~> 4.6)
sass (~> 3.5)
sass-rails (~> 5.0.6)
scss_lint (~> 0.56.0)
seed-fu (~> 2.3.7)
......
# BUNDLE_GEMFILE=Gemfile.rails4 bundle install
ENV["RAILS5"] = "false"
gemfile = File.expand_path("../Gemfile", __FILE__)
eval(File.read(gemfile), nil, gemfile)
This diff is collapsed.
......@@ -25,6 +25,7 @@ const Api = {
userStatusPath: '/api/:version/users/:id/status',
userPostStatusPath: '/api/:version/user/status',
commitPath: '/api/:version/projects/:id/repository/commits',
applySuggestionPath: '/api/:version/suggestions/:id/apply',
commitPipelinesPath: '/:project_id/commit/:sha/pipelines',
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
createBranchPath: '/api/:version/projects/:id/repository/branches',
......@@ -185,6 +186,12 @@ const Api = {
});
},
applySuggestion(id) {
const url = Api.buildUrl(Api.applySuggestionPath).replace(':id', encodeURIComponent(id));
return axios.put(url);
},
commitPipelines(projectId, sha) {
const encodedProjectId = projectId
.split('/')
......
......@@ -17,6 +17,11 @@ export default () => {
const currentAction = $('.js-file-title').data('currentAction');
const projectId = editBlobForm.data('project-id');
const commitButton = $('.js-commit-button');
const cancelLink = $('.btn.btn-cancel');
cancelLink.on('click', () => {
window.onbeforeunload = null;
});
commitButton.on('click', () => {
window.onbeforeunload = null;
......
......@@ -37,7 +37,7 @@ export default function initNewListDropdown() {
});
},
renderRow(label) {
const active = boardsStore.findList('title', label.title);
const active = boardsStore.findListByLabelId(label.id);
const $li = $('<li />');
const $a = $('<a />', {
class: active ? `is-active js-board-list-${active.id}` : '',
......@@ -63,7 +63,7 @@ export default function initNewListDropdown() {
const label = options.selectedObj;
e.preventDefault();
if (!boardsStore.findList('title', label.title)) {
if (!boardsStore.findListByLabelId(label.id)) {
boardsStore.new({
title: label.title,
position: boardsStore.state.lists.length - 2,
......
......@@ -55,12 +55,12 @@ class ListIssue {
}
findLabel(findLabel) {
return this.labels.filter(label => label.title === findLabel.title)[0];
return this.labels.find(label => label.id === findLabel.id);
}
removeLabel(removeLabel) {
if (removeLabel) {
this.labels = this.labels.filter(label => removeLabel.title !== label.title);
this.labels = this.labels.filter(label => removeLabel.id !== label.id);
}
}
......@@ -75,7 +75,7 @@ class ListIssue {
}
findAssignee(findAssignee) {
return this.assignees.filter(assignee => assignee.id === findAssignee.id)[0];
return this.assignees.find(assignee => assignee.id === findAssignee.id);
}
removeAssignee(removeAssignee) {
......
......@@ -166,6 +166,9 @@ const boardsStore = {
});
return filteredList[0];
},
findListByLabelId(id) {
return this.state.lists.find(list => list.type === 'label' && list.label.id === id);
},
updateFiltersUrl() {
window.history.pushState(null, null, `?${this.filter.path}`);
},
......
......@@ -42,6 +42,16 @@ export default {
type: Object,
required: true,
},
helpPagePath: {
type: String,
required: false,
default: '',
},
changesEmptyStateIllustration: {
type: String,
required: false,
default: '',
},
},
data() {
return {
......@@ -63,7 +73,7 @@ export default {
plainDiffPath: state => state.diffs.plainDiffPath,
emailPatchPath: state => state.diffs.emailPatchPath,
}),
...mapState('diffs', ['showTreeList', 'isLoading']),
...mapState('diffs', ['showTreeList', 'isLoading', 'startVersion']),
...mapGetters('diffs', ['isParallelView']),
...mapGetters(['isNotesFetched', 'getNoteableData']),
targetBranch() {
......@@ -79,6 +89,13 @@ export default {
showCompareVersions() {
return this.mergeRequestDiffs && this.mergeRequestDiff;
},
renderDiffFiles() {
return (
this.diffFiles.length > 0 ||
(this.startVersion &&
this.startVersion.version_index === this.mergeRequestDiff.version_index)
);
},
},
watch: {
diffViewType() {
......@@ -191,15 +208,16 @@ export default {
<div v-show="showTreeList" class="diff-tree-list"><tree-list /></div>
<div class="diff-files-holder">
<commit-widget v-if="commit" :commit="commit" />
<template v-if="diffFiles.length > 0">
<template v-if="renderDiffFiles">
<diff-file
v-for="file in diffFiles"
:key="file.newPath"
:file="file"
:help-page-path="helpPagePath"
:can-current-user-fork="canCurrentUserFork"
/>
</template>
<no-changes v-else />
<no-changes v-else :changes-empty-state-illustration="changesEmptyStateIllustration" />
</div>
</div>
</div>
......
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import EmptyFileViewer from '~/vue_shared/components/diff_viewer/viewers/empty_file.vue';
import InlineDiffView from './inline_diff_view.vue';
import ParallelDiffView from './parallel_diff_view.vue';
import NoteForm from '../../notes/components/note_form.vue';
......@@ -17,12 +18,18 @@ export default {
NoteForm,
DiffDiscussions,
ImageDiffOverlay,
EmptyFileViewer,
},
props: {
diffFile: {
type: Object,
required: true,
},
helpPagePath: {
type: String,
required: false,
default: '',
},
},
computed: {
...mapState({
......@@ -70,15 +77,18 @@ export default {
<div class="diff-content">
<div class="diff-viewer">
<template v-if="isTextFile">
<empty-file-viewer v-if="diffFile.empty" />
<inline-diff-view
v-if="isInlineView"
v-else-if="isInlineView"
:diff-file="diffFile"
:diff-lines="diffFile.highlighted_diff_lines || []"
:help-page-path="helpPagePath"
/>
<parallel-diff-view
v-if="isParallelView"
v-else-if="isParallelView"
:diff-file="diffFile"
:diff-lines="diffFile.parallel_diff_lines || []"
:help-page-path="helpPagePath"
/>
</template>
<diff-viewer
......
......@@ -13,6 +13,11 @@ export default {
type: Array,
required: true,
},
line: {
type: Object,
required: false,
default: null,
},
shouldCollapseDiscussions: {
type: Boolean,
required: false,
......@@ -23,6 +28,11 @@ export default {
required: false,
default: false,
},
helpPagePath: {
type: String,
required: false,
default: '',
},
},
methods: {
...mapActions(['toggleDiscussion']),
......@@ -72,6 +82,8 @@ export default {
:render-diff-file="false"
:always-expanded="true"
:discussions-by-diff-order="true"
:line="line"
:help-page-path="helpPagePath"
@noteDeleted="deleteNoteHandler"
>
<span v-if="renderAvatarBadge" slot="avatar-badge" class="badge badge-pill">
......
......@@ -23,6 +23,11 @@ export default {
type: Boolean,
required: true,
},
helpPagePath: {
type: String,
required: false,
default: '',
},
},
data() {
return {
......@@ -164,6 +169,7 @@ export default {
v-if="!isCollapsed && file.renderIt"
:class="{ hidden: isCollapsed || file.too_large }"
:diff-file="file"
:help-page-path="helpPagePath"
/>
<gl-loading-icon v-if="showLoadingIcon" class="diff-content loading" />
<div v-else-if="showExpandMessage" class="nothing-here-block diff-collapsed">
......
......@@ -94,6 +94,7 @@ export default {
ref="noteForm"
:is-editing="true"
:line-code="line.line_code"
:line="line"
save-button-title="Comment"
class="diff-comment-form"
@cancelForm="handleCancelCommentForm"
......
......@@ -16,6 +16,11 @@ export default {
type: String,
required: true,
},
helpPagePath: {
type: String,
required: false,
default: '',
},
},
computed: {
className() {
......@@ -38,7 +43,12 @@ export default {
<tr v-if="shouldRender" :class="className" class="notes_holder">
<td class="notes_content" colspan="3">
<div class="content">
<diff-discussions v-if="line.discussions.length" :discussions="line.discussions" />
<diff-discussions
v-if="line.discussions.length"
:line="line"
:discussions="line.discussions"
:help-page-path="helpPagePath"
/>
<diff-line-note-form
v-if="line.hasForm"
:diff-file-hash="diffFileHash"
......
......@@ -17,6 +17,11 @@ export default {
type: Array,
required: true,
},
helpPagePath: {
type: String,
required: false,
default: '',
},
},
computed: {
...mapGetters('diffs', ['commitId']),
......@@ -47,6 +52,7 @@ export default {
:key="`icr-${index}`"
:diff-file-hash="diffFile.file_hash"
:line="line"
:help-page-path="helpPagePath"
/>
</template>
</tbody>
......
<script>
import { mapState } from 'vuex';
import emptyImage from '~/../../views/shared/icons/_mr_widget_empty_state.svg';
import { mapGetters } from 'vuex';
import _ from 'underscore';
import { GlButton } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
export default {
data() {
return {
emptyImage,
};
components: {
GlButton,
},
props: {
changesEmptyStateIllustration: {
type: String,
required: true,
},
},
computed: {
...mapState({
sourceBranch: state => state.notes.noteableData.source_branch,
targetBranch: state => state.notes.noteableData.target_branch,
newBlobPath: state => state.notes.noteableData.new_blob_path,
}),
...mapGetters(['getNoteableData']),
emptyStateText() {
return sprintf(
__(
'No changes between %{ref_start}%{source_branch}%{ref_end} and %{ref_start}%{target_branch}%{ref_end}',
),
{
ref_start: '<span class="ref-name">',
ref_end: '</span>',
source_branch: _.escape(this.getNoteableData.source_branch),
target_branch: _.escape(this.getNoteableData.target_branch),
},
false,
);
},
},
};
</script>
<template>
<div class="row empty-state nothing-here-block">
<div class="col-xs-12">
<div class="svg-content"><span v-html="emptyImage"></span></div>
<div class="row empty-state">
<div class="col-12">
<div class="svg-content svg-250"><img :src="changesEmptyStateIllustration" /></div>
</div>
<div class="col-xs-12">
<div class="col-12">
<div class="text-content text-center">
No changes between <span class="ref-name">{{ sourceBranch }}</span> and
<span class="ref-name">{{ targetBranch }}</span>
<span v-html="emptyStateText"></span>
<div class="text-center">
<a :href="newBlobPath" class="btn btn-success"> {{ __('Create commit') }} </a>
<gl-button :href="getNoteableData.new_blob_path" variant="success">{{
__('Create commit')
}}</gl-button>
</div>
</div>
</div>
......
......@@ -20,6 +20,11 @@ export default {
type: Number,
required: true,
},
helpPagePath: {
type: String,
required: false,
default: '',
},
},
computed: {
hasExpandedDiscussionOnLeft() {
......@@ -87,6 +92,8 @@ export default {
<diff-discussions
v-if="line.left.discussions.length"
:discussions="line.left.discussions"
:line="line.left"
:help-page-path="helpPagePath"
/>
</div>
<diff-line-note-form
......@@ -102,6 +109,8 @@ export default {
<diff-discussions
v-if="line.right.discussions.length"
:discussions="line.right.discussions"
:line="line.right"
:help-page-path="helpPagePath"
/>
</div>
<diff-line-note-form
......
......@@ -17,6 +17,11 @@ export default {
type: Array,
required: true,
},
helpPagePath: {
type: String,
required: false,
default: '',
},
},
computed: {
...mapGetters('diffs', ['commitId']),
......@@ -49,6 +54,7 @@ export default {
:line="line"
:diff-file-hash="diffFile.file_hash"
:line-index="index"
:help-page-path="helpPagePath"
/>
</template>
</tbody>
......
......@@ -16,7 +16,9 @@ export default function initDiffsApp(store) {
return {
endpoint: dataset.endpoint,
projectPath: dataset.projectPath,
helpPagePath: dataset.helpPagePath,
currentUser: JSON.parse(dataset.currentUserData) || {},
changesEmptyStateIllustration: dataset.changesEmptyStateIllustration,
};
},
computed: {
......@@ -30,7 +32,9 @@ export default function initDiffsApp(store) {
endpoint: this.endpoint,
currentUser: this.currentUser,
projectPath: this.projectPath,
helpPagePath: this.helpPagePath,
shouldShow: this.activeTab === 'diffs',
changesEmptyStateIllustration: this.changesEmptyStateIllustration,
},
});
},
......
......@@ -123,22 +123,23 @@ export default {
diffPosition: diffPositionByLineCode[line.line_code],
latestDiff,
});
const mapDiscussions = (line, extraCheck = () => true) => ({
...line,
discussions: extraCheck()
? line.discussions
.filter(() => !line.discussions.some(({ id }) => discussion.id === id))
.concat(lineCheck(line) ? discussion : line.discussions)
: [],
});
state.diffFiles = state.diffFiles.map(diffFile => {
if (diffFile.file_hash === fileHash) {
const file = { ...diffFile };
if (file.highlighted_diff_lines) {
file.highlighted_diff_lines = file.highlighted_diff_lines.map(line => {
if (!line.discussions.some(({ id }) => discussion.id === id) && lineCheck(line)) {
return {
...line,
discussions: line.discussions.concat(discussion),
};
}
return line;
});
file.highlighted_diff_lines = file.highlighted_diff_lines.map(line =>
mapDiscussions(line),
);
}
if (file.parallel_diff_lines) {
......@@ -148,20 +149,8 @@ export default {
if (left || right) {
return {
left: {
...line.left,
discussions:
left && !line.left.discussions.some(({ id }) => id === discussion.id)
? line.left.discussions.concat(discussion)
: (line.left && line.left.discussions) || [],
},
right: {
...line.right,
discussions:
right && !left && !line.right.discussions.some(({ id }) => id === discussion.id)
? line.right.discussions.concat(discussion)
: (line.right && line.right.discussions) || [],
},
left: line.left ? mapDiscussions(line.left) : null,
right: line.right ? mapDiscussions(line.right, () => !left) : null,
};
}
......@@ -180,7 +169,7 @@ export default {
});
},
[types.REMOVE_LINE_DISCUSSIONS_FOR_FILE](state, { fileHash, lineCode, id }) {
[types.REMOVE_LINE_DISCUSSIONS_FOR_FILE](state, { fileHash, lineCode }) {
const selectedFile = state.diffFiles.find(f => f.file_hash === fileHash);
if (selectedFile) {
if (selectedFile.parallel_diff_lines) {
......@@ -193,7 +182,7 @@ export default {
const side = targetLine.left && targetLine.left.line_code === lineCode ? 'left' : 'right';
Object.assign(targetLine[side], {
discussions: [],
discussions: targetLine[side].discussions.filter(discussion => discussion.notes.length),
});
}
}
......@@ -205,14 +194,14 @@ export default {
if (targetInlineLine) {
Object.assign(targetInlineLine, {
discussions: [],
discussions: targetInlineLine.discussions.filter(discussion => discussion.notes.length),
});
}
}
if (selectedFile.discussions && selectedFile.discussions.length) {
selectedFile.discussions = selectedFile.discussions.filter(
discussion => discussion.id !== id,
discussion => discussion.notes.length,
);
}
}
......
......@@ -54,67 +54,6 @@ export default class DropdownUtils {
return updatedItem;
}
static mergeDuplicateLabels(dataMap, newLabel) {
const updatedMap = dataMap;
const key = newLabel.title;
const hasKeyProperty = Object.prototype.hasOwnProperty.call(updatedMap, key);
if (!hasKeyProperty) {
updatedMap[key] = newLabel;
} else {
const existing = updatedMap[key];
if (!existing.multipleColors) {
existing.multipleColors = [existing.color];
}
existing.multipleColors.push(newLabel.color);
}
return updatedMap;
}
static duplicateLabelColor(labelColors) {
const colors = labelColors;
const spacing = 100 / colors.length;
// Reduce the colors to 4
colors.length = Math.min(colors.length, 4);
const color = colors
.map((c, i) => {
const percentFirst = Math.floor(spacing * i);
const percentSecond = Math.floor(spacing * (i + 1));
return `${c} ${percentFirst}%, ${c} ${percentSecond}%`;
})
.join(', ');
return `linear-gradient(${color})`;
}
static duplicateLabelPreprocessing(data) {
const results = [];
const dataMap = {};
data.forEach(DropdownUtils.mergeDuplicateLabels.bind(null, dataMap));
Object.keys(dataMap).forEach(key => {
const label = dataMap[key];
if (label.multipleColors) {
label.color = DropdownUtils.duplicateLabelColor(label.multipleColors);
label.text_color = '#000000';
}
results.push(label);
});
results.preprocessed = true;
return results;
}
static filterHint(config, item) {
const { input, allowedKeys } = config;
const updatedItem = item;
......
......@@ -79,11 +79,7 @@ export default class FilteredSearchVisualTokens {
static setTokenStyle(tokenContainer, backgroundColor, textColor) {
const token = tokenContainer;
// Labels with linear gradient should not override default background color
if (backgroundColor.indexOf('linear-gradient') === -1) {
token.style.backgroundColor = backgroundColor;
}
token.style.backgroundColor = backgroundColor;
token.style.color = textColor;
if (textColor === '#FFFFFF') {
......@@ -94,18 +90,6 @@ export default class FilteredSearchVisualTokens {
return token;
}
static preprocessLabel(labelsEndpoint, labels) {
let processed = labels;
if (!labels.preprocessed) {
processed = DropdownUtils.duplicateLabelPreprocessing(labels);
AjaxCache.override(labelsEndpoint, processed);
processed.preprocessed = true;
}
return processed;
}
static updateLabelTokenColor(tokenValueContainer, tokenValue) {
const filteredSearchInput = FilteredSearchContainer.container.querySelector('.filtered-search');
const { baseEndpoint } = filteredSearchInput.dataset;
......@@ -115,7 +99,6 @@ export default class FilteredSearchVisualTokens {
);
return AjaxCache.retrieve(labelsEndpoint)
.then(FilteredSearchVisualTokens.preprocessLabel.bind(null, labelsEndpoint))
.then(labels => {
const matchingLabel = (labels || []).find(
label => `~${DropdownUtils.getEscapedText(label.title)}` === tokenValue,
......
......@@ -27,7 +27,7 @@ export default {
apollo: {
issues: {
query,
debounce: 250,
debounce: 1000,
skip() {
return this.isSearchEmpty;
},
......
......@@ -7,7 +7,6 @@ import _ from 'underscore';
import { sprintf, __ } from './locale';
import axios from './lib/utils/axios_utils';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
import DropdownUtils from './filtered_search/dropdown_utils';
import CreateLabelDropdown from './create_label';
import flash from './flash';
import ModalStore from './boards/stores/modal_store';
......@@ -171,23 +170,7 @@ export default class LabelsSelect {
axios
.get(labelUrl)
.then(res => {
let data = _.chain(res.data)
.groupBy(function(label) {
return label.title;
})
.map(function(label) {
var color;
color = _.map(label, function(dup) {
return dup.color;
});
return {
id: label[0].id,
title: label[0].title,
color: color,
duplicate: color.length > 1,
};
})
.value();
let { data } = res;
if ($dropdown.hasClass('js-extra-options')) {
var extraData = [];
if (showNo) {
......@@ -272,15 +255,9 @@ export default class LabelsSelect {
selectedClass.push('dropdown-clear-active');
}
}
if (label.duplicate) {
color = DropdownUtils.duplicateLabelColor(label.color);
} else {
if (label.color != null) {
[color] = label.color;
}
}
if (color) {
colorEl = "<span class='dropdown-label-box' style='background: " + color + "'></span>";
if (label.color) {
colorEl =
"<span class='dropdown-label-box' style='background: " + label.color + "'></span>";
} else {
colorEl = '';
}
......@@ -435,7 +412,7 @@ export default class LabelsSelect {
new ListLabel({
id: label.id,
title: label.title,
color: label.color[0],
color: label.color,
textColor: '#fff',
}),
);
......
......@@ -39,7 +39,14 @@ function blockTagText(text, textArea, blockTag, selected) {
}
}
function moveCursor({ textArea, tag, positionBetweenTags, removedLastNewLine, select }) {
function moveCursor({
textArea,
tag,
cursorOffset,
positionBetweenTags,
removedLastNewLine,
select,
}) {
var pos;
if (!textArea.setSelectionRange) {
return;
......@@ -61,11 +68,24 @@ function moveCursor({ textArea, tag, positionBetweenTags, removedLastNewLine, se
pos -= 1;
}
if (cursorOffset) {
pos -= cursorOffset;
}
return textArea.setSelectionRange(pos, pos);
}
}
export function insertMarkdownText({ textArea, text, tag, blockTag, selected, wrap, select }) {
export function insertMarkdownText({
textArea,
text,
tag,
cursorOffset,
blockTag,
selected,
wrap,
select,
}) {
var textToInsert,
selectedSplit,
startChar,
......@@ -154,20 +174,30 @@ export function insertMarkdownText({ textArea, text, tag, blockTag, selected, wr
return moveCursor({
textArea,
tag: tag.replace(textPlaceholder, selected),
cursorOffset,
positionBetweenTags: wrap && selected.length === 0,
removedLastNewLine,
select,
});
}
function updateText({ textArea, tag, blockTag, wrap, select }) {
function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagContent }) {
var $textArea, selected, text;
$textArea = $(textArea);
textArea = $textArea.get(0);
text = $textArea.val();
selected = selectedText(text, textArea);
selected = selectedText(text, textArea) || tagContent;
$textArea.focus();
return insertMarkdownText({ textArea, text, tag, blockTag, selected, wrap, select });
return insertMarkdownText({
textArea,
text,
tag,
cursorOffset,
blockTag,
selected,
wrap,
select,
});
}
export function addMarkdownListeners(form) {
......@@ -178,9 +208,11 @@ export function addMarkdownListeners(form) {
return updateText({
textArea: $this.closest('.md-area').find('textarea'),
tag: $this.data('mdTag'),
cursorOffset: $this.data('mdCursorOffset'),
blockTag: $this.data('mdBlock'),
wrap: !$this.data('mdPrepend'),
select: $this.data('mdSelect'),
tagContent: $this.data('mdTagContent').toString(),
});
});
}
......
......@@ -33,6 +33,7 @@ export default function initMrNotes() {
noteableData,
currentUserData: JSON.parse(notesDataset.currentUserData),
notesData: JSON.parse(notesDataset.notesData),
helpPagePath: notesDataset.helpPagePath,
};
},
computed: {
......@@ -71,6 +72,7 @@ export default function initMrNotes() {
notesData: this.notesData,
userData: this.currentUserData,
shouldShow: this.activeTab === 'show',
helpPagePath: this.helpPagePath,
},
});
},
......
<script>
import { mapActions } from 'vuex';
import $ from 'jquery';
import noteEditedText from './note_edited_text.vue';
import noteAwardsList from './note_awards_list.vue';
import noteAttachment from './note_attachment.vue';
import noteForm from './note_form.vue';
import autosave from '../mixins/autosave';
import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
export default {
components: {
......@@ -12,6 +14,7 @@ export default {
noteAwardsList,
noteAttachment,
noteForm,
Suggestions,
},
mixins: [autosave],
props: {
......@@ -19,6 +22,11 @@ export default {
type: Object,
required: true,
},
line: {
type: Object,
required: false,
default: null,
},
canEdit: {
type: Boolean,
required: true,
......@@ -28,11 +36,22 @@ export default {
required: false,
default: false,
},
helpPagePath: {
type: String,
required: false,
default: '',
},
},
computed: {
noteBody() {
return this.note.note;
},
hasSuggestion() {
return this.note.suggestions && this.note.suggestions.length;
},
lineType() {
return this.line ? this.line.type : null;
},
},
mounted() {
this.renderGFM();
......@@ -53,6 +72,7 @@ export default {
}
},
methods: {
...mapActions(['submitSuggestion']),
renderGFM() {
$(this.$refs['note-body']).renderGFM();
},
......@@ -62,19 +82,35 @@ export default {
formCancelHandler(shouldConfirm, isDirty) {
this.$emit('cancelForm', shouldConfirm, isDirty);
},
applySuggestion({ suggestionId, flashContainer, callback }) {
const { discussion_id: discussionId, id: noteId } = this.note;
this.submitSuggestion({ discussionId, noteId, suggestionId, flashContainer, callback });
},
},
};
</script>
<template>
<div ref="note-body" :class="{ 'js-task-list-container': canEdit }" class="note-body">
<div class="note-text md" v-html="note.note_html"></div>
<suggestions
v-if="hasSuggestion && !isEditing"
:suggestions="note.suggestions"
:note-html="note.note_html"
:line-type="lineType"
:help-page-path="helpPagePath"
@apply="applySuggestion"
/>
<div v-else class="note-text md" v-html="note.note_html"></div>
<note-form
v-if="isEditing"
ref="noteForm"
:is-editing="isEditing"
:note-body="noteBody"
:note-id="note.id"
:line="line"
:note="note"
:help-page-path="helpPagePath"
:markdown-version="note.cached_markdown_version"
@handleFormUpdate="handleFormUpdate"
@cancelForm="formCancelHandler"
......
<script>
import { mergeUrlParams } from '~/lib/utils/url_utility';
import { mapGetters, mapActions } from 'vuex';
import eventHub from '../event_hub';
import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
......@@ -53,6 +54,21 @@ export default {
required: false,
default: false,
},
line: {
type: Object,
required: false,
default: null,
},
note: {
type: Object,
required: false,
default: null,
},
helpPagePath: {
type: String,
required: false,
default: '',
},
},
data() {
return {
......@@ -79,7 +95,8 @@ export default {
return '#';
},
markdownPreviewPath() {
return this.getNoteableDataByProp('preview_note_path');
const notable = this.getNoteableDataByProp('preview_note_path');
return mergeUrlParams({ preview_suggestions: true }, notable);
},
markdownDocsPath() {
return this.getNotesDataByProp('markdownDocsPath');
......@@ -93,6 +110,18 @@ export default {
isDisabled() {
return !this.updatedNoteBody.length || this.isSubmitting;
},
discussionNote() {
const discussionNote = this.discussion.id
? this.getDiscussionLastNote(this.discussion)
: this.note;
return discussionNote || {};
},
canSuggest() {
return (
this.getNoteableData.can_receive_suggestion &&
(this.line && this.line.can_receive_suggestion)
);
},
},
watch: {
noteBody() {
......@@ -171,7 +200,11 @@ export default {
:markdown-docs-path="markdownDocsPath"
:markdown-version="markdownVersion"
:quick-actions-docs-path="quickActionsDocsPath"
:line="line"
:note="discussionNote"
:can-suggest="canSuggest"
:add-spacing-classes="false"
:help-page-path="helpPagePath"
>
<textarea
id="note_note"
......
......@@ -49,6 +49,11 @@ export default {
type: Object,
required: true,
},
line: {
type: Object,
required: false,
default: null,
},
renderDiffFile: {
type: Boolean,
required: false,
......@@ -64,6 +69,11 @@ export default {
required: false,
default: false,
},
helpPagePath: {
type: String,
required: false,
default: '',
},
},
data() {
const { diff_discussion: isDiffDiscussion, resolved } = this.discussion;
......@@ -194,6 +204,13 @@ export default {
false,
);
},
diffLine() {
if (this.discussion.diff_discussion && this.discussion.truncated_diff_lines) {
return this.discussion.truncated_diff_lines.slice(-1)[0];
}
return this.line;
},
},
watch: {
isReplying() {
......@@ -357,6 +374,8 @@ Please check your network connection and try again.`;
<component
:is="componentName(initialDiscussion)"
:note="componentData(initialDiscussion)"
:line="line"
:help-page-path="helpPagePath"
@handleDeleteNote="deleteNoteHandler"
>
<slot slot="avatar-badge" name="avatar-badge"></slot>
......@@ -373,6 +392,8 @@ Please check your network connection and try again.`;
v-for="note in replies"
:key="note.id"
:note="componentData(note)"
:help-page-path="helpPagePath"
:line="line"
@handleDeleteNote="deleteNoteHandler"
/>
</template>
......@@ -383,6 +404,8 @@ Please check your network connection and try again.`;
v-for="(note, index) in discussion.notes"
:key="note.id"
:note="componentData(note)"
:help-page-path="helpPagePath"
:line="diffLine"
@handleDeleteNote="deleteNoteHandler"
>
<slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"></slot>
......@@ -390,7 +413,7 @@ Please check your network connection and try again.`;
</template>
</ul>
<div
v-if="!isRepliesCollapsed"
v-if="!isRepliesCollapsed || !hasReplies"
:class="{ 'is-replying': isReplying }"
class="discussion-reply-holder"
>
......@@ -447,6 +470,7 @@ Please check your network connection and try again.`;
ref="noteForm"
:discussion="discussion"
:is-editing="false"
:line="diffLine"
save-button-title="Comment"
@handleFormUpdate="saveReply"
@cancelForm="cancelReplyForm"
......
......@@ -27,6 +27,16 @@ export default {
type: Object,
required: true,
},
line: {
type: Object,
required: false,
default: null,
},
helpPagePath: {
type: String,
required: false,
default: '',
},
},
data() {
return {
......@@ -220,8 +230,10 @@ export default {
<note-body
ref="noteBody"
:note="note"
:line="line"
:can-edit="note.current_user.can_edit"
:is-editing="isEditing"
:help-page-path="helpPagePath"
@handleFormUpdate="formUpdateHandler"
@cancelForm="formCancelHandler"
/>
......
......@@ -49,6 +49,11 @@ export default {
required: false,
default: 0,
},
helpPagePath: {
type: String,
required: false,
default: '',
},
},
data() {
return {
......@@ -102,7 +107,7 @@ export default {
if (parentElement && parentElement.classList.contains('js-vue-notes-event')) {
parentElement.addEventListener('toggleAward', event => {
const { awardName, noteId } = event.detail;
this.actionToggleAward({ awardName, noteId });
this.toggleAward({ awardName, noteId });
});
}
},
......@@ -206,6 +211,7 @@ export default {
:key="discussion.id"
:discussion="discussion"
:render-diff-file="true"
:help-page-path="helpPagePath"
/>
</template>
</ul>
......
import Vue from 'vue';
import Api from '~/api';
import VueResource from 'vue-resource';
import * as constants from '../constants';
......@@ -44,4 +45,7 @@ export default {
toggleIssueState(endpoint, data) {
return Vue.http.put(endpoint, data);
},
applySuggestion(id) {
return Api.applySuggestion(id);
},
};
......@@ -405,5 +405,25 @@ export const startTaskList = ({ dispatch }) =>
export const updateResolvableDiscussonsCounts = ({ commit }) =>
commit(types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS);
export const submitSuggestion = (
{ commit },
{ discussionId, noteId, suggestionId, flashContainer, callback },
) => {
service
.applySuggestion(suggestionId)
.then(() => {
commit(types.APPLY_SUGGESTION, { discussionId, noteId, suggestionId });
callback();
})
.catch(() => {
Flash(
__('Something went wrong while applying the suggestion. Please try again.'),
'alert',
flashContainer,
);
callback();
});
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
......@@ -20,6 +20,7 @@ export default () => ({
userData: {},
noteableData: {
current_user: {},
preview_note_path: 'path/to/preview',
},
commentsDisabled: false,
resolvableDiscussionsCount: 0,
......
......@@ -16,6 +16,7 @@ export const SET_DISCUSSION_DIFF_LINES = 'SET_DISCUSSION_DIFF_LINES';
export const SET_NOTES_FETCHED_STATE = 'SET_NOTES_FETCHED_STATE';
export const SET_NOTES_LOADING_STATE = 'SET_NOTES_LOADING_STATE';
export const DISABLE_COMMENTS = 'DISABLE_COMMENTS';
export const APPLY_SUGGESTION = 'APPLY_SUGGESTION';
// DISCUSSION
export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION';
......
......@@ -197,6 +197,17 @@ export default {
}
},
[types.APPLY_SUGGESTION](state, { noteId, discussionId, suggestionId }) {
const noteObj = utils.findNoteObjectById(state.discussions, discussionId);
const comment = utils.findNoteObjectById(noteObj.notes, noteId);
comment.suggestions = comment.suggestions.map(suggestion => ({
...suggestion,
applied: suggestion.applied || suggestion.id === suggestionId,
appliable: false,
}));
},
[types.UPDATE_DISCUSSION](state, noteData) {
const note = noteData;
const selectedDiscussion = state.discussions.find(disc => disc.id === note.id);
......
import ProjectsList from '~/projects_list';
import Star from '../../../star';
document.addEventListener('DOMContentLoaded', () => new ProjectsList());
document.addEventListener('DOMContentLoaded', () => {
new ProjectsList(); // eslint-disable-line no-new
new Star('.project-row'); // eslint-disable-line no-new
});
......@@ -17,7 +17,7 @@ export default () => {
new MilestoneSelect();
new IssuableTemplateSelectors();
if (gon.features.issueSuggestions && gon.features.graphql) {
if (gon.features.graphql) {
initSuggestions();
}
};
// if the "projects dashboard" is a user's default dashboard, when they visit the
// instance root index, the dashboard will be served by the root controller instead
// of a dashboard controller. The root index redirects for all other default dashboards.
import '../dashboard/projects/index';
......@@ -151,8 +151,10 @@ export default class UserTabs {
loadTab(action, endpoint) {
this.toggleLoading(true);
const params = action === 'projects' ? { skip_namespace: true } : {};
return axios
.get(endpoint)
.get(endpoint, { params })
.then(({ data }) => {
const tabSelector = `div#${action}`;
this.$parentEl.find(tabSelector).html(data.html);
......@@ -188,7 +190,7 @@ export default class UserTabs {
requestParams: { limit: 10 },
});
UserTabs.renderMostRecentBlocks('#js-overview .projects-block', {
requestParams: { limit: 10, skip_pagination: true },
requestParams: { limit: 10, skip_pagination: true, skip_namespace: true, compact_mode: true },
});
this.loaded.overview = true;
......@@ -206,6 +208,8 @@ export default class UserTabs {
loadActivityCalendar() {
const $calendarWrap = this.$parentEl.find('.tab-pane.active .user-calendar');
if (!$calendarWrap.length) return;
const calendarPath = $calendarWrap.data('calendarPath');
AjaxCache.retrieve(calendarPath)
......
<script>
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { sprintf } from '../../locale';
export default {
name: 'ReleaseBlock',
components: {
GlLink,
Icon,
UserAvatarLink,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [timeagoMixin],
props: {
name: {
type: String,
required: true,
},
tag: {
type: String,
required: true,
},
commit: {
type: Object,
required: true,
},
description: {
type: String,
required: false,
default: '',
},
author: {
type: Object,
required: true,
},
createdAt: {
type: String,
required: false,
default: '',
},
assetsCount: {
type: Number,
required: false,
default: 0,
},
sources: {
type: Array,
required: false,
default: () => [],
},
links: {
type: Array,
required: false,
default: () => [],
},
},
computed: {
releasedTimeAgo() {
return sprintf('released %{time}', {
time: this.timeFormated(this.createdAt),
});
},
userImageAltDescription() {
return this.author && this.author.username
? sprintf("%{username}'s avatar", { username: this.author.username })
: null;
},
},
};
</script>
<template>
<div class="card">
<div class="card-body">
<h2 class="card-title mt-0">{{ name }}</h2>
<div class="card-subtitle d-flex flex-wrap text-secondary">
<div class="append-right-8">
<icon name="commit" class="align-middle" />
<span v-gl-tooltip.bottom :title="commit.title">{{ commit.short_id }}</span>
</div>
<div class="append-right-8">
<icon name="tag" class="align-middle" />
<span v-gl-tooltip.bottom :title="__('Tag')">{{ tag }}</span>
</div>
<div class="append-right-4">
&bull;
<span v-gl-tooltip.bottom :title="tooltipTitle(createdAt)">{{ releasedTimeAgo }}</span>
</div>
<div class="d-flex">
by
<user-avatar-link
class="prepend-left-4"
:link-href="author.path"
:img-src="author.avatar_url"
:img-alt="userImageAltDescription"
:tooltip-text="author.username"
/>
</div>
</div>
<div class="card-text prepend-top-default">
<b>
{{ __('Assets') }} <span class="js-assets-count badge badge-pill">{{ assetsCount }}</span>
</b>
<ul class="pl-0 mb-0 prepend-top-8 list-unstyled js-assets-list">
<li v-for="link in links" :key="link.name" class="append-bottom-8">
<gl-link v-gl-tooltip.bottom :title="__('Download asset')" :href="link.url">
<icon name="package" class="align-middle append-right-4" /> {{ link.name }}
</gl-link>
</li>
</ul>
<div class="dropdown">
<button
type="button"
class="btn btn-link"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
>
<icon name="doc-code" class="align-top append-right-4" /> {{ __('Source code') }}
<icon name="arrow-down" />
</button>
<div class="js-sources-dropdown dropdown-menu">
<li v-for="asset in sources" :key="asset.url">
<gl-link :href="asset.url">{{ __('Download') }} {{ asset.format }}</gl-link>
</li>
</div>
</div>
</div>
<div class="card-text prepend-top-default"><div v-html="description"></div></div>
</div>
</div>
</template>
......@@ -5,11 +5,12 @@ import { spriteIcon } from './lib/utils/common_utils';
import axios from './lib/utils/axios_utils';
export default class Star {
constructor() {
$('.project-home-panel .toggle-star').on('click', function toggleStarClickCallback() {
constructor(container = '.project-home-panel') {
$(`${container} .toggle-star`).on('click', function toggleStarClickCallback() {
const $this = $(this);
const $starSpan = $this.find('span');
const $startIcon = $this.find('svg');
const $starIcon = $this.find('svg');
const iconClasses = $starIcon.attr('class').split(' ');
axios
.post($this.data('endpoint'))
......@@ -22,12 +23,12 @@ export default class Star {
if (isStarred) {
$starSpan.removeClass('starred').text(s__('StarProject|Star'));
$startIcon.remove();
$this.prepend(spriteIcon('star-o', 'icon'));
$starIcon.remove();
$this.prepend(spriteIcon('star-o', iconClasses));
} else {
$starSpan.addClass('starred').text(__('Unstar'));
$startIcon.remove();
$this.prepend(spriteIcon('star', 'icon'));
$starIcon.remove();
$this.prepend(spriteIcon('star', iconClasses));
}
})
.catch(() => Flash('Star toggle failed. Try again later.'));
......
......@@ -13,5 +13,7 @@ export default {
</script>
<template>
<div class="circle-icon-container append-right-default"><icon :name="name" /></div>
<div class="circle-icon-container append-right-default align-self-start align-self-lg-center">
<icon :name="name" />
</div>
</template>
......@@ -72,7 +72,7 @@ export default {
Flash('Something went wrong. Please try again.');
}
eventHub.$emit('MRWidgetUpdateRequested');
eventHub.$emit('MRWidgetRebaseSuccess');
stopPolling();
}
})
......
......@@ -155,13 +155,13 @@ export default {
};
return new MRWidgetService(endpoints);
},
checkStatus(cb) {
checkStatus(cb, isRebased) {
return this.service
.checkStatus()
.then(res => res.data)
.then(data => {
this.handleNotification(data);
this.mr.setData(data);
this.mr.setData(data, isRebased);
this.setFaviconHelper();
if (cb) {
......@@ -263,6 +263,10 @@ export default {
this.checkStatus(cb);
});
eventHub.$on('MRWidgetRebaseSuccess', cb => {
this.checkStatus(cb, true);
});
// `params` should be an Array contains a Boolean, like `[true]`
// Passing parameter as Boolean didn't work.
eventHub.$on('SetBranchRemoveFlag', params => {
......
......@@ -19,7 +19,7 @@ export default function deviseState(data) {
return stateKey.unresolvedDiscussions;
} else if (this.isPipelineBlocked) {
return stateKey.pipelineBlocked;
} else if (this.hasSHAChanged) {
} else if (this.isSHAMismatch) {
return stateKey.shaMismatch;
} else if (this.mergeWhenPipelineSucceeds) {
return this.mergeError ? stateKey.autoMergeFailed : stateKey.mergeWhenPipelineSucceeds;
......
......@@ -11,7 +11,11 @@ export default class MergeRequestStore {
this.setData(data);
}
setData(data) {
setData(data, isRebased) {
if (isRebased) {
this.sha = data.diff_head_sha;
}
const currentUser = data.current_user;
const pipelineStatus = data.pipeline ? data.pipeline.details.status : null;
......@@ -84,7 +88,7 @@ export default class MergeRequestStore {
this.canMerge = !!data.merge_path;
this.canCreateIssue = currentUser.can_create_issue || false;
this.canCancelAutomaticMerge = !!data.cancel_merge_when_pipeline_succeeds_path;
this.hasSHAChanged = this.sha !== data.diff_head_sha;
this.isSHAMismatch = this.sha !== data.diff_head_sha;
this.canBeMerged = data.can_be_merged || false;
this.isMergeAllowed = data.mergeable || false;
this.mergeOngoing = data.merge_ongoing;
......
<template>
<div class="nothing-here-block">{{ __('Empty file') }}</div>
</template>
<script>
import $ from 'jquery';
import { s__ } from '~/locale';
import _ from 'underscore';
import { __ } from '~/locale';
import { stripHtml } from '~/lib/utils/text_utility';
import Flash from '../../../flash';
import GLForm from '../../../gl_form';
import markdownHeader from './header.vue';
import markdownToolbar from './toolbar.vue';
import icon from '../icon.vue';
import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
export default {
components: {
markdownHeader,
markdownToolbar,
icon,
Suggestions,
},
props: {
markdownPreviewPath: {
......@@ -48,12 +52,33 @@ export default {
required: false,
default: true,
},
line: {
type: Object,
required: false,
default: null,
},
note: {
type: Object,
required: false,
default: () => ({}),
},
canSuggest: {
type: Boolean,
required: false,
default: false,
},
helpPagePath: {
type: String,
required: false,
default: '',
},
},
data() {
return {
markdownPreview: '',
referencedCommands: '',
referencedUsers: '',
hasSuggestion: false,
markdownPreviewLoading: false,
previewMarkdown: false,
};
......@@ -63,6 +88,39 @@ export default {
const referencedUsersThreshold = 10;
return this.referencedUsers.length >= referencedUsersThreshold;
},
lineContent() {
const FIRST_CHAR_REGEX = /^(\+|-)/;
const [firstSuggestion] = this.suggestions;
if (firstSuggestion) {
return firstSuggestion.from_content;
}
if (this.line) {
const { rich_text: richText, text } = this.line;
if (text) {
return text.replace(FIRST_CHAR_REGEX, '');
}
return _.unescape(stripHtml(richText).replace(/\n/g, ''));
}
return '';
},
lineNumber() {
let lineNumber;
if (this.line) {
const { new_line: newLine, old_line: oldLine } = this.line;
lineNumber = newLine || oldLine;
}
return lineNumber;
},
suggestions() {
return this.note.suggestions || [];
},
lineType() {
return this.line ? this.line.type : '';
},
},
mounted() {
/*
......@@ -99,11 +157,12 @@ export default {
if (text) {
this.markdownPreviewLoading = true;
this.markdownPreview = __('Loading…');
this.$http
.post(this.versionedPreviewPath(), { text })
.then(resp => resp.json())
.then(data => this.renderMarkdown(data))
.catch(() => new Flash(s__('Error loading markdown preview')));
.catch(() => new Flash(__('Error loading markdown preview')));
} else {
this.renderMarkdown();
}
......@@ -121,6 +180,7 @@ export default {
if (data.references) {
this.referencedCommands = data.references.commands;
this.referencedUsers = data.references.users;
this.hasSuggestion = data.references.suggestions && data.references.suggestions.length;
}
this.$nextTick(() => {
......@@ -146,6 +206,8 @@ export default {
>
<markdown-header
:preview-markdown="previewMarkdown"
:line-content="lineContent"
:can-suggest="canSuggest"
@preview-markdown="showPreviewTab"
@write-markdown="showWriteTab"
/>
......@@ -162,17 +224,39 @@ export default {
/>
</div>
</div>
<div v-show="previewMarkdown" class="md md-preview-holder md-preview js-vue-md-preview">
<div ref="markdown-preview" v-html="markdownPreview"></div>
<span v-if="markdownPreviewLoading"> Loading... </span>
</div>
<template v-if="hasSuggestion">
<div
v-show="previewMarkdown"
ref="markdown-preview"
class="md-preview js-vue-md-preview md md-preview-holder"
>
<suggestions
v-if="hasSuggestion"
:note-html="markdownPreview"
:from-line="lineNumber"
:from-content="lineContent"
:line-type="lineType"
:disabled="true"
:suggestions="suggestions"
:help-page-path="helpPagePath"
/>
</div>
</template>
<template v-else>
<div
v-show="previewMarkdown"
ref="markdown-preview"
class="md-preview js-vue-md-preview md md-preview-holder"
v-html="markdownPreview"
></div>
</template>
<template v-if="previewMarkdown && !markdownPreviewLoading">
<div v-if="referencedCommands" class="referenced-commands" v-html="referencedCommands"></div>
<div v-if="shouldShowReferencedUsers" class="referenced-users">
<span>
<i class="fa fa-exclamation-triangle" aria-hidden="true"> </i> You are about to add
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i> You are about to add
<strong>
<span class="js-referenced-users-count"> {{ referencedUsers.length }} </span>
<span class="js-referenced-users-count">{{ referencedUsers.length }}</span>
</strong>
people to the discussion. Proceed with caution.
</span>
......
......@@ -17,6 +17,16 @@ export default {
type: Boolean,
required: true,
},
lineContent: {
type: String,
required: false,
default: '',
},
canSuggest: {
type: Boolean,
required: false,
default: true,
},
},
computed: {
mdTable() {
......@@ -27,6 +37,9 @@ export default {
'| cell | cell |',
].join('\n');
},
mdSuggestion() {
return ['```suggestion', `{text}`, '```'].join('\n');
},
},
mounted() {
$(document).on('markdown-preview:show.vue', this.previewMarkdownTab);
......@@ -119,6 +132,16 @@ export default {
:button-title="__('Add a table')"
icon="table"
/>
<toolbar-button
v-if="canSuggest"
:tag="mdSuggestion"
:prepend="true"
:button-title="__('Insert suggestion')"
:cursor-offset="4"
:tag-content="lineContent"
icon="doc-code"
class="qa-suggestion-btn"
/>
<button
v-gl-tooltip
aria-label="Go full screen"
......
<script>
import SuggestionDiffHeader from './suggestion_diff_header.vue';
export default {
components: {
SuggestionDiffHeader,
},
props: {
newLines: {
type: Array,
required: true,
},
fromContent: {
type: String,
required: false,
default: '',
},
fromLine: {
type: Number,
required: true,
},
suggestion: {
type: Object,
required: true,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
helpPagePath: {
type: String,
required: true,
},
},
methods: {
applySuggestion(callback) {
this.$emit('apply', { suggestionId: this.suggestion.id, callback });
},
},
};
</script>
<template>
<div>
<suggestion-diff-header
class="qa-suggestion-diff-header"
:can-apply="suggestion.appliable && suggestion.current_user.can_apply && !disabled"
:is-applied="suggestion.applied"
:help-page-path="helpPagePath"
@apply="applySuggestion"
/>
<table class="mb-3 md-suggestion-diff">
<tbody>
<!-- Old Line -->
<tr class="line_holder old">
<td class="diff-line-num old_line qa-old-diff-line-number old">{{ fromLine }}</td>
<td class="diff-line-num new_line old"></td>
<td class="line_content old">
<span>{{ fromContent }}</span>
</td>
</tr>
<!-- New Line(s) -->
<tr v-for="(line, key) of newLines" :key="key" class="line_holder new">
<td class="diff-line-num old_line new"></td>
<td class="diff-line-num new_line qa-new-diff-line-number new">{{ line.lineNumber }}</td>
<td class="line_content new">
<span>{{ line.content }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script>
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: { Icon },
props: {
canApply: {
type: Boolean,
required: false,
default: false,
},
isApplied: {
type: Boolean,
required: true,
default: false,
},
helpPagePath: {
type: String,
required: true,
},
},
data() {
return {
isAppliedSuccessfully: false,
isApplying: false,
};
},
methods: {
applySuggestion() {
if (!this.canApply) return;
this.isApplying = true;
this.$emit('apply', this.applySuggestionCallback);
},
applySuggestionCallback() {
this.isApplying = false;
},
},
};
</script>
<template>
<div class="md-suggestion-header border-bottom-0 mt-2">
<div class="qa-suggestion-diff-header font-weight-bold">
{{ __('Suggested change') }}
<a v-if="helpPagePath" :href="helpPagePath" :aria-label="__('Help')">
<icon name="question-o" css-classes="link-highlight" />
</a>
</div>
<span v-if="isApplied" class="badge badge-success">{{ __('Applied') }}</span>
<button
v-if="canApply"
type="button"
class="btn qa-apply-btn"
:disabled="isApplying"
@click="applySuggestion"
>
{{ __('Apply suggestion') }}
</button>
</div>
</template>
<script>
import Vue from 'vue';
import SuggestionDiff from './suggestion_diff.vue';
import Flash from '~/flash';
export default {
components: { SuggestionDiff },
props: {
fromLine: {
type: Number,
required: false,
default: 0,
},
fromContent: {
type: String,
required: false,
default: '',
},
lineType: {
type: String,
required: false,
default: '',
},
suggestions: {
type: Array,
required: false,
default: () => [],
},
noteHtml: {
type: String,
required: true,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
helpPagePath: {
type: String,
required: true,
},
},
data() {
return {
isRendered: false,
};
},
watch: {
suggestions() {
this.reset();
},
noteHtml() {
this.reset();
},
},
mounted() {
this.renderSuggestions();
},
methods: {
renderSuggestions() {
// swaps out suggestion(s) markdown with rich diff components
// (while still keeping non-suggestion markdown in place)
if (!this.noteHtml) return;
const { container } = this.$refs;
const suggestionElements = container.querySelectorAll('.js-render-suggestion');
if (this.lineType === 'old') {
Flash('Unable to apply suggestions to a deleted line.', 'alert', this.$el);
}
suggestionElements.forEach((suggestionEl, i) => {
const suggestionParentEl = suggestionEl.parentElement;
const newLines = this.extractNewLines(suggestionParentEl);
const diffComponent = this.generateDiff(newLines, i);
diffComponent.$mount(suggestionParentEl);
});
this.isRendered = true;
},
extractNewLines(suggestionEl) {
// extracts the suggested lines from the markdown
// calculates a line number for each line
const FIRST_CHAR_REGEX = /^(\+|-)/;
const newLines = suggestionEl.querySelectorAll('.line');
const fromLine = this.suggestions.length ? this.suggestions[0].from_line : this.fromLine;
const lines = [];
newLines.forEach((line, i) => {
const content = `${line.innerText.replace(FIRST_CHAR_REGEX, '')}\n`;
const lineNumber = fromLine + i;
lines.push({ content, lineNumber });
});
return lines;
},
generateDiff(newLines, suggestionIndex) {
// generates the diff <suggestion-diff /> component
// all `suggestion` markdown will be swapped out by this component
const { suggestions, disabled, helpPagePath } = this;
const suggestion =
suggestions && suggestions[suggestionIndex] ? suggestions[suggestionIndex] : {};
const fromContent = suggestion.from_content || this.fromContent;
const fromLine = suggestion.from_line || this.fromLine;
const SuggestionDiffComponent = Vue.extend(SuggestionDiff);
const suggestionDiff = new SuggestionDiffComponent({
propsData: { newLines, fromLine, fromContent, disabled, suggestion, helpPagePath },
});
suggestionDiff.$on('apply', ({ suggestionId, callback }) => {
this.$emit('apply', { suggestionId, callback, flashContainer: this.$el });
});
return suggestionDiff;
},
reset() {
// resets the container HTML (replaces it with the updated noteHTML)
// calls `renderSuggestions` once the updated noteHTML is added to the DOM
this.$refs.container.innerHTML = this.noteHtml;
this.isRendered = false;
this.renderSuggestions();
this.$nextTick(() => this.renderSuggestions());
},
},
};
</script>
<template>
<div>
<div class="flash-container mt-3"></div>
<div v-show="isRendered" ref="container" v-html="noteHtml"></div>
</div>
</template>
......@@ -37,6 +37,16 @@ export default {
required: false,
default: false,
},
tagContent: {
type: String,
required: false,
default: '',
},
cursorOffset: {
type: Number,
required: false,
default: 0,
},
},
};
</script>
......@@ -45,8 +55,10 @@ export default {
<button
v-gl-tooltip
:data-md-tag="tag"
:data-md-cursor-offset="cursorOffset"
:data-md-select="tagSelect"
:data-md-block="tagBlock"
:data-md-tag-content="tagContent"
:data-md-prepend="prepend"
:title="buttonTitle"
:aria-label="buttonTitle"
......
......@@ -30,10 +30,14 @@ export default {
computed: {
jobLine() {
if (this.user.bio && this.user.organization) {
return sprintf(__('%{bio} at %{organization}'), {
bio: this.user.bio,
organization: this.user.organization,
});
return sprintf(
__('%{bio} at %{organization}'),
{
bio: this.user.bio,
organization: this.user.organization,
},
false,
);
} else if (this.user.bio) {
return this.user.bio;
} else if (this.user.organization) {
......
......@@ -108,6 +108,7 @@
width: 100%;
height: 100%;
display: flex;
text-decoration: none;
}
.avatar {
......@@ -120,6 +121,7 @@
}
&.s40 { min-width: 40px; min-height: 40px; }
&.s64 { min-width: 64px; min-height: 64px; }
}
.avatar-counter {
......
......@@ -148,8 +148,8 @@
&.btn-xs {
padding: 2px $gl-btn-padding;
font-size: $gl-btn-small-font-size;
line-height: $gl-btn-small-line-height;
font-size: $gl-btn-xs-font-size;
line-height: $gl-btn-xs-line-height;
}
&.btn-success,
......
......@@ -387,3 +387,17 @@ img.emoji {
.mw-460 { max-width: 460px; }
.ws-initial { white-space: initial; }
.min-height-0 { min-height: 0; }
.gl-pl-0 { padding-left: 0; }
.gl-pl-1 { padding-left: #{0.5 * $grid-size}; }
.gl-pl-2 { padding-left: $grid-size; }
.gl-pl-3 { padding-left: #{2 * $grid-size}; }
.gl-pl-4 { padding-left: #{3 * $grid-size}; }
.gl-pl-5 { padding-left: #{4 * $grid-size}; }
.gl-pr-0 { padding-right: 0; }
.gl-pr-1 { padding-right: #{0.5 * $grid-size}; }
.gl-pr-2 { padding-right: $grid-size; }
.gl-pr-3 { padding-right: #{2 * $grid-size}; }
.gl-pr-4 { padding-right: #{3 * $grid-size}; }
.gl-pr-5 { padding-right: #{4 * $grid-size}; }
......@@ -290,6 +290,10 @@
}
}
.dropdown-item {
@include dropdown-link;
}
.divider {
height: 1px;
margin: #{$grid-size / 2} 0;
......@@ -530,8 +534,9 @@
.dropdown-title {
position: relative;
padding: 2px 25px 10px;
margin: 0 10px 10px;
padding: $dropdown-item-padding-y $dropdown-item-padding-x;
padding-bottom: #{2 * $dropdown-item-padding-y};
margin-bottom: $dropdown-item-padding-y;
font-weight: $gl-font-weight-bold;
line-height: 1;
text-align: center;
......
......@@ -22,6 +22,10 @@
.container-fluid {
.navbar-toggler {
border-left: 1px solid lighten($border-and-box-shadow, 10%);
svg {
fill: $search-and-nav-links;
}
}
}
......@@ -309,12 +313,14 @@ body {
.navbar-nav {
> li {
> a:hover,
> a:focus {
> a:focus,
> button:hover {
color: $theme-gray-900;
}
&.active > a,
&.active > a:hover {
&.active > a:hover,
&.active > button {
color: $white-light;
}
}
......
......@@ -33,6 +33,7 @@
.close-icon {
display: block;
margin: auto;
}
}
......@@ -168,12 +169,6 @@
color: currentColor;
background-color: transparent;
}
.more-icon,
.close-icon {
fill: $white-light;
margin: auto;
}
}
.navbar-nav {
......
......@@ -277,6 +277,27 @@
}
}
.md-suggestion-diff {
display: table !important;
border: 1px solid $border-color !important;
}
.md-suggestion-header {
height: $suggestion-header-height;
display: flex;
align-items: center;
justify-content: space-between;
background-color: $gray-light;
border: 1px solid $border-color;
padding: $gl-padding;
border-radius: $border-radius-default $border-radius-default 0 0;
svg {
vertical-align: middle;
margin-bottom: 3px;
}
}
@include media-breakpoint-down(xs) {
.atwho-view-ul {
width: 350px;
......
......@@ -198,6 +198,7 @@ $well-light-text-color: #5b6169;
$gl-font-size: 14px;
$gl-font-size-xs: 11px;
$gl-font-size-small: 12px;
$gl-font-size-medium: 1.43rem;
$gl-font-size-large: 16px;
$gl-font-weight-normal: 400;
$gl-font-weight-bold: 600;
......@@ -251,6 +252,7 @@ $browserScrollbarSize: 10px;
* Misc
*/
$header-height: 40px;
$suggestion-header-height: 46px;
$ide-statusbar-height: 25px;
$fixed-layout-width: 1280px;
$limited-layout-width: 990px;
......@@ -276,6 +278,7 @@ $project-title-row-height: 64px;
$project-avatar-mobile-size: 24px;
$gl-line-height: 16px;
$gl-line-height-24: 24px;
$gl-line-height-14: 14px;
/*
* Common component specific colors
......@@ -369,7 +372,9 @@ $gl-btn-line-height: 16px;
$gl-btn-vert-padding: 8px;
$gl-btn-horz-padding: 12px;
$gl-btn-small-font-size: 13px;
$gl-btn-small-line-height: 13px;
$gl-btn-small-line-height: 18px;
$gl-btn-xs-font-size: 13px;
$gl-btn-xs-line-height: 13px;
/*
* Badges
......
......@@ -49,8 +49,8 @@
.login-box,
.omniauth-container {
box-shadow: 0 0 0 1px $border-color;
border-bottom-right-radius: 2px;
border-bottom-left-radius: 2px;
border-bottom-right-radius: $border-radius-small;
border-bottom-left-radius: $border-radius-small;
padding: 15px;
.login-heading h3 {
......@@ -95,6 +95,7 @@
}
.omniauth-container {
border-radius: $border-radius-small;
font-size: 13px;
p {
......
......@@ -969,34 +969,73 @@ pre.light-well {
@include basic-list-stats;
display: flex;
align-items: center;
}
color: $gl-text-color-secondary;
padding: $gl-padding 0;
h3 {
font-size: $gl-font-size;
@include media-breakpoint-up(lg) {
padding: $gl-padding-24 0;
}
&.no-description {
@include media-breakpoint-up(sm) {
.avatar-container {
align-self: center;
}
.metadata-info {
margin-bottom: 0;
}
}
}
}
.avatar-container,
.controls {
flex: 0 0 auto;
h2 {
font-size: $gl-font-size-medium;
font-weight: $gl-font-weight-bold;
margin-bottom: 0;
@include media-breakpoint-up(sm) {
.namespace-name {
font-weight: $gl-font-weight-normal;
}
}
}
.avatar-container {
flex: 0 0 auto;
align-self: flex-start;
}
.project-details {
min-width: 0;
line-height: $gl-line-height;
.flex-wrapper {
min-width: 0;
margin-top: -$gl-padding-8; // negative margin required for flex-wrap
}
p,
.commit-row-message {
@include str-truncated(100%);
margin-bottom: 0;
}
}
.controls {
margin-left: auto;
text-align: right;
.user-access-role {
margin: 0;
}
@include media-breakpoint-up(md) {
.description {
color: $gl-text-color;
}
}
@include media-breakpoint-down(md) {
.user-access-role {
line-height: $gl-line-height-14;
}
}
}
.ci-status-link {
......@@ -1008,6 +1047,149 @@ pre.light-well {
text-decoration: none;
}
}
.controls {
margin-top: $gl-padding;
@include media-breakpoint-down(md) {
margin-top: 0;
}
@include media-breakpoint-down(xs) {
margin-top: $gl-padding-8;
}
.icon-wrapper {
color: inherit;
margin-right: $gl-padding;
@include media-breakpoint-down(md) {
margin-right: 0;
margin-left: $gl-padding-8;
}
@include media-breakpoint-down(xs) {
&:first-child {
margin-left: 0;
}
}
}
.ci-status-link {
display: inline-flex;
}
}
.star-button {
.icon {
top: 0;
}
}
.icon-container {
@include media-breakpoint-down(xs) {
margin-right: $gl-padding-8;
}
}
&.compact {
.project-row {
padding: $gl-padding 0;
}
h2 {
font-size: $gl-font-size;
}
.avatar-container {
@include avatar-size(40px, 10px);
min-height: 40px;
min-width: 40px;
.identicon.s64 {
font-size: 16px;
}
}
.controls {
@include media-breakpoint-up(sm) {
margin-top: 0;
}
}
.updated-note {
@include media-breakpoint-up(sm) {
margin-top: $gl-padding-8;
}
}
.icon-wrapper {
margin-left: $gl-padding-8;
margin-right: 0;
@include media-breakpoint-down(xs) {
&:first-child {
margin-left: 0;
}
}
}
.user-access-role {
line-height: $gl-line-height-14;
}
}
@include media-breakpoint-down(md) {
h2 {
font-size: $gl-font-size;
}
.avatar-container {
@include avatar-size(40px, 10px);
min-height: 40px;
min-width: 40px;
.identicon.s64 {
font-size: 16px;
}
}
}
@include media-breakpoint-down(md) {
.updated-note {
margin-top: $gl-padding-8;
text-align: right;
}
}
.forks,
.pipeline-status,
.updated-note {
display: flex;
}
@include media-breakpoint-down(md) {
&:not(.explore) {
.forks {
display: none;
}
}
&.explore {
.pipeline-status,
.updated-note {
display: none !important;
}
}
}
@include media-breakpoint-down(xs) {
.updated-note {
margin-top: 0;
text-align: left;
}
}
}
.card .projects-list li {
......
......@@ -12,9 +12,6 @@ class ApplicationController < ActionController::Base
include EnforcesTwoFactorAuthentication
include WithPerformanceBar
include SessionlessAuthentication
# this can be removed after switching to rails 5
# https://gitlab.com/gitlab-org/gitlab-ce/issues/51908
include InvalidUTF8ErrorHandler unless Gitlab.rails5?
before_action :authenticate_user!
before_action :enforce_terms!, if: :should_enforce_terms?
......@@ -157,7 +154,7 @@ class ApplicationController < ActionController::Base
def log_exception(exception)
Gitlab::Sentry.track_acceptable_exception(exception)
backtrace_cleaner = Gitlab.rails5? ? request.env["action_dispatch.backtrace_cleaner"] : env
backtrace_cleaner = request.env["action_dispatch.backtrace_cleaner"]
application_trace = ActionDispatch::ExceptionWrapper.new(backtrace_cleaner, exception).application_trace
application_trace.map! { |t| " #{t}\n" }
logger.error "\n#{exception.class.name} (#{exception.message}):\n#{application_trace.join}"
......
......@@ -18,8 +18,20 @@ class Clusters::ClustersController < Clusters::BaseController
STATUS_POLLING_INTERVAL = 10_000
def index
clusters = ClustersFinder.new(clusterable, current_user, :all).execute
@clusters = clusters.page(params[:page]).per(20)
finder = ClusterAncestorsFinder.new(clusterable.subject, current_user)
clusters = finder.execute
# Note: We are paginating through an array here but this should OK as:
#
# In CE, we can have a maximum group nesting depth of 21, so including
# project cluster, we can have max 22 clusters for a group hierachy.
# In EE (Premium) we can have any number, as multiple clusters are
# supported, but the number of clusters are fairly low currently.
#
# See https://gitlab.com/gitlab-org/gitlab-ce/issues/55260 also.
@clusters = Kaminari.paginate_array(clusters).page(params[:page]).per(20)
@has_ancestor_clusters = finder.has_ancestor_clusters?
end
def new
......
# frozen_string_literal: true
module InvalidUTF8ErrorHandler
extend ActiveSupport::Concern
included do
rescue_from ArgumentError, with: :handle_invalid_utf8
end
private
def handle_invalid_utf8(error)
if error.message == "invalid byte sequence in UTF-8"
render_412
else
raise(error)
end
end
def render_412
respond_to do |format|
format.html { render "errors/precondition_failed", layout: "errors", status: 412 }
format.js { render json: { error: 'Invalid UTF-8' }, status: :precondition_failed, content_type: 'application/json' }
format.any { head :precondition_failed }
end
end
end
......@@ -126,6 +126,8 @@ module IssuableCollections
sort_param = params[:sort]
sort_param ||= user_preference[issuable_sorting_field]
return sort_param if Gitlab::Database.read_only?
if user_preference[issuable_sorting_field] != sort_param
user_preference.update_attribute(issuable_sorting_field, sort_param)
end
......
......@@ -12,7 +12,7 @@ module PreviewMarkdown
when 'wikis' then { pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id] }
when 'snippets' then { skip_project_check: true }
when 'groups' then { group: group }
when 'projects' then { issuable_state_filter_enabled: true }
when 'projects' then projects_filter_params
else {}
end
......@@ -22,9 +22,17 @@ module PreviewMarkdown
body: view_context.markdown(result[:text], markdown_params),
references: {
users: result[:users],
suggestions: result[:suggestions],
commands: view_context.markdown(result[:commands])
}
}
end
def projects_filter_params
{
issuable_state_filter_enabled: true,
suggestions_filter_enabled: params[:preview_suggestions].present?
}
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
end
......@@ -43,6 +43,6 @@ class GraphqlController < ApplicationController
end
def check_graphql_feature_flag!
render_404 unless Feature.enabled?(:graphql)
render_404 unless Gitlab::Graphql.enabled?
end
end
......@@ -268,7 +268,6 @@ class Projects::IssuesController < Projects::ApplicationController
end
def set_suggested_issues_feature_flags
push_frontend_feature_flag(:graphql)
push_frontend_feature_flag(:issue_suggestions)
push_frontend_feature_flag(:graphql, default_enabled: true)
end
end
......@@ -58,11 +58,13 @@ class UsersController < ApplicationController
load_projects
skip_pagination = Gitlab::Utils.to_boolean(params[:skip_pagination])
skip_namespace = Gitlab::Utils.to_boolean(params[:skip_namespace])
compact_mode = Gitlab::Utils.to_boolean(params[:compact_mode])
respond_to do |format|
format.html { render 'show' }
format.json do
pager_json("shared/projects/_list", @projects.count, projects: @projects, skip_pagination: skip_pagination)
pager_json("shared/projects/_list", @projects.count, projects: @projects, skip_pagination: skip_pagination, skip_namespace: skip_namespace, compact_mode: compact_mode)
end
end
end
......
# frozen_string_literal: true
class ClusterAncestorsFinder
include Gitlab::Utils::StrongMemoize
def initialize(clusterable, current_user)
@clusterable = clusterable
@current_user = current_user
end
def execute
return [] unless can_read_clusters?
clusterable.clusters + ancestor_clusters
end
def has_ancestor_clusters?
ancestor_clusters.any?
end
private
attr_reader :clusterable, :current_user
def can_read_clusters?
Ability.allowed?(current_user, :read_cluster, clusterable)
end
# This unfortunately returns an Array, not a Relation!
def ancestor_clusters
strong_memoize(:ancestor_clusters) do
Clusters::Cluster.ancestor_clusters_for_clusterable(clusterable)
end
end
end
# frozen_string_literal: true
class RemoteMirrorFinder
attr_accessor :params
def initialize(params)
@params = params
end
# rubocop: disable CodeReuse/ActiveRecord
def execute
RemoteMirror.find_by(id: params[:id])
end
# rubocop: enable CodeReuse/ActiveRecord
end
......@@ -98,4 +98,29 @@ module EmailsHelper
"#{string} on #{Gitlab.config.gitlab.host}"
end
def create_list_id_string(project, list_id_max_length = 255)
project_path_as_domain = project.full_path.downcase
.split('/').reverse.join('/')
.gsub(%r{[^a-z0-9\/]}, '-')
.gsub(%r{\/+}, '.')
.gsub(/(\A\.+|\.+\z)/, '')
max_domain_length = list_id_max_length - Gitlab.config.gitlab.host.length - project.id.to_s.length - 2
if max_domain_length < 3
return project.id.to_s + "..." + Gitlab.config.gitlab.host
end
if project_path_as_domain.length > max_domain_length
project_path_as_domain = project_path_as_domain.slice(0, max_domain_length)
last_dot_index = project_path_as_domain[0..-2].rindex(".")
last_dot_index ||= max_domain_length - 2
project_path_as_domain = project_path_as_domain.slice(0, last_dot_index).concat("..")
end
project.id.to_s + "." + project_path_as_domain + "." + Gitlab.config.gitlab.host
end
end
......@@ -515,6 +515,20 @@ module ProjectsHelper
end
end
def explore_projects_tab?
current_page?(explore_projects_path) ||
current_page?(trending_explore_projects_path) ||
current_page?(starred_explore_projects_path)
end
def show_merge_request_count?(merge_requests, compact_mode)
merge_requests && !compact_mode && Feature.enabled?(:project_list_show_mr_count, default_enabled: true)
end
def show_issue_count?(issues, compact_mode)
issues && !compact_mode && Feature.enabled?(:project_list_show_issue_count, default_enabled: true)
end
def sidebar_projects_paths
%w[
projects#show
......
......@@ -164,7 +164,7 @@ module SortingHelper
reverse_sort = issuable_reverse_sort_order_hash[sort_value]
if reverse_sort
reverse_url = page_filter_path(sort: reverse_sort)
reverse_url = page_filter_path(sort: reverse_sort, label: true)
else
reverse_url = '#'
link_class += ' disabled'
......
......@@ -6,8 +6,7 @@ module VersionCheckHelper
return unless Gitlab::CurrentSettings.version_check_enabled
return if User.single_user&.requires_usage_stats_consent?
image_url = VersionCheck.new.url
image_tag image_url, class: 'js-version-status-badge'
image_tag VersionCheck.url, class: 'js-version-status-badge'
end
def link_to_version
......
# frozen_string_literal: true
module Emails
module RemoteMirrors
def remote_mirror_update_failed_email(remote_mirror_id, recipient_id)
@remote_mirror = RemoteMirrorFinder.new(id: remote_mirror_id).execute
@project = @remote_mirror.project
mail(to: recipient(recipient_id), subject: subject('Remote mirror update failed'))
end
end
end
......@@ -3,6 +3,7 @@
class Notify < BaseMailer
include ActionDispatch::Routing::PolymorphicRoutes
include GitlabRoutingHelper
include EmailsHelper
include Emails::Issues
include Emails::MergeRequests
......@@ -13,6 +14,7 @@ class Notify < BaseMailer
include Emails::Pipelines
include Emails::Members
include Emails::AutoDevops
include Emails::RemoteMirrors
helper MergeRequestsHelper
helper DiffHelper
......@@ -128,7 +130,7 @@ class Notify < BaseMailer
address.display_name = reply_display_name(model)
end
fallback_reply_message_id = "<reply-#{reply_key}@#{Gitlab.config.gitlab.host}>".freeze
fallback_reply_message_id = "<reply-#{reply_key}@#{Gitlab.config.gitlab.host}>"
headers['References'] ||= []
headers['References'].unshift(fallback_reply_message_id)
......@@ -178,7 +180,7 @@ class Notify < BaseMailer
headers['X-GitLab-Discussion-ID'] = note.discussion.id if note.part_of_discussion?
headers[:subject]&.prepend('Re: ')
headers[:subject] = "Re: #{headers[:subject]}" if headers[:subject]
mail_thread(model, headers)
end
......@@ -193,6 +195,7 @@ class Notify < BaseMailer
headers['X-GitLab-Project'] = @project.name
headers['X-GitLab-Project-Id'] = @project.id
headers['X-GitLab-Project-Path'] = @project.full_path
headers['List-Id'] = "#{@project.full_path} <#{create_list_id_string(@project)}>"
end
def add_unsubscription_headers_and_links
......
......@@ -145,6 +145,10 @@ class NotifyPreview < ActionMailer::Preview
Notify.autodevops_disabled_email(pipeline, user.email).message
end
def remote_mirror_update_failed_email
Notify.remote_mirror_update_failed_email(remote_mirror.id, user.id).message
end
private
def project
......@@ -167,6 +171,10 @@ class NotifyPreview < ActionMailer::Preview
@pipeline = Ci::Pipeline.last
end
def remote_mirror
@remote_mirror ||= RemoteMirror.last
end
def user
@user ||= User.last
end
......
# frozen_string_literal: true
module Ci
class Bridge < CommitStatus
include Importable
include AfterCommitQueue
include Gitlab::Utils::StrongMemoize
belongs_to :project
validates :ref, presence: true
def self.retry(bridge, current_user)
raise NotImplementedError
end
def tags
[:bridge]
end
def detailed_status(current_user)
Gitlab::Ci::Status::Bridge::Factory
.new(self, current_user)
.fabricate!
end
def predefined_variables
raise NotImplementedError
end
def execute_hooks
raise NotImplementedError
end
end
end
......@@ -742,7 +742,7 @@ module Ci
def collect_test_reports!(test_reports)
test_reports.get_suite(group_name).tap do |test_suite|
each_report(Ci::JobArtifact::TEST_REPORT_FILE_TYPES) do |file_type, blob|
Gitlab::Ci::Parsers::Test.fabricate!(file_type).parse!(blob, test_suite)
Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, test_suite)
end
end
end
......
......@@ -56,11 +56,7 @@ module Ci
validates :tag, inclusion: { in: [false], if: :merge_request? }
validates :status, presence: { unless: :importing? }
validate :valid_commit_sha, unless: :importing?
# Replace validator below with
# `validates :source, presence: { unless: :importing? }, on: :create`
# when removing Gitlab.rails5? code.
validate :valid_source, unless: :importing?, on: :create
validates :source, exclusion: { in: %w(unknown), unless: :importing? }, on: :create
after_create :keep_around_commits, unless: :importing?
......@@ -68,11 +64,7 @@ module Ci
# this `Hash` with new values.
enum_with_nil source: ::Ci::PipelineEnums.sources
enum_with_nil config_source: {
unknown_source: nil,
repository_source: 1,
auto_devops_source: 2
}
enum_with_nil config_source: ::Ci::PipelineEnums.config_sources
# We use `Ci::PipelineEnums.failure_reasons` here so that EE can more easily
# extend this `Hash` with new values.
......@@ -742,11 +734,5 @@ module Ci
project.repository.keep_around(self.sha, self.before_sha)
end
def valid_source
if source.nil? || source == "unknown"
errors.add(:source, "invalid source")
end
end
end
end
......@@ -25,5 +25,15 @@ module Ci
merge_request: 10
}
end
# Returns the `Hash` to use for creating the `config_sources` enum for
# `Ci::Pipeline`.
def self.config_sources
{
unknown_source: nil,
repository_source: 1,
auto_devops_source: 2
}
end
end
end
......@@ -58,8 +58,7 @@ module Ci
# BACKWARD COMPATIBILITY: There are needed to maintain compatibility with `AVAILABLE_SCOPES` used by `lib/api/runners.rb`
scope :deprecated_shared, -> { instance_type }
# this should get replaced with `project_type.or(group_type)` once using Rails5
scope :deprecated_specific, -> { where(runner_type: [runner_types[:project_type], runner_types[:group_type]]) }
scope :deprecated_specific, -> { project_type.or(group_type) }
scope :belonging_to_project, -> (project_id) {
joins(:runner_projects).where(ci_runner_projects: { project_id: project_id })
......
......@@ -3,7 +3,7 @@
module Clusters
module Applications
class Knative < ActiveRecord::Base
VERSION = '0.1.3'.freeze
VERSION = '0.2.2'.freeze
REPOSITORY = 'https://storage.googleapis.com/triggermesh-charts'.freeze
FETCH_IP_ADDRESS_DELAY = 30.seconds
......
......@@ -16,7 +16,7 @@ module EnumWithNil
# E.g. for enum_with_nil failure_reason: { unknown_failure: nil }
# this overrides auto-generated method `unknown_failure?`
define_method("#{key_with_nil}?") do
Gitlab.rails5? ? self[name].nil? : super()
self[name].nil?
end
# E.g. for enum_with_nil failure_reason: { unknown_failure: nil }
......@@ -24,7 +24,6 @@ module EnumWithNil
define_method(name) do
orig = super()
return orig unless Gitlab.rails5?
return orig unless orig.nil?
self.class.public_send(name.to_s.pluralize).key(nil) # rubocop:disable GitlabSecurity/PublicSend
......
......@@ -26,6 +26,10 @@ module Noteable
DiscussionNote.noteable_types.include?(base_class_name)
end
def supports_suggestion?
false
end
def discussions_rendered_on_frontend?
false
end
......
......@@ -49,10 +49,6 @@ module RedisCacheable
end
def cast_value_from_cache(attribute, value)
if Gitlab.rails5?
self.class.type_for_attribute(attribute.to_s).cast(value)
else
self.class.column_for_attribute(attribute).type_cast_from_database(value)
end
self.class.type_for_attribute(attribute.to_s).cast(value)
end
end
......@@ -66,10 +66,23 @@ class DiffNote < Note
self.original_position.diff_refs == diff_refs
end
def supports_suggestion?
return false unless noteable.supports_suggestion? && on_text?
# We don't want to trigger side-effects of `diff_file` call.
return false unless file = fetch_diff_file
return false unless line = file.line_for_position(self.original_position)
line&.suggestible?
end
def discussion_first_note?
self == discussion.first_note
end
def banzai_render_context(field)
super.merge(suggestions_filter_enabled: supports_suggestion?)
end
private
def enqueue_diff_file_creation_job
......
......@@ -114,19 +114,6 @@ class Event < ActiveRecord::Base
end
end
# Remove this method when removing Gitlab.rails5? code.
def subclass_from_attributes(attrs)
return super if Gitlab.rails5?
# Without this Rails will keep calling this method on the returned class,
# resulting in an infinite loop.
return unless self == Event
action = attrs.with_indifferent_access[inheritance_column].to_i
PushEvent if action == PUSHED
end
# Update Gitlab::ContributionsCalendar#activity_dates if this changes
def contributions
where("action = ? OR (target_type IN (?) AND action IN (?)) OR (target_type = ? AND action = ?)",
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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