summaryrefslogtreecommitdiff
path: root/app/assets
diff options
context:
space:
mode:
authorPawel Chojnacki <pawel@chojnacki.ws>2017-06-07 10:27:52 +0200
committerPawel Chojnacki <pawel@chojnacki.ws>2017-06-07 10:27:52 +0200
commitdbb3c28088b63c8cf40a90c599b6eedb4dfbbb66 (patch)
tree9e6c4d495e45355ef62c56c0c3e102f95220f89f /app/assets
parenta924152219c1367bf494f3f387d050ac3ff2d7d3 (diff)
parentdddc54aa0aea4088e5a233d18a62cb2435590fe9 (diff)
downloadgitlab-ce-dbb3c28088b63c8cf40a90c599b6eedb4dfbbb66.tar.gz
Merge remote-tracking branch 'upstream/master' into 28717-additional-metrics-review-branch
# Conflicts: # app/models/project_services/prometheus_service.rb # app/views/projects/services/_form.html.haml
Diffstat (limited to 'app/assets')
-rw-r--r--app/assets/images/i2p-step.svg4
-rw-r--r--app/assets/javascripts/activities.js5
-rw-r--r--app/assets/javascripts/blob/viewer/index.js2
-rw-r--r--app/assets/javascripts/boards/boards_bundle.js1
-rw-r--r--app/assets/javascripts/boards/components/board_list.js13
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.js2
-rw-r--r--app/assets/javascripts/boards/components/modal/filters.js1
-rw-r--r--app/assets/javascripts/boards/filtered_search_boards.js8
-rw-r--r--app/assets/javascripts/boards/models/list.js8
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js1
-rw-r--r--app/assets/javascripts/build.js336
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.js2
-rw-r--r--app/assets/javascripts/commits.js37
-rw-r--r--app/assets/javascripts/deploy_keys/components/app.vue2
-rw-r--r--app/assets/javascripts/diff_notes/components/jump_to_discussion.js7
-rw-r--r--app/assets/javascripts/dispatcher.js20
-rw-r--r--app/assets/javascripts/droplab/keyboard.js2
-rw-r--r--app/assets/javascripts/droplab/plugins/ajax_filter.js3
-rw-r--r--app/assets/javascripts/dropzone_input.js1
-rw-r--r--app/assets/javascripts/environments/components/environment.vue99
-rw-r--r--app/assets/javascripts/environments/components/environment_actions.vue2
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue59
-rw-r--r--app/assets/javascripts/environments/components/environment_monitoring.vue2
-rw-r--r--app/assets/javascripts/environments/components/environment_rollback.vue2
-rw-r--r--app/assets/javascripts/environments/components/environment_stop.vue2
-rw-r--r--app/assets/javascripts/environments/components/environment_terminal_button.vue2
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue109
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_view.vue100
-rw-r--r--app/assets/javascripts/environments/mixins/environments_mixin.js17
-rw-r--r--app/assets/javascripts/environments/services/environments_service.js3
-rw-r--r--app/assets/javascripts/environments/stores/environments_store.js6
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_user.js8
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_utils.js5
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_bundle.js2
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js74
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js97
-rw-r--r--app/assets/javascripts/flash.js34
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js15
-rw-r--r--app/assets/javascripts/gl_field_errors.js10
-rw-r--r--app/assets/javascripts/importer_status.js2
-rw-r--r--app/assets/javascripts/integrations/index.js7
-rw-r--r--app/assets/javascripts/integrations/integration_settings_form.js123
-rw-r--r--app/assets/javascripts/issuable_bulk_update_actions.js159
-rw-r--r--app/assets/javascripts/issuable_bulk_update_sidebar.js165
-rw-r--r--app/assets/javascripts/issuable_index.js (renamed from app/assets/javascripts/issuable.js)81
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue217
-rw-r--r--app/assets/javascripts/issue_show/components/description.vue15
-rw-r--r--app/assets/javascripts/issue_show/components/edit_actions.vue79
-rw-r--r--app/assets/javascripts/issue_show/components/edited.vue56
-rw-r--r--app/assets/javascripts/issue_show/components/fields/confidential_checkbox.vue23
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description.vue54
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description_template.vue111
-rw-r--r--app/assets/javascripts/issue_show/components/fields/project_move.vue83
-rw-r--r--app/assets/javascripts/issue_show/components/fields/title.vue31
-rw-r--r--app/assets/javascripts/issue_show/components/form.vue104
-rw-r--r--app/assets/javascripts/issue_show/components/locked_warning.vue20
-rw-r--r--app/assets/javascripts/issue_show/event_hub.js3
-rw-r--r--app/assets/javascripts/issue_show/index.js84
-rw-r--r--app/assets/javascripts/issue_show/mixins/animate.js2
-rw-r--r--app/assets/javascripts/issue_show/mixins/update.js10
-rw-r--r--app/assets/javascripts/issue_show/services/index.js17
-rw-r--r--app/assets/javascripts/issue_show/stores/index.js31
-rw-r--r--app/assets/javascripts/issues_bulk_assignment.js166
-rw-r--r--app/assets/javascripts/labels_select.js15
-rw-r--r--app/assets/javascripts/lib/utils/ajax_cache.js4
-rw-r--r--app/assets/javascripts/lib/utils/notify.js85
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js2
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js3
-rw-r--r--app/assets/javascripts/locale/zh_CN/app.js1
-rw-r--r--app/assets/javascripts/locale/zh_HK/app.js1
-rw-r--r--app/assets/javascripts/locale/zh_TW/app.js1
-rw-r--r--app/assets/javascripts/main.js4
-rw-r--r--app/assets/javascripts/notebook/cells/markdown.vue24
-rw-r--r--app/assets/javascripts/notes.js101
-rw-r--r--app/assets/javascripts/pager.js5
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue97
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_url.js56
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_url.vue65
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js41
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_mediatior.js8
-rw-r--r--app/assets/javascripts/pipelines/pipelines.js2
-rw-r--r--app/assets/javascripts/pipelines/services/pipeline_service.js5
-rw-r--r--app/assets/javascripts/pipelines/services/pipelines_service.js2
-rw-r--r--app/assets/javascripts/project_new.js63
-rw-r--r--app/assets/javascripts/project_select.js9
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_dropdown.js4
-rw-r--r--app/assets/javascripts/settings_panels.js27
-rw-r--r--app/assets/javascripts/shortcuts_issuable.js4
-rw-r--r--app/assets/javascripts/task_list.js2
-rw-r--r--app/assets/javascripts/user_callout.js11
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/dependencies.js1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/index.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js12
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/commit.js10
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue53
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue107
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue113
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue33
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue58
-rw-r--r--app/assets/javascripts/vue_shared/components/pipelines_table_row.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue8
-rw-r--r--app/assets/javascripts/vue_shared/mixins/tooltip.js4
-rw-r--r--app/assets/stylesheets/framework.scss1
-rw-r--r--app/assets/stylesheets/framework/common.scss6
-rw-r--r--app/assets/stylesheets/framework/files.scss2
-rw-r--r--app/assets/stylesheets/framework/filters.scss59
-rw-r--r--app/assets/stylesheets/framework/flash.scss16
-rw-r--r--app/assets/stylesheets/framework/mobile.scss9
-rw-r--r--app/assets/stylesheets/framework/responsive-tables.scss90
-rw-r--r--app/assets/stylesheets/framework/sidebar.scss36
-rw-r--r--app/assets/stylesheets/framework/timeline.scss4
-rw-r--r--app/assets/stylesheets/framework/variables.scss10
-rw-r--r--app/assets/stylesheets/pages/boards.scss41
-rw-r--r--app/assets/stylesheets/pages/builds.scss227
-rw-r--r--app/assets/stylesheets/pages/convdev_index.scss255
-rw-r--r--app/assets/stylesheets/pages/environments.scss77
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss8
-rw-r--r--app/assets/stylesheets/pages/note_form.scss19
-rw-r--r--app/assets/stylesheets/pages/notes.scss6
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss8
-rw-r--r--app/assets/stylesheets/pages/profile.scss25
-rw-r--r--app/assets/stylesheets/pages/projects.scss20
-rw-r--r--app/assets/stylesheets/pages/settings.scss87
124 files changed, 3702 insertions, 1099 deletions
diff --git a/app/assets/images/i2p-step.svg b/app/assets/images/i2p-step.svg
new file mode 100644
index 00000000000..8886092ed82
--- /dev/null
+++ b/app/assets/images/i2p-step.svg
@@ -0,0 +1,4 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 120" enable-background="new 0 0 12 120">
+ <path d="m12 6c0-3.309-2.691-6-6-6s-6 2.691-6 6c0 2.967 2.167 5.431 5 5.91v108.09h2v-108.09c2.833-.479 5-2.943 5-5.91m-6 4c-2.206 0-4-1.794-4-4s1.794-4 4-4 4 1.794 4 4-1.794 4-4 4"/>
+</svg>
diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js
index d816df831eb..5d060165f4b 100644
--- a/app/assets/javascripts/activities.js
+++ b/app/assets/javascripts/activities.js
@@ -5,7 +5,8 @@ import Cookies from 'js-cookie';
class Activities {
constructor() {
- Pager.init(20, true, false, this.updateTooltips);
+ Pager.init(20, true, false, data => data, this.updateTooltips);
+
$('.event-filter-link').on('click', (e) => {
e.preventDefault();
this.toggleFilter(e.currentTarget);
@@ -19,7 +20,7 @@ class Activities {
reloadActivities() {
$('.content_list').html('');
- Pager.init(20, true, false, this.updateTooltips);
+ Pager.init(20, true, false, data => data, this.updateTooltips);
}
toggleFilter(sender) {
diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js
index d7c62889dde..187fab084fd 100644
--- a/app/assets/javascripts/blob/viewer/index.js
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -111,7 +111,7 @@ export default class BlobViewer {
BlobViewer.loadViewer(newViewer)
.then((viewer) => {
- $(viewer).syntaxHighlight();
+ $(viewer).renderGFM();
this.$fileHolder.trigger('highlight:line');
gl.utils.handleLocationHash();
diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js
index e0a6f64dd42..0e4aa39226b 100644
--- a/app/assets/javascripts/boards/boards_bundle.js
+++ b/app/assets/javascripts/boards/boards_bundle.js
@@ -70,6 +70,7 @@ $(() => {
gl.boardService = new BoardService(this.endpoint, this.bulkUpdatePath, this.boardId);
this.filterManager = new FilteredSearchBoards(Store.filter, true);
+ this.filterManager.setup();
// Listen for updateTokens event
eventHub.$on('updateTokens', this.updateTokens);
diff --git a/app/assets/javascripts/boards/components/board_list.js b/app/assets/javascripts/boards/components/board_list.js
index 7ee2696e720..bebca17fb1e 100644
--- a/app/assets/javascripts/boards/components/board_list.js
+++ b/app/assets/javascripts/boards/components/board_list.js
@@ -57,6 +57,9 @@ export default {
scrollTop() {
return this.$refs.list.scrollTop + this.listHeight();
},
+ scrollToTop() {
+ this.$refs.list.scrollTop = 0;
+ },
loadNextPage() {
const getIssues = this.list.nextPage();
const loadingDone = () => {
@@ -108,6 +111,7 @@ export default {
},
created() {
eventHub.$on(`hide-issue-form-${this.list.id}`, this.toggleForm);
+ eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop);
},
mounted() {
const options = gl.issueBoards.getBoardSortableDefaultOptions({
@@ -150,6 +154,7 @@ export default {
},
beforeDestroy() {
eventHub.$off(`hide-issue-form-${this.list.id}`, this.toggleForm);
+ eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop);
this.$refs.list.removeEventListener('scroll', this.onScroll);
},
template: `
@@ -160,9 +165,11 @@ export default {
v-if="loading">
<loading-icon />
</div>
- <board-new-issue
- :list="list"
- v-if="list.type !== 'closed' && showIssueForm"/>
+ <transition name="slide-down">
+ <board-new-issue
+ :list="list"
+ v-if="list.type !== 'closed' && showIssueForm"/>
+ </transition>
<ul
class="board-list"
v-show="!loading"
diff --git a/app/assets/javascripts/boards/components/board_new_issue.js b/app/assets/javascripts/boards/components/board_new_issue.js
index 1ce95b62138..b1c47b09c35 100644
--- a/app/assets/javascripts/boards/components/board_new_issue.js
+++ b/app/assets/javascripts/boards/components/board_new_issue.js
@@ -48,6 +48,7 @@ export default {
this.error = true;
});
+ eventHub.$emit(`scroll-board-list-${this.list.id}`);
this.cancel();
},
cancel() {
@@ -75,6 +76,7 @@ export default {
type="text"
v-model="title"
ref="input"
+ autocomplete="off"
:id="list.id + '-title'" />
<div class="clearfix prepend-top-10">
<button class="btn btn-success pull-left"
diff --git a/app/assets/javascripts/boards/components/modal/filters.js b/app/assets/javascripts/boards/components/modal/filters.js
index b214b5a7199..56a0fde5a91 100644
--- a/app/assets/javascripts/boards/components/modal/filters.js
+++ b/app/assets/javascripts/boards/components/modal/filters.js
@@ -13,6 +13,7 @@ export default {
FilteredSearchContainer.container = this.$el;
this.filteredSearch = new FilteredSearchBoards(this.store);
+ this.filteredSearch.setup();
this.filteredSearch.removeTokens();
this.filteredSearch.handleInputPlaceholder();
this.filteredSearch.toggleClearSearchButton();
diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js
index 1264280284c..b37698fe9ca 100644
--- a/app/assets/javascripts/boards/filtered_search_boards.js
+++ b/app/assets/javascripts/boards/filtered_search_boards.js
@@ -2,7 +2,7 @@
import FilteredSearchContainer from '../filtered_search/container';
export default class FilteredSearchBoards extends gl.FilteredSearchManager {
- constructor(store, updateUrl = false) {
+ constructor(store, updateUrl = false, cantEdit = []) {
super('boards');
this.store = store;
@@ -11,6 +11,8 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager {
// Issue boards is slightly different, we handle all the requests async
// instead or reloading the page, we just re-fire the list ajax requests
this.isHandledAsync = true;
+
+ this.cantEdit = cantEdit;
}
updateObject(path) {
@@ -40,4 +42,8 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager {
// Get the placeholder back if search is empty
this.filteredSearchInput.dispatchEvent(new Event('input'));
}
+
+ canEdit(tokenName) {
+ return this.cantEdit.indexOf(tokenName) === -1;
+ }
}
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index 90561d0f7a8..8514e7df1d3 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -103,13 +103,19 @@ class List {
}
newIssue (issue) {
- this.addIssue(issue);
+ this.addIssue(issue, null, 0);
this.issuesSize += 1;
return gl.boardService.newIssue(this.id, issue)
.then((resp) => {
const data = resp.json();
issue.id = data.iid;
+ })
+ .then(() => {
+ if (this.issuesSize > 1) {
+ const moveBeforeIid = this.issues[1].id;
+ gl.boardService.moveIssue(issue.id, null, null, null, moveBeforeIid);
+ }
});
}
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index ad9997ac334..7bddcdc3c1d 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -22,6 +22,7 @@ gl.issueBoards.BoardsStore = {
create () {
this.state.lists = [];
this.filter.path = gl.utils.getUrlParamsArray().join('&');
+ this.detail = { issue: {} };
},
addList (listObj, defaultAvatar) {
const list = new List(listObj, defaultAvatar);
diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js
index 97f279e4be4..072a899e9f2 100644
--- a/app/assets/javascripts/build.js
+++ b/app/assets/javascripts/build.js
@@ -2,15 +2,11 @@
consistent-return, prefer-rest-params */
/* global Breakpoints */
+import _ from 'underscore';
import { bytesToKiB } from './lib/utils/number_utils';
-const bind = function (fn, me) { return function () { return fn.apply(me, arguments); }; };
-const AUTO_SCROLL_OFFSET = 75;
-const DOWN_BUILD_TRACE = '#down-build-trace';
-
window.Build = (function () {
Build.timeout = null;
-
Build.state = null;
function Build(options) {
@@ -23,21 +19,22 @@ window.Build = (function () {
this.buildStage = this.options.buildStage;
this.$document = $(document);
this.logBytes = 0;
+ this.scrollOffsetPadding = 30;
- this.updateDropdown = bind(this.updateDropdown, this);
+ this.updateDropdown = this.updateDropdown.bind(this);
+ this.getBuildTrace = this.getBuildTrace.bind(this);
+ this.scrollToBottom = this.scrollToBottom.bind(this);
this.$body = $('body');
this.$buildTrace = $('#build-trace');
- this.$autoScrollContainer = $('.autoscroll-container');
- this.$autoScrollStatus = $('#autoscroll-status');
- this.$autoScrollStatusText = this.$autoScrollStatus.find('.status-text');
- this.$upBuildTrace = $('#up-build-trace');
- this.$downBuildTrace = $(DOWN_BUILD_TRACE);
- this.$scrollTopBtn = $('#scroll-top');
- this.$scrollBottomBtn = $('#scroll-bottom');
this.$buildRefreshAnimation = $('.js-build-refresh');
- this.$buildScroll = $('#js-build-scroll');
this.$truncatedInfo = $('.js-truncated-info');
+ this.$buildTraceOutput = $('.js-build-output');
+ this.$scrollContainer = $('.js-scroll-container');
+
+ // Scroll controllers
+ this.$scrollTopBtn = $('.js-scroll-up');
+ this.$scrollBottomBtn = $('.js-scroll-down');
clearTimeout(Build.timeout);
// Init breakpoint checker
@@ -56,54 +53,149 @@ window.Build = (function () {
.off('click', '.stage-item')
.on('click', '.stage-item', this.updateDropdown);
- this.$document.on('scroll', this.initScrollMonitor.bind(this));
+ // add event listeners to the scroll buttons
+ this.$scrollTopBtn
+ .off('click')
+ .on('click', this.scrollToTop.bind(this));
+
+ this.$scrollBottomBtn
+ .off('click')
+ .on('click', this.scrollToBottom.bind(this));
$(window)
.off('resize.build')
- .on('resize.build', this.sidebarOnResize.bind(this));
-
- $('a', this.$buildScroll)
- .off('click.stepTrace')
- .on('click.stepTrace', this.stepTrace);
+ .on('resize.build', _.throttle(this.sidebarOnResize.bind(this), 100));
this.updateArtifactRemoveDate();
- this.initScrollButtonAffix();
- this.invokeBuildTrace();
+
+ // eslint-disable-next-line
+ this.getBuildTrace()
+ .then(() => this.makeTraceScrollable())
+ .then(() => this.scrollToBottom());
+
+ this.verifyTopPosition();
}
+ Build.prototype.makeTraceScrollable = function () {
+ this.$scrollContainer.niceScroll({
+ cursorcolor: '#fff',
+ cursoropacitymin: 1,
+ cursorwidth: '3px',
+ railpadding: { top: 5, bottom: 5, right: 5 },
+ });
+
+ this.$scrollContainer.on('scroll', _.throttle(this.toggleScroll.bind(this), 100));
+
+ this.toggleScroll();
+ };
+
+ Build.prototype.canScroll = function () {
+ return (this.$scrollContainer.prop('scrollHeight') - this.scrollOffsetPadding) > this.$scrollContainer.height();
+ };
+
+ /**
+ * | | Up | Down |
+ * |--------------------------|----------|----------|
+ * | on scroll bottom | active | disabled |
+ * | on scroll top | disabled | active |
+ * | no scroll | disabled | disabled |
+ * | on.('scroll') is on top | disabled | active |
+ * | on('scroll) is on bottom | active | disabled |
+ *
+ */
+ Build.prototype.toggleScroll = function () {
+ const bottomScroll = this.$scrollContainer.scrollTop() +
+ this.scrollOffsetPadding +
+ this.$scrollContainer.height();
+
+ if (this.canScroll()) {
+ if (this.$scrollContainer.scrollTop() === 0) {
+ this.toggleDisableButton(this.$scrollTopBtn, true);
+ this.toggleDisableButton(this.$scrollBottomBtn, false);
+ } else if (bottomScroll === this.$scrollContainer.prop('scrollHeight')) {
+ this.toggleDisableButton(this.$scrollTopBtn, false);
+ this.toggleDisableButton(this.$scrollBottomBtn, true);
+ } else {
+ this.toggleDisableButton(this.$scrollTopBtn, false);
+ this.toggleDisableButton(this.$scrollBottomBtn, false);
+ }
+ }
+ };
+
+ Build.prototype.scrollToTop = function () {
+ this.$scrollContainer.getNiceScroll(0).doScrollTop(0);
+ this.toggleScroll();
+ };
+
+ Build.prototype.scrollToBottom = function () {
+ this.$scrollContainer.getNiceScroll(0).doScrollTo(this.$scrollContainer.prop('scrollHeight'));
+ this.toggleScroll();
+ };
+
+ Build.prototype.toggleDisableButton = function ($button, disable) {
+ if (disable && $button.prop('disabled')) return;
+ $button.prop('disabled', disable);
+ };
+
+ Build.prototype.toggleScrollAnimation = function (toggle) {
+ this.$scrollBottomBtn.toggleClass('animate', toggle);
+ };
+
+ /**
+ * Build trace top position depends on the space ocupied by the elments rendered before
+ */
+ Build.prototype.verifyTopPosition = function () {
+ const $buildPage = $('.build-page');
+
+ const $header = $('.build-header', $buildPage);
+ const $runnersStuck = $('.js-build-stuck', $buildPage);
+ const $startsEnvironment = $('.js-environment-container', $buildPage);
+ const $erased = $('.js-build-erased', $buildPage);
+
+ let topPostion = 168;
+
+ if ($header) {
+ topPostion += $header.outerHeight();
+ }
+
+ if ($runnersStuck) {
+ topPostion += $runnersStuck.outerHeight();
+ }
+
+ if ($startsEnvironment) {
+ topPostion += $startsEnvironment.outerHeight();
+ }
+
+ if ($erased) {
+ topPostion += $erased.outerHeight() + 10;
+ }
+
+ this.$buildTrace.css({
+ top: topPostion,
+ });
+ };
+
Build.prototype.initSidebar = function () {
this.$sidebar = $('.js-build-sidebar');
this.$sidebar.niceScroll();
- this.$document
- .off('click', '.js-sidebar-build-toggle')
- .on('click', '.js-sidebar-build-toggle', this.toggleSidebar);
- };
-
- Build.prototype.invokeBuildTrace = function () {
- return this.getBuildTrace();
};
Build.prototype.getBuildTrace = function () {
return $.ajax({
url: `${this.pageUrl}/trace.json`,
- dataType: 'json',
- data: {
- state: this.state,
- },
- success: ((log) => {
- const $buildContainer = $('.js-build-output');
-
+ data: this.state,
+ })
+ .done((log) => {
gl.utils.setCiStatusFavicon(`${this.pageUrl}/status.json`);
-
if (log.state) {
this.state = log.state;
}
if (log.append) {
- $buildContainer.append(log.html);
+ this.$buildTraceOutput.append(log.html);
this.logBytes += log.size;
} else {
- $buildContainer.html(log.html);
+ this.$buildTraceOutput.html(log.html);
this.logBytes = log.size;
}
@@ -114,141 +206,30 @@ window.Build = (function () {
const size = bytesToKiB(this.logBytes);
$('.js-truncated-info-size').html(`${size}`);
this.$truncatedInfo.removeClass('hidden');
- this.initAffixTruncatedInfo();
} else {
this.$truncatedInfo.addClass('hidden');
}
- this.checkAutoscroll();
-
if (!log.complete) {
+ this.toggleScrollAnimation(true);
+
Build.timeout = setTimeout(() => {
- this.invokeBuildTrace();
+ //eslint-disable-next-line
+ this.getBuildTrace()
+ .then(() => this.scrollToBottom());
}, 4000);
} else {
this.$buildRefreshAnimation.remove();
+ this.toggleScrollAnimation(false);
}
if (log.status !== this.buildStatus) {
- let pageUrl = this.pageUrl;
-
- if (this.$autoScrollStatus.data('state') === 'enabled') {
- pageUrl += DOWN_BUILD_TRACE;
- }
-
- gl.utils.visitUrl(pageUrl);
+ gl.utils.visitUrl(this.pageUrl);
}
- }),
- error: () => {
+ })
+ .fail(() => {
this.$buildRefreshAnimation.remove();
- return this.initScrollMonitor();
- },
- });
- };
-
- Build.prototype.checkAutoscroll = function () {
- if (this.$autoScrollStatus.data('state') === 'enabled') {
- return $('html,body').scrollTop(this.$buildTrace.height());
- }
-
- // Handle a situation where user started new build
- // but never scrolled a page
- if (!this.$scrollTopBtn.is(':visible') &&
- !this.$scrollBottomBtn.is(':visible') &&
- !gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
- this.$scrollBottomBtn.show();
- }
- };
-
- Build.prototype.initScrollButtonAffix = function () {
- // Hide everything initially
- this.$scrollTopBtn.hide();
- this.$scrollBottomBtn.hide();
- this.$autoScrollContainer.hide();
- };
-
- // Page scroll listener to detect if user has scrolling page
- // and handle following cases
- // 1) User is at Top of Build Log;
- // - Hide Top Arrow button
- // - Show Bottom Arrow button
- // - Disable Autoscroll and hide indicator (when build is running)
- // 2) User is at Bottom of Build Log;
- // - Show Top Arrow button
- // - Hide Bottom Arrow button
- // - Enable Autoscroll and show indicator (when build is running)
- // 3) User is somewhere in middle of Build Log;
- // - Show Top Arrow button
- // - Show Bottom Arrow button
- // - Disable Autoscroll and hide indicator (when build is running)
- Build.prototype.initScrollMonitor = function () {
- if (!gl.utils.isInViewport(this.$upBuildTrace.get(0)) &&
- !gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
- // User is somewhere in middle of Build Log
-
- this.$scrollTopBtn.show();
-
- if (this.buildStatus === 'success' || this.buildStatus === 'failed') { // Check if Build is completed
- this.$scrollBottomBtn.show();
- } else if (this.$buildRefreshAnimation.is(':visible') &&
- !gl.utils.isInViewport(this.$buildRefreshAnimation.get(0))) {
- this.$scrollBottomBtn.show();
- } else {
- this.$scrollBottomBtn.hide();
- }
-
- // Hide Autoscroll Status Indicator
- if (this.$scrollBottomBtn.is(':visible')) {
- this.$autoScrollContainer.hide();
- this.$autoScrollStatusText.removeClass('animate');
- } else {
- this.$autoScrollContainer.css({
- top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET,
- }).show();
- this.$autoScrollStatusText.addClass('animate');
- }
- } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) &&
- !gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
- // User is at Top of Build Log
-
- this.$scrollTopBtn.hide();
- this.$scrollBottomBtn.show();
-
- this.$autoScrollContainer.hide();
- this.$autoScrollStatusText.removeClass('animate');
- } else if ((!gl.utils.isInViewport(this.$upBuildTrace.get(0)) &&
- gl.utils.isInViewport(this.$downBuildTrace.get(0))) ||
- (this.$buildRefreshAnimation.is(':visible') &&
- gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)))) {
- // User is at Bottom of Build Log
-
- this.$scrollTopBtn.show();
- this.$scrollBottomBtn.hide();
-
- // Show and Reposition Autoscroll Status Indicator
- this.$autoScrollContainer.css({
- top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET,
- }).show();
- this.$autoScrollStatusText.addClass('animate');
- } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) &&
- gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
- // Build Log height is small
-
- this.$scrollTopBtn.hide();
- this.$scrollBottomBtn.hide();
-
- // Hide Autoscroll Status Indicator
- this.$autoScrollContainer.hide();
- this.$autoScrollStatusText.removeClass('animate');
- }
-
- if (this.buildStatus === 'running' || this.buildStatus === 'pending') {
- // Check if Refresh Animation is in Viewport and enable Autoscroll, disable otherwise.
- this.$autoScrollStatus.data(
- 'state',
- gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)) ? 'enabled' : 'disabled',
- );
- }
+ });
};
Build.prototype.shouldHideSidebarForViewport = function () {
@@ -257,18 +238,24 @@ window.Build = (function () {
};
Build.prototype.toggleSidebar = function (shouldHide) {
- const shouldShow = typeof shouldHide === 'boolean' ? !shouldHide : undefined;
+ const shouldShow = !shouldHide;
- this.$buildScroll.toggleClass('sidebar-expanded', shouldShow)
- .toggleClass('sidebar-collapsed', shouldHide);
- this.$truncatedInfo.toggleClass('sidebar-expanded', shouldShow)
+ this.$buildTrace
+ .toggleClass('sidebar-expanded', shouldShow)
.toggleClass('sidebar-collapsed', shouldHide);
- this.$sidebar.toggleClass('right-sidebar-expanded', shouldShow)
+ this.$sidebar
+ .toggleClass('right-sidebar-expanded', shouldShow)
.toggleClass('right-sidebar-collapsed', shouldHide);
};
Build.prototype.sidebarOnResize = function () {
this.toggleSidebar(this.shouldHideSidebarForViewport());
+
+ this.verifyTopPosition();
+
+ if (this.$scrollContainer.getNiceScroll(0)) {
+ this.toggleScroll();
+ }
};
Build.prototype.sidebarOnClick = function () {
@@ -301,24 +288,5 @@ window.Build = (function () {
this.populateJobs(stage);
};
- Build.prototype.stepTrace = function (e) {
- e.preventDefault();
-
- const $currentTarget = $(e.currentTarget);
- $.scrollTo($currentTarget.attr('href'), {
- offset: 0,
- });
- };
-
- Build.prototype.initAffixTruncatedInfo = function () {
- const offsetTop = this.$buildTrace.offset().top;
-
- this.$truncatedInfo.affix({
- offset: {
- top: offsetTop,
- },
- });
- };
-
return Build;
})();
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.js b/app/assets/javascripts/commit/pipelines/pipelines_table.js
index 98698143d22..082fbafb740 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_table.js
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.js
@@ -118,7 +118,7 @@ export default Vue.component('pipelines-table', {
eventHub.$on('refreshPipelines', this.fetchPipelines);
},
- beforeDestroyed() {
+ beforeDestroy() {
eventHub.$off('refreshPipelines');
},
diff --git a/app/assets/javascripts/commits.js b/app/assets/javascripts/commits.js
index e3f9eaaf39c..2b0bf49cf92 100644
--- a/app/assets/javascripts/commits.js
+++ b/app/assets/javascripts/commits.js
@@ -7,6 +7,8 @@ window.CommitsList = (function() {
CommitsList.timer = null;
CommitsList.init = function(limit) {
+ this.$contentList = $('.content_list');
+
$("body").on("click", ".day-commits-table li.commit", function(e) {
if (e.target.nodeName !== "A") {
location.href = $(this).attr("url");
@@ -14,9 +16,9 @@ window.CommitsList = (function() {
return false;
}
});
- Pager.init(limit, false, false, function() {
- gl.utils.localTimeAgo($('.js-timeago'));
- });
+
+ Pager.init(limit, false, false, this.processCommits);
+
this.content = $("#commits-list");
this.searchField = $("#commits-search");
this.lastSearch = this.searchField.val();
@@ -62,5 +64,34 @@ window.CommitsList = (function() {
});
};
+ // Prepare loaded data.
+ CommitsList.processCommits = (data) => {
+ let processedData = data;
+ const $processedData = $(processedData);
+ const $commitsHeadersLast = CommitsList.$contentList.find('li.js-commit-header').last();
+ const lastShownDay = $commitsHeadersLast.data('day');
+ const $loadedCommitsHeadersFirst = $processedData.filter('li.js-commit-header').first();
+ const loadedShownDayFirst = $loadedCommitsHeadersFirst.data('day');
+ let commitsCount;
+
+ // If commits headers show the same date,
+ // remove the last header and change the previous one.
+ if (lastShownDay === loadedShownDayFirst) {
+ // Last shown commits count under the last commits header.
+ commitsCount = $commitsHeadersLast.nextUntil('li.js-commit-header').find('li.commit').length;
+
+ // Remove duplicate of commits header.
+ processedData = $processedData.not(`li.js-commit-header[data-day="${loadedShownDayFirst}"]`);
+
+ // Update commits count in the previous commits header.
+ commitsCount += Number($(processedData).nextUntil('li.js-commit-header').first().find('li.commit').length);
+ $commitsHeadersLast.find('span.commits-count').text(`${commitsCount} ${gl.text.pluralize('commit', commitsCount)}`);
+ }
+
+ gl.utils.localTimeAgo($processedData.find('.js-timeago'));
+
+ return processedData;
+ };
+
return CommitsList;
})();
diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue
index 5f6eed0c67c..285124e9515 100644
--- a/app/assets/javascripts/deploy_keys/components/app.vue
+++ b/app/assets/javascripts/deploy_keys/components/app.vue
@@ -75,7 +75,7 @@
</script>
<template>
- <div class="col-lg-9 col-lg-offset-3 append-bottom-default deploy-keys">
+ <div class="append-bottom-default deploy-keys">
<loading-icon
v-if="isLoading && !hasKeys"
size="2"
diff --git a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
index 8a0fd3bb4a7..37ddca29e71 100644
--- a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
+++ b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
@@ -16,6 +16,13 @@ const JumpToDiscussion = Vue.extend({
};
},
computed: {
+ buttonText: function () {
+ if (this.discussionId) {
+ return 'Jump to next unresolved discussion';
+ } else {
+ return 'Jump to first unresolved discussion';
+ }
+ },
allResolved: function () {
return this.unresolvedDiscussionCount === 0;
},
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js
index c5fffea8bb0..c3e096561e4 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -3,7 +3,7 @@
/* global ActiveTabMemoizer */
/* global ShortcutsNavigation */
/* global Build */
-/* global Issuable */
+/* global IssuableIndex */
/* global ShortcutsIssuable */
/* global ZenMode */
/* global Milestone */
@@ -55,6 +55,7 @@ import UsersSelect from './users_select';
import RefSelectDropdown from './ref_select_dropdown';
import GfmAutoComplete from './gfm_auto_complete';
import ShortcutsBlob from './shortcuts_blob';
+import initSettingsPanels from './settings_panels';
(function() {
var Dispatcher;
@@ -118,18 +119,18 @@ import ShortcutsBlob from './shortcuts_blob';
shortcut_handler = new ShortcutsNavigation();
new UsersSelect();
break;
- case 'projects:builds:show':
+ case 'projects:jobs:show':
new Build();
break;
case 'projects:merge_requests:index':
case 'projects:issues:index':
if (gl.FilteredSearchManager && document.querySelector('.filtered-search')) {
- new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests');
+ const filteredSearchManager = new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests');
+ filteredSearchManager.setup();
}
- Issuable.init();
- new gl.IssuableBulkActions({
- prefixId: page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_',
- });
+ const pagePrefix = page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_';
+ IssuableIndex.init(pagePrefix);
+
shortcut_handler = new ShortcutsNavigation();
new UsersSelect();
break;
@@ -381,6 +382,8 @@ import ShortcutsBlob from './shortcuts_blob';
// Initialize Protected Tag Settings
new ProtectedTagCreate();
new ProtectedTagEditList();
+ // Initialize expandable settings panels
+ initSettingsPanels();
break;
case 'projects:ci_cd:show':
new gl.ProjectVariables();
@@ -392,6 +395,9 @@ import ShortcutsBlob from './shortcuts_blob';
case 'users:show':
new UserCallout();
break;
+ case 'admin:conversational_development_index:show':
+ new UserCallout();
+ break;
case 'snippets:show':
new LineHighlighter();
new BlobViewer();
diff --git a/app/assets/javascripts/droplab/keyboard.js b/app/assets/javascripts/droplab/keyboard.js
index 36740a430e1..02f1b805ce4 100644
--- a/app/assets/javascripts/droplab/keyboard.js
+++ b/app/assets/javascripts/droplab/keyboard.js
@@ -8,7 +8,7 @@ const Keyboard = function () {
var isUpArrow = false;
var isDownArrow = false;
var removeHighlight = function removeHighlight(list) {
- var itemElements = Array.prototype.slice.call(list.list.querySelectorAll('li:not(.divider)'), 0);
+ var itemElements = Array.prototype.slice.call(list.list.querySelectorAll('li:not(.divider):not(.hidden)'), 0);
var listItems = [];
for(var i = 0; i < itemElements.length; i++) {
var listItem = itemElements[i];
diff --git a/app/assets/javascripts/droplab/plugins/ajax_filter.js b/app/assets/javascripts/droplab/plugins/ajax_filter.js
index a5427417031..1db20227a16 100644
--- a/app/assets/javascripts/droplab/plugins/ajax_filter.js
+++ b/app/assets/javascripts/droplab/plugins/ajax_filter.js
@@ -63,6 +63,9 @@ const AjaxFilter = {
return AjaxCache.retrieve(url)
.then((data) => {
this._loadData(data, config);
+ if (config.onLoadingFinished) {
+ config.onLoadingFinished(data);
+ }
})
.catch(config.onError);
},
diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js
index 266cd3966c6..111449bb8f7 100644
--- a/app/assets/javascripts/dropzone_input.js
+++ b/app/assets/javascripts/dropzone_input.js
@@ -194,6 +194,7 @@ window.DropzoneInput = (function() {
$(child).val(beforeSelection + formattedText + afterSelection);
textarea.setSelectionRange(caretStart + formattedText.length, caretEnd + formattedText.length);
textarea.style.height = `${textarea.scrollHeight}px`;
+ formTextarea.get(0).dispatchEvent(new Event('input'));
return formTextarea.trigger('input');
};
diff --git a/app/assets/javascripts/environments/components/environment.vue b/app/assets/javascripts/environments/components/environment.vue
index d4e13f3c84a..28597c799df 100644
--- a/app/assets/javascripts/environments/components/environment.vue
+++ b/app/assets/javascripts/environments/components/environment.vue
@@ -1,5 +1,6 @@
<script>
/* global Flash */
+import Visibility from 'visibilityjs';
import EnvironmentsService from '../services/environments_service';
import environmentTable from './environments_table.vue';
import EnvironmentsStore from '../stores/environments_store';
@@ -7,6 +8,8 @@ import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import tablePagination from '../../vue_shared/components/table_pagination.vue';
import '../../lib/utils/common_utils';
import eventHub from '../event_hub';
+import Poll from '../../lib/utils/poll';
+import environmentsMixin from '../mixins/environments_mixin';
export default {
@@ -16,6 +19,10 @@ export default {
loadingIcon,
},
+ mixins: [
+ environmentsMixin,
+ ],
+
data() {
const environmentsData = document.querySelector('#environments-list-view').dataset;
const store = new EnvironmentsStore();
@@ -35,6 +42,7 @@ export default {
projectStoppedEnvironmentsPath: environmentsData.projectStoppedEnvironmentsPath,
newEnvironmentPath: environmentsData.newEnvironmentPath,
helpPagePath: environmentsData.helpPagePath,
+ isMakingRequest: false,
// Pagination Properties,
paginationInformation: {},
@@ -65,17 +73,43 @@ export default {
* Toggles loading property.
*/
created() {
+ const scope = gl.utils.getParameterByName('scope') || this.visibility;
+ const page = gl.utils.getParameterByName('page') || this.pageNumber;
+
this.service = new EnvironmentsService(this.endpoint);
- this.fetchEnvironments();
+ const poll = new Poll({
+ resource: this.service,
+ method: 'get',
+ data: { scope, page },
+ successCallback: this.successCallback,
+ errorCallback: this.errorCallback,
+ notificationCallback: (isMakingRequest) => {
+ this.isMakingRequest = isMakingRequest;
+
+ // We need to verify if any folder is open to also fecth it
+ this.openFolders = this.store.getOpenFolders();
+ },
+ });
+
+ if (!Visibility.hidden()) {
+ this.isLoading = true;
+ poll.makeRequest();
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ poll.restart();
+ } else {
+ poll.stop();
+ }
+ });
- eventHub.$on('refreshEnvironments', this.fetchEnvironments);
eventHub.$on('toggleFolder', this.toggleFolder);
eventHub.$on('postAction', this.postAction);
},
- beforeDestroyed() {
- eventHub.$off('refreshEnvironments');
+ beforeDestroy() {
eventHub.$off('toggleFolder');
eventHub.$off('postAction');
},
@@ -104,29 +138,13 @@ export default {
fetchEnvironments() {
const scope = gl.utils.getParameterByName('scope') || this.visibility;
- const pageNumber = gl.utils.getParameterByName('page') || this.pageNumber;
+ const page = gl.utils.getParameterByName('page') || this.pageNumber;
this.isLoading = true;
- return this.service.get(scope, pageNumber)
- .then(resp => ({
- headers: resp.headers,
- body: resp.json(),
- }))
- .then((response) => {
- this.store.storeAvailableCount(response.body.available_count);
- this.store.storeStoppedCount(response.body.stopped_count);
- this.store.storeEnvironments(response.body.environments);
- this.store.setPagination(response.headers);
- })
- .then(() => {
- this.isLoading = false;
- })
- .catch(() => {
- this.isLoading = false;
- // eslint-disable-next-line no-new
- new Flash('An error occurred while fetching the environments.');
- });
+ return this.service.get({ scope, page })
+ .then(this.successCallback)
+ .catch(this.errorCallback);
},
fetchChildEnvironments(folder, folderUrl) {
@@ -146,9 +164,34 @@ export default {
},
postAction(endpoint) {
- this.service.postAction(endpoint)
- .then(() => this.fetchEnvironments())
- .catch(() => new Flash('An error occured while making the request.'));
+ if (!this.isMakingRequest) {
+ this.isLoading = true;
+
+ this.service.postAction(endpoint)
+ .then(() => this.fetchEnvironments())
+ .catch(() => new Flash('An error occured while making the request.'));
+ }
+ },
+
+ successCallback(resp) {
+ this.saveData(resp);
+
+ // If folders are open while polling we need to open them again
+ if (this.openFolders.length) {
+ this.openFolders.map((folder) => {
+ // TODO - Move this to the backend
+ const folderUrl = `${window.location.pathname}/folders/${folder.folderName}`;
+
+ this.store.updateFolder(folder, 'isOpen', true);
+ return this.fetchChildEnvironments(folder, folderUrl);
+ });
+ }
+ },
+
+ errorCallback() {
+ this.isLoading = false;
+ // eslint-disable-next-line no-new
+ new Flash('An error occurred while fetching the environments.');
},
},
};
@@ -212,7 +255,7 @@ export default {
v-if="canCreateEnvironmentParsed"
:href="newEnvironmentPath"
class="btn btn-create js-new-environment-button">
- New Environment
+ New environment
</a>
</div>
diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue
index a2448520a5f..41d5453f1b2 100644
--- a/app/assets/javascripts/environments/components/environment_actions.vue
+++ b/app/assets/javascripts/environments/components/environment_actions.vue
@@ -70,7 +70,7 @@ export default {
</span>
</button>
- <ul class="dropdown-menu dropdown-menu-align-right">
+ <ul class="dropdown-menu">
<li v-for="action in actions">
<button
type="button"
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index 012ff1f975b..03eb51ba1b2 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -421,14 +421,19 @@ export default {
};
</script>
<template>
- <tr :class="{ 'js-child-row': model.isChildren }">
- <td>
+ <div
+ :class="{ 'js-child-row environment-child-row': model.isChildren, 'folder-row': model.isFolder, 'gl-responsive-table-row': !model.isFolder }">
+ <div class="table-section section-10" role="gridcell">
+ <div
+ v-if="!model.isFolder"
+ class="table-mobile-header">
+ Environment
+ </div>
<a
v-if="!model.isFolder"
- class="environment-name"
- :class="{ 'prepend-left-default': model.isChildren }"
+ class="environment-name flex-truncate-parent table-mobile-content"
:href="environmentPath">
- {{model.name}}
+ <span class="flex-truncate-child">{{model.name}}</span>
</a>
<span
v-else
@@ -461,9 +466,9 @@ export default {
{{model.size}}
</span>
</span>
- </td>
+ </div>
- <td class="deployment-column">
+ <div class="table-section section-10 deployment-column hidden-xs hidden-sm" role="gridcell">
<span v-if="shouldRenderDeploymentID">
{{deploymentInternalId}}
</span>
@@ -478,21 +483,26 @@ export default {
:tooltip-text="deploymentUser.username"
/>
</span>
- </td>
+ </div>
- <td class="environments-build-cell">
+ <div class="table-section section-15 hidden-xs hidden-sm" role="gridcell">
<a
v-if="shouldRenderBuildName"
class="build-link"
:href="buildPath">
{{buildName}}
</a>
- </td>
+ </div>
- <td>
+ <div class="table-section section-25" role="gridcell">
+ <div
+ v-if="!model.isFolder"
+ class="table-mobile-header">
+ Commit
+ </div>
<div
v-if="!model.isFolder && hasLastDeploymentKey"
- class="js-commit-component">
+ class="js-commit-component table-mobile-content">
<commit-component
:tag="commitTag"
:commit-ref="commitRef"
@@ -501,25 +511,30 @@ export default {
:title="commitTitle"
:author="commitAuthor"/>
</div>
- <p
+ <div
v-if="!model.isFolder && !hasLastDeploymentKey"
class="commit-title">
No deployments yet
- </p>
- </td>
+ </div>
+ </div>
- <td>
+ <div class="table-section section-10" role="gridcell">
+ <div
+ v-if="!model.isFolder"
+ class="table-mobile-header">
+ Updated
+ </div>
<span
v-if="!model.isFolder && canShowDate"
- class="environment-created-date-timeago">
+ class="environment-created-date-timeago table-mobile-content">
{{createdDate}}
</span>
- </td>
+ </div>
- <td class="environments-actions">
+ <div class="table-section section-30 environments-actions table-button-footer" role="gridcell">
<div
v-if="!model.isFolder"
- class="btn-group pull-right"
+ class="btn-group environment-action-buttons"
role="group">
<actions-component
@@ -553,6 +568,6 @@ export default {
:retry-url="retryUrl"
/>
</div>
- </td>
- </tr>
+ </div>
+ </div>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_monitoring.vue b/app/assets/javascripts/environments/components/environment_monitoring.vue
index 79c019b3491..07cf92281a0 100644
--- a/app/assets/javascripts/environments/components/environment_monitoring.vue
+++ b/app/assets/javascripts/environments/components/environment_monitoring.vue
@@ -19,7 +19,7 @@ export default {
</script>
<template>
<a
- class="btn monitoring-url has-tooltip"
+ class="btn monitoring-url has-tooltip hidden-xs hidden-sm"
data-container="body"
rel="noopener noreferrer nofollow"
:href="monitoringUrl"
diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue
index 2ba985bfe3e..49dba38edfb 100644
--- a/app/assets/javascripts/environments/components/environment_rollback.vue
+++ b/app/assets/javascripts/environments/components/environment_rollback.vue
@@ -43,7 +43,7 @@ export default {
<template>
<button
type="button"
- class="btn"
+ class="btn hidden-xs hidden-sm"
@click="onClick"
:disabled="isLoading">
diff --git a/app/assets/javascripts/environments/components/environment_stop.vue b/app/assets/javascripts/environments/components/environment_stop.vue
index a904453ffa9..091c543860b 100644
--- a/app/assets/javascripts/environments/components/environment_stop.vue
+++ b/app/assets/javascripts/environments/components/environment_stop.vue
@@ -47,7 +47,7 @@ export default {
<template>
<button
type="button"
- class="btn stop-env-link has-tooltip"
+ class="btn stop-env-link has-tooltip hidden-xs hidden-sm"
data-container="body"
@click="onClick"
:disabled="isLoading"
diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.vue b/app/assets/javascripts/environments/components/environment_terminal_button.vue
index c8c1f17d4d8..1ca65a79951 100644
--- a/app/assets/javascripts/environments/components/environment_terminal_button.vue
+++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue
@@ -29,7 +29,7 @@ export default {
</script>
<template>
<a
- class="btn terminal-button has-tooltip"
+ class="btn terminal-button has-tooltip hidden-xs hidden-sm"
data-container="body"
:title="title"
:aria-label="title"
diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue
index 5148a2ae79b..f9262ab85c5 100644
--- a/app/assets/javascripts/environments/components/environments_table.vue
+++ b/app/assets/javascripts/environments/components/environments_table.vue
@@ -45,68 +45,59 @@ export default {
};
</script>
<template>
- <table class="table ci-table">
- <thead>
- <tr>
- <th class="environments-name">
- Environment
- </th>
- <th class="environments-deploy">
- Last deployment
- </th>
- <th class="environments-build">
- Job
- </th>
- <th class="environments-commit">
- Commit
- </th>
- <th class="environments-date">
- Updated
- </th>
- <th class="environments-actions"></th>
- </tr>
- </thead>
- <tbody>
- <template
- v-for="model in environments"
- v-bind:model="model">
- <tr
- is="environment-item"
- :model="model"
- :can-create-deployment="canCreateDeployment"
- :can-read-environment="canReadEnvironment"
- />
+ <div class="ci-table" role="grid">
+ <div class="gl-responsive-table-row table-row-header" role="row">
+ <div class="table-section section-10 environments-name" role="rowheader">
+ Environment
+ </div>
+ <div class="table-section section-10 environments-deploy" role="rowheader">
+ Deployment
+ </div>
+ <div class="table-section section-15 environments-build" role="rowheader">
+ Job
+ </div>
+ <div class="table-section section-25 environments-commit" role="rowheader">
+ Commit
+ </div>
+ <div class="table-section section-10 environments-date" role="rowheader">
+ Updated
+ </div>
+ </div>
+ <template
+ v-for="model in environments"
+ v-bind:model="model">
+ <div
+ is="environment-item"
+ :model="model"
+ :can-create-deployment="canCreateDeployment"
+ :can-read-environment="canReadEnvironment"
+ />
- <template v-if="model.isFolder && model.isOpen && model.children && model.children.length > 0">
- <tr v-if="isLoadingFolderContent">
- <td colspan="6">
- <loading-icon size="2" />
- </td>
- </tr>
+ <template v-if="model.isFolder && model.isOpen && model.children && model.children.length > 0">
+ <div v-if="isLoadingFolderContent">
+ <loading-icon size="2" />
+ </div>
- <template v-else>
- <tr
- is="environment-item"
- v-for="children in model.children"
- :model="children"
- :can-create-deployment="canCreateDeployment"
- :can-read-environment="canReadEnvironment"
- />
+ <template v-else>
+ <div
+ is="environment-item"
+ v-for="children in model.children"
+ :model="children"
+ :can-create-deployment="canCreateDeployment"
+ :can-read-environment="canReadEnvironment"
+ />
- <tr>
- <td
- colspan="6"
- class="text-center">
- <a
- :href="folderUrl(model)"
- class="btn btn-default">
- Show all
- </a>
- </td>
- </tr>
- </template>
+ <div>
+ <div class="text-center prepend-top-10">
+ <a
+ :href="folderUrl(model)"
+ class="btn btn-default">
+ Show all
+ </a>
+ </div>
+ </div>
</template>
</template>
- </tbody>
- </table>
+ </template>
+ </div>
</template>
diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue
index bd161c8a379..925503a01c4 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_view.vue
+++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue
@@ -1,12 +1,15 @@
<script>
/* global Flash */
+import Visibility from 'visibilityjs';
import EnvironmentsService from '../services/environments_service';
import environmentTable from '../components/environments_table.vue';
import EnvironmentsStore from '../stores/environments_store';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import tablePagination from '../../vue_shared/components/table_pagination.vue';
+import Poll from '../../lib/utils/poll';
+import eventHub from '../event_hub';
+import environmentsMixin from '../mixins/environments_mixin';
import '../../lib/utils/common_utils';
-import '../../vue_shared/vue_resource_interceptor';
export default {
components: {
@@ -15,6 +18,10 @@ export default {
loadingIcon,
},
+ mixins: [
+ environmentsMixin,
+ ],
+
data() {
const environmentsData = document.querySelector('#environments-folder-list-view').dataset;
const store = new EnvironmentsStore();
@@ -76,33 +83,39 @@ export default {
*/
created() {
const scope = gl.utils.getParameterByName('scope') || this.visibility;
- const pageNumber = gl.utils.getParameterByName('page') || this.pageNumber;
-
- const endpoint = `${this.endpoint}?scope=${scope}&page=${pageNumber}`;
-
- this.service = new EnvironmentsService(endpoint);
-
- this.isLoading = true;
-
- return this.service.get()
- .then(resp => ({
- headers: resp.headers,
- body: resp.json(),
- }))
- .then((response) => {
- this.store.storeAvailableCount(response.body.available_count);
- this.store.storeStoppedCount(response.body.stopped_count);
- this.store.storeEnvironments(response.body.environments);
- this.store.setPagination(response.headers);
- })
- .then(() => {
- this.isLoading = false;
- })
- .catch(() => {
- this.isLoading = false;
- // eslint-disable-next-line no-new
- new Flash('An error occurred while fetching the environments.', 'alert');
- });
+ const page = gl.utils.getParameterByName('page') || this.pageNumber;
+
+ this.service = new EnvironmentsService(this.endpoint);
+
+ const poll = new Poll({
+ resource: this.service,
+ method: 'get',
+ data: { scope, page },
+ successCallback: this.successCallback,
+ errorCallback: this.errorCallback,
+ notificationCallback: (isMakingRequest) => {
+ this.isMakingRequest = isMakingRequest;
+ },
+ });
+
+ if (!Visibility.hidden()) {
+ this.isLoading = true;
+ poll.makeRequest();
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ poll.restart();
+ } else {
+ poll.stop();
+ }
+ });
+
+ eventHub.$on('postAction', this.postAction);
+ },
+
+ beforeDestroyed() {
+ eventHub.$off('postAction');
},
methods: {
@@ -117,6 +130,37 @@ export default {
gl.utils.visitUrl(param);
return param;
},
+
+ fetchEnvironments() {
+ const scope = gl.utils.getParameterByName('scope') || this.visibility;
+ const page = gl.utils.getParameterByName('page') || this.pageNumber;
+
+ this.isLoading = true;
+
+ return this.service.get({ scope, page })
+ .then(this.successCallback)
+ .catch(this.errorCallback);
+ },
+
+ successCallback(resp) {
+ this.saveData(resp);
+ },
+
+ errorCallback() {
+ this.isLoading = false;
+ // eslint-disable-next-line no-new
+ new Flash('An error occurred while fetching the environments.');
+ },
+
+ postAction(endpoint) {
+ if (!this.isMakingRequest) {
+ this.isLoading = true;
+
+ this.service.postAction(endpoint)
+ .then(() => this.fetchEnvironments())
+ .catch(() => new Flash('An error occured while making the request.'));
+ }
+ },
},
};
</script>
diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js
new file mode 100644
index 00000000000..25b24fbd6dc
--- /dev/null
+++ b/app/assets/javascripts/environments/mixins/environments_mixin.js
@@ -0,0 +1,17 @@
+export default {
+ methods: {
+ saveData(resp) {
+ const response = {
+ headers: resp.headers,
+ body: resp.json(),
+ };
+
+ this.isLoading = false;
+
+ this.store.storeAvailableCount(response.body.available_count);
+ this.store.storeStoppedCount(response.body.stopped_count);
+ this.store.storeEnvironments(response.body.environments);
+ this.store.setPagination(response.headers);
+ },
+ },
+};
diff --git a/app/assets/javascripts/environments/services/environments_service.js b/app/assets/javascripts/environments/services/environments_service.js
index 8adb53ea86d..03ab74b3338 100644
--- a/app/assets/javascripts/environments/services/environments_service.js
+++ b/app/assets/javascripts/environments/services/environments_service.js
@@ -10,7 +10,8 @@ export default class EnvironmentsService {
this.folderResults = 3;
}
- get(scope, page) {
+ get(options = {}) {
+ const { scope, page } = options;
return this.environments.get({ scope, page });
}
diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js
index 158e7922e3c..8a2f6a473de 100644
--- a/app/assets/javascripts/environments/stores/environments_store.js
+++ b/app/assets/javascripts/environments/stores/environments_store.js
@@ -153,4 +153,10 @@ export default class EnvironmentsStore {
return updatedEnvironments;
}
+ getOpenFolders() {
+ const environments = this.state.environments;
+
+ return environments.filter(env => env.isFolder && env.isOpen);
+ }
+
}
diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js b/app/assets/javascripts/filtered_search/dropdown_user.js
index 6b4338ca1d6..65c1b2050ac 100644
--- a/app/assets/javascripts/filtered_search/dropdown_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_user.js
@@ -18,6 +18,9 @@ class DropdownUser extends gl.FilteredSearchDropdown {
},
searchValueFunction: this.getSearchInput.bind(this),
loadingTemplate: this.loadingTemplate,
+ onLoadingFinished: () => {
+ this.hideCurrentUser();
+ },
onError() {
/* eslint-disable no-new */
new Flash('An error occured fetching the dropdown data.');
@@ -28,6 +31,11 @@ class DropdownUser extends gl.FilteredSearchDropdown {
this.tokenKeys = tokenKeys;
}
+ hideCurrentUser() {
+ const currenUserItem = this.dropdown.querySelector('.js-current-user');
+ currenUserItem.classList.add('hidden');
+ }
+
itemClicked(e) {
super.itemClicked(e,
selected => selected.querySelector('.dropdown-light-content').innerText.trim());
diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js
index 5c02a7a53d3..ef8fe071012 100644
--- a/app/assets/javascripts/filtered_search/dropdown_utils.js
+++ b/app/assets/javascripts/filtered_search/dropdown_utils.js
@@ -102,10 +102,13 @@ class DropdownUtils {
if (token.classList.contains('js-visual-token')) {
const name = token.querySelector('.name');
const value = token.querySelector('.value');
+ const valueContainer = token.querySelector('.value-container');
const symbol = value && value.dataset.symbol ? value.dataset.symbol : '';
let valueText = '';
- if (value && value.innerText) {
+ if (valueContainer && valueContainer.dataset.originalValue) {
+ valueText = valueContainer.dataset.originalValue;
+ } else if (value && value.innerText) {
valueText = value.innerText;
}
diff --git a/app/assets/javascripts/filtered_search/filtered_search_bundle.js b/app/assets/javascripts/filtered_search/filtered_search_bundle.js
index 5d48b8aacb2..132b6fe698a 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_bundle.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_bundle.js
@@ -2,9 +2,9 @@ import './dropdown_hint';
import './dropdown_non_user';
import './dropdown_user';
import './dropdown_utils';
+import './filtered_search_token_keys';
import './filtered_search_dropdown_manager';
import './filtered_search_dropdown';
import './filtered_search_manager';
-import './filtered_search_token_keys';
import './filtered_search_tokenizer';
import './filtered_search_visual_tokens';
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index 58f2b75bd50..72214321be1 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -6,6 +6,7 @@ import eventHub from './event_hub';
class FilteredSearchManager {
constructor(page) {
+ this.page = page;
this.container = FilteredSearchContainer.container;
this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.filteredSearchInputForm = this.filteredSearchInput.form;
@@ -17,16 +18,18 @@ class FilteredSearchManager {
isLocalStorageAvailable: RecentSearchesService.isAvailable(),
allowedKeys: this.filteredSearchTokenKeys.getKeys(),
});
- const searchHistoryDropdownElement = document.querySelector('.js-filtered-search-history-dropdown');
- const projectPath = searchHistoryDropdownElement ?
- searchHistoryDropdownElement.dataset.projectFullPath : 'project';
+ this.searchHistoryDropdownElement = document.querySelector('.js-filtered-search-history-dropdown');
+ const projectPath = this.searchHistoryDropdownElement ?
+ this.searchHistoryDropdownElement.dataset.projectFullPath : 'project';
let recentSearchesPagePrefix = 'issue-recent-searches';
- if (page === 'merge_requests') {
+ if (this.page === 'merge_requests') {
recentSearchesPagePrefix = 'merge-request-recent-searches';
}
const recentSearchesKey = `${projectPath}-${recentSearchesPagePrefix}`;
this.recentSearchesService = new RecentSearchesService(recentSearchesKey);
+ }
+ setup() {
// Fetch recent searches from localStorage
this.fetchingRecentSearchesPromise = this.recentSearchesService.fetch()
.catch((error) => {
@@ -47,12 +50,12 @@ class FilteredSearchManager {
if (this.filteredSearchInput) {
this.tokenizer = gl.FilteredSearchTokenizer;
- this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', this.tokenizer, page);
+ this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', this.tokenizer, this.page);
this.recentSearchesRoot = new RecentSearchesRoot(
this.recentSearchesStore,
this.recentSearchesService,
- searchHistoryDropdownElement,
+ this.searchHistoryDropdownElement,
);
this.recentSearchesRoot.init();
@@ -102,11 +105,9 @@ class FilteredSearchManager {
this.filteredSearchInput.addEventListener('click', this.tokenChange);
this.filteredSearchInput.addEventListener('keyup', this.tokenChange);
this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper);
- this.tokensContainer.addEventListener('click', FilteredSearchManager.selectToken);
this.tokensContainer.addEventListener('click', this.removeTokenWrapper);
- this.tokensContainer.addEventListener('dblclick', this.editTokenWrapper);
+ this.tokensContainer.addEventListener('click', this.editTokenWrapper);
this.clearSearchButton.addEventListener('click', this.onClearSearchWrapper);
- document.addEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens);
document.addEventListener('click', this.unselectEditTokensWrapper);
document.addEventListener('click', this.removeInputContainerFocusWrapper);
document.addEventListener('keydown', this.removeSelectedTokenKeydownWrapper);
@@ -124,11 +125,9 @@ class FilteredSearchManager {
this.filteredSearchInput.removeEventListener('click', this.tokenChange);
this.filteredSearchInput.removeEventListener('keyup', this.tokenChange);
this.filteredSearchInput.removeEventListener('focus', this.addInputContainerFocusWrapper);
- this.tokensContainer.removeEventListener('click', FilteredSearchManager.selectToken);
this.tokensContainer.removeEventListener('click', this.removeTokenWrapper);
- this.tokensContainer.removeEventListener('dblclick', this.editTokenWrapper);
+ this.tokensContainer.removeEventListener('click', this.editTokenWrapper);
this.clearSearchButton.removeEventListener('click', this.onClearSearchWrapper);
- document.removeEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens);
document.removeEventListener('click', this.unselectEditTokensWrapper);
document.removeEventListener('click', this.removeInputContainerFocusWrapper);
document.removeEventListener('keydown', this.removeSelectedTokenKeydownWrapper);
@@ -141,7 +140,9 @@ class FilteredSearchManager {
if (e.keyCode === 8 || e.keyCode === 46) {
const { lastVisualToken } = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
- if (this.filteredSearchInput.value === '' && lastVisualToken) {
+ const sanitizedTokenName = lastVisualToken && lastVisualToken.querySelector('.name').textContent.trim();
+ const canEdit = sanitizedTokenName && this.canEdit && this.canEdit(sanitizedTokenName);
+ if (this.filteredSearchInput.value === '' && lastVisualToken && canEdit) {
this.filteredSearchInput.value = gl.FilteredSearchVisualTokens.getLastTokenPartial();
gl.FilteredSearchVisualTokens.removeLastTokenPartial();
}
@@ -202,23 +203,13 @@ class FilteredSearchManager {
}
}
- static selectToken(e) {
- const button = e.target.closest('.selectable');
- const removeButtonSelected = e.target.closest('.remove-token');
-
- if (!removeButtonSelected && button) {
- e.preventDefault();
- e.stopPropagation();
- gl.FilteredSearchVisualTokens.selectToken(button);
- }
- }
-
removeToken(e) {
const removeButtonSelected = e.target.closest('.remove-token');
if (removeButtonSelected) {
e.preventDefault();
- e.stopPropagation();
+ // Prevent editToken from being triggered after token is removed
+ e.stopImmediatePropagation();
const button = e.target.closest('.selectable');
gl.FilteredSearchVisualTokens.selectToken(button, true);
@@ -240,8 +231,12 @@ class FilteredSearchManager {
editToken(e) {
const token = e.target.closest('.js-visual-token');
+ const sanitizedTokenName = token && token.querySelector('.name').textContent.trim();
+ const canEdit = this.canEdit && this.canEdit(sanitizedTokenName);
- if (token) {
+ if (token && canEdit) {
+ e.preventDefault();
+ e.stopPropagation();
gl.FilteredSearchVisualTokens.editToken(token);
this.tokenChange();
}
@@ -391,7 +386,12 @@ class FilteredSearchManager {
if (condition) {
hasFilteredSearch = true;
- gl.FilteredSearchVisualTokens.addFilterVisualToken(condition.tokenKey, condition.value);
+ const canEdit = this.canEdit && this.canEdit(condition.tokenKey);
+ gl.FilteredSearchVisualTokens.addFilterVisualToken(
+ condition.tokenKey,
+ condition.value,
+ canEdit,
+ );
} else {
// Sanitize value since URL converts spaces into +
// Replace before decode so that we know what was originally + versus the encoded +
@@ -410,18 +410,27 @@ class FilteredSearchManager {
}
hasFilteredSearch = true;
- gl.FilteredSearchVisualTokens.addFilterVisualToken(sanitizedKey, `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`);
+ const canEdit = this.canEdit && this.canEdit(sanitizedKey);
+ gl.FilteredSearchVisualTokens.addFilterVisualToken(
+ sanitizedKey,
+ `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`,
+ canEdit,
+ );
} else if (!match && keyParam === 'assignee_id') {
const id = parseInt(value, 10);
if (usernameParams[id]) {
hasFilteredSearch = true;
- gl.FilteredSearchVisualTokens.addFilterVisualToken('assignee', `@${usernameParams[id]}`);
+ const tokenName = 'assignee';
+ const canEdit = this.canEdit && this.canEdit(tokenName);
+ gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit);
}
} else if (!match && keyParam === 'author_id') {
const id = parseInt(value, 10);
if (usernameParams[id]) {
hasFilteredSearch = true;
- gl.FilteredSearchVisualTokens.addFilterVisualToken('author', `@${usernameParams[id]}`);
+ const tokenName = 'author';
+ const canEdit = this.canEdit && this.canEdit(tokenName);
+ gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit);
}
} else if (!match && keyParam === 'search') {
hasFilteredSearch = true;
@@ -516,6 +525,11 @@ class FilteredSearchManager {
this.filteredSearchInput.dispatchEvent(new CustomEvent('input'));
this.search();
}
+
+ // eslint-disable-next-line class-methods-use-this
+ canEdit() {
+ return true;
+ }
}
window.gl = window.gl || {};
diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
index f3003b86493..e9278140af0 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
@@ -1,6 +1,7 @@
-import AjaxCache from '~/lib/utils/ajax_cache';
-import '~/flash'; /* global Flash */
+import AjaxCache from '../lib/utils/ajax_cache';
+import '../flash'; /* global Flash */
import FilteredSearchContainer from './container';
+import UsersCache from '../lib/utils/users_cache';
class FilteredSearchVisualTokens {
static getLastVisualTokenBeforeInput() {
@@ -36,15 +37,22 @@ class FilteredSearchVisualTokens {
}
}
- static createVisualTokenElementHTML() {
+ static createVisualTokenElementHTML(canEdit = true) {
+ let removeTokenMarkup = '';
+ if (canEdit) {
+ removeTokenMarkup = `
+ <div class="remove-token" role="button">
+ <i class="fa fa-close"></i>
+ </div>
+ `;
+ }
+
return `
<div class="selectable" role="button">
<div class="name"></div>
<div class="value-container">
<div class="value"></div>
- <div class="remove-token" role="button">
- <i class="fa fa-close"></i>
- </div>
+ ${removeTokenMarkup}
</div>
</div>
`;
@@ -75,22 +83,52 @@ class FilteredSearchVisualTokens {
.catch(() => new Flash('An error occurred while fetching label colors.'));
}
+ static updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) {
+ if (tokenValue === 'none') {
+ return Promise.resolve();
+ }
+
+ const username = tokenValue.replace(/^@/, '');
+ return UsersCache.retrieve(username)
+ .then((user) => {
+ if (!user) {
+ return;
+ }
+
+ /* eslint-disable no-param-reassign */
+ tokenValueContainer.dataset.originalValue = tokenValue;
+ tokenValueElement.innerHTML = `
+ <img class="avatar s20" src="${user.avatar_url}" alt="${user.name}'s avatar">
+ ${user.name}
+ `;
+ /* eslint-enable no-param-reassign */
+ })
+ // ignore error and leave username in the search bar
+ .catch(() => { });
+ }
+
static renderVisualTokenValue(parentElement, tokenName, tokenValue) {
const tokenValueContainer = parentElement.querySelector('.value-container');
- tokenValueContainer.querySelector('.value').innerText = tokenValue;
+ const tokenValueElement = tokenValueContainer.querySelector('.value');
+ tokenValueElement.innerText = tokenValue;
- if (tokenName.toLowerCase() === 'label') {
+ const tokenType = tokenName.toLowerCase();
+ if (tokenType === 'label') {
FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue);
+ } else if ((tokenType === 'author') || (tokenType === 'assignee')) {
+ FilteredSearchVisualTokens.updateUserTokenAppearance(
+ tokenValueContainer, tokenValueElement, tokenValue,
+ );
}
}
- static addVisualTokenElement(name, value, isSearchTerm) {
+ static addVisualTokenElement(name, value, isSearchTerm, canEdit) {
const li = document.createElement('li');
li.classList.add('js-visual-token');
li.classList.add(isSearchTerm ? 'filtered-search-term' : 'filtered-search-token');
if (value) {
- li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML();
+ li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(canEdit);
FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value);
} else {
li.innerHTML = '<div class="name"></div>';
@@ -114,20 +152,20 @@ class FilteredSearchVisualTokens {
}
}
- static addFilterVisualToken(tokenName, tokenValue) {
+ static addFilterVisualToken(tokenName, tokenValue, canEdit) {
const { lastVisualToken, isLastVisualTokenValid }
= FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
const addVisualTokenElement = FilteredSearchVisualTokens.addVisualTokenElement;
if (isLastVisualTokenValid) {
- addVisualTokenElement(tokenName, tokenValue, false);
+ addVisualTokenElement(tokenName, tokenValue, false, canEdit);
} else {
const previousTokenName = lastVisualToken.querySelector('.name').innerText;
const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container');
tokensContainer.removeChild(lastVisualToken);
const value = tokenValue || tokenName;
- addVisualTokenElement(previousTokenName, value, false);
+ addVisualTokenElement(previousTokenName, value, false, canEdit);
}
}
@@ -146,6 +184,12 @@ class FilteredSearchVisualTokens {
if (!lastVisualToken) return '';
+ const valueContainer = lastVisualToken.querySelector('.value-container');
+ const originalValue = valueContainer && valueContainer.dataset.originalValue;
+ if (originalValue) {
+ return originalValue;
+ }
+
const value = lastVisualToken.querySelector('.value');
const name = lastVisualToken.querySelector('.name');
@@ -198,17 +242,28 @@ class FilteredSearchVisualTokens {
const inputLi = input.parentElement;
tokenContainer.replaceChild(inputLi, token);
- const name = token.querySelector('.name');
- const value = token.querySelector('.value');
+ const nameElement = token.querySelector('.name');
+ let value;
- if (token.classList.contains('filtered-search-token') && value) {
- FilteredSearchVisualTokens.addFilterVisualToken(name.innerText);
- input.value = value.innerText;
- } else {
- // token is a search term
- input.value = name.innerText;
+ if (token.classList.contains('filtered-search-token')) {
+ FilteredSearchVisualTokens.addFilterVisualToken(nameElement.innerText);
+
+ const valueContainerElement = token.querySelector('.value-container');
+ value = valueContainerElement.dataset.originalValue;
+
+ if (!value) {
+ const valueElement = valueContainerElement.querySelector('.value');
+ value = valueElement.innerText;
+ }
+ }
+
+ // token is a search term
+ if (!value) {
+ value = nameElement.innerText;
}
+ input.value = value;
+
// Opens dropdown
const inputEvent = new Event('input');
input.dispatchEvent(inputEvent);
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index eec30624ff2..ccff8f0ace7 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -7,8 +7,21 @@ window.Flash = (function() {
return $(this).fadeOut();
};
- function Flash(message, type, parent) {
- var flash, textDiv;
+ /**
+ * Flash banner supports different types of Flash configurations
+ * along with ability to provide actionConfig which can be used to show
+ * additional action or link on banner next to message
+ *
+ * @param {String} message Flash message
+ * @param {String} type Type of Flash, it can be `notice` or `alert` (default)
+ * @param {Object} parent Reference to Parent element under which Flash needs to appear
+ * @param {Object} actionConfig Map of config to show action on banner
+ * @param {String} href URL to which action link should point (default '#')
+ * @param {String} title Title of action
+ * @param {Function} clickHandler Method to call when action is clicked on
+ */
+ function Flash(message, type, parent, actionConfig) {
+ var flash, textDiv, actionLink;
if (type == null) {
type = 'alert';
}
@@ -30,6 +43,23 @@ window.Flash = (function() {
text: message
});
textDiv.appendTo(flash);
+
+ if (actionConfig) {
+ const actionLinkConfig = {
+ class: 'flash-action',
+ href: actionConfig.href || '#',
+ text: actionConfig.title
+ };
+
+ if (!actionConfig.href) {
+ actionLinkConfig.role = 'button';
+ }
+
+ actionLink = $('<a/>', actionLinkConfig);
+
+ actionLink.appendTo(flash);
+ this.flashContainer.on('click', '.flash-action', actionConfig.clickHandler);
+ }
if (this.flashContainer.parent().hasClass('content-wrapper')) {
textDiv.addClass('container-fluid container-limited');
}
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index b8a923cf619..401dec1a370 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -2,6 +2,7 @@ import emojiMap from 'emojis/digests.json';
import emojiAliases from 'emojis/aliases.json';
import { glEmojiTag } from '~/behaviors/gl_emoji';
import glRegexp from '~/lib/utils/regexp';
+import AjaxCache from '~/lib/utils/ajax_cache';
function sanitize(str) {
return str.replace(/<(?:.|\n)*?>/gm, '');
@@ -35,6 +36,7 @@ class GfmAutoComplete {
// This triggers at.js again
// Needed for slash commands with suffixes (ex: /label ~)
$input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup'));
+ $input.on('clear-commands-cache.atwho', () => this.clearCache());
});
}
@@ -375,11 +377,14 @@ class GfmAutoComplete {
} else if (GfmAutoComplete.atTypeMap[at] === 'emojis') {
this.loadData($input, at, Object.keys(emojiMap).concat(Object.keys(emojiAliases)));
} else {
- $.getJSON(this.dataSources[GfmAutoComplete.atTypeMap[at]], (data) => {
- this.loadData($input, at, data);
- }).fail(() => { this.isLoadingData[at] = false; });
+ AjaxCache.retrieve(this.dataSources[GfmAutoComplete.atTypeMap[at]], true)
+ .then((data) => {
+ this.loadData($input, at, data);
+ })
+ .catch(() => { this.isLoadingData[at] = false; });
}
}
+
loadData($input, at, data) {
this.isLoadingData[at] = false;
this.cachedData[at] = data;
@@ -389,6 +394,10 @@ class GfmAutoComplete {
return $input.trigger('keyup');
}
+ clearCache() {
+ this.cachedData = {};
+ }
+
static isLoading(data) {
let dataToInspect = data;
if (data && data.length > 0) {
diff --git a/app/assets/javascripts/gl_field_errors.js b/app/assets/javascripts/gl_field_errors.js
index 4f226ff96ea..4bef60264bb 100644
--- a/app/assets/javascripts/gl_field_errors.js
+++ b/app/assets/javascripts/gl_field_errors.js
@@ -31,9 +31,13 @@ class GlFieldErrors {
* and prevents disabling of invalid submit button by application.js */
catchInvalidFormSubmit (event) {
- if (!event.currentTarget.checkValidity()) {
- event.preventDefault();
- event.stopPropagation();
+ const $form = $(event.currentTarget);
+
+ if (!$form.attr('novalidate')) {
+ if (!event.currentTarget.checkValidity()) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
}
}
diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js
index 34e4a257ff9..5b4ca94ed30 100644
--- a/app/assets/javascripts/importer_status.js
+++ b/app/assets/javascripts/importer_status.js
@@ -56,6 +56,8 @@
if (job.import_status === 'finished') {
job_item.removeClass("active").addClass("success");
return status_field.html('<span><i class="fa fa-check"></i> done</span>');
+ } else if (job.import_status === 'scheduled') {
+ return status_field.html("<i class='fa fa-spinner fa-spin'></i> scheduled");
} else if (job.import_status === 'started') {
return status_field.html("<i class='fa fa-spinner fa-spin'></i> started");
} else {
diff --git a/app/assets/javascripts/integrations/index.js b/app/assets/javascripts/integrations/index.js
new file mode 100644
index 00000000000..10fe6bac0e8
--- /dev/null
+++ b/app/assets/javascripts/integrations/index.js
@@ -0,0 +1,7 @@
+/* eslint-disable no-new */
+import IntegrationSettingsForm from './integration_settings_form';
+
+$(() => {
+ const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
+ integrationSettingsForm.init();
+});
diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js
new file mode 100644
index 00000000000..ddd3a6aab99
--- /dev/null
+++ b/app/assets/javascripts/integrations/integration_settings_form.js
@@ -0,0 +1,123 @@
+/* global Flash */
+
+export default class IntegrationSettingsForm {
+ constructor(formSelector) {
+ this.$form = $(formSelector);
+
+ // Form Metadata
+ this.canTestService = this.$form.data('can-test');
+ this.testEndPoint = this.$form.data('test-url');
+
+ // Form Child Elements
+ this.$serviceToggle = this.$form.find('#service_active');
+ this.$submitBtn = this.$form.find('button[type="submit"]');
+ this.$submitBtnLoader = this.$submitBtn.find('.js-btn-spinner');
+ this.$submitBtnLabel = this.$submitBtn.find('.js-btn-label');
+ }
+
+ init() {
+ // Initialize View
+ this.toggleServiceState(this.$serviceToggle.is(':checked'));
+
+ // Bind Event Listeners
+ this.$serviceToggle.on('change', e => this.handleServiceToggle(e));
+ this.$submitBtn.on('click', e => this.handleSettingsSave(e));
+ }
+
+ handleSettingsSave(e) {
+ // Check if Service is marked active, as if not marked active,
+ // We can skip testing it and directly go ahead to allow form to
+ // be submitted
+ if (!this.$serviceToggle.is(':checked')) {
+ return;
+ }
+
+ // Service was marked active so now we check;
+ // 1) If form contents are valid
+ // 2) If this service can be tested
+ // If both conditions are true, we override form submission
+ // and test the service using provided configuration.
+ if (this.$form.get(0).checkValidity() && this.canTestService) {
+ e.preventDefault();
+ this.testSettings(this.$form.serialize());
+ }
+ }
+
+ handleServiceToggle(e) {
+ this.toggleServiceState($(e.currentTarget).is(':checked'));
+ }
+
+ /**
+ * Change Form's validation enforcement based on service status (active/inactive)
+ */
+ toggleServiceState(serviceActive) {
+ this.toggleSubmitBtnLabel(serviceActive);
+ if (serviceActive) {
+ this.$form.removeAttr('novalidate');
+ } else if (!this.$form.attr('novalidate')) {
+ this.$form.attr('novalidate', 'novalidate');
+ }
+ }
+
+ /**
+ * Toggle Submit button label based on Integration status and ability to test service
+ */
+ toggleSubmitBtnLabel(serviceActive) {
+ let btnLabel = 'Save changes';
+
+ if (serviceActive && this.canTestService) {
+ btnLabel = 'Test settings and save changes';
+ }
+
+ this.$submitBtnLabel.text(btnLabel);
+ }
+
+ /**
+ * Toggle Submit button state based on provided boolean value of `saveTestActive`
+ * When enabled, it does two things, and reverts back when disabled
+ *
+ * 1. It shows load spinner on submit button
+ * 2. Makes submit button disabled
+ */
+ toggleSubmitBtnState(saveTestActive) {
+ if (saveTestActive) {
+ this.$submitBtn.disable();
+ this.$submitBtnLoader.removeClass('hidden');
+ } else {
+ this.$submitBtn.enable();
+ this.$submitBtnLoader.addClass('hidden');
+ }
+ }
+
+ /* eslint-disable promise/catch-or-return, no-new */
+ /**
+ * Test Integration config
+ */
+ testSettings(formData) {
+ this.toggleSubmitBtnState(true);
+ $.ajax({
+ type: 'PUT',
+ url: this.testEndPoint,
+ data: formData,
+ })
+ .done((res) => {
+ if (res.error) {
+ new Flash(res.message, null, null, {
+ 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);
+ });
+ }
+}
diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js
new file mode 100644
index 00000000000..e46c0e90255
--- /dev/null
+++ b/app/assets/javascripts/issuable_bulk_update_actions.js
@@ -0,0 +1,159 @@
+/* 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 */
+/* global IssuableIndex */
+/* global Flash */
+
+export default {
+ init({ container, form, issues, prefixId } = {}) {
+ this.prefixId = prefixId || 'issue_';
+ this.form = form || this.getElement('.bulk-update');
+ this.$labelDropdown = this.form.find('.js-label-select');
+ this.issues = issues || this.getElement('.issues-list .issue');
+ this.willUpdateLabels = false;
+ this.bindEvents();
+ },
+
+ bindEvents() {
+ return this.form.off('submit').on('submit', this.onFormSubmit.bind(this));
+ },
+
+ onFormSubmit(e) {
+ e.preventDefault();
+ return this.submit();
+ },
+
+ 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());
+ },
+
+ onFormSubmitFailure() {
+ this.form.find('[type="submit"]').enable();
+ return new Flash("Issue update failed");
+ },
+
+ getSelectedIssues() {
+ return this.issues.has('.selected_issue:checked');
+ },
+
+ getLabelsFromSelection() {
+ const labels = [];
+ this.getSelectedIssues().map(function() {
+ const labelsData = $(this).data('labels');
+ if (labelsData) {
+ return labelsData.map(function(labelId) {
+ if (labels.indexOf(labelId) === -1) {
+ return labels.push(labelId);
+ }
+ });
+ }
+ });
+ return labels;
+ },
+
+ /**
+ * Will return only labels that were marked previously and the user has unmarked
+ * @return {Array} Label IDs
+ */
+
+ getUnmarkedIndeterminedLabels() {
+ const result = [];
+ const labelsToKeep = this.$labelDropdown.data('indeterminate');
+
+ this.getLabelsFromSelection().forEach((id) => {
+ if (labelsToKeep.indexOf(id) === -1) {
+ result.push(id);
+ }
+ });
+
+ return result;
+ },
+
+ /**
+ * Simple form serialization, it will return just what we need
+ * Returns key/value pairs from form data
+ */
+
+ getFormDataAsObject() {
+ const formData = {
+ update: {
+ state_event: this.form.find('input[name="update[state_event]"]').val(),
+ // For Merge Requests
+ assignee_id: this.form.find('input[name="update[assignee_id]"]').val(),
+ // For Issues
+ assignee_ids: [this.form.find('input[name="update[assignee_ids][]"]').val()],
+ milestone_id: this.form.find('input[name="update[milestone_id]"]').val(),
+ issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(),
+ subscription_event: this.form.find('input[name="update[subscription_event]"]').val(),
+ add_label_ids: [],
+ remove_label_ids: []
+ }
+ };
+ if (this.willUpdateLabels) {
+ formData.update.add_label_ids = this.$labelDropdown.data('marked');
+ formData.update.remove_label_ids = this.$labelDropdown.data('unmarked');
+ }
+ return formData;
+ },
+
+ setOriginalDropdownData() {
+ const $labelSelect = $('.bulk-update .js-label-select');
+ $labelSelect.data('common', this.getOriginalCommonIds());
+ $labelSelect.data('marked', this.getOriginalMarkedIds());
+ $labelSelect.data('indeterminate', this.getOriginalIndeterminateIds());
+ },
+
+ // From issuable's initial bulk selection
+ getOriginalCommonIds() {
+ const labelIds = [];
+
+ this.getElement('.selected_issue:checked').each((i, el) => {
+ labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'));
+ });
+ return _.intersection.apply(this, labelIds);
+ },
+
+ // From issuable's initial bulk selection
+ getOriginalMarkedIds() {
+ const labelIds = [];
+ this.getElement('.selected_issue:checked').each((i, el) => {
+ labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'));
+ });
+ return _.intersection.apply(this, labelIds);
+ },
+
+ // From issuable's initial bulk selection
+ getOriginalIndeterminateIds() {
+ const uniqueIds = [];
+ const labelIds = [];
+ let issuableLabels = [];
+
+ // Collect unique label IDs for all checked issues
+ this.getElement('.selected_issue:checked').each((i, el) => {
+ issuableLabels = this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels');
+ issuableLabels.forEach((labelId) => {
+ // Store unique IDs
+ if (uniqueIds.indexOf(labelId) === -1) {
+ uniqueIds.push(labelId);
+ }
+ });
+ // Store array of IDs per issuable
+ labelIds.push(issuableLabels);
+ });
+ // Add uniqueIds to add it as argument for _.intersection
+ labelIds.unshift(uniqueIds);
+ // Return IDs that are present but not in all selected issueables
+ return _.difference(uniqueIds, _.intersection.apply(this, labelIds));
+ },
+
+ getElement(selector) {
+ this.scopeEl = this.scopeEl || $('.content');
+ return this.scopeEl.find(selector);
+ },
+};
diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js
new file mode 100644
index 00000000000..84bd2e092e6
--- /dev/null
+++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js
@@ -0,0 +1,165 @@
+/* eslint-disable class-methods-use-this, no-new */
+/* global LabelsSelect */
+/* global MilestoneSelect */
+/* global IssueStatusSelect */
+/* global SubscriptionSelect */
+
+import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
+
+const HIDDEN_CLASS = 'hidden';
+const DISABLED_CONTENT_CLASS = 'disabled-content';
+const SIDEBAR_EXPANDED_CLASS = 'right-sidebar-expanded issuable-bulk-update-sidebar';
+const SIDEBAR_COLLAPSED_CLASS = 'right-sidebar-collapsed issuable-bulk-update-sidebar';
+
+export default class IssuableBulkUpdateSidebar {
+ constructor() {
+ this.initDomElements();
+ this.bindEvents();
+ this.initDropdowns();
+ this.setupBulkUpdateActions();
+ }
+
+ initDomElements() {
+ this.$page = $('.page-with-sidebar');
+ this.$sidebar = $('.right-sidebar');
+ this.$bulkEditCancelBtn = $('.js-bulk-update-menu-hide');
+ this.$bulkEditSubmitBtn = $('.update-selected-issues');
+ this.$bulkUpdateEnableBtn = $('.js-bulk-update-toggle');
+ this.$otherFilters = $('.issues-other-filters');
+ this.$checkAllContainer = $('.check-all-holder');
+ this.$issueChecks = $('.issue-check');
+ this.$issuesList = $('.selected_issue');
+ this.$issuableIdsInput = $('#update_issuable_ids');
+ }
+
+ bindEvents() {
+ this.$bulkUpdateEnableBtn.on('click', e => this.toggleBulkEdit(e, true));
+ this.$bulkEditCancelBtn.on('click', e => this.toggleBulkEdit(e, false));
+ this.$checkAllContainer.on('click', e => this.selectAll(e));
+ this.$issuesList.on('change', () => this.updateFormState());
+ this.$bulkEditSubmitBtn.on('click', () => this.prepForSubmit());
+ this.$checkAllContainer.on('click', () => this.updateFormState());
+ }
+
+ initDropdowns() {
+ new LabelsSelect();
+ new MilestoneSelect();
+ new IssueStatusSelect();
+ new SubscriptionSelect();
+ }
+
+ getNavHeight() {
+ const navbarHeight = $('.navbar-gitlab').outerHeight();
+ const layoutNavHeight = $('.layout-nav').outerHeight();
+ const subNavScroll = $('.sub-nav-scroll').outerHeight();
+ return navbarHeight + layoutNavHeight + subNavScroll;
+ }
+
+ initSidebar() {
+ if (!this.navHeight) {
+ this.navHeight = this.getNavHeight();
+ }
+
+ if (!this.sidebarInitialized) {
+ $(document).off('scroll').on('scroll', _.throttle(this.setSidebarHeight, 10).bind(this));
+ $(window).off('resize').on('resize', _.throttle(this.setSidebarHeight, 10).bind(this));
+ this.sidebarInitialized = true;
+ }
+ }
+
+ setupBulkUpdateActions() {
+ IssuableBulkUpdateActions.setOriginalDropdownData();
+ }
+
+ updateFormState() {
+ const noCheckedIssues = !$('.selected_issue:checked').length;
+
+ this.toggleSubmitButtonDisabled(noCheckedIssues);
+ this.updateSelectedIssuableIds();
+
+ IssuableBulkUpdateActions.setOriginalDropdownData();
+ }
+
+ prepForSubmit() {
+ // if submit button is disabled, submission is blocked. This ensures we disable after
+ // form submission is carried out
+ setTimeout(() => this.$bulkEditSubmitBtn.disable());
+ this.updateSelectedIssuableIds();
+ }
+
+ toggleBulkEdit(e, enable) {
+ e.preventDefault();
+
+ this.toggleSidebarDisplay(enable);
+ this.toggleBulkEditButtonDisabled(enable);
+ this.toggleOtherFiltersDisabled(enable);
+ this.toggleCheckboxDisplay(enable);
+
+ if (enable) {
+ this.initSidebar();
+ }
+ }
+
+ updateSelectedIssuableIds() {
+ this.$issuableIdsInput.val(IssuableBulkUpdateSidebar.getCheckedIssueIds());
+ }
+
+ selectAll() {
+ const checkAllButtonState = this.$checkAllContainer.find('input').prop('checked');
+
+ this.$issuesList.prop('checked', checkAllButtonState);
+ }
+
+ toggleSidebarDisplay(show) {
+ this.$page.toggleClass(SIDEBAR_EXPANDED_CLASS, show);
+ this.$page.toggleClass(SIDEBAR_COLLAPSED_CLASS, !show);
+ this.$sidebar.toggleClass(SIDEBAR_EXPANDED_CLASS, show);
+ this.$sidebar.toggleClass(SIDEBAR_COLLAPSED_CLASS, !show);
+ }
+
+ toggleBulkEditButtonDisabled(disable) {
+ if (disable) {
+ this.$bulkUpdateEnableBtn.disable();
+ } else {
+ this.$bulkUpdateEnableBtn.enable();
+ }
+ }
+
+ toggleCheckboxDisplay(show) {
+ this.$checkAllContainer.toggleClass(HIDDEN_CLASS, !show);
+ this.$issueChecks.toggleClass(HIDDEN_CLASS, !show);
+ }
+
+ toggleOtherFiltersDisabled(disable) {
+ this.$otherFilters.toggleClass(DISABLED_CONTENT_CLASS, disable);
+ }
+
+ toggleSubmitButtonDisabled(disable) {
+ if (disable) {
+ this.$bulkEditSubmitBtn.disable();
+ } else {
+ this.$bulkEditSubmitBtn.enable();
+ }
+ }
+ // loosely based on method of the same name in right_sidebar.js
+ setSidebarHeight() {
+ const currentScrollDepth = window.pageYOffset || 0;
+ const diff = this.navHeight - currentScrollDepth;
+
+ if (diff > 0) {
+ this.$sidebar.outerHeight(window.innerHeight - diff);
+ } else {
+ this.$sidebar.outerHeight('100%');
+ }
+ }
+
+ static getCheckedIssueIds() {
+ const $checkedIssues = $('.selected_issue:checked');
+
+ if ($checkedIssues.length > 0) {
+ return $.map($checkedIssues, value => $(value).data('id'));
+ }
+
+ return [];
+ }
+}
diff --git a/app/assets/javascripts/issuable.js b/app/assets/javascripts/issuable_index.js
index 3bfce32768a..5c96646def8 100644
--- a/app/assets/javascripts/issuable.js
+++ b/app/assets/javascripts/issuable_index.js
@@ -1,30 +1,33 @@
/* eslint-disable no-param-reassign, func-names, no-var, camelcase, no-unused-vars, object-shorthand, space-before-function-paren, no-return-assign, comma-dangle, consistent-return, one-var, one-var-declaration-per-line, quotes, prefer-template, prefer-arrow-callback, wrap-iife, max-len */
-/* global Issuable */
+/* global IssuableIndex */
+
+import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar';
+import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
((global) => {
var issuable_created;
issuable_created = false;
- global.Issuable = {
- init: function() {
- Issuable.initTemplates();
- Issuable.initSearch();
- Issuable.initChecks();
- Issuable.initResetFilters();
- Issuable.resetIncomingEmailToken();
- return Issuable.initLabelFilterRemove();
+ global.IssuableIndex = {
+ init: function(pagePrefix) {
+ IssuableIndex.initTemplates();
+ IssuableIndex.initSearch();
+ IssuableIndex.initBulkUpdate(pagePrefix);
+ IssuableIndex.initResetFilters();
+ IssuableIndex.resetIncomingEmailToken();
+ IssuableIndex.initLabelFilterRemove();
},
initTemplates: function() {
- return Issuable.labelRow = _.template('<% _.each(labels, function(label){ %> <span class="label-row btn-group" role="group" aria-label="<%- label.title %>" style="color: <%- label.text_color %>;"> <a href="#" class="btn btn-transparent has-tooltip" style="background-color: <%- label.color %>;" title="<%- label.description %>" data-container="body"> <%- label.title %> </a> <button type="button" class="btn btn-transparent label-remove js-label-filter-remove" style="background-color: <%- label.color %>;" data-label="<%- label.title %>"> <i class="fa fa-times"></i> </button> </span> <% }); %>');
+ return IssuableIndex.labelRow = _.template('<% _.each(labels, function(label){ %> <span class="label-row btn-group" role="group" aria-label="<%- label.title %>" style="color: <%- label.text_color %>;"> <a href="#" class="btn btn-transparent has-tooltip" style="background-color: <%- label.color %>;" title="<%- label.description %>" data-container="body"> <%- label.title %> </a> <button type="button" class="btn btn-transparent label-remove js-label-filter-remove" style="background-color: <%- label.color %>;" data-label="<%- label.title %>"> <i class="fa fa-times"></i> </button> </span> <% }); %>');
},
initSearch: function() {
const $searchInput = $('#issuable_search');
- Issuable.initSearchState($searchInput);
+ IssuableIndex.initSearchState($searchInput);
// `immediate` param set to false debounces on the `trailing` edge, lets user finish typing
- const debouncedExecSearch = _.debounce(Issuable.executeSearch, 1000, false);
+ const debouncedExecSearch = _.debounce(IssuableIndex.executeSearch, 1000, false);
$searchInput.off('keyup').on('keyup', debouncedExecSearch);
@@ -37,16 +40,16 @@
initSearchState: function($searchInput) {
const currentSearchVal = $searchInput.val();
- Issuable.searchState = {
+ IssuableIndex.searchState = {
elem: $searchInput,
current: currentSearchVal
};
- Issuable.maybeFocusOnSearch();
+ IssuableIndex.maybeFocusOnSearch();
},
accessSearchPristine: function(set) {
// store reference to previous value to prevent search on non-mutating keyup
- const state = Issuable.searchState;
+ const state = IssuableIndex.searchState;
const currentSearchVal = state.elem.val();
if (set) {
@@ -56,10 +59,10 @@
}
},
maybeFocusOnSearch: function() {
- const currentSearchVal = Issuable.searchState.current;
+ const currentSearchVal = IssuableIndex.searchState.current;
if (currentSearchVal && currentSearchVal !== '') {
const queryLength = currentSearchVal.length;
- const $searchInput = Issuable.searchState.elem;
+ const $searchInput = IssuableIndex.searchState.elem;
/* The following ensures that the cursor is initially placed at
* the end of search input when focus is applied. It accounts
@@ -80,7 +83,7 @@
const $searchValue = $search.val();
const $filtersForm = $('.js-filter-form');
const $input = $(`input[name='${$searchName}']`, $filtersForm);
- const isPristine = Issuable.accessSearchPristine();
+ const isPristine = IssuableIndex.accessSearchPristine();
if (isPristine) {
return;
@@ -92,7 +95,7 @@
$input.val($searchValue);
}
- Issuable.filterResults($filtersForm);
+ IssuableIndex.filterResults($filtersForm);
},
initLabelFilterRemove: function() {
return $(document).off('click', '.js-label-filter-remove').on('click', '.js-label-filter-remove', function(e) {
@@ -103,7 +106,7 @@
return this.value === $button.data('label');
}).remove();
// Submit the form to get new data
- Issuable.filterResults($('.filter-form'));
+ IssuableIndex.filterResults($('.filter-form'));
});
},
filterResults: (function(_this) {
@@ -132,38 +135,18 @@
gl.utils.visitUrl(baseIssuesUrl);
});
},
- initChecks: function() {
- this.issuableBulkActions = $('.bulk-update').data('bulkActions');
- $('.check_all_issues').off('click').on('click', function() {
- $('.selected_issue').prop('checked', this.checked);
- return Issuable.checkChanged();
- });
- return $('.selected_issue').off('change').on('change', Issuable.checkChanged.bind(this));
- },
- checkChanged: function() {
- const $checkedIssues = $('.selected_issue:checked');
- const $updateIssuesIds = $('#update_issuable_ids');
- const $issuesOtherFilters = $('.issues-other-filters');
- const $issuesBulkUpdate = $('.issues_bulk_update');
-
- this.issuableBulkActions.willUpdateLabels = false;
- this.issuableBulkActions.setOriginalDropdownData();
-
- if ($checkedIssues.length > 0) {
- const ids = $.map($checkedIssues, function(value) {
- return $(value).data('id');
+ initBulkUpdate: function(pagePrefix) {
+ const userCanBulkUpdate = $('.issues-bulk-update').length > 0;
+ const alreadyInitialized = !!this.bulkUpdateSidebar;
+
+ if (userCanBulkUpdate && !alreadyInitialized) {
+ IssuableBulkUpdateActions.init({
+ prefixId: pagePrefix,
});
- $updateIssuesIds.val(ids);
- $issuesOtherFilters.hide();
- $issuesBulkUpdate.show();
- } else {
- $updateIssuesIds.val([]);
- $issuesBulkUpdate.hide();
- $issuesOtherFilters.show();
+
+ this.bulkUpdateSidebar = new IssuableBulkUpdateSidebar();
}
- return true;
},
-
resetIncomingEmailToken: function() {
$('.incoming-email-token-reset').on('click', function(e) {
e.preventDefault();
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index 770a0dcd27e..e14414d3f68 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -1,10 +1,15 @@
<script>
+/* global Flash */
import Visibility from 'visibilityjs';
import Poll from '../../lib/utils/poll';
+import eventHub from '../event_hub';
import Service from '../services/index';
import Store from '../stores';
import titleComponent from './title.vue';
import descriptionComponent from './description.vue';
+import editedComponent from './edited.vue';
+import formComponent from './form.vue';
+import '../../lib/utils/url_utility';
export default {
props: {
@@ -12,15 +17,27 @@ export default {
required: true,
type: String,
},
+ canMove: {
+ required: true,
+ type: Boolean,
+ },
canUpdate: {
required: true,
type: Boolean,
},
+ canDestroy: {
+ required: true,
+ type: Boolean,
+ },
issuableRef: {
type: String,
required: true,
},
- initialTitle: {
+ initialTitleHtml: {
+ type: String,
+ required: true,
+ },
+ initialTitleText: {
type: String,
required: true,
},
@@ -34,30 +51,160 @@ export default {
required: false,
default: '',
},
+ updatedAt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedByName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedByPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ issuableTemplates: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ isConfidential: {
+ type: Boolean,
+ required: true,
+ },
+ markdownPreviewUrl: {
+ type: String,
+ required: true,
+ },
+ markdownDocs: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ projectNamespace: {
+ type: String,
+ required: true,
+ },
+ projectsAutocompleteUrl: {
+ type: String,
+ required: true,
+ },
},
data() {
const store = new Store({
- titleHtml: this.initialTitle,
+ titleHtml: this.initialTitleHtml,
+ titleText: this.initialTitleText,
descriptionHtml: this.initialDescriptionHtml,
descriptionText: this.initialDescriptionText,
+ updatedAt: this.updatedAt,
+ updatedByName: this.updatedByName,
+ updatedByPath: this.updatedByPath,
});
return {
store,
state: store.state,
+ showForm: false,
};
},
+ computed: {
+ formState() {
+ return this.store.formState;
+ },
+ hasUpdated() {
+ return !!this.state.updatedAt;
+ },
+ },
components: {
descriptionComponent,
titleComponent,
+ editedComponent,
+ formComponent,
+ },
+ methods: {
+ openForm() {
+ if (!this.showForm) {
+ this.showForm = true;
+ this.store.setFormState({
+ title: this.state.titleText,
+ confidential: this.isConfidential,
+ description: this.state.descriptionText,
+ lockedWarningVisible: false,
+ move_to_project_id: 0,
+ updateLoading: false,
+ });
+ }
+ },
+ closeForm() {
+ this.showForm = false;
+ },
+ updateIssuable() {
+ const canPostUpdate = this.store.formState.move_to_project_id !== 0 ?
+ confirm('Are you sure you want to move this issue to another project?') : true; // eslint-disable-line no-alert
+
+ if (!canPostUpdate) {
+ this.store.setFormState({
+ updateLoading: false,
+ });
+ return;
+ }
+
+ this.service.updateIssuable(this.store.formState)
+ .then(res => res.json())
+ .then((data) => {
+ if (location.pathname !== data.web_url) {
+ gl.utils.visitUrl(data.web_url);
+ } else if (data.confidential !== this.isConfidential) {
+ gl.utils.visitUrl(location.pathname);
+ }
+
+ return this.service.getData();
+ })
+ .then(res => res.json())
+ .then((data) => {
+ this.store.updateState(data);
+ eventHub.$emit('close.form');
+ })
+ .catch(() => {
+ eventHub.$emit('close.form');
+ return new Flash('Error updating issue');
+ });
+ },
+ deleteIssuable() {
+ this.service.deleteIssuable()
+ .then(res => res.json())
+ .then((data) => {
+ // Stop the poll so we don't get 404's with the issue not existing
+ this.poll.stop();
+
+ gl.utils.visitUrl(data.web_url);
+ })
+ .catch(() => {
+ eventHub.$emit('close.form');
+ return new Flash('Error deleting issue');
+ });
+ },
},
created() {
- const resource = new Service(this.endpoint);
- const poll = new Poll({
- resource,
+ this.service = new Service(this.endpoint);
+ this.poll = new Poll({
+ resource: this.service,
method: 'getData',
successCallback: (res) => {
- this.store.updateState(res.json());
+ const data = res.json();
+ const shouldUpdate = this.store.stateShouldUpdate(data);
+
+ this.store.updateState(data);
+
+ if (this.showForm && (shouldUpdate.title || shouldUpdate.description)) {
+ this.store.formState.lockedWarningVisible = true;
+ }
},
errorCallback(err) {
throw new Error(err);
@@ -65,32 +212,62 @@ export default {
});
if (!Visibility.hidden()) {
- poll.makeRequest();
+ this.poll.makeRequest();
}
Visibility.change(() => {
if (!Visibility.hidden()) {
- poll.restart();
+ this.poll.restart();
} else {
- poll.stop();
+ this.poll.stop();
}
});
+
+ eventHub.$on('delete.issuable', this.deleteIssuable);
+ eventHub.$on('update.issuable', this.updateIssuable);
+ eventHub.$on('close.form', this.closeForm);
+ eventHub.$on('open.form', this.openForm);
+ },
+ beforeDestroy() {
+ eventHub.$off('delete.issuable', this.deleteIssuable);
+ eventHub.$off('update.issuable', this.updateIssuable);
+ eventHub.$off('close.form', this.closeForm);
+ eventHub.$off('open.form', this.openForm);
},
};
</script>
<template>
<div>
- <title-component
- :issuable-ref="issuableRef"
- :title-html="state.titleHtml"
- :title-text="state.titleText" />
- <description-component
- v-if="state.descriptionHtml"
- :can-update="canUpdate"
- :description-html="state.descriptionHtml"
- :description-text="state.descriptionText"
- :updated-at="state.updatedAt"
- :task-status="state.taskStatus" />
+ <form-component
+ v-if="canUpdate && showForm"
+ :form-state="formState"
+ :can-move="canMove"
+ :can-destroy="canDestroy"
+ :issuable-templates="issuableTemplates"
+ :markdown-docs="markdownDocs"
+ :markdown-preview-url="markdownPreviewUrl"
+ :project-path="projectPath"
+ :project-namespace="projectNamespace"
+ :projects-autocomplete-url="projectsAutocompleteUrl"
+ />
+ <div v-else>
+ <title-component
+ :issuable-ref="issuableRef"
+ :title-html="state.titleHtml"
+ :title-text="state.titleText" />
+ <description-component
+ v-if="state.descriptionHtml"
+ :can-update="canUpdate"
+ :description-html="state.descriptionHtml"
+ :description-text="state.descriptionText"
+ :updated-at="state.updatedAt"
+ :task-status="state.taskStatus" />
+ <edited-component
+ v-if="hasUpdated"
+ :updated-at="state.updatedAt"
+ :updated-by-name="state.updatedByName"
+ :updated-by-path="state.updatedByPath" />
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue
index 4ad3eb7dfd7..5ae617356e0 100644
--- a/app/assets/javascripts/issue_show/components/description.vue
+++ b/app/assets/javascripts/issue_show/components/description.vue
@@ -16,20 +16,16 @@
type: String,
required: true,
},
- updatedAt: {
- type: String,
- required: true,
- },
taskStatus: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
},
data() {
return {
preAnimation: false,
pulseAnimation: false,
- timeAgoEl: $('.js-issue-edited-ago'),
};
},
watch: {
@@ -37,12 +33,6 @@
this.animateChange();
this.$nextTick(() => {
- const toolTipTime = gl.utils.formatDate(this.updatedAt);
-
- this.timeAgoEl.attr('datetime', this.updatedAt)
- .attr('title', toolTipTime)
- .tooltip('fixTitle');
-
this.renderGFM();
});
},
@@ -83,6 +73,7 @@
<template>
<div
+ v-if="descriptionHtml"
class="description"
:class="{
'js-task-list-container': canUpdate
diff --git a/app/assets/javascripts/issue_show/components/edit_actions.vue b/app/assets/javascripts/issue_show/components/edit_actions.vue
new file mode 100644
index 00000000000..8c81575fe6f
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/edit_actions.vue
@@ -0,0 +1,79 @@
+<script>
+ import updateMixin from '../mixins/update';
+ import eventHub from '../event_hub';
+
+ export default {
+ mixins: [updateMixin],
+ props: {
+ canDestroy: {
+ type: Boolean,
+ required: true,
+ },
+ formState: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ deleteLoading: false,
+ };
+ },
+ computed: {
+ isSubmitEnabled() {
+ return this.formState.title.trim() !== '';
+ },
+ },
+ methods: {
+ closeForm() {
+ eventHub.$emit('close.form');
+ },
+ deleteIssuable() {
+ // eslint-disable-next-line no-alert
+ if (confirm('Issue will be removed! Are you sure?')) {
+ this.deleteLoading = true;
+
+ eventHub.$emit('delete.issuable');
+ }
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="prepend-top-default append-bottom-default clearfix">
+ <button
+ class="btn btn-save pull-left"
+ :class="{ disabled: formState.updateLoading || !isSubmitEnabled }"
+ type="submit"
+ :disabled="formState.updateLoading || !isSubmitEnabled"
+ @click.prevent="updateIssuable">
+ Save changes
+ <i
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true"
+ v-if="formState.updateLoading">
+ </i>
+ </button>
+ <button
+ class="btn btn-default pull-right"
+ type="button"
+ @click="closeForm">
+ Cancel
+ </button>
+ <button
+ v-if="canDestroy"
+ class="btn btn-danger pull-right append-right-default"
+ :class="{ disabled: deleteLoading }"
+ type="button"
+ :disabled="deleteLoading"
+ @click="deleteIssuable">
+ Delete
+ <i
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true"
+ v-if="deleteLoading">
+ </i>
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/edited.vue b/app/assets/javascripts/issue_show/components/edited.vue
new file mode 100644
index 00000000000..d59e6d11032
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/edited.vue
@@ -0,0 +1,56 @@
+<script>
+import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
+
+export default {
+ props: {
+ updatedAt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedByName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedByPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ components: {
+ timeAgoTooltip,
+ },
+ computed: {
+ hasUpdatedBy() {
+ return this.updatedByName && this.updatedByPath;
+ },
+ },
+};
+</script>
+
+<template>
+ <small
+ class="edited-text"
+ >
+ Edited
+ <time-ago-tooltip
+ v-if="updatedAt"
+ placement="bottom"
+ :time="updatedAt"
+ />
+ <span
+ v-if="hasUpdatedBy"
+ >
+ by
+ <a
+ class="author_link"
+ :href="updatedByPath"
+ >
+ <span>{{updatedByName}}</span>
+ </a>
+ </span>
+ </small>
+</template>
+
diff --git a/app/assets/javascripts/issue_show/components/fields/confidential_checkbox.vue b/app/assets/javascripts/issue_show/components/fields/confidential_checkbox.vue
new file mode 100644
index 00000000000..a0ff08e9111
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/fields/confidential_checkbox.vue
@@ -0,0 +1,23 @@
+<script>
+ export default {
+ props: {
+ formState: {
+ type: Object,
+ required: true,
+ },
+ },
+ };
+</script>
+
+<template>
+ <fieldset class="checkbox">
+ <label for="issue-confidential">
+ <input
+ type="checkbox"
+ value="1"
+ id="issue-confidential"
+ v-model="formState.confidential" />
+ This issue is confidential and should only be visible to team members with at least Reporter access.
+ </label>
+ </fieldset>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue
new file mode 100644
index 00000000000..30a1be5cb50
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/fields/description.vue
@@ -0,0 +1,54 @@
+<script>
+ /* global Flash */
+ import updateMixin from '../../mixins/update';
+ import markdownField from '../../../vue_shared/components/markdown/field.vue';
+
+ export default {
+ mixins: [updateMixin],
+ props: {
+ formState: {
+ type: Object,
+ required: true,
+ },
+ markdownPreviewUrl: {
+ type: String,
+ required: true,
+ },
+ markdownDocs: {
+ type: String,
+ required: true,
+ },
+ },
+ components: {
+ markdownField,
+ },
+ mounted() {
+ this.$refs.textarea.focus();
+ },
+ };
+</script>
+
+<template>
+ <div class="common-note-form">
+ <label
+ class="sr-only"
+ for="issue-description">
+ Description
+ </label>
+ <markdown-field
+ :markdown-preview-url="markdownPreviewUrl"
+ :markdown-docs="markdownDocs">
+ <textarea
+ id="issue-description"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
+ data-supports-slash-commands="false"
+ aria-label="Description"
+ v-model="formState.description"
+ ref="textarea"
+ slot="textarea"
+ placeholder="Write a comment or drag your files here..."
+ @keydown.meta.enter="updateIssuable">
+ </textarea>
+ </markdown-field>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issue_show/components/fields/description_template.vue
new file mode 100644
index 00000000000..1c40b286513
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/fields/description_template.vue
@@ -0,0 +1,111 @@
+<script>
+ export default {
+ props: {
+ formState: {
+ type: Object,
+ required: true,
+ },
+ issuableTemplates: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ projectNamespace: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ issuableTemplatesJson() {
+ return JSON.stringify(this.issuableTemplates);
+ },
+ },
+ mounted() {
+ // Create the editor for the template
+ const editor = document.querySelector('.detail-page-description .note-textarea') || {};
+ editor.setValue = (val) => {
+ this.formState.description = val;
+ };
+ editor.getValue = () => this.formState.description;
+
+ this.issuableTemplate = new gl.IssuableTemplateSelectors({
+ $dropdowns: $(this.$refs.toggle),
+ editor,
+ });
+ },
+ };
+</script>
+
+<template>
+ <div
+ class="dropdown js-issuable-selector-wrap"
+ data-issuable-type="issue">
+ <button
+ class="dropdown-menu-toggle js-issuable-selector"
+ type="button"
+ ref="toggle"
+ data-field-name="issuable_template"
+ data-selected="null"
+ data-toggle="dropdown"
+ :data-namespace-path="projectNamespace"
+ :data-project-path="projectPath"
+ :data-data="issuableTemplatesJson">
+ <span class="dropdown-toggle-text">
+ Choose a template
+ </span>
+ <i
+ aria-hidden="true"
+ class="fa fa-chevron-down">
+ </i>
+ </button>
+ <div class="dropdown-menu dropdown-select">
+ <div class="dropdown-title">
+ Choose a template
+ <button
+ class="dropdown-title-button dropdown-menu-close"
+ aria-label="Close"
+ type="button">
+ <i
+ aria-hidden="true"
+ class="fa fa-times dropdown-menu-close-icon">
+ </i>
+ </button>
+ </div>
+ <div class="dropdown-input">
+ <input
+ type="search"
+ class="dropdown-input-field"
+ placeholder="Filter"
+ autocomplete="off" />
+ <i
+ aria-hidden="true"
+ class="fa fa-search dropdown-input-search">
+ </i>
+ <i
+ role="button"
+ aria-label="Clear templates search input"
+ class="fa fa-times dropdown-input-clear js-dropdown-input-clear">
+ </i>
+ </div>
+ <div class="dropdown-content"></div>
+ <div class="dropdown-footer">
+ <ul class="dropdown-footer-list">
+ <li>
+ <a class="no-template">
+ No template
+ </a>
+ </li>
+ <li>
+ <a class="reset-template">
+ Reset template
+ </a>
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/fields/project_move.vue b/app/assets/javascripts/issue_show/components/fields/project_move.vue
new file mode 100644
index 00000000000..f811fb0de24
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/fields/project_move.vue
@@ -0,0 +1,83 @@
+<script>
+ import tooltipMixin from '../../../vue_shared/mixins/tooltip';
+
+ export default {
+ mixins: [
+ tooltipMixin,
+ ],
+ props: {
+ formState: {
+ type: Object,
+ required: true,
+ },
+ projectsAutocompleteUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ mounted() {
+ const $moveDropdown = $(this.$refs['move-dropdown']);
+
+ $moveDropdown.select2({
+ ajax: {
+ url: this.projectsAutocompleteUrl,
+ quietMillis: 125,
+ data(term, page, context) {
+ return {
+ search: term,
+ offset_id: context,
+ };
+ },
+ results(data) {
+ const more = data.length >= 50;
+ const context = data[data.length - 1] ? data[data.length - 1].id : null;
+
+ return {
+ results: data,
+ more,
+ context,
+ };
+ },
+ },
+ formatResult(project) {
+ return project.name_with_namespace;
+ },
+ formatSelection(project) {
+ return project.name_with_namespace;
+ },
+ })
+ .on('change', (e) => {
+ this.formState.move_to_project_id = parseInt(e.target.value, 10);
+ });
+ },
+ beforeDestroy() {
+ $(this.$refs['move-dropdown']).select2('destroy');
+ },
+ };
+</script>
+
+<template>
+ <fieldset>
+ <label
+ for="issuable-move"
+ class="sr-only">
+ Move
+ </label>
+ <div class="issuable-form-select-holder append-right-5">
+ <input
+ ref="move-dropdown"
+ type="hidden"
+ id="issuable-move"
+ data-placeholder="Move to a different project" />
+ </div>
+ <span
+ data-placement="auto top"
+ title="Moving an issue will copy the discussion to a different project and close it here. All participants will be notified of the new location."
+ ref="tooltip">
+ <i
+ class="fa fa-question-circle"
+ aria-hidden="true">
+ </i>
+ </span>
+ </fieldset>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/fields/title.vue b/app/assets/javascripts/issue_show/components/fields/title.vue
new file mode 100644
index 00000000000..6556bf117e2
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/fields/title.vue
@@ -0,0 +1,31 @@
+<script>
+ import updateMixin from '../../mixins/update';
+
+ export default {
+ mixins: [updateMixin],
+ props: {
+ formState: {
+ type: Object,
+ required: true,
+ },
+ },
+ };
+</script>
+
+<template>
+ <fieldset>
+ <label
+ class="sr-only"
+ for="issue-title">
+ Title
+ </label>
+ <input
+ id="issue-title"
+ class="form-control"
+ type="text"
+ placeholder="Issue title"
+ aria-label="Issue title"
+ v-model="formState.title"
+ @keydown.meta.enter="updateIssuable" />
+ </fieldset>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue
new file mode 100644
index 00000000000..76ec3dc9a5d
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/form.vue
@@ -0,0 +1,104 @@
+<script>
+ import lockedWarning from './locked_warning.vue';
+ import titleField from './fields/title.vue';
+ import descriptionField from './fields/description.vue';
+ import editActions from './edit_actions.vue';
+ import descriptionTemplate from './fields/description_template.vue';
+ import projectMove from './fields/project_move.vue';
+ import confidentialCheckbox from './fields/confidential_checkbox.vue';
+
+ export default {
+ props: {
+ canMove: {
+ type: Boolean,
+ required: true,
+ },
+ canDestroy: {
+ type: Boolean,
+ required: true,
+ },
+ formState: {
+ type: Object,
+ required: true,
+ },
+ issuableTemplates: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ markdownPreviewUrl: {
+ type: String,
+ required: true,
+ },
+ markdownDocs: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ projectNamespace: {
+ type: String,
+ required: true,
+ },
+ projectsAutocompleteUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ components: {
+ lockedWarning,
+ titleField,
+ descriptionField,
+ descriptionTemplate,
+ editActions,
+ projectMove,
+ confidentialCheckbox,
+ },
+ computed: {
+ hasIssuableTemplates() {
+ return this.issuableTemplates.length;
+ },
+ },
+ };
+</script>
+
+<template>
+ <form>
+ <locked-warning v-if="formState.lockedWarningVisible" />
+ <div class="row">
+ <div
+ class="col-sm-4 col-lg-3"
+ v-if="hasIssuableTemplates">
+ <description-template
+ :form-state="formState"
+ :issuable-templates="issuableTemplates"
+ :project-path="projectPath"
+ :project-namespace="projectNamespace" />
+ </div>
+ <div
+ :class="{
+ 'col-sm-8 col-lg-9': hasIssuableTemplates,
+ 'col-xs-12': !hasIssuableTemplates,
+ }">
+ <title-field
+ :form-state="formState"
+ :issuable-templates="issuableTemplates" />
+ </div>
+ </div>
+ <description-field
+ :form-state="formState"
+ :markdown-preview-url="markdownPreviewUrl"
+ :markdown-docs="markdownDocs" />
+ <confidential-checkbox
+ :form-state="formState" />
+ <project-move
+ v-if="canMove"
+ :form-state="formState"
+ :projects-autocomplete-url="projectsAutocompleteUrl" />
+ <edit-actions
+ :form-state="formState"
+ :can-destroy="canDestroy" />
+ </form>
+</template>
diff --git a/app/assets/javascripts/issue_show/components/locked_warning.vue b/app/assets/javascripts/issue_show/components/locked_warning.vue
new file mode 100644
index 00000000000..1c2789f154a
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/locked_warning.vue
@@ -0,0 +1,20 @@
+<script>
+ export default {
+ computed: {
+ currentPath() {
+ return location.pathname;
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="alert alert-danger">
+ Someone edited the issue at the same time you did. Please check out
+ <a
+ :href="currentPath"
+ target="_blank"
+ rel="nofollow">the issue</a>
+ and make sure your changes will not unintentionally remove theirs.
+ </div>
+</template>
diff --git a/app/assets/javascripts/issue_show/event_hub.js b/app/assets/javascripts/issue_show/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/issue_show/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js
index f06e33dee60..14b2a1e18e9 100644
--- a/app/assets/javascripts/issue_show/index.js
+++ b/app/assets/javascripts/issue_show/index.js
@@ -1,42 +1,52 @@
import Vue from 'vue';
+import eventHub from './event_hub';
import issuableApp from './components/app.vue';
import '../vue_shared/vue_resource_interceptor';
-document.addEventListener('DOMContentLoaded', () => new Vue({
- el: document.getElementById('js-issuable-app'),
- components: {
- issuableApp,
- },
- data() {
- const issuableElement = this.$options.el;
- const issuableTitleElement = issuableElement.querySelector('.title');
- const issuableDescriptionElement = issuableElement.querySelector('.wiki');
- const issuableDescriptionTextarea = issuableElement.querySelector('.js-task-list-field');
- const {
- canUpdate,
- endpoint,
- issuableRef,
- } = issuableElement.dataset;
+document.addEventListener('DOMContentLoaded', () => {
+ const initialDataEl = document.getElementById('js-issuable-app-initial-data');
+ const initialData = JSON.parse(initialDataEl.innerHTML.replace(/&quot;/g, '"'));
- return {
- canUpdate: gl.utils.convertPermissionToBoolean(canUpdate),
- endpoint,
- issuableRef,
- initialTitle: issuableTitleElement.innerHTML,
- initialDescriptionHtml: issuableDescriptionElement ? issuableDescriptionElement.innerHTML : '',
- initialDescriptionText: issuableDescriptionTextarea ? issuableDescriptionTextarea.textContent : '',
- };
- },
- render(createElement) {
- return createElement('issuable-app', {
- props: {
- canUpdate: this.canUpdate,
- endpoint: this.endpoint,
- issuableRef: this.issuableRef,
- initialTitle: this.initialTitle,
- initialDescriptionHtml: this.initialDescriptionHtml,
- initialDescriptionText: this.initialDescriptionText,
- },
- });
- },
-}));
+ $('.issuable-edit').on('click', (e) => {
+ e.preventDefault();
+
+ eventHub.$emit('open.form');
+ });
+
+ return new Vue({
+ el: document.getElementById('js-issuable-app'),
+ components: {
+ issuableApp,
+ },
+ data() {
+ return {
+ ...initialData,
+ };
+ },
+ render(createElement) {
+ return createElement('issuable-app', {
+ props: {
+ canUpdate: this.canUpdate,
+ canDestroy: this.canDestroy,
+ canMove: this.canMove,
+ endpoint: this.endpoint,
+ issuableRef: this.issuableRef,
+ initialTitleHtml: this.initialTitleHtml,
+ initialTitleText: this.initialTitleText,
+ initialDescriptionHtml: this.initialDescriptionHtml,
+ initialDescriptionText: this.initialDescriptionText,
+ issuableTemplates: this.issuableTemplates,
+ isConfidential: this.isConfidential,
+ markdownPreviewUrl: this.markdownPreviewUrl,
+ markdownDocs: this.markdownDocs,
+ projectPath: this.projectPath,
+ projectNamespace: this.projectNamespace,
+ projectsAutocompleteUrl: this.projectsAutocompleteUrl,
+ updatedAt: this.updatedAt,
+ updatedByName: this.updatedByName,
+ updatedByPath: this.updatedByPath,
+ },
+ });
+ },
+ });
+});
diff --git a/app/assets/javascripts/issue_show/mixins/animate.js b/app/assets/javascripts/issue_show/mixins/animate.js
index eda6302aa8b..4816393da1f 100644
--- a/app/assets/javascripts/issue_show/mixins/animate.js
+++ b/app/assets/javascripts/issue_show/mixins/animate.js
@@ -4,7 +4,7 @@ export default {
this.preAnimation = true;
this.pulseAnimation = false;
- this.$nextTick(() => {
+ setTimeout(() => {
this.preAnimation = false;
this.pulseAnimation = true;
});
diff --git a/app/assets/javascripts/issue_show/mixins/update.js b/app/assets/javascripts/issue_show/mixins/update.js
new file mode 100644
index 00000000000..72be65b426f
--- /dev/null
+++ b/app/assets/javascripts/issue_show/mixins/update.js
@@ -0,0 +1,10 @@
+import eventHub from '../event_hub';
+
+export default {
+ methods: {
+ updateIssuable() {
+ this.formState.updateLoading = true;
+ eventHub.$emit('update.issuable');
+ },
+ },
+};
diff --git a/app/assets/javascripts/issue_show/services/index.js b/app/assets/javascripts/issue_show/services/index.js
index 348ad8d6813..6f0fd0b1768 100644
--- a/app/assets/javascripts/issue_show/services/index.js
+++ b/app/assets/javascripts/issue_show/services/index.js
@@ -7,10 +7,23 @@ export default class Service {
constructor(endpoint) {
this.endpoint = endpoint;
- this.resource = Vue.resource(this.endpoint);
+ this.resource = Vue.resource(`${this.endpoint}.json`, {}, {
+ realtimeChanges: {
+ method: 'GET',
+ url: `${this.endpoint}/realtime_changes`,
+ },
+ });
}
getData() {
- return this.resource.get();
+ return this.resource.realtimeChanges();
+ }
+
+ deleteIssuable() {
+ return this.resource.delete();
+ }
+
+ updateIssuable(data) {
+ return this.resource.update(data);
}
}
diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js
index 8e89a2b7730..27c2d349f52 100644
--- a/app/assets/javascripts/issue_show/stores/index.js
+++ b/app/assets/javascripts/issue_show/stores/index.js
@@ -1,16 +1,30 @@
export default class Store {
constructor({
titleHtml,
+ titleText,
descriptionHtml,
descriptionText,
+ updatedAt,
+ updatedByName,
+ updatedByPath,
}) {
this.state = {
titleHtml,
- titleText: '',
+ titleText,
descriptionHtml,
descriptionText,
taskStatus: '',
- updatedAt: '',
+ updatedAt,
+ updatedByName,
+ updatedByPath,
+ };
+ this.formState = {
+ title: '',
+ confidential: false,
+ description: '',
+ lockedWarningVisible: false,
+ move_to_project_id: 0,
+ updateLoading: false,
};
}
@@ -21,5 +35,18 @@ export default class Store {
this.state.descriptionText = data.description_text;
this.state.taskStatus = data.task_status;
this.state.updatedAt = data.updated_at;
+ this.state.updatedByName = data.updated_by_name;
+ this.state.updatedByPath = data.updated_by_path;
+ }
+
+ stateShouldUpdate(data) {
+ return {
+ title: this.state.titleText !== data.title_text,
+ description: this.state.descriptionText !== data.description_text,
+ };
+ }
+
+ setFormState(state) {
+ this.formState = Object.assign(this.formState, state);
}
}
diff --git a/app/assets/javascripts/issues_bulk_assignment.js b/app/assets/javascripts/issues_bulk_assignment.js
deleted file mode 100644
index fee3429e2b8..00000000000
--- a/app/assets/javascripts/issues_bulk_assignment.js
+++ /dev/null
@@ -1,166 +0,0 @@
-/* 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 */
-/* global Issuable */
-/* global Flash */
-
-((global) => {
- class IssuableBulkActions {
- constructor({ container, form, issues, prefixId } = {}) {
- this.prefixId = prefixId || 'issue_';
- this.form = form || this.getElement('.bulk-update');
- this.$labelDropdown = this.form.find('.js-label-select');
- this.issues = issues || this.getElement('.issues-list .issue');
- this.form.data('bulkActions', this);
- this.willUpdateLabels = false;
- this.bindEvents();
- // Fixes bulk-assign not working when navigating through pages
- Issuable.initChecks();
- }
-
- bindEvents() {
- return this.form.off('submit').on('submit', this.onFormSubmit.bind(this));
- }
-
- onFormSubmit(e) {
- e.preventDefault();
- return this.submit();
- }
-
- 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(() => new Flash("Issue update failed"));
- return xhr.always(this.onFormSubmitAlways.bind(this));
- }
-
- onFormSubmitAlways() {
- return this.form.find('[type="submit"]').enable();
- }
-
- getSelectedIssues() {
- return this.issues.has('.selected_issue:checked');
- }
-
- getLabelsFromSelection() {
- const labels = [];
- this.getSelectedIssues().map(function() {
- const labelsData = $(this).data('labels');
- if (labelsData) {
- return labelsData.map(function(labelId) {
- if (labels.indexOf(labelId) === -1) {
- return labels.push(labelId);
- }
- });
- }
- });
- return labels;
- }
-
- /**
- * Will return only labels that were marked previously and the user has unmarked
- * @return {Array} Label IDs
- */
-
- getUnmarkedIndeterminedLabels() {
- const result = [];
- const labelsToKeep = this.$labelDropdown.data('indeterminate');
-
- this.getLabelsFromSelection().forEach((id) => {
- if (labelsToKeep.indexOf(id) === -1) {
- result.push(id);
- }
- });
-
- return result;
- }
-
- /**
- * Simple form serialization, it will return just what we need
- * Returns key/value pairs from form data
- */
-
- getFormDataAsObject() {
- const formData = {
- update: {
- state_event: this.form.find('input[name="update[state_event]"]').val(),
- // For Merge Requests
- assignee_id: this.form.find('input[name="update[assignee_id]"]').val(),
- // For Issues
- assignee_ids: [this.form.find('input[name="update[assignee_ids][]"]').val()],
- milestone_id: this.form.find('input[name="update[milestone_id]"]').val(),
- issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(),
- subscription_event: this.form.find('input[name="update[subscription_event]"]').val(),
- add_label_ids: [],
- remove_label_ids: []
- }
- };
- if (this.willUpdateLabels) {
- formData.update.add_label_ids = this.$labelDropdown.data('marked');
- formData.update.remove_label_ids = this.$labelDropdown.data('unmarked');
- }
- return formData;
- }
-
- setOriginalDropdownData() {
- const $labelSelect = $('.bulk-update .js-label-select');
- $labelSelect.data('common', this.getOriginalCommonIds());
- $labelSelect.data('marked', this.getOriginalMarkedIds());
- $labelSelect.data('indeterminate', this.getOriginalIndeterminateIds());
- }
-
- // From issuable's initial bulk selection
- getOriginalCommonIds() {
- const labelIds = [];
-
- this.getElement('.selected_issue:checked').each((i, el) => {
- labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'));
- });
- return _.intersection.apply(this, labelIds);
- }
-
- // From issuable's initial bulk selection
- getOriginalMarkedIds() {
- const labelIds = [];
- this.getElement('.selected_issue:checked').each((i, el) => {
- labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'));
- });
- return _.intersection.apply(this, labelIds);
- }
-
- // From issuable's initial bulk selection
- getOriginalIndeterminateIds() {
- const uniqueIds = [];
- const labelIds = [];
- let issuableLabels = [];
-
- // Collect unique label IDs for all checked issues
- this.getElement('.selected_issue:checked').each((i, el) => {
- issuableLabels = this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels');
- issuableLabels.forEach((labelId) => {
- // Store unique IDs
- if (uniqueIds.indexOf(labelId) === -1) {
- uniqueIds.push(labelId);
- }
- });
- // Store array of IDs per issuable
- labelIds.push(issuableLabels);
- });
- // Add uniqueIds to add it as argument for _.intersection
- labelIds.unshift(uniqueIds);
- // Return IDs that are present but not in all selected issueables
- return _.difference(uniqueIds, _.intersection.apply(this, labelIds));
- }
-
- getElement(selector) {
- this.scopeEl = this.scopeEl || $('.content');
- return this.scopeEl.find(selector);
- }
- }
-
- global.IssuableBulkActions = IssuableBulkActions;
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index ac5ce84e31b..8d7d3d73571 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -2,6 +2,8 @@
/* global Issuable */
/* global ListLabel */
+import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
+
(function() {
this.LabelsSelect = (function() {
function LabelsSelect(els) {
@@ -430,20 +432,15 @@
if ($('.selected_issue:checked').length) {
return;
}
- return $('.issues_bulk_update .labels-filter .dropdown-toggle-text').text('Label');
+ return $('.issues-bulk-update .labels-filter .dropdown-toggle-text').text('Label');
};
LabelsSelect.prototype.enableBulkLabelDropdown = function() {
- var issuableBulkActions;
- if ($('.selected_issue:checked').length) {
- issuableBulkActions = $('.bulk-update').data('bulkActions');
- return issuableBulkActions.willUpdateLabels = true;
- }
+ IssuableBulkUpdateActions.willUpdateLabels = true;
};
LabelsSelect.prototype.setDropdownData = function($dropdown, isMarking, value) {
var i, markedIds, unmarkedIds, indeterminateIds;
- var issuableBulkActions = $('.bulk-update').data('bulkActions');
markedIds = $dropdown.data('marked') || [];
unmarkedIds = $dropdown.data('unmarked') || [];
@@ -469,13 +466,13 @@
}
// If an indeterminate item is being unmarked
- if (issuableBulkActions.getOriginalIndeterminateIds().indexOf(value) > -1) {
+ if (IssuableBulkUpdateActions.getOriginalIndeterminateIds().indexOf(value) > -1) {
unmarkedIds.push(value);
}
// If a marked item is being unmarked
// (a marked item could also be a label that is present in all selection)
- if (issuableBulkActions.getOriginalCommonIds().indexOf(value) > -1) {
+ if (IssuableBulkUpdateActions.getOriginalCommonIds().indexOf(value) > -1) {
unmarkedIds.push(value);
}
}
diff --git a/app/assets/javascripts/lib/utils/ajax_cache.js b/app/assets/javascripts/lib/utils/ajax_cache.js
index f1fe95e12e8..7477b5a5214 100644
--- a/app/assets/javascripts/lib/utils/ajax_cache.js
+++ b/app/assets/javascripts/lib/utils/ajax_cache.js
@@ -6,8 +6,8 @@ class AjaxCache extends Cache {
this.pendingRequests = { };
}
- retrieve(endpoint) {
- if (this.hasData(endpoint)) {
+ retrieve(endpoint, forceRetrieve) {
+ if (this.hasData(endpoint) && !forceRetrieve) {
return Promise.resolve(this.get(endpoint));
}
diff --git a/app/assets/javascripts/lib/utils/notify.js b/app/assets/javascripts/lib/utils/notify.js
index 66f39122a66..973d6119158 100644
--- a/app/assets/javascripts/lib/utils/notify.js
+++ b/app/assets/javascripts/lib/utils/notify.js
@@ -1,47 +1,48 @@
/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, one-var, one-var-declaration-per-line, consistent-return, prefer-arrow-callback, no-return-assign, object-shorthand, comma-dangle, no-param-reassign, max-len */
-(function() {
- (function(w) {
- var notificationGranted, notifyMe, notifyPermissions;
- notificationGranted = function(message, opts, onclick) {
- var notification;
- notification = new Notification(message, opts);
- setTimeout(function() {
- return notification.close();
- // Hide the notification after X amount of seconds
- }, 8000);
- if (onclick) {
- return notification.onclick = onclick;
- }
- };
- notifyPermissions = function() {
- if ('Notification' in window) {
- return Notification.requestPermission();
- }
- };
- notifyMe = function(message, body, icon, onclick) {
- var opts;
- opts = {
- body: body,
- icon: icon
- };
- // Let's check if the browser supports notifications
- if (!('Notification' in window)) {
+function notificationGranted(message, opts, onclick) {
+ var notification;
+ notification = new Notification(message, opts);
+ setTimeout(function() {
+ // Hide the notification after X amount of seconds
+ return notification.close();
+ }, 8000);
+
+ return notification.onclick = onclick || notification.close;
+}
- // do nothing
- } else if (Notification.permission === 'granted') {
- // If it's okay let's create a notification
+function notifyPermissions() {
+ if ('Notification' in window) {
+ return Notification.requestPermission();
+ }
+}
+
+function notifyMe(message, body, icon, onclick) {
+ var opts;
+ opts = {
+ body: body,
+ icon: icon
+ };
+ // Let's check if the browser supports notifications
+ if (!('Notification' in window)) {
+ // do nothing
+ } else if (Notification.permission === 'granted') {
+ // If it's okay let's create a notification
+ return notificationGranted(message, opts, onclick);
+ } else if (Notification.permission !== 'denied') {
+ return Notification.requestPermission(function(permission) {
+ // If the user accepts, let's create a notification
+ if (permission === 'granted') {
return notificationGranted(message, opts, onclick);
- } else if (Notification.permission !== 'denied') {
- return Notification.requestPermission(function(permission) {
- // If the user accepts, let's create a notification
- if (permission === 'granted') {
- return notificationGranted(message, opts, onclick);
- }
- });
}
- };
- w.notify = notifyMe;
- return w.notifyPermissions = notifyPermissions;
- })(window);
-}).call(window);
+ });
+ }
+}
+
+const notify = {
+ notificationGranted,
+ notifyPermissions,
+ notifyMe,
+};
+
+export default notify;
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index b43c1c3aac6..601d01e1be1 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -170,7 +170,7 @@ gl.text.init = function(form) {
});
};
gl.text.removeListeners = function(form) {
- return $('.js-md', form).off();
+ return $('.js-md', form).off('click');
};
gl.text.humanize = function(string) {
return string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1);
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index b9d2fc25c39..3328ff9cc23 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -66,7 +66,8 @@ w.gl.utils.removeParamQueryString = function(url, param) {
})()).join('&');
};
w.gl.utils.removeParams = (params) => {
- const url = new URL(window.location.href);
+ const url = document.createElement('a');
+ url.href = window.location.href;
params.forEach((param) => {
url.search = w.gl.utils.removeParamQueryString(url.search, param);
});
diff --git a/app/assets/javascripts/locale/zh_CN/app.js b/app/assets/javascripts/locale/zh_CN/app.js
new file mode 100644
index 00000000000..9525bc88190
--- /dev/null
+++ b/app/assets/javascripts/locale/zh_CN/app.js
@@ -0,0 +1 @@
+var locales = locales || {}; locales['zh_CN'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","POT-Creation-Date":"2017-05-04 19:24-0500","PO-Revision-Date":"2017-05-04 19:24-0500","Last-Translator":"HuangTao <htve@outlook.com>, 2017","Language-Team":"Chinese (China) (https://www.transifex.com/gitlab-zh/teams/75177/zh_CN/)","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Language":"zh_CN","Plural-Forms":"nplurals=1; plural=0;","lang":"zh_CN","domain":"app","plural_forms":"nplurals=1; plural=0;"},"ByAuthor|by":["作者:"],"Commit":["提交"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["周期分析概述了项目从想法到产品实现的各阶段所需的时间。"],"CycleAnalyticsStage|Code":["编码"],"CycleAnalyticsStage|Issue":["议题"],"CycleAnalyticsStage|Plan":["计划"],"CycleAnalyticsStage|Production":["生产"],"CycleAnalyticsStage|Review":["评审"],"CycleAnalyticsStage|Staging":["预发布"],"CycleAnalyticsStage|Test":["测试"],"Deploy":["部署"],"FirstPushedBy|First":["首次推送"],"FirstPushedBy|pushed by":["推送者:"],"From issue creation until deploy to production":["从创建议题到部署至生产环境"],"From merge request merge until deploy to production":["从合并请求被合并后到部署至生产环境"],"Introducing Cycle Analytics":["周期分析简介"],"Last %d day":["最后 %d 天"],"Limited to showing %d event at most":["最多显示 %d 个事件"],"Median":["中位数"],"New Issue":["新议题"],"Not available":["数据不足"],"Not enough data":["数据不足"],"OpenedNDaysAgo|Opened":["开始于"],"Pipeline Health":["流水线健康指标"],"ProjectLifecycle|Stage":["项目生命周期"],"Read more":["了解更多"],"Related Commits":["相关的提交"],"Related Deployed Jobs":["相关的部署作业"],"Related Issues":["相关的议题"],"Related Jobs":["相关的作业"],"Related Merge Requests":["相关的合并请求"],"Related Merged Requests":["相关已合并的合并请求"],"Showing %d event":["显示 %d 个事件"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["编码阶段概述了从第一次提交到创建合并请求的时间。创建第一个合并请求后,数据将自动添加到此处。"],"The collection of events added to the data gathered for that stage.":["与该阶段相关的事件。"],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["议题阶段概述了从创建议题到将议题设置里程碑或将议题添加到议题看板的时间。开始创建议题以查看此阶段的数据。"],"The phase of the development lifecycle.":["项目生命周期中的各个阶段。"],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["计划阶段概述了从议题添加到日程后到推送首次提交的时间。当首次推送提交后,数据将自动添加到此处。"],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["生产阶段概述了从创建一个议题到将代码部署到生产环境的总时间。当完成想法到部署生产的循环,数据将自动添加到此处。"],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["评审阶段概述了从创建合并请求到被合并的时间。当创建第一个合并请求后,数据将自动添加到此处。"],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["预发布阶段概述了从合并请求被合并到部署至生产环境的总时间。首次部署到生产环境后,数据将自动添加到此处。"],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["测试阶段概述了GitLab CI为相关合并请求运行每个流水线所需的时间。当第一个流水线运行完成后,数据将自动添加到此处。"],"The time taken by each data entry gathered by that stage.":["该阶段每条数据所花的时间"],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["中位数是一个数列中最中间的值。例如在 3、5、9 之间,中位数是 5。在 3、5、7、8 之间,中位数是 (5 + 7)/ 2 = 6。"],"Time before an issue gets scheduled":["议题被列入日程表的时间"],"Time before an issue starts implementation":["开始进行编码前的时间"],"Time between merge request creation and merge/close":["从创建合并请求到被合并或关闭的时间"],"Time until first merge request":["创建第一个合并请求之前的时间"],"Time|hr":["小时"],"Time|min":["分钟"],"Time|s":["秒"],"Total Time":["总时间"],"Total test time for all commits/merges":["所有提交和合并的总测试时间"],"Want to see the data? Please ask an administrator for access.":["权限不足。如需查看相关数据,请向管理员申请权限。"],"We don't have enough data to show this stage.":["该阶段的数据不足,无法显示。"],"You need permission.":["您需要相关的权限。"],"day":["天"]}}}; \ No newline at end of file
diff --git a/app/assets/javascripts/locale/zh_HK/app.js b/app/assets/javascripts/locale/zh_HK/app.js
new file mode 100644
index 00000000000..fd0bcd988c5
--- /dev/null
+++ b/app/assets/javascripts/locale/zh_HK/app.js
@@ -0,0 +1 @@
+var locales = locales || {}; locales['zh_HK'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","POT-Creation-Date":"2017-05-04 19:24-0500","PO-Revision-Date":"2017-05-04 19:24-0500","Last-Translator":"HuangTao <htve@outlook.com>, 2017","Language-Team":"Chinese (Hong Kong) (https://www.transifex.com/gitlab-zh/teams/75177/zh_HK/)","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Language":"zh_HK","Plural-Forms":"nplurals=1; plural=0;","lang":"zh_HK","domain":"app","plural_forms":"nplurals=1; plural=0;"},"ByAuthor|by":["作者:"],"Commit":["提交"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["週期分析概述了項目從想法到產品實現的各階段所需的時間。"],"CycleAnalyticsStage|Code":["編碼"],"CycleAnalyticsStage|Issue":["議題"],"CycleAnalyticsStage|Plan":["計劃"],"CycleAnalyticsStage|Production":["生產"],"CycleAnalyticsStage|Review":["評審"],"CycleAnalyticsStage|Staging":["預發布"],"CycleAnalyticsStage|Test":["測試"],"Deploy":["部署"],"FirstPushedBy|First":["首次推送"],"FirstPushedBy|pushed by":["推送者:"],"From issue creation until deploy to production":["從創建議題到部署到生產環境"],"From merge request merge until deploy to production":["從合併請求的合併到部署至生產環境"],"Introducing Cycle Analytics":["週期分析簡介"],"Last %d day":["最後 %d 天"],"Limited to showing %d event at most":["最多顯示 %d 個事件"],"Median":["中位數"],"New Issue":["新議題"],"Not available":["不可用"],"Not enough data":["數據不足"],"OpenedNDaysAgo|Opened":["開始於"],"Pipeline Health":["流水線健康指標"],"ProjectLifecycle|Stage":["項目生命週期"],"Read more":["了解更多"],"Related Commits":["相關的提交"],"Related Deployed Jobs":["相關的部署作業"],"Related Issues":["相關的議題"],"Related Jobs":["相關的作業"],"Related Merge Requests":["相關的合併請求"],"Related Merged Requests":["相關已合併的合並請求"],"Showing %d event":["顯示 %d 個事件"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["編碼階段概述了從第一次提交到創建合併請求的時間。創建第壹個合並請求後,數據將自動添加到此處。"],"The collection of events added to the data gathered for that stage.":["與該階段相關的事件。"],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["議題階段概述了從創建議題到將議題設置裏程碑或將議題添加到議題看板的時間。創建一個議題後,數據將自動添加到此處。"],"The phase of the development lifecycle.":["項目生命週期中的各個階段。"],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["計劃階段概述了從議題添加到日程後到推送首次提交的時間。當首次推送提交後,數據將自動添加到此處。"],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["生產階段概述了從創建議題到將代碼部署到生產環境的時間。當完成完整的想法到部署生產,數據將自動添加到此處。"],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["評審階段概述了從創建合並請求到合併的時間。當創建第壹個合並請求後,數據將自動添加到此處。"],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["預發布階段概述了合並請求的合併到部署代碼到生產環境的總時間。當首次部署到生產環境後,數據將自動添加到此處。"],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["測試階段概述了GitLab CI為相關合併請求運行每個流水線所需的時間。當第壹個流水線運行完成後,數據將自動添加到此處。"],"The time taken by each data entry gathered by that stage.":["該階段每條數據所花的時間"],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["中位數是一個數列中最中間的值。例如在 3、5、9 之間,中位數是 5。在 3、5、7、8 之間,中位數是 (5 + 7)/ 2 = 6。"],"Time before an issue gets scheduled":["議題被列入日程表的時間"],"Time before an issue starts implementation":["開始進行編碼前的時間"],"Time between merge request creation and merge/close":["從創建合併請求到被合並或關閉的時間"],"Time until first merge request":["創建第壹個合併請求之前的時間"],"Time|hr":["小時"],"Time|min":["分鐘"],"Time|s":["秒"],"Total Time":["總時間"],"Total test time for all commits/merges":["所有提交和合併的總測試時間"],"Want to see the data? Please ask an administrator for access.":["權限不足。如需查看相關數據,請向管理員申請權限。"],"We don't have enough data to show this stage.":["該階段的數據不足,無法顯示。"],"You need permission.":["您需要相關的權限。"],"day":["天"]}}}; \ No newline at end of file
diff --git a/app/assets/javascripts/locale/zh_TW/app.js b/app/assets/javascripts/locale/zh_TW/app.js
new file mode 100644
index 00000000000..79904d17bf6
--- /dev/null
+++ b/app/assets/javascripts/locale/zh_TW/app.js
@@ -0,0 +1 @@
+var locales = locales || {}; locales['zh_TW'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","POT-Creation-Date":"2017-05-04 19:24-0500","PO-Revision-Date":"2017-05-04 19:24-0500","Last-Translator":"HuangTao <htve@outlook.com>, 2017","Language-Team":"Chinese (Taiwan) (https://www.transifex.com/gitlab-zh/teams/75177/zh_TW/)","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","Language":"zh_TW","Plural-Forms":"nplurals=1; plural=0;","lang":"zh_TW","domain":"app","plural_forms":"nplurals=1; plural=0;"},"ByAuthor|by":["作者:"],"Commit":["送交"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["週期分析概述了你的專案從想法到產品實現,各階段所需的時間。"],"CycleAnalyticsStage|Code":["程式開發"],"CycleAnalyticsStage|Issue":["議題"],"CycleAnalyticsStage|Plan":["計劃"],"CycleAnalyticsStage|Production":["上線"],"CycleAnalyticsStage|Review":["複閱"],"CycleAnalyticsStage|Staging":["預備"],"CycleAnalyticsStage|Test":["測試"],"Deploy":["部署"],"FirstPushedBy|First":["首次推送"],"FirstPushedBy|pushed by":["推送者:"],"From issue creation until deploy to production":["從議題建立至線上部署"],"From merge request merge until deploy to production":["從請求被合併後至線上部署"],"Introducing Cycle Analytics":["週期分析簡介"],"Last %d day":["最後 %d 天"],"Limited to showing %d event at most":["最多顯示 %d 個事件"],"Median":["中位數"],"New Issue":["新議題"],"Not available":["無法使用"],"Not enough data":["資料不足"],"OpenedNDaysAgo|Opened":["開始於"],"Pipeline Health":["流水線健康指標"],"ProjectLifecycle|Stage":["專案生命週期"],"Read more":["了解更多"],"Related Commits":["相關的送交"],"Related Deployed Jobs":["相關的部署作業"],"Related Issues":["相關的議題"],"Related Jobs":["相關的作業"],"Related Merge Requests":["相關的合併請求"],"Related Merged Requests":["相關已合併的請求"],"Showing %d event":["顯示 %d 個事件"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["程式開發階段顯示從第一次送交到建立合併請求的時間。建立第一個合併請求後,資料將自動填入。"],"The collection of events added to the data gathered for that stage.":["與該階段相關的事件。"],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["議題階段顯示從議題建立到設置里程碑、或將該議題加至議題看板的時間。建立第一個議題後,資料將自動填入。"],"The phase of the development lifecycle.":["專案開發生命週期的各個階段。"],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["計劃階段顯示從議題添加到日程後至推送第一個送交的時間。當第一次推送送交後,資料將自動填入。"],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["上線階段顯示從建立一個議題到部署程式至線上的總時間。當完成從想法到產品實現的循環後,資料將自動填入。"],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["複閱階段顯示從合併請求建立後至被合併的時間。當建立第一個合併請求後,資料將自動填入。"],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["預備階段顯示從合併請求被合併後至部署上線的時間。當第一次部署上線後,資料將自動填入。"],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["測試階段顯示相關合併請求的流水線所花的時間。當第一個流水線運作完畢後,資料將自動填入。"],"The time taken by each data entry gathered by that stage.":["每筆該階段相關資料所花的時間。"],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["中位數是一個數列中最中間的值。例如在 3、5、9 之間,中位數是 5。在 3、5、7、8 之間,中位數是 (5 + 7)/ 2 = 6。"],"Time before an issue gets scheduled":["議題被列入日程表的時間"],"Time before an issue starts implementation":["議題等待開始實作的時間"],"Time between merge request creation and merge/close":["合併請求被合併或是關閉的時間"],"Time until first merge request":["第一個合併請求被建立前的時間"],"Time|hr":["小時"],"Time|min":["分鐘"],"Time|s":["秒"],"Total Time":["總時間"],"Total test time for all commits/merges":["所有送交和合併的總測試時間"],"Want to see the data? Please ask an administrator for access.":["權限不足。如需查看相關資料,請向管理員申請權限。"],"We don't have enough data to show this stage.":["因該階段的資料不足而無法顯示相關資訊"],"You need permission.":["您需要相關的權限。"],"day":["天"]}}}; \ No newline at end of file
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index f0958972130..fe367d0c42a 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -56,7 +56,6 @@ import './lib/utils/animate';
import './lib/utils/bootstrap_linked_tabs';
import './lib/utils/common_utils';
import './lib/utils/datetime_utility';
-import './lib/utils/notify';
import './lib/utils/pretty_time';
import './lib/utils/text_utility';
import './lib/utils/url_utility';
@@ -105,12 +104,11 @@ import './group_label_subscription';
import './groups_select';
import './header';
import './importer_status';
-import './issuable';
+import './issuable_index';
import './issuable_context';
import './issuable_form';
import './issue';
import './issue_status_select';
-import './issues_bulk_assignment';
import './label_manager';
import './labels';
import './labels_select';
diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue
index 3e8240d10ec..814d2ea92b4 100644
--- a/app/assets/javascripts/notebook/cells/markdown.vue
+++ b/app/assets/javascripts/notebook/cells/markdown.vue
@@ -30,7 +30,7 @@
|
\\s\\$(?!\\$)
)
- (.+?)
+ ((.|\\n)+?)
(
\\s\\\\end{[a-zA-Z]+}$
|
@@ -45,15 +45,25 @@
let inline = false;
if (typeof katex !== 'undefined') {
- const katexString = text.replace(/\\/g, '\\');
- const matches = new RegExp(katexRegexString, 'gi').exec(katexString);
+ const katexString = text.replace(/&amp;/g, '&')
+ .replace(/&=&/g, '\\space=\\space')
+ .replace(/<(\/?)em>/g, '_');
+ const regex = new RegExp(katexRegexString, 'gi');
+ const matchLocation = katexString.search(regex);
+ const numberOfMatches = katexString.match(regex);
- if (matches && matches.length > 0) {
- if (matches[1].trim() === '$' && matches[3].trim() === '$') {
+ if (numberOfMatches && numberOfMatches.length !== 0) {
+ if (matchLocation > 0) {
+ let matches = regex.exec(katexString);
inline = true;
- text = `${katexString.replace(matches[0], '')} ${katex.renderToString(matches[2])}`;
+ while (matches !== null) {
+ const renderedKatex = katex.renderToString(matches[0].replace(/\$/g, ''));
+ text = `${text.replace(matches[0], ` ${renderedKatex}`)}`;
+ matches = regex.exec(katexString);
+ }
} else {
+ const matches = regex.exec(katexString);
text = katex.renderToString(matches[2]);
}
}
@@ -79,7 +89,7 @@
},
computed: {
markdown() {
- return marked(this.cell.source.join(''));
+ return marked(this.cell.source.join('').replace(/\\/g, '\\\\'));
},
},
};
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 0ca7cabfc5a..929965de5c1 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -16,6 +16,7 @@ import autosize from 'vendor/autosize';
import Dropzone from 'dropzone';
import 'vendor/jquery.caret'; // required by jquery.atwho
import 'vendor/jquery.atwho';
+import AjaxCache from '~/lib/utils/ajax_cache';
import CommentTypeToggle from './comment_type_toggle';
import './autosave';
import './dropzone_input';
@@ -66,7 +67,6 @@ const normalizeNewlines = function(str) {
this.notesCountBadge || (this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge'));
this.basePollingInterval = 15000;
this.maxPollingSteps = 4;
- this.flashErrors = [];
this.cleanBinding();
this.addBinding();
@@ -325,6 +325,9 @@ const normalizeNewlines = function(str) {
if (Notes.isNewNote(noteEntity, this.note_ids)) {
this.note_ids.push(noteEntity.id);
+ if ($notesList.length) {
+ $notesList.find('.system-note.being-posted').remove();
+ }
const $newNote = Notes.animateAppendNote(noteEntity.html, $notesList);
this.setupNewNote($newNote);
@@ -1118,12 +1121,14 @@ const normalizeNewlines = function(str) {
};
Notes.prototype.addFlash = function(...flashParams) {
- this.flashErrors.push(new Flash(...flashParams));
+ this.flashInstance = new Flash(...flashParams);
};
Notes.prototype.clearFlash = function() {
- this.flashErrors.forEach(flash => flash.flashContainer.remove());
- this.flashErrors = [];
+ if (this.flashInstance && this.flashInstance.flashContainer) {
+ this.flashInstance.flashContainer.hide();
+ this.flashInstance = null;
+ }
};
Notes.prototype.cleanForm = function($form) {
@@ -1187,7 +1192,7 @@ const normalizeNewlines = function(str) {
Notes.prototype.getFormData = function($form) {
return {
formData: $form.serialize(),
- formContent: $form.find('.js-note-text').val(),
+ formContent: _.escape($form.find('.js-note-text').val()),
formAction: $form.attr('action'),
};
};
@@ -1207,19 +1212,46 @@ const normalizeNewlines = function(str) {
};
/**
+ * Gets appropriate description from slash commands found in provided `formContent`
+ */
+ Notes.prototype.getSlashCommandDescription = function (formContent, availableSlashCommands = []) {
+ let tempFormContent;
+
+ // Identify executed slash commands from `formContent`
+ const executedCommands = availableSlashCommands.filter((command, index) => {
+ const commandRegex = new RegExp(`/${command.name}`);
+ return commandRegex.test(formContent);
+ });
+
+ if (executedCommands && executedCommands.length) {
+ if (executedCommands.length > 1) {
+ tempFormContent = 'Applying multiple commands';
+ } else {
+ const commandDescription = executedCommands[0].description.toLowerCase();
+ tempFormContent = `Applying command to ${commandDescription}`;
+ }
+ } else {
+ tempFormContent = 'Applying command';
+ }
+
+ return tempFormContent;
+ };
+
+ /**
* Create placeholder note DOM element populated with comment body
* that we will show while comment is being posted.
* Once comment is _actually_ posted on server, we will have final element
* in response that we will show in place of this temporary element.
*/
- Notes.prototype.createPlaceholderNote = function({ formContent, uniqueId, isDiscussionNote, currentUsername, currentUserFullname }) {
+ Notes.prototype.createPlaceholderNote = function ({ formContent, uniqueId, isDiscussionNote, currentUsername, currentUserFullname, currentUserAvatar }) {
const discussionClass = isDiscussionNote ? 'discussion' : '';
- const escapedFormContent = _.escape(formContent);
const $tempNote = $(
`<li id="${uniqueId}" class="note being-posted fade-in-half timeline-entry">
<div class="timeline-entry-inner">
<div class="timeline-icon">
- <a href="/${currentUsername}"><span class="avatar dummy-avatar"></span></a>
+ <a href="/${currentUsername}">
+ <img class="avatar s40" src="${currentUserAvatar}">
+ </a>
</div>
<div class="timeline-content ${discussionClass}">
<div class="note-header">
@@ -1232,7 +1264,7 @@ const normalizeNewlines = function(str) {
</div>
<div class="note-body">
<div class="note-text">
- <p>${escapedFormContent}</p>
+ <p>${formContent}</p>
</div>
</div>
</div>
@@ -1244,6 +1276,23 @@ const normalizeNewlines = function(str) {
};
/**
+ * Create Placeholder System Note DOM element populated with slash command description
+ */
+ Notes.prototype.createPlaceholderSystemNote = function ({ formContent, uniqueId }) {
+ const $tempNote = $(
+ `<li id="${uniqueId}" class="note system-note timeline-entry being-posted fade-in-half">
+ <div class="timeline-entry-inner">
+ <div class="timeline-content">
+ <i>${formContent}</i>
+ </div>
+ </div>
+ </li>`
+ );
+
+ return $tempNote;
+ };
+
+ /**
* This method does following tasks step-by-step whenever a new comment
* is submitted by user (both main thread comments as well as discussion comments).
*
@@ -1274,7 +1323,9 @@ const normalizeNewlines = function(str) {
const isDiscussionForm = $form.hasClass('js-discussion-note-form');
const isDiscussionResolve = $submitBtn.hasClass('js-comment-resolve-button');
const { formData, formContent, formAction } = this.getFormData($form);
- const uniqueId = _.uniqueId('tempNote_');
+ let noteUniqueId;
+ let systemNoteUniqueId;
+ let hasSlashCommands = false;
let $notesContainer;
let tempFormContent;
@@ -1295,16 +1346,28 @@ const normalizeNewlines = function(str) {
tempFormContent = formContent;
if (this.hasSlashCommands(formContent)) {
tempFormContent = this.stripSlashCommands(formContent);
+ hasSlashCommands = true;
}
+ // Show placeholder note
if (tempFormContent) {
- // Show placeholder note
+ noteUniqueId = _.uniqueId('tempNote_');
$notesContainer.append(this.createPlaceholderNote({
formContent: tempFormContent,
- uniqueId,
+ uniqueId: noteUniqueId,
isDiscussionNote,
currentUsername: gon.current_username,
currentUserFullname: gon.current_user_fullname,
+ currentUserAvatar: gon.current_user_avatar_url,
+ }));
+ }
+
+ // Show placeholder system note
+ if (hasSlashCommands) {
+ systemNoteUniqueId = _.uniqueId('tempSystemNote_');
+ $notesContainer.append(this.createPlaceholderSystemNote({
+ formContent: this.getSlashCommandDescription(formContent, AjaxCache.get(gl.GfmAutoComplete.dataSources.commands)),
+ uniqueId: systemNoteUniqueId,
}));
}
@@ -1322,7 +1385,13 @@ const normalizeNewlines = function(str) {
gl.utils.ajaxPost(formAction, formData)
.then((note) => {
// Submission successful! remove placeholder
- $notesContainer.find(`#${uniqueId}`).remove();
+ $notesContainer.find(`#${noteUniqueId}`).remove();
+
+ // Reset cached commands list when command is applied
+ if (hasSlashCommands) {
+ $form.find('textarea.js-note-text').trigger('clear-commands-cache.atwho');
+ }
+
// Clear previous form errors
this.clearFlashWrapper();
@@ -1359,7 +1428,11 @@ const normalizeNewlines = function(str) {
$form.trigger('ajax:success', [note]);
}).fail(() => {
// Submission failed, remove placeholder note and show Flash error message
- $notesContainer.find(`#${uniqueId}`).remove();
+ $notesContainer.find(`#${noteUniqueId}`).remove();
+
+ if (hasSlashCommands) {
+ $notesContainer.find(`#${systemNoteUniqueId}`).remove();
+ }
// Show form again on UI on failure
if (isDiscussionForm && $notesContainer.length) {
diff --git a/app/assets/javascripts/pager.js b/app/assets/javascripts/pager.js
index 0ef20af9260..01110420cca 100644
--- a/app/assets/javascripts/pager.js
+++ b/app/assets/javascripts/pager.js
@@ -6,11 +6,12 @@ import '~/lib/utils/url_utility';
const ENDLESS_SCROLL_FIRE_DELAY_MS = 1000;
const Pager = {
- init(limit = 0, preload = false, disable = false, callback = $.noop) {
+ init(limit = 0, preload = false, disable = false, prepareData = $.noop, callback = $.noop) {
this.url = $('.content_list').data('href') || gl.utils.removeParams(['limit', 'offset']);
this.limit = limit;
this.offset = parseInt(gl.utils.getParameterByName('offset'), 10) || this.limit;
this.disable = disable;
+ this.prepareData = prepareData;
this.callback = callback;
this.loading = $('.loading').first();
if (preload) {
@@ -29,7 +30,7 @@ import '~/lib/utils/url_utility';
dataType: 'json',
error: () => this.loading.hide(),
success: (data) => {
- this.append(data.count, data.html);
+ this.append(data.count, this.prepareData(data.html));
this.callback();
// keep loading until we've filled the viewport height
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
new file mode 100644
index 00000000000..4f6c5c177cf
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -0,0 +1,97 @@
+<script>
+import ciHeader from '../../vue_shared/components/header_ci_component.vue';
+import eventHub from '../event_hub';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+
+export default {
+ name: 'PipelineHeaderSection',
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ components: {
+ ciHeader,
+ loadingIcon,
+ },
+
+ data() {
+ return {
+ actions: this.getActions(),
+ };
+ },
+
+ computed: {
+ status() {
+ return this.pipeline.details && this.pipeline.details.status;
+ },
+ shouldRenderContent() {
+ return !this.isLoading && Object.keys(this.pipeline).length;
+ },
+ },
+
+ methods: {
+ postAction(action) {
+ const index = this.actions.indexOf(action);
+
+ this.$set(this.actions[index], 'isLoading', true);
+
+ eventHub.$emit('headerPostAction', action);
+ },
+
+ getActions() {
+ const actions = [];
+
+ if (this.pipeline.retry_path) {
+ actions.push({
+ label: 'Retry',
+ path: this.pipeline.retry_path,
+ cssClass: 'js-retry-button btn btn-inverted-secondary',
+ type: 'button',
+ isLoading: false,
+ });
+ }
+
+ if (this.pipeline.cancel_path) {
+ actions.push({
+ label: 'Cancel running',
+ path: this.pipeline.cancel_path,
+ cssClass: 'js-btn-cancel-pipeline btn btn-danger',
+ type: 'button',
+ isLoading: false,
+ });
+ }
+
+ return actions;
+ },
+ },
+
+ watch: {
+ pipeline() {
+ this.actions = this.getActions();
+ },
+ },
+};
+</script>
+<template>
+ <div class="pipeline-header-container">
+ <ci-header
+ v-if="shouldRenderContent"
+ :status="status"
+ item-name="Pipeline"
+ :item-id="pipeline.id"
+ :time="pipeline.created_at"
+ :user="pipeline.user"
+ :actions="actions"
+ @actionClicked="postAction"
+ />
+ <loading-icon
+ v-else
+ size="2"/>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.js b/app/assets/javascripts/pipelines/components/pipeline_url.js
deleted file mode 100644
index 7cd2e0f9366..00000000000
--- a/app/assets/javascripts/pipelines/components/pipeline_url.js
+++ /dev/null
@@ -1,56 +0,0 @@
-import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
-
-export default {
- props: [
- 'pipeline',
- ],
- computed: {
- user() {
- return !!this.pipeline.user;
- },
- },
- components: {
- userAvatarLink,
- },
- template: `
- <td>
- <a
- :href="pipeline.path"
- class="js-pipeline-url-link">
- <span class="pipeline-id">#{{pipeline.id}}</span>
- </a>
- <span>by</span>
- <user-avatar-link
- v-if="user"
- class="js-pipeline-url-user"
- :link-href="pipeline.user.web_url"
- :img-src="pipeline.user.avatar_url"
- :tooltip-text="pipeline.user.name"
- />
- <span
- v-if="!user"
- class="js-pipeline-url-api api">
- API
- </span>
- <span
- v-if="pipeline.flags.latest"
- class="js-pipeline-url-lastest label label-success has-tooltip"
- title="Latest pipeline for this branch"
- data-original-title="Latest pipeline for this branch">
- latest
- </span>
- <span
- v-if="pipeline.flags.yaml_errors"
- class="js-pipeline-url-yaml label label-danger has-tooltip"
- :title="pipeline.yaml_errors"
- :data-original-title="pipeline.yaml_errors">
- yaml invalid
- </span>
- <span
- v-if="pipeline.flags.stuck"
- class="js-pipeline-url-stuck label label-warning">
- stuck
- </span>
- </td>
- `,
-};
diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue
new file mode 100644
index 00000000000..4781a8ff1da
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue
@@ -0,0 +1,65 @@
+<script>
+import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
+import tooltipMixin from '../../vue_shared/mixins/tooltip';
+
+export default {
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ },
+ components: {
+ userAvatarLink,
+ },
+ mixins: [
+ tooltipMixin,
+ ],
+ computed: {
+ user() {
+ return this.pipeline.user;
+ },
+ },
+};
+</script>
+<template>
+ <td>
+ <a
+ :href="pipeline.path"
+ class="js-pipeline-url-link">
+ <span class="pipeline-id">#{{pipeline.id}}</span>
+ </a>
+ <span>by</span>
+ <user-avatar-link
+ v-if="user"
+ class="js-pipeline-url-user"
+ :link-href="pipeline.user.path"
+ :img-src="pipeline.user.avatar_url"
+ :tooltip-text="pipeline.user.name"
+ />
+ <span
+ v-if="!user"
+ class="js-pipeline-url-api api">
+ API
+ </span>
+ <span
+ v-if="pipeline.flags.latest"
+ class="js-pipeline-url-lastest label label-success"
+ title="Latest pipeline for this branch"
+ ref="tooltip">
+ latest
+ </span>
+ <span
+ v-if="pipeline.flags.yaml_errors"
+ class="js-pipeline-url-yaml label label-danger"
+ :title="pipeline.yaml_errors"
+ ref="tooltip">
+ yaml invalid
+ </span>
+ <span
+ v-if="pipeline.flags.stuck"
+ class="js-pipeline-url-stuck label label-warning">
+ stuck
+ </span>
+ </td>
+</template>
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index 5aab25e0348..bfc416da50b 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -1,6 +1,10 @@
+/* global Flash */
+
import Vue from 'vue';
import PipelinesMediator from './pipeline_details_mediatior';
import pipelineGraph from './components/graph/graph_component.vue';
+import pipelineHeader from './components/header_component.vue';
+import eventHub from './event_hub';
document.addEventListener('DOMContentLoaded', () => {
const dataset = document.querySelector('.js-pipeline-details-vue').dataset;
@@ -9,7 +13,8 @@ document.addEventListener('DOMContentLoaded', () => {
mediator.fetchPipeline();
- const pipelineGraphApp = new Vue({
+ // eslint-disable-next-line
+ new Vue({
el: '#js-pipeline-graph-vue',
data() {
return {
@@ -29,5 +34,37 @@ document.addEventListener('DOMContentLoaded', () => {
},
});
- return pipelineGraphApp;
+ // eslint-disable-next-line
+ new Vue({
+ el: '#js-pipeline-header-vue',
+ data() {
+ return {
+ mediator,
+ };
+ },
+ components: {
+ pipelineHeader,
+ },
+ created() {
+ eventHub.$on('headerPostAction', this.postAction);
+ },
+ beforeDestroy() {
+ eventHub.$off('headerPostAction', this.postAction);
+ },
+ methods: {
+ postAction(action) {
+ this.mediator.service.postAction(action.path)
+ .then(() => this.mediator.refreshPipeline())
+ .catch(() => new Flash('An error occurred while making the request.'));
+ },
+ },
+ render(createElement) {
+ return createElement('pipeline-header', {
+ props: {
+ isLoading: this.mediator.state.isLoading,
+ pipeline: this.mediator.store.state.pipeline,
+ },
+ });
+ },
+ });
});
diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediatior.js b/app/assets/javascripts/pipelines/pipeline_details_mediatior.js
index b9a6d5ca5fc..82537ea06f5 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_mediatior.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_mediatior.js
@@ -26,6 +26,8 @@ export default class pipelinesMediator {
if (!Visibility.hidden()) {
this.state.isLoading = true;
this.poll.makeRequest();
+ } else {
+ this.refreshPipeline();
}
Visibility.change(() => {
@@ -48,4 +50,10 @@ export default class pipelinesMediator {
this.state.isLoading = false;
return new Flash('An error occurred while fetching the pipeline.');
}
+
+ refreshPipeline() {
+ this.service.getPipeline()
+ .then(response => this.successCallback(response))
+ .catch(() => this.errorCallback());
+ }
}
diff --git a/app/assets/javascripts/pipelines/pipelines.js b/app/assets/javascripts/pipelines/pipelines.js
index d6952d1ee5f..9f247af1dec 100644
--- a/app/assets/javascripts/pipelines/pipelines.js
+++ b/app/assets/javascripts/pipelines/pipelines.js
@@ -169,7 +169,7 @@ export default {
eventHub.$on('refreshPipelines', this.fetchPipelines);
},
- beforeDestroyed() {
+ beforeDestroy() {
eventHub.$off('refreshPipelines');
},
diff --git a/app/assets/javascripts/pipelines/services/pipeline_service.js b/app/assets/javascripts/pipelines/services/pipeline_service.js
index f1cc60c1ee0..3e0c52c7726 100644
--- a/app/assets/javascripts/pipelines/services/pipeline_service.js
+++ b/app/assets/javascripts/pipelines/services/pipeline_service.js
@@ -11,4 +11,9 @@ export default class PipelineService {
getPipeline() {
return this.pipeline.get();
}
+
+ // eslint-disable-next-line
+ postAction(endpoint) {
+ return Vue.http.post(`${endpoint}.json`);
+ }
}
diff --git a/app/assets/javascripts/pipelines/services/pipelines_service.js b/app/assets/javascripts/pipelines/services/pipelines_service.js
index b21f84b4545..e2285494e62 100644
--- a/app/assets/javascripts/pipelines/services/pipelines_service.js
+++ b/app/assets/javascripts/pipelines/services/pipelines_service.js
@@ -33,8 +33,6 @@ export default class PipelinesService {
/**
* Post request for all pipelines actions.
- * Endpoint content type needs to be:
- * `Content-Type:application/x-www-form-urlencoded`
*
* @param {String} endpoint
* @return {Promise}
diff --git a/app/assets/javascripts/project_new.js b/app/assets/javascripts/project_new.js
index 04b381fe0e0..c0f757269cb 100644
--- a/app/assets/javascripts/project_new.js
+++ b/app/assets/javascripts/project_new.js
@@ -1,11 +1,17 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-unused-vars, one-var, no-underscore-dangle, prefer-template, no-else-return, prefer-arrow-callback, max-len */
+function highlightChanges($elm) {
+ $elm.addClass('highlight-changes');
+ setTimeout(() => $elm.removeClass('highlight-changes'), 10);
+}
+
(function() {
this.ProjectNew = (function() {
function ProjectNew() {
this.toggleSettings = this.toggleSettings.bind(this);
this.$selects = $('.features select');
this.$repoSelects = this.$selects.filter('.js-repo-select');
+ this.$projectSelects = this.$selects.not('.js-repo-select');
$('.project-edit-container').on('ajax:before', (function(_this) {
return function() {
@@ -26,6 +32,42 @@
if (!visibilityContainer) return;
const visibilitySelect = new gl.VisibilitySelect(visibilityContainer);
visibilitySelect.init();
+
+ const $visibilitySelect = $(visibilityContainer).find('select');
+ let projectVisibility = $visibilitySelect.val();
+ const PROJECT_VISIBILITY_PRIVATE = '0';
+
+ $visibilitySelect.on('change', () => {
+ const newProjectVisibility = $visibilitySelect.val();
+
+ if (projectVisibility !== newProjectVisibility) {
+ this.$projectSelects.each((idx, select) => {
+ const $select = $(select);
+ const $options = $select.find('option');
+ const values = $.map($options, e => e.value);
+
+ // if switched to "private", limit visibility options
+ if (newProjectVisibility === PROJECT_VISIBILITY_PRIVATE) {
+ if ($select.val() !== values[0] && $select.val() !== values[1]) {
+ $select.val(values[1]).trigger('change');
+ highlightChanges($select);
+ }
+ $options.slice(2).disable();
+ }
+
+ // if switched from "private", increase visibility for non-disabled options
+ if (projectVisibility === PROJECT_VISIBILITY_PRIVATE) {
+ $options.enable();
+ if ($select.val() !== values[0] && $select.val() !== values[values.length - 1]) {
+ $select.val(values[values.length - 1]).trigger('change');
+ highlightChanges($select);
+ }
+ }
+ });
+
+ projectVisibility = newProjectVisibility;
+ }
+ });
};
ProjectNew.prototype.toggleSettings = function() {
@@ -56,8 +98,10 @@
ProjectNew.prototype.toggleRepoVisibility = function () {
var $repoAccessLevel = $('.js-repo-access-level select');
+ var $lfsEnabledOption = $('.js-lfs-enabled select');
var containerRegistry = document.querySelectorAll('.js-container-registry')[0];
var containerRegistryCheckbox = document.getElementById('project_container_registry_enabled');
+ var prevSelectedVal = parseInt($repoAccessLevel.val(), 10);
this.$repoSelects.find("option[value='" + $repoAccessLevel.val() + "']")
.nextAll()
@@ -71,29 +115,40 @@
var $this = $(this);
var repoSelectVal = parseInt($this.val(), 10);
- $this.find('option').show();
+ $this.find('option').enable();
- if (selectedVal < repoSelectVal) {
- $this.val(selectedVal);
+ if (selectedVal < repoSelectVal || repoSelectVal === prevSelectedVal) {
+ $this.val(selectedVal).trigger('change');
+ highlightChanges($this);
}
- $this.find("option[value='" + selectedVal + "']").nextAll().hide();
+ $this.find("option[value='" + selectedVal + "']").nextAll().disable();
});
if (selectedVal) {
this.$repoSelects.removeClass('disabled');
+ if ($lfsEnabledOption.length) {
+ $lfsEnabledOption.removeClass('disabled');
+ highlightChanges($lfsEnabledOption);
+ }
if (containerRegistry) {
containerRegistry.style.display = '';
}
} else {
this.$repoSelects.addClass('disabled');
+ if ($lfsEnabledOption.length) {
+ $lfsEnabledOption.val('false').addClass('disabled');
+ highlightChanges($lfsEnabledOption);
+ }
if (containerRegistry) {
containerRegistry.style.display = 'none';
containerRegistryCheckbox.checked = false;
}
}
+
+ prevSelectedVal = selectedVal;
}.bind(this));
};
diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js
index 0ff0a3b6cc4..9896b88d487 100644
--- a/app/assets/javascripts/project_select.js
+++ b/app/assets/javascripts/project_select.js
@@ -51,6 +51,9 @@ import Api from './api';
this.groupId = $(select).data('group-id');
this.includeGroups = $(select).data('include-groups');
this.orderBy = $(select).data('order-by') || 'id';
+ this.withIssuesEnabled = $(select).data('with-issues-enabled');
+ this.withMergeRequestsEnabled = $(select).data('with-merge-requests-enabled');
+
placeholder = "Search for project";
if (this.includeGroups) {
placeholder += " or group";
@@ -84,7 +87,11 @@ import Api from './api';
if (_this.groupId) {
return Api.groupProjects(_this.groupId, query.term, projectsCallback);
} else {
- return Api.projects(query.term, { order_by: _this.orderBy }, projectsCallback);
+ return Api.projects(query.term, {
+ order_by: _this.orderBy,
+ with_issues_enabled: _this.withIssuesEnabled,
+ with_merge_requests_enabled: _this.withMergeRequestsEnabled
+ }, projectsCallback);
}
};
})(this),
diff --git a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
index 068e9698e1d..9d045886262 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
@@ -10,7 +10,7 @@ export default class ProtectedTagDropdown {
this.$dropdown = options.$dropdown;
this.$dropdownContainer = this.$dropdown.parent();
this.$dropdownFooter = this.$dropdownContainer.find('.dropdown-footer');
- this.$protectedTag = this.$dropdownContainer.find('.create-new-protected-tag');
+ this.$protectedTag = this.$dropdownContainer.find('.js-create-new-protected-tag');
this.buildDropdown();
this.bindEvents();
@@ -73,7 +73,7 @@ export default class ProtectedTagDropdown {
};
this.$dropdownContainer
- .find('.create-new-protected-tag code')
+ .find('.js-create-new-protected-tag code')
.text(tagName);
}
diff --git a/app/assets/javascripts/settings_panels.js b/app/assets/javascripts/settings_panels.js
new file mode 100644
index 00000000000..e67f449e1a2
--- /dev/null
+++ b/app/assets/javascripts/settings_panels.js
@@ -0,0 +1,27 @@
+function expandSection($section) {
+ $section.find('.js-settings-toggle').text('Close');
+ $section.find('.settings-content').addClass('expanded').off('scroll').scrollTop(0);
+}
+
+function closeSection($section) {
+ $section.find('.js-settings-toggle').text('Expand');
+ $section.find('.settings-content').removeClass('expanded').on('scroll', () => expandSection($section));
+}
+
+function toggleSection($section) {
+ const $content = $section.find('.settings-content');
+ $content.removeClass('no-animate');
+ if ($content.hasClass('expanded')) {
+ closeSection($section);
+ } else {
+ expandSection($section);
+ }
+}
+
+export default function initSettingsPanels() {
+ $('.settings').each((i, elm) => {
+ const $section = $(elm);
+ $section.on('click', '.js-settings-toggle', () => toggleSection($section));
+ $section.find('.settings-content:not(.expanded)').on('scroll', () => expandSection($section));
+ });
+}
diff --git a/app/assets/javascripts/shortcuts_issuable.js b/app/assets/javascripts/shortcuts_issuable.js
index dace03554e8..51448252c0f 100644
--- a/app/assets/javascripts/shortcuts_issuable.js
+++ b/app/assets/javascripts/shortcuts_issuable.js
@@ -77,7 +77,9 @@ import './shortcuts_navigation';
ShortcutsIssuable.prototype.editIssue = function() {
var $editBtn;
$editBtn = $('.issuable-edit');
- return gl.utils.visitUrl($editBtn.attr('href'));
+ // Need to click the element as on issues, editing is inline
+ // on merge request, editing is on a different page
+ $editBtn.get(0).click();
};
ShortcutsIssuable.prototype.openSidebarDropdown = function(name) {
diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js
index 3392cb9da29..419c458ff34 100644
--- a/app/assets/javascripts/task_list.js
+++ b/app/assets/javascripts/task_list.js
@@ -1,6 +1,6 @@
/* global Flash */
-import 'vendor/task_list';
+import 'deckar01-task_list';
class TaskList {
constructor(options = {}) {
diff --git a/app/assets/javascripts/user_callout.js b/app/assets/javascripts/user_callout.js
index b9d57cbcad4..ff2208baeab 100644
--- a/app/assets/javascripts/user_callout.js
+++ b/app/assets/javascripts/user_callout.js
@@ -1,11 +1,10 @@
import Cookies from 'js-cookie';
-const USER_CALLOUT_COOKIE = 'user_callout_dismissed';
-
export default class UserCallout {
- constructor() {
- this.isCalloutDismissed = Cookies.get(USER_CALLOUT_COOKIE);
- this.userCalloutBody = $('.user-callout');
+ constructor(className = 'user-callout') {
+ this.userCalloutBody = $(`.${className}`);
+ this.cookieName = this.userCalloutBody.data('uid');
+ this.isCalloutDismissed = Cookies.get(this.cookieName);
this.init();
}
@@ -18,7 +17,7 @@ export default class UserCallout {
dismissCallout(e) {
const $currentTarget = $(e.currentTarget);
- Cookies.set(USER_CALLOUT_COOKIE, 'true', { expires: 365 });
+ Cookies.set(this.cookieName, 'true', { expires: 365 });
if ($currentTarget.hasClass('close')) {
this.userCalloutBody.remove();
diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js
index bfe30ee4c08..fe5e1bbb55c 100644
--- a/app/assets/javascripts/vue_merge_request_widget/dependencies.js
+++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js
@@ -41,3 +41,4 @@ export { default as getStateKey } from './stores/get_state_key';
export { default as mrWidgetOptions } from './mr_widget_options';
export { default as stateMaps } from './stores/state_maps';
export { default as SquashBeforeMerge } from './components/states/mr_widget_squash_before_merge';
+export { default as notify } from '../lib/utils/notify';
diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js
index cd65ac069c5..43ef468c303 100644
--- a/app/assets/javascripts/vue_merge_request_widget/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/index.js
@@ -4,6 +4,8 @@ import {
} from './dependencies';
document.addEventListener('DOMContentLoaded', () => {
+ gl.mrWidgetData.gitlabLogo = gon.gitlab_logo;
+
const vm = new Vue(mrWidgetOptions);
window.gl.mrWidget = {
diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js
index 99600b6664e..2339a00ddd0 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js
@@ -29,6 +29,7 @@ import {
eventHub,
stateMaps,
SquashBeforeMerge,
+ notify,
} from './dependencies';
export default {
@@ -77,8 +78,10 @@ export default {
this.service.checkStatus()
.then(res => res.json())
.then((res) => {
+ this.handleNotification(res);
this.mr.setData(res);
this.setFavicon();
+
if (cb) {
cb.call(null, res);
}
@@ -136,6 +139,15 @@ export default {
new Flash('Something went wrong. Please try again.'); // eslint-disable-line
});
},
+ handleNotification(data) {
+ if (data.ci_status === this.mr.ciStatus) return;
+
+ const label = data.pipeline.details.status.label;
+ const title = `Pipeline ${label}`;
+ const message = `Pipeline ${label} for "${data.title}"`;
+
+ notify.notifyMe(title, message, this.mr.gitlabLogo);
+ },
resumePolling() {
this.pollingInterval.resume();
},
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
index dc4b2195050..69bc1436284 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
@@ -5,6 +5,8 @@ export default class MergeRequestStore {
constructor(data) {
this.sha = data.diff_head_sha;
+ this.gitlabLogo = data.gitlabLogo;
+
this.setData(data);
}
diff --git a/app/assets/javascripts/vue_shared/components/commit.js b/app/assets/javascripts/vue_shared/components/commit.js
index 23bc5fbc034..ff5ae28e062 100644
--- a/app/assets/javascripts/vue_shared/components/commit.js
+++ b/app/assets/javascripts/vue_shared/components/commit.js
@@ -91,7 +91,7 @@ export default {
hasAuthor() {
return this.author &&
this.author.avatar_url &&
- this.author.web_url &&
+ this.author.path &&
this.author.username;
},
@@ -135,12 +135,12 @@ export default {
{{shortSha}}
</a>
- <p class="commit-title">
- <span v-if="title">
+ <div class="commit-title flex-truncate-parent">
+ <span v-if="title" class="flex-truncate-child">
<user-avatar-link
v-if="hasAuthor"
class="avatar-image-container"
- :link-href="author.web_url"
+ :link-href="author.path"
:img-src="author.avatar_url"
:img-alt="userImageAltDescription"
:tooltip-text="author.username"
@@ -153,7 +153,7 @@ export default {
<span v-else>
Cant find HEAD commit for this branch
</span>
- </p>
+ </div>
</div>
`,
};
diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
index fd0dcd716d6..fe6d6a792e7 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -1,8 +1,9 @@
<script>
import ciIconBadge from './ci_badge_link.vue';
+import loadingIcon from './loading_icon.vue';
import timeagoTooltip from './time_ago_tooltip.vue';
import tooltipMixin from '../mixins/tooltip';
-import userAvatarLink from './user_avatar/user_avatar_link.vue';
+import userAvatarImage from './user_avatar/user_avatar_image.vue';
/**
* Renders header component for job and pipeline page based on UI mockups
@@ -31,7 +32,8 @@ export default {
},
user: {
type: Object,
- required: true,
+ required: false,
+ default: () => ({}),
},
actions: {
type: Array,
@@ -46,8 +48,9 @@ export default {
components: {
ciIconBadge,
+ loadingIcon,
timeagoTooltip,
- userAvatarLink,
+ userAvatarImage,
},
computed: {
@@ -58,13 +61,13 @@ export default {
methods: {
onClickAction(action) {
- this.$emit('postAction', action);
+ this.$emit('actionClicked', action);
},
},
};
</script>
<template>
- <header class="page-content-header top-area">
+ <header class="page-content-header">
<section class="header-main-content">
<ci-icon-badge :status="status" />
@@ -79,21 +82,23 @@ export default {
by
- <user-avatar-link
- :link-href="user.web_url"
- :img-src="user.avatar_url"
- :img-alt="userAvatarAltText"
- :tooltip-text="user.name"
- :img-size="24"
- />
-
- <a
- :href="user.web_url"
- :title="user.email"
- class="js-user-link commit-committer-link"
- ref="tooltip">
- {{user.name}}
- </a>
+ <template v-if="user">
+ <a
+ :href="user.path"
+ :title="user.email"
+ class="js-user-link commit-committer-link"
+ ref="tooltip">
+
+ <user-avatar-image
+ :img-src="user.avatar_url"
+ :img-alt="userAvatarAltText"
+ :tooltip-text="user.name"
+ :img-size="24"
+ />
+
+ {{user.name}}
+ </a>
+ </template>
</section>
<section
@@ -111,11 +116,17 @@ export default {
<button
v-else="action.type === 'button'"
@click="onClickAction(action)"
+ :disabled="action.isLoading"
:class="action.cssClass"
type="button">
{{action.label}}
- </button>
+ <i
+ v-show="action.isLoading"
+ class="fa fa-spin fa-spinner"
+ aria-hidden="true">
+ </i>
+ </button>
</template>
</section>
</header>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
new file mode 100644
index 00000000000..e6977681e96
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -0,0 +1,107 @@
+<script>
+ /* global Flash */
+ import markdownHeader from './header.vue';
+ import markdownToolbar from './toolbar.vue';
+
+ export default {
+ props: {
+ markdownPreviewUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ markdownDocs: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ markdownPreview: '',
+ markdownPreviewLoading: false,
+ previewMarkdown: false,
+ };
+ },
+ components: {
+ markdownHeader,
+ markdownToolbar,
+ },
+ methods: {
+ toggleMarkdownPreview() {
+ this.previewMarkdown = !this.previewMarkdown;
+
+ if (!this.previewMarkdown) {
+ this.markdownPreview = '';
+ } else {
+ this.markdownPreviewLoading = true;
+ this.$http.post(
+ this.markdownPreviewUrl,
+ {
+ /*
+ Can't use `$refs` as the component is technically in the parent component
+ so we access the VNode & then get the element
+ */
+ text: this.$slots.textarea[0].elm.value,
+ },
+ )
+ .then((res) => {
+ const data = res.json();
+
+ this.markdownPreviewLoading = false;
+ this.markdownPreview = data.body;
+
+ this.$nextTick(() => {
+ $(this.$refs['markdown-preview']).renderGFM();
+ });
+ })
+ .catch(() => new Flash('Error loading markdown preview'));
+ }
+ },
+ },
+ mounted() {
+ /*
+ GLForm class handles all the toolbar buttons
+ */
+ return new gl.GLForm($(this.$refs['gl-form']), true);
+ },
+ };
+</script>
+
+<template>
+ <div
+ class="md-area prepend-top-default append-bottom-default js-vue-markdown-field"
+ ref="gl-form">
+ <markdown-header
+ :preview-markdown="previewMarkdown"
+ @toggle-markdown="toggleMarkdownPreview" />
+ <div
+ class="md-write-holder"
+ v-show="!previewMarkdown">
+ <div class="zen-backdrop">
+ <slot name="textarea"></slot>
+ <a
+ class="zen-control zen-control-leave js-zen-leave"
+ href="#"
+ aria-label="Enter zen mode">
+ <i
+ class="fa fa-compress"
+ aria-hidden="true">
+ </i>
+ </a>
+ <markdown-toolbar
+ :markdown-docs="markdownDocs" />
+ </div>
+ </div>
+ <div
+ class="md md-preview-holder md-preview"
+ v-show="previewMarkdown">
+ <div
+ ref="markdown-preview"
+ v-html="markdownPreview">
+ </div>
+ <span v-if="markdownPreviewLoading">
+ Loading...
+ </span>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
new file mode 100644
index 00000000000..1a11f493b7f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -0,0 +1,113 @@
+<script>
+ import tooltipMixin from '../../mixins/tooltip';
+ import toolbarButton from './toolbar_button.vue';
+
+ export default {
+ mixins: [
+ tooltipMixin,
+ ],
+ props: {
+ previewMarkdown: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ components: {
+ toolbarButton,
+ },
+ methods: {
+ toggleMarkdownPreview(e, form) {
+ if (form && !form.find('.js-vue-markdown-field').length) {
+ return;
+ } else if (e.target.blur) {
+ e.target.blur();
+ }
+
+ this.$emit('toggle-markdown');
+ },
+ },
+ mounted() {
+ $(document).on('markdown-preview:show.vue', this.toggleMarkdownPreview);
+ $(document).on('markdown-preview:hide.vue', this.toggleMarkdownPreview);
+ },
+ beforeDestroy() {
+ $(document).on('markdown-preview:show.vue', this.toggleMarkdownPreview);
+ $(document).off('markdown-preview:hide.vue', this.toggleMarkdownPreview);
+ },
+ };
+</script>
+
+<template>
+ <div class="md-header">
+ <ul class="nav-links clearfix">
+ <li :class="{ active: !previewMarkdown }">
+ <a
+ href="#md-write-holder"
+ tabindex="-1"
+ @click.prevent="toggleMarkdownPreview($event)">
+ Write
+ </a>
+ </li>
+ <li :class="{ active: previewMarkdown }">
+ <a
+ href="#md-preview-holder"
+ tabindex="-1"
+ @click.prevent="toggleMarkdownPreview($event)">
+ Preview
+ </a>
+ </li>
+ <li class="pull-right">
+ <div class="toolbar-group">
+ <toolbar-button
+ tag="**"
+ button-title="Add bold text"
+ icon="bold" />
+ <toolbar-button
+ tag="*"
+ button-title="Add italic text"
+ icon="italic" />
+ <toolbar-button
+ tag="> "
+ :prepend="true"
+ button-title="Insert a quote"
+ icon="quote-right" />
+ <toolbar-button
+ tag="`"
+ tag-block="```"
+ button-title="Insert code"
+ icon="code" />
+ <toolbar-button
+ tag="* "
+ :prepend="true"
+ button-title="Add a bullet list"
+ icon="list-ul" />
+ <toolbar-button
+ tag="1. "
+ :prepend="true"
+ button-title="Add a numbered list"
+ icon="list-ol" />
+ <toolbar-button
+ tag="* [ ] "
+ :prepend="true"
+ button-title="Add a task list"
+ icon="check-square-o" />
+ </div>
+ <div class="toolbar-group">
+ <button
+ aria-label="Go full screen"
+ class="toolbar-btn js-zen-enter"
+ data-container="body"
+ tabindex="-1"
+ title="Go full screen"
+ type="button"
+ ref="tooltip">
+ <i
+ aria-hidden="true"
+ class="fa fa-arrows-alt fa-fw">
+ </i>
+ </button>
+ </div>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
new file mode 100644
index 00000000000..93252293ba6
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -0,0 +1,33 @@
+<script>
+ export default {
+ props: {
+ markdownDocs: {
+ type: String,
+ required: true,
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="comment-toolbar clearfix">
+ <div class="toolbar-text">
+ <a
+ :href="markdownDocs"
+ target="_blank"
+ tabindex="-1">
+ Markdown is supported
+ </a>
+ </div>
+ <button
+ class="toolbar-button markdown-selector"
+ type="button"
+ tabindex="-1">
+ <i
+ class="fa fa-file-image-o toolbar-button-icon"
+ aria-hidden="true">
+ </i>
+ Attach a file
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
new file mode 100644
index 00000000000..096be507625
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
@@ -0,0 +1,58 @@
+<script>
+ import tooltipMixin from '../../mixins/tooltip';
+
+ export default {
+ mixins: [
+ tooltipMixin,
+ ],
+ props: {
+ buttonTitle: {
+ type: String,
+ required: true,
+ },
+ icon: {
+ type: String,
+ required: true,
+ },
+ tag: {
+ type: String,
+ required: true,
+ },
+ tagBlock: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ prepend: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ iconClass() {
+ return `fa-${this.icon}`;
+ },
+ },
+ };
+</script>
+
+<template>
+ <button
+ type="button"
+ class="toolbar-btn js-md hidden-xs"
+ tabindex="-1"
+ ref="tooltip"
+ data-container="body"
+ :data-md-tag="tag"
+ :data-md-block="tagBlock"
+ :data-md-prepend="prepend"
+ :title="buttonTitle"
+ :aria-label="buttonTitle">
+ <i
+ aria-hidden="true"
+ class="fa fa-fw"
+ :class="iconClass">
+ </i>
+ </button>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js
index 30d16e4ed3e..f60f8eeb43d 100644
--- a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js
+++ b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js
@@ -4,7 +4,7 @@ import PipelinesActionsComponent from '../../pipelines/components/pipelines_acti
import PipelinesArtifactsComponent from '../../pipelines/components/pipelines_artifacts';
import ciBadge from './ci_badge_link.vue';
import PipelinesStageComponent from '../../pipelines/components/stage.vue';
-import PipelinesUrlComponent from '../../pipelines/components/pipeline_url';
+import PipelinesUrlComponent from '../../pipelines/components/pipeline_url.vue';
import PipelinesTimeagoComponent from '../../pipelines/components/time_ago';
import CommitComponent from './commit';
@@ -83,7 +83,7 @@ export default {
} else {
commitAuthorInformation = {
avatar_url: this.pipeline.commit.author_gravatar_url,
- web_url: `mailto:${this.pipeline.commit.author_email}`,
+ path: `mailto:${this.pipeline.commit.author_email}`,
username: this.pipeline.commit.author_name,
};
}
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
index b8db6afda12..cd6f8c7aee4 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
@@ -60,6 +60,12 @@ export default {
avatarSizeClass() {
return `s${this.size}`;
},
+ // API response sends null when gravatar is disabled and
+ // we provide an empty string when we use it inside user avatar link.
+ // In both cases we should render the defaultAvatarUrl
+ imageSource() {
+ return this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
+ },
},
};
</script>
@@ -68,7 +74,7 @@ export default {
<img
class="avatar"
:class="[avatarSizeClass, cssClasses]"
- :src="imgSrc"
+ :src="imageSource"
:width="size"
:height="size"
:alt="imgAlt"
diff --git a/app/assets/javascripts/vue_shared/mixins/tooltip.js b/app/assets/javascripts/vue_shared/mixins/tooltip.js
index 9bb948bff66..995c0c98505 100644
--- a/app/assets/javascripts/vue_shared/mixins/tooltip.js
+++ b/app/assets/javascripts/vue_shared/mixins/tooltip.js
@@ -6,4 +6,8 @@ export default {
updated() {
$(this.$refs.tooltip).tooltip('fixTitle');
},
+
+ beforeDestroy() {
+ $(this.$refs.tooltip).tooltip('destroy');
+ },
};
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index b8ba77f4513..9dc9f9a9068 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -49,3 +49,4 @@
@import "framework/icons.scss";
@import "framework/snippets.scss";
@import "framework/memory_graph.scss";
+@import "framework/responsive-tables.scss";
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 57387b913dc..00c981f64c5 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -445,3 +445,9 @@ table {
word-wrap: break-word;
}
}
+
+.disabled-content {
+ pointer-events: none;
+ opacity: .5;
+}
+
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 78f425057eb..d08df05fd6c 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -85,7 +85,7 @@
}
/**
- * Blame file
+ * Annotate file
*/
&.blame {
table {
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index f0994e968c8..cfbaaaa04c7 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -22,12 +22,6 @@
}
@media (min-width: $screen-sm-min) {
- .issues_bulk_update {
- .dropdown-menu-toggle {
- width: 132px;
- }
- }
-
.filter-item:not(:last-child) {
margin-right: 6px;
}
@@ -90,6 +84,7 @@
.filtered-search-term {
display: -webkit-flex;
display: flex;
+ flex-shrink: 0;
margin-top: 5px;
margin-bottom: 5px;
@@ -104,6 +99,22 @@
padding: 2px 7px;
}
+ .name {
+ background-color: $filter-name-resting-color;
+ color: $filter-name-text-color;
+ border-radius: 2px 0 0 2px;
+ margin-right: 1px;
+ text-transform: capitalize;
+ }
+
+ .value-container {
+ background-color: $white-normal;
+ color: $filter-value-text-color;
+ border-radius: 0 2px 2px 0;
+ margin-right: 5px;
+ padding-right: 8px;
+ }
+
.value {
padding-right: 0;
}
@@ -111,7 +122,7 @@
.remove-token {
display: inline-block;
padding-left: 4px;
- padding-right: 8px;
+ padding-right: 0;
.fa-close {
color: $gl-text-color-secondary;
@@ -131,30 +142,17 @@
}
}
}
+}
+.filtered-search-token:hover,
+.filtered-search-token .selected,
+.filtered-search-term .selected {
.name {
- background-color: $filter-name-resting-color;
- color: $filter-name-text-color;
- border-radius: 2px 0 0 2px;
- margin-right: 1px;
- text-transform: capitalize;
+ background-color: $filter-name-selected-color;
}
.value-container {
- background-color: $white-normal;
- color: $filter-value-text-color;
- border-radius: 0 2px 2px 0;
- margin-right: 5px;
- }
-
- .selected {
- .name {
- background-color: $filter-name-selected-color;
- }
-
- .value-container {
- background-color: $filter-value-selected-color;
- }
+ background-color: $filter-value-selected-color;
}
}
@@ -238,7 +236,7 @@
width: 35px;
background-color: $white-light;
border: none;
- position: absolute;
+ position: static;
right: 0;
height: 100%;
outline: none;
@@ -374,12 +372,6 @@
padding: 0;
}
-@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
- .issue-bulk-update-dropdown-toggle {
- width: 100px;
- }
-}
-
@media (max-width: $screen-xs-max) {
.issues-details-filters {
padding: 0 0 10px;
@@ -474,4 +466,5 @@
.filter-dropdown-loading {
padding: 8px 16px;
+ text-align: center;
}
diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss
index 25b4feca3c3..38d884bc7eb 100644
--- a/app/assets/stylesheets/framework/flash.scss
+++ b/app/assets/stylesheets/framework/flash.scss
@@ -16,6 +16,22 @@
@extend .alert;
@extend .alert-danger;
margin: 0;
+
+ .flash-text,
+ .flash-action {
+ display: inline-block;
+ }
+
+ a.flash-action {
+ margin-left: 5px;
+ text-decoration: none;
+ font-weight: normal;
+ border-bottom: 1px solid;
+
+ &:hover {
+ border-color: transparent;
+ }
+ }
}
.flash-notice,
diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss
index 678af978edd..600a1f53b58 100644
--- a/app/assets/stylesheets/framework/mobile.scss
+++ b/app/assets/stylesheets/framework/mobile.scss
@@ -29,10 +29,6 @@
display: none;
}
- .issues-holder .issue-check {
- display: none;
- }
-
.rss-btn {
display: none;
}
@@ -112,11 +108,6 @@
}
}
- .issue-edited-ago,
- .note_edited_ago {
- display: none;
- }
-
aside:not(.right-sidebar) {
display: none;
}
diff --git a/app/assets/stylesheets/framework/responsive-tables.scss b/app/assets/stylesheets/framework/responsive-tables.scss
new file mode 100644
index 00000000000..a24483fa431
--- /dev/null
+++ b/app/assets/stylesheets/framework/responsive-tables.scss
@@ -0,0 +1,90 @@
+@mixin flex-max-width($max) {
+ flex: 0 0 #{$max + '%'};
+ max-width: #{$max + '%'};
+}
+
+.gl-responsive-table-row {
+ margin-top: 10px;
+ border: 1px solid $border-color;
+
+ @media (min-width: $screen-md-min) {
+ padding: 15px 0;
+ margin: 0;
+ display: flex;
+ align-items: center;
+ border: none;
+ border-bottom: 1px solid $white-normal;
+ }
+
+ .table-section {
+ white-space: nowrap;
+
+ .branch-commit {
+ max-width: 100%;
+ }
+
+ $section-widths: 10 15 20 25 30 40;
+ @each $width in $section-widths {
+ &.section-#{$width} {
+ flex: 0 0 #{$width + '%'};
+
+ @media (min-width: $screen-md-min) {
+ max-width: #{$width + '%'};
+ }
+ }
+ }
+
+ &:not(.table-button-footer) {
+ @media (max-width: $screen-sm-max) {
+ display: flex;
+ align-self: stretch;
+ padding: 10px;
+ align-items: center;
+ height: 62px;
+
+ &:not(:first-of-type) {
+ border-top: 1px solid $white-normal;
+ }
+ }
+ }
+ }
+}
+
+.table-row-header {
+ font-size: 13px;
+
+ @media (max-width: $screen-sm-max) {
+ display: none;
+ }
+}
+
+.table-mobile-header {
+ color: $gl-text-color-secondary;
+ @include flex-max-width(40);
+
+ @media (min-width: $screen-md-min) {
+ display: none;
+ }
+}
+
+.table-mobile-content {
+ @media (max-width: $screen-sm-max) {
+ @include flex-max-width(60);
+ text-align: right;
+ }
+}
+
+.flex-truncate-parent {
+ display: flex;
+}
+
+.flex-truncate-child {
+ flex: 1;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ @media (min-width: $screen-md-min) {
+ flex: 0 0 90%;
+ }
+}
diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss
index 5b62d7fa3a7..d4421e3af74 100644
--- a/app/assets/stylesheets/framework/sidebar.scss
+++ b/app/assets/stylesheets/framework/sidebar.scss
@@ -33,7 +33,7 @@
padding-right: 0;
@media (min-width: $screen-sm-min) {
- &:not(.wiki-sidebar):not(.build-sidebar) .content-wrapper {
+ &:not(.wiki-sidebar):not(.build-sidebar):not(.issuable-bulk-update-sidebar) .content-wrapper {
padding-right: $gutter_collapsed_width;
}
@@ -56,7 +56,7 @@
z-index: 300;
@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
- &:not(.wiki-sidebar):not(.build-sidebar) .content-wrapper {
+ &:not(.wiki-sidebar):not(.build-sidebar):not(.issuable-bulk-update-sidebar) .content-wrapper {
padding-right: $gutter_collapsed_width;
}
}
@@ -88,3 +88,35 @@
min-height: 100%;
}
}
+
+@mixin maintain-sidebar-dimensions {
+ display: block;
+ width: $gutter-width;
+ padding: 10px 20px;
+}
+
+.issues-bulk-update.right-sidebar {
+ @include maintain-sidebar-dimensions;
+ transition: right $sidebar-transition-duration;
+ right: -$gutter-width;
+
+ &.right-sidebar-expanded {
+ @include maintain-sidebar-dimensions;
+ right: 0;
+ }
+
+ &.right-sidebar-collapsed {
+ @include maintain-sidebar-dimensions;
+ right: -$gutter-width;
+
+ .block {
+ padding: 16px 0;
+ width: 250px;
+ border-bottom: 1px solid $border-color;
+ }
+ }
+
+ .issuable-sidebar {
+ padding: 0 3px;
+ }
+}
diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss
index cec3b54d567..10881987038 100644
--- a/app/assets/stylesheets/framework/timeline.scss
+++ b/app/assets/stylesheets/framework/timeline.scss
@@ -4,7 +4,7 @@
padding: 0;
&::before {
- @include notes-media('max', $screen-xs-max) {
+ @include notes-media('max', $screen-xs-min) {
background: none;
}
}
@@ -30,7 +30,7 @@
.timeline-entry-inner {
position: relative;
- @include notes-media('max', $screen-xs-max) {
+ @include notes-media('max', $screen-xs-min) {
.timeline-icon {
display: none;
}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 4db77752c0c..4114a050d9a 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -187,6 +187,7 @@ $divergence-graph-bar-bg: #ccc;
$divergence-graph-separator-bg: #ccc;
$general-hover-transition-duration: 100ms;
$general-hover-transition-curve: linear;
+$highlight-changes-color: rgb(235, 255, 232);
/*
@@ -293,7 +294,7 @@ $btn-white-active: #848484;
/*
* Badges
*/
-$badge-bg: #eee;
+$badge-bg: rgba(0, 0, 0, 0.07);
$badge-color: $gl-text-color-secondary;
/*
@@ -569,3 +570,10 @@ $filter-value-selected-color: #d7d7d7;
Animation Functions
*/
$dropdown-animation-timing: cubic-bezier(0.23, 1, 0.32, 1);
+
+/*
+Convdev Index
+*/
+$color-high-score: $green-400;
+$color-average-score: $orange-400;
+$color-low-score: $red-400;
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss
index ebe662136d5..e3e94c8ca50 100644
--- a/app/assets/stylesheets/pages/boards.scss
+++ b/app/assets/stylesheets/pages/boards.scss
@@ -175,21 +175,53 @@
}
}
+.slide-down-enter {
+ transform: translateY(-100%);
+}
+
+.slide-down-enter-active {
+ transition: transform $fade-in-duration;
+
+ + .board-list {
+ transform: translateY(-136px);
+ transition: none;
+ }
+}
+
+.slide-down-enter-to {
+ + .board-list {
+ transform: translateY(0);
+ transition: transform $fade-in-duration ease;
+ }
+}
+
+.slide-down-leave {
+ transform: translateY(0);
+}
+
+.slide-down-leave-active {
+ transition: all $fade-in-duration;
+ transform: translateY(-136px);
+
+ + .board-list {
+ transition: transform $fade-in-duration ease;
+ transform: translateY(-136px);
+ }
+}
+
.board-list-component {
height: calc(100% - 49px);
+ overflow: hidden;
}
.board-list {
height: 100%;
+ width: 100%;
margin-bottom: 0;
padding: 5px;
list-style: none;
overflow-y: scroll;
overflow-x: hidden;
-
- &.is-smaller {
- height: calc(100% - 136px);
- }
}
.board-list-loading {
@@ -351,6 +383,7 @@
}
.board-new-issue-form {
+ z-index: 1;
margin: 5px;
}
diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss
index 14a62b6cbf0..e35558ad8e8 100644
--- a/app/assets/stylesheets/pages/builds.scss
+++ b/app/assets/stylesheets/pages/builds.scss
@@ -29,129 +29,140 @@
}
}
-.build-page {
- pre.trace {
- background: $builds-trace-bg;
- color: $white-light;
- font-family: $monospace_font;
- white-space: pre-wrap;
- overflow: auto;
- overflow-y: hidden;
- font-size: 12px;
-
- .fa-spinner {
- font-size: 24px;
- margin-left: 20px;
- }
- }
-
- .environment-information {
- background-color: $gray-light;
- border: 1px solid $border-color;
- padding: 12px $gl-padding;
- border-radius: $border-radius-default;
+@keyframes blinking-scroll-button {
+ 0% { opacity: 0.2; }
+ 25% { opacity: 0.5; }
+ 50% { opacity: 0.7; }
+ 100% { opacity: 1; }
+}
- svg {
- position: relative;
- top: 1px;
- margin-right: 5px;
- }
+.build-page {
+ .sticky {
+ position: absolute;
+ left: 0;
+ right: 0;
}
- .truncated-info {
- text-align: center;
- border-bottom: 1px solid;
- background-color: $black;
- height: 45px;
- padding: 15px;
+ .build-trace-container {
+ position: absolute;
+ top: 225px;
+ left: 15px;
+ bottom: 10px;
+ background: $black;
+ color: $gray-darkest;
+ font-family: $monospace_font;
+ font-size: 12px;
- &.affix {
- top: 0;
+ &.sidebar-expanded {
+ right: 305px;
}
- // with sidebar
- &.affix.sidebar-expanded {
- right: 312px;
- left: 22px;
+ &.sidebar-collapsed {
+ right: 16px;
}
- // without sidebar
- &.affix.sidebar-collapsed {
- right: 20px;
- left: 20px;
+ code {
+ background: $black;
+ color: $gray-darkest;
}
- &.affix-top {
- position: absolute;
+ .top-bar {
top: 0;
- margin: 0 auto;
- right: 5px;
- left: 5px;
- }
+ height: 35px;
+ display: flex;
+ justify-content: flex-end;
+ border-bottom: 1px outset $white-light;
- .truncated-info-size {
- margin: 0 5px;
- }
+ .truncated-info {
+ margin: 0 auto;
+ align-self: center;
- .raw-link {
- color: inherit;
- margin-left: 5px;
- text-decoration: underline;
+ .truncated-info-size {
+ margin: 0 5px;
+ }
+
+ .raw-link {
+ color: inherit;
+ margin-left: 5px;
+ text-decoration: underline;
+ }
+ }
}
- }
-}
-.scroll-controls {
- height: 100%;
+ .controllers {
+ display: flex;
+ align-self: center;
+ font-size: 15px;
- .scroll-step {
- width: 31px;
- margin: 0 0 0 auto;
- }
+ svg {
+ height: 15px;
+ display: block;
+ fill: $white-light;
+ }
- .scroll-link,
- .autoscroll-container {
- right: 25px;
- z-index: 1;
- }
+ a,
+ .btn-scroll {
+ margin: 0 8px;
+ color: $white-light;
+ }
- .scroll-link {
- position: fixed;
- display: block;
- margin-bottom: 10px;
+ .btn-scroll.animate {
+ .first-triangle {
+ animation: blinking-scroll-button 1s ease infinite;
+ animation-delay: .3s;
+ }
- &.scroll-top .gitlab-icon-scroll-up-hover,
- &.scroll-top:hover .gitlab-icon-scroll-up,
- &.scroll-bottom .gitlab-icon-scroll-down-hover,
- &.scroll-bottom:hover .gitlab-icon-scroll-down {
- display: none;
- }
+ .second-triangle {
+ animation: blinking-scroll-button 1s ease infinite;
+ animation-delay: .2s;
+ }
- &.scroll-top:hover .gitlab-icon-scroll-up-hover,
- &.scroll-bottom:hover .gitlab-icon-scroll-down-hover {
- display: inline-block;
- }
+ .third-triangle {
+ animation: blinking-scroll-button 1s ease infinite;
+ }
- &.scroll-top {
- top: 10px;
- }
+ &:disabled {
+ opacity: 1;
+ }
+ }
- &.scroll-bottom {
- bottom: -2px;
+ .btn-scroll:disabled {
+ opacity: 0.35;
+ cursor: not-allowed;
+ }
}
}
- .autoscroll-container {
- position: absolute;
+ .bash {
+ top: 35px;
+ left: 10px;
+ bottom: 0;
+ overflow-y: hidden;
+ padding-bottom: 20px;
+ padding-right: 20px;
}
- &.sidebar-expanded {
+ .environment-information {
+ background-color: $gray-light;
+ border: 1px solid $border-color;
+ padding: 12px $gl-padding;
+ border-radius: $border-radius-default;
- .scroll-link,
- .autoscroll-container {
- right: ($gutter_width + ($gl-padding * 2));
+ svg {
+ position: relative;
+ top: 1px;
+ margin-right: 5px;
}
}
+
+ .build-loader-animation {
+ position: relative;
+ width: 6px;
+ height: 6px;
+ margin: auto auto 12px 2px;
+ border-radius: 50%;
+ animation: blinking-dots 1s linear infinite;
+ }
}
.status-message {
@@ -223,32 +234,6 @@
}
}
-.build-trace {
- background: $black;
- color: $gray-darkest;
- white-space: pre;
- overflow-x: auto;
- font-size: 12px;
- position: relative;
-
- .fa-spinner {
- font-size: 24px;
- }
-
- .bash {
- display: block;
- }
-
- .build-loader-animation {
- position: relative;
- width: 6px;
- height: 6px;
- margin: auto auto 12px 2px;
- border-radius: 50%;
- animation: blinking-dots 1s linear infinite;
- }
-}
-
.right-sidebar.build-sidebar {
padding: $gl-padding 0;
@@ -390,6 +375,10 @@
.container-fluid.container-limited {
max-width: 100%;
}
+
+ .content-wrapper {
+ padding-bottom: 6px;
+ }
}
.build-detail-row {
diff --git a/app/assets/stylesheets/pages/convdev_index.scss b/app/assets/stylesheets/pages/convdev_index.scss
new file mode 100644
index 00000000000..0413114c279
--- /dev/null
+++ b/app/assets/stylesheets/pages/convdev_index.scss
@@ -0,0 +1,255 @@
+$space-between-cards: 8px;
+
+.convdev-empty svg {
+ margin: 64px auto 32px;
+ max-width: 420px;
+}
+
+.convdev-header {
+ margin-top: $gl-padding;
+ margin-bottom: $gl-padding;
+ padding: 0 4px;
+ display: flex;
+ align-items: center;
+
+ .convdev-header-title {
+ font-size: 48px;
+ line-height: 1;
+ margin: 0;
+ }
+
+ .convdev-header-subtitle {
+ font-size: 22px;
+ line-height: 1;
+ color: $gl-text-color-secondary;
+ margin-left: 8px;
+ font-weight: 500;
+
+ a {
+ font-size: 18px;
+ color: $gl-text-color-secondary;
+
+ &:hover {
+ color: $blue-500;
+ }
+ }
+ }
+}
+
+.convdev-cards {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+}
+
+.convdev-card-wrapper {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ text-align: center;
+ width: 50%;
+ border-color: $border-color;
+ margin: 0 0 32px;
+ padding: $space-between-cards / 2;
+ position: relative;
+
+ @media (min-width: $screen-xs-min) {
+ width: percentage(1 / 4);
+ }
+
+ @media (min-width: $screen-sm-min) {
+ width: percentage(1 / 5);
+ }
+
+ @media (min-width: $screen-md-min) {
+ width: percentage(1 / 6);
+ }
+
+ @media (min-width: $screen-lg-min) {
+ width: percentage(1 / 10);
+ }
+}
+
+.convdev-card {
+ border: solid 1px $border-color;
+ border-radius: 3px;
+ border-top-width: 3px;
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+}
+
+.convdev-card-low {
+ border-top-color: $color-low-score;
+
+ .card-score-big {
+ background-color: $red-25;
+ }
+}
+
+.convdev-card-average {
+ border-top-color: $color-average-score;
+
+ .card-score-big {
+ background-color: $orange-25;
+ }
+}
+
+.convdev-card-high {
+ border-top-color: $color-high-score;
+
+ .card-score-big {
+ background-color: $green-25;
+ }
+}
+
+.convdev-card-title {
+ margin: $gl-padding auto auto;
+ max-width: 100px;
+
+ h3 {
+ font-size: 14px;
+ margin: 0 0 2px;
+ }
+
+ .text-light {
+ font-size: 13px;
+ line-height: 1.25;
+ color: $gl-text-color-secondary;
+ }
+}
+
+.card-scores {
+ display: flex;
+ justify-content: space-around;
+ align-items: center;
+ margin: $gl-padding $gl-btn-padding;
+ line-height: 1;
+}
+
+.card-score {
+ color: $gl-text-color-secondary;
+
+ .card-score-name {
+ font-size: 13px;
+ margin-top: 4px;
+ }
+}
+
+.card-score-value {
+ font-size: 16px;
+ color: $gl-text-color;
+ font-weight: 500;
+}
+
+.card-score-big {
+ border-top: 2px solid $border-color;
+ border-bottom: 1px solid $border-color;
+ font-size: 22px;
+ padding: 10px 0;
+ font-weight: 500;
+}
+
+.card-buttons {
+ display: flex;
+
+ > * {
+ font-size: 16px;
+ color: $gl-text-color-secondary;
+ padding: 10px;
+ flex-grow: 1;
+
+ &:hover {
+ background-color: $border-color;
+ color: $gl-text-color;
+ }
+
+ + * {
+ border-left: solid 1px $border-color;
+ }
+ }
+}
+
+.convdev-steps {
+ margin-top: $gl-padding;
+ height: 1px;
+ min-width: 100%;
+ justify-content: space-around;
+ position: relative;
+ background: $border-color;
+}
+
+.convdev-step {
+ $step-positions: 5% 10% 30% 42% 48% 55% 60% 70% 75% 90%;
+ @each $pos in $step-positions {
+ $i: index($step-positions, $pos);
+
+ &:nth-child(#{$i}) {
+ left: $pos;
+ }
+ }
+
+ position: absolute;
+ transform-origin: 75% 50%;
+ padding: 8px;
+ height: 50px;
+ width: 50px;
+ border-radius: 3px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ border: solid 1px $border-color;
+ background: $white-light;
+ transform: translate(-50%, -50%);
+ color: $gl-text-color-secondary;
+ fill: $gl-text-color-secondary;
+ box-shadow: 0 2px 4px $dropdown-shadow-color;
+
+ &:hover {
+ padding: 8px 10px;
+ fill: currentColor;
+ z-index: 100;
+ height: auto;
+ width: auto;
+
+ .convdev-step-title {
+ max-height: 2em;
+ opacity: 1;
+ transition: opacity 0.2s;
+ }
+
+ svg {
+ transform: scale(1.5);
+ margin: $gl-btn-padding;
+ }
+ }
+
+ svg {
+ transition: transform 0.1s;
+ width: 30px;
+ height: 30px;
+ min-height: 30px;
+ min-width: 30px;
+ }
+}
+
+.convdev-step-title {
+ max-height: 0;
+ opacity: 0;
+ text-transform: uppercase;
+ margin: $gl-vert-padding 0 0;
+ text-align: center;
+ font-size: 12px;
+}
+
+.convdev-high-score {
+ color: $color-high-score;
+}
+
+.convdev-average-score {
+ color: $color-average-score;
+}
+
+.convdev-low-score {
+ color: $color-low-score;
+}
diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss
index f269d53093d..56f20778637 100644
--- a/app/assets/stylesheets/pages/environments.scss
+++ b/app/assets/stylesheets/pages/environments.scss
@@ -11,34 +11,7 @@
}
.environments-container {
- .table-holder {
- width: 100%;
-
- @media (max-width: $screen-sm-max) {
- overflow: auto;
- }
- }
-
- .table.ci-table {
- .environments-actions {
- min-width: 300px;
- }
-
- .environments-commit,
- .environments-actions {
- width: 20%;
- }
-
- .environments-date {
- width: 10%;
- }
-
- .environments-name,
- .environments-deploy,
- .environments-build {
- width: 15%;
- }
-
+ .ci-table {
.deployment-column {
> span {
word-break: break-all;
@@ -150,6 +123,54 @@
}
}
+.gl-responsive-table-row {
+ .environments-actions {
+ @media (min-width: $screen-md-min) {
+ text-align: right;
+ }
+
+ @media (max-width: $screen-sm-max) {
+ background-color: $gray-normal;
+ align-self: stretch;
+ border-top: 1px solid $border-color;
+
+ .environment-action-buttons {
+ padding: 10px;
+ display: flex;
+
+ .btn {
+ border-radius: 3px;
+ }
+
+ > .btn-group,
+ .external-url,
+ .btn {
+ flex: 1;
+ flex-basis: 28px;
+ }
+
+ .dropdown-new {
+ width: 100%;
+ }
+ }
+ }
+ }
+
+ .branch-commit {
+ max-width: 100%;
+ }
+}
+
+.folder-row {
+ padding: 15px 0;
+ border-bottom: 1px solid $white-normal;
+
+ @media (max-width: $screen-sm-max) {
+ border-top: 1px solid $white-normal;
+ margin-top: 10px;
+ }
+}
+
.prometheus-graph {
text {
fill: $gl-text-color;
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 183be86f650..2dc7f73a295 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -520,17 +520,13 @@
position: absolute;
border-top: 2px solid $border-color;
height: 1px;
- top: 8px;
+ top: 9px;
width: 8px;
left: 0;
}
&:last-child {
margin-bottom: 0;
-
- &::before {
- top: 14px;
- }
}
}
@@ -539,7 +535,7 @@
width: 2px;
background: $border-color;
position: absolute;
- top: -5px;
+ top: -9px;
}
}
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index 875e47cdff3..0ddaab0da14 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -111,13 +111,28 @@
margin-top: 0;
text-align: center;
font-size: 12px;
+ align-items: center;
- @media (max-width: $screen-sm-max) {
+ @media (max-width: $screen-md-max) {
// On smaller devices the warning becomes the fourth item in the list,
// rather than centering, and grows to span the full width of the
// comment area.
order: 4;
- -webkit-order: 4;
+ margin: 6px auto;
+ width: 100%;
+ }
+
+ .fa {
+ margin-right: 8px;
+ }
+}
+
+.right-sidebar-expanded {
+ .confidential-issue-warning {
+ // When the sidebar is open the warning becomes the fourth item in the list,
+ // rather than centering, and grows to span the full width of the
+ // comment area.
+ order: 4;
margin: 6px auto;
width: 100%;
}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index cffd3b6060d..f956e3757bf 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -550,13 +550,13 @@ ul.notes {
position: relative;
top: -2px;
display: inline-block;
- padding-left: 4px;
- padding-right: 4px;
+ padding-left: 7px;
+ padding-right: 7px;
color: $notes-role-color;
font-size: 12px;
line-height: 20px;
border: 1px solid $border-color;
- border-radius: $border-radius-base;
+ border-radius: $label-border-radius;
}
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index cf2e565dd2d..58b458cd837 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -984,3 +984,11 @@
width: 12px;
}
}
+
+.pipeline-header-container {
+ min-height: 55px;
+
+ .text-center {
+ padding-top: 12px;
+ }
+}
diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss
index fe084eb9397..c207159f606 100644
--- a/app/assets/stylesheets/pages/profile.scss
+++ b/app/assets/stylesheets/pages/profile.scss
@@ -287,6 +287,7 @@ table.u2f-registrations {
.user-callout {
margin: 0 auto;
+ max-width: $screen-lg-min;
.bordered-box {
border: 1px solid $blue-300;
@@ -295,14 +296,15 @@ table.u2f-registrations {
position: relative;
display: flex;
justify-content: center;
+ align-items: center;
}
.landing {
- margin-top: $gl-padding;
- margin-bottom: $gl-padding;
+ padding: 32px;
.close {
position: absolute;
+ top: 20px;
right: 20px;
opacity: 1;
@@ -330,11 +332,20 @@ table.u2f-registrations {
height: 110px;
vertical-align: top;
}
+
+ &.convdev {
+ margin: 0 0 0 30px;
+
+ svg {
+ height: 127px;
+ }
+ }
}
.user-callout-copy {
display: inline-block;
vertical-align: top;
+ max-width: 570px;
}
}
@@ -348,12 +359,20 @@ table.u2f-registrations {
.landing {
.svg-container,
.user-callout-copy {
- margin: 0;
+ margin: 0 auto;
display: block;
svg {
height: 75px;
}
+
+ &.convdev {
+ margin: $gl-padding auto 0;
+
+ svg {
+ height: 120px;
+ }
+ }
}
}
}
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 24ab2bedea2..a2f781a6a6e 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -29,6 +29,20 @@
& > .form-group {
padding-left: 0;
}
+
+ select option[disabled] {
+ display: none;
+ }
+ }
+
+ select {
+ background: transparent;
+ transition: background 2s ease-out;
+
+ &.highlight-changes {
+ background: $highlight-changes-color;
+ transition: none;
+ }
}
.help-block {
@@ -675,14 +689,16 @@ pre.light-well {
}
}
-.new_protected_branch {
+.new_protected_branch,
+.new-protected-tag {
label {
margin-top: 6px;
font-weight: normal;
}
}
-.create-new-protected-branch-button {
+.create-new-protected-branch-button,
+.create-new-protected-tag-button {
@include dropdown-link;
width: 100%;
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index 7f39a99b59d..f83fa421de5 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -1,3 +1,90 @@
+@keyframes expandMaxHeight {
+ 0% {
+ max-height: 0;
+ }
+
+ 99% {
+ max-height: 100vh;
+ }
+
+ 100% {
+ max-height: none;
+ }
+}
+
+@keyframes collapseMaxHeight {
+ 0% {
+ max-height: 100vh;
+ }
+
+ 100% {
+ max-height: 0;
+ }
+}
+
+.settings {
+ overflow: hidden;
+ border-bottom: 1px solid $gray-darker;
+
+ &:first-of-type {
+ margin-top: 10px;
+ }
+}
+
+.settings-header {
+ position: relative;
+ padding: 20px 110px 10px 0;
+
+ h4 {
+ margin-top: 0;
+ }
+
+ button {
+ position: absolute;
+ top: 20px;
+ right: 6px;
+ min-width: 80px;
+ }
+}
+
+.settings-content {
+ max-height: 1px;
+ overflow-y: scroll;
+ margin-right: -20px;
+ padding-right: 130px;
+ animation: collapseMaxHeight 300ms ease-out;
+
+ &.expanded {
+ max-height: none;
+ overflow-y: hidden;
+ animation: expandMaxHeight 300ms ease-in;
+ }
+
+ &.no-animate {
+ animation: none;
+ }
+
+ @media(max-width: $screen-sm-max) {
+ padding-right: 20px;
+ }
+
+ &::before {
+ content: ' ';
+ display: block;
+ height: 1px;
+ overflow: hidden;
+ margin-bottom: 4px;
+ }
+
+ &::after {
+ content: ' ';
+ display: block;
+ height: 1px;
+ overflow: hidden;
+ margin-top: 20px;
+ }
+}
+
.settings-list-icon {
color: $gl-text-color-secondary;
font-size: $settings-icon-size;